package models import ( "strconv" "sync" "time" log "github.com/sirupsen/logrus" ) type MessageStructure string type Protocol string type MessageAttribute struct { BinaryListValues []string `json:"BinaryListValues,omitempty" xml:"BinaryListValues,omitempty"` // currently unsupported by AWS BinaryValue string `json:"BinaryValue,omitempty" xml:"BinaryValue,omitempty"` DataType string `json:"DataType,omitempty" xml:"DataType,omitempty"` StringListValues []string `json:"StringListValues,omitempty" xml:"StringListValues,omitempty"` // currently unsupported by AWS StringValue string `json:"StringValue,omitempty" xml:"StringValue,omitempty"` } type SqsMessage struct { MessageBody string Uuid string MD5OfMessageAttributes string MD5OfMessageBody string ReceiptHandle string ReceiptTime time.Time VisibilityTimeout time.Time NumberOfReceives int Retry int MessageAttributes map[string]MessageAttribute GroupID string DeduplicationID string SentTime time.Time DelaySecs int } func (m *SqsMessage) IsReadyForReceipt() bool { randomLatency, err := generateRandomLatency() if err != nil { log.Error(err) return true } showAt := m.SentTime.Add(randomLatency).Add(time.Duration(m.DelaySecs) * time.Second) return showAt.Before(time.Now()) } type Queue struct { // mu — per-queue блокировка содержимого очереди (Messages, Duplicates, FIFO- // состояние, LastPurgeTime). Заменяет удержание глобального SyncQueues.Lock // на время операций с одной очередью. sync.RWMutex сериализации не подлежит. mu sync.RWMutex `json:"-"` Name string URL string Arn string VisibilityTimeout int // seconds ReceiveMessageWaitTimeSeconds int DelaySeconds int MaximumMessageSize int MessageRetentionPeriod int // seconds (реализовано в processQueueTick) Messages []SqsMessage // ─── Индексация сообщений (P1.2) ──────────────────────────────────────── // deadIdx — параллельный Messages слайс tombstone-флагов: true = слот // логически удалён. Удаление НЕ сдвигает элементы (O(1)), поэтому индексы // (receiptIdx/uuidIdx) остаются валидными; физическая чистка — Compact. // Менять Messages напрямую НЕЛЬЗЯ: только через AppendMessage/RemoveIndex/ // Compact/Reset (см. ниже). Прямая замена допустима лишь для полной очистки // и загрузки из Redis (с последующим RebuildIndexes). deadIdx []bool `json:"-"` // receiptIdx — ReceiptHandle → индекс в Messages (только in-flight сообщения). receiptIdx map[string]int `json:"-"` // uuidIdx — Uuid → индекс в Messages (только живые сообщения). uuidIdx map[string]int `json:"-"` // liveCount — число живых сообщений; deadCount — число tombstone-слотов. liveCount int `json:"-"` deadCount int `json:"-"` DeadLetterQueue *Queue // DeadLetterQueueName — имя DLQ. В Redis-метаданных хранится ТОЛЬКО имя // (а не вложенный объект): после загрузки указатель DeadLetterQueue // релинкуется на объект из общей map (см. persistence.LoadAllQueues). DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"` MaxReceiveCount int IsFIFO bool FIFOMessages map[string]int FIFOSequenceNumbers map[string]int EnableDuplicates bool Duplicates map[string]time.Time Tags map[string]string // LastPurgeTime — время последнего успешного PurgeQueue. // Используется для запрета повторного purge ранее чем через 60 секунд // (требование AWS: PurgeQueueInProgress). Нулевое значение = purge ещё не выполнялся. LastPurgeTime time.Time } // LockMu/UnlockMu/RLockMu/RUnlockMu — доступ к per-queue блокировке извне пакета models // (поле mu неэкспортируемо). func (q *Queue) LockMu() { q.mu.Lock() } func (q *Queue) UnlockMu() { q.mu.Unlock() } func (q *Queue) RLockMu() { q.mu.RLock() } func (q *Queue) RUnlockMu() { q.mu.RUnlock() } // NextSequenceNumber — возвращает следующий порядковый номер сообщения FIFO-группы. // // Логическая схема: каждый вызов инкрементирует счётчик группы и возвращает // его строковое представление (порядковые номера в FIFO должны расти монотонно). // // ВАЖНО (фикс): раньше при ПЕРВОМ обращении к группе map пересоздавалась целиком // (q.FIFOSequenceNumbers = map[string]int{groupId: 0}) — счётчики ВСЕХ остальных // групп терялись, и порядковые номера начинали дублироваться. Теперь map только // инициализируется один раз (если nil), а счётчик конкретной группы инкрементируется. func (q *Queue) NextSequenceNumber(groupId string) string { if q.FIFOSequenceNumbers == nil { q.FIFOSequenceNumbers = make(map[string]int) } q.FIFOSequenceNumbers[groupId]++ return strconv.Itoa(q.FIFOSequenceNumbers[groupId]) } // IsLocked — проверяет, заблокирована ли FIFO-группа. // Группа блокируется при выдаче её сообщения в ReceiveMessage и остаётся // заблокированной, пока сообщение in-flight (до DeleteMessage или таймаута // видимости) — это гарантирует порядок обработки внутри группы. func (q *Queue) IsLocked(groupId string) bool { _, ok := q.FIFOMessages[groupId] return ok } // LockGroup — блокирует FIFO-группу (помечает как занятую). // // ВАЖНО (фикс): раньше при ПЕРВОМ обращении map пересоздавалась целиком // (q.FIFOMessages = map[string]int{groupId: 0}) — блокировки ВСЕХ остальных // групп терялись, и их сообщения могли выдаваться одновременно. Теперь map // только инициализируется один раз, а для группы проставляется ключ. func (q *Queue) LockGroup(groupId string) { if q.FIFOMessages == nil { q.FIFOMessages = make(map[string]int) } q.FIFOMessages[groupId] = 0 } // UnlockGroup — снимает блокировку FIFO-группы. // Вызывается после удаления сообщения или при истечении таймаута видимости // (см. PeriodicTasks → processQueueTick). func (q *Queue) UnlockGroup(groupId string) { if _, ok := q.FIFOMessages[groupId]; ok { delete(q.FIFOMessages, groupId) } } // IsDuplicate — проверяет, было ли сообщение с таким deduplicationId уже // добавлено в очередь в пределах deduplication-окна. // // Логическая схема: дубликаты отслеживаются ТОЛЬКО для FIFO-очередей с // включённой дупликацией (EnableDuplicates) и непустым deduplicationId. // Для остальных случаев дубликаты не отслеживаются (AWS-семантика). func (q *Queue) IsDuplicate(deduplicationId string) bool { if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { return false } _, ok := q.Duplicates[deduplicationId] return ok } // InitDuplicatation — регистрирует deduplicationId с текущим временем. // Эта отметка — начало deduplication-окна; просроченные записи удаляются // фоновой задачей PeriodicTasks (deduplication period expired). // Для не-FIFO очередей или пустого deduplicationId — no-op. func (q *Queue) InitDuplicatation(deduplicationId string) { if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { return } if _, ok := q.Duplicates[deduplicationId]; !ok { q.Duplicates[deduplicationId] = time.Now() } } // ─── P1.2: индексация сообщений (tombstone + индексы) ────────────────────── // // Все методы ниже мутируют состояние очереди → вызывать ТОЛЬКО под // per-queue write-блокировкой (queue.LockMu). Читающие методы (LiveCount, // IsDead) безопасны под RLock. // compactDeadThreshold — минимальное число tombstone-слотов, после которого // компакция при удалении разрешена. Защищает от O(N)-чистки на мелких очередях: // при удалении слот подрезается с хвоста, остальная чистка — в processQueueTick. const compactDeadThreshold = 1024 // ensureIndexes — ленивая инициализация/починка индексов. // При корректном состоянии — две дешёвые проверки; O(N) — если Messages // изменили в обход методов (загрузка из Redis, тесты, старый JSON-формат). // Вызывать под write-блокировкой. func (q *Queue) ensureIndexes() { if len(q.deadIdx) == len(q.Messages) && q.receiptIdx != nil && q.uuidIdx != nil && q.liveCount == len(q.Messages)-q.deadCount { return } q.RebuildIndexes() } // RebuildIndexes — перестраивает deadIdx/receiptIdx/uuidIdx/liveCount/deadCount // по текущему Messages. Существующие tombstone СОХРАНЯЮТСЯ по позициям (если // deadIdx длиннее Messages — обрезается, короче — добивается false). // Вызывать под write-блокировкой. func (q *Queue) RebuildIndexes() { if len(q.deadIdx) > len(q.Messages) { q.deadIdx = q.deadIdx[:len(q.Messages)] } for len(q.deadIdx) < len(q.Messages) { q.deadIdx = append(q.deadIdx, false) } q.receiptIdx = make(map[string]int, len(q.Messages)) q.uuidIdx = make(map[string]int, len(q.Messages)) q.liveCount = 0 q.deadCount = 0 for i := range q.Messages { if q.deadIdx[i] { q.deadCount++ continue } q.liveCount++ if u := q.Messages[i].Uuid; u != "" { q.uuidIdx[u] = i } if rh := q.Messages[i].ReceiptHandle; rh != "" { q.receiptIdx[rh] = i } } } // AppendMessage — добавляет сообщение в конец очереди, поддерживая индексы. // Возвращает индекс добавленного сообщения. Вызывать под write-блокировкой. func (q *Queue) AppendMessage(m SqsMessage) int { q.ensureIndexes() q.Messages = append(q.Messages, m) q.deadIdx = append(q.deadIdx, false) idx := len(q.Messages) - 1 q.liveCount++ if m.Uuid != "" { q.uuidIdx[m.Uuid] = idx } if m.ReceiptHandle != "" { q.receiptIdx[m.ReceiptHandle] = idx } return idx } // RemoveIndex — логически удаляет сообщение по индексу (tombstone) за O(1). // ВАЖНО: длина q.Messages НЕ меняется (слот остаётся tombstone) — поэтому // индексы, взятые до вызова, остаются валидными, а итерации по срезу // безопасны. Физическая чистка — Compact/CompactIfNeeded (в processQueueTick). // Вызывать под write-блокировкой. func (q *Queue) RemoveIndex(i int) { q.ensureIndexes() if i < 0 || i >= len(q.Messages) || q.deadIdx[i] { return } msg := &q.Messages[i] q.deadIdx[i] = true q.deadCount++ if q.liveCount > 0 { q.liveCount-- } if msg.Uuid != "" { if idx, ok := q.uuidIdx[msg.Uuid]; ok && idx == i { delete(q.uuidIdx, msg.Uuid) } } if rh := msg.ReceiptHandle; rh != "" { if idx, ok := q.receiptIdx[rh]; ok && idx == i { delete(q.receiptIdx, rh) } } } // CompactIfNeeded — компакция при заметной доле tombstone-слотов. // Амортизированно: копия среза делается не на каждое удаление, а когда число // «мёртвых» слотов становится заметным (≥ 1024 или ≥ половины среза). // Вызывать под write-блокировкой ВНЕ итераций по Messages (меняет индексы). func (q *Queue) CompactIfNeeded() { if q.deadCount == 0 { return } if q.deadCount >= compactDeadThreshold || q.deadCount*2 >= len(q.Messages) { q.Compact() } } // Compact — физически удаляет tombstone-слоты, сохраняя порядок живых, и // перестраивает индексы. O(N). Вызывать под write-блокировкой. func (q *Queue) Compact() { if q.deadCount == 0 { return } if len(q.deadIdx) != len(q.Messages) { q.RebuildIndexes() if q.deadCount == 0 { return } } n := 0 for i := range q.Messages { if q.deadIdx[i] { continue } if n != i { q.Messages[n] = q.Messages[i] } n++ } // Обнуляем «хвост», чтобы не держать ссылки на удалённые сообщения (GC). tail := q.Messages[n:] for i := range tail { tail[i] = SqsMessage{} } q.Messages = q.Messages[:n] // После чистки tombstone нет — сбрасываем флаги, чтобы RebuildIndexes // построил индексы только по живым (иначе старые deadIdx-флаги «съедут»). q.deadIdx = q.deadIdx[:0] q.deadCount = 0 q.RebuildIndexes() } // Reset — полностью очищает сообщения и индексы (purge). // Вызывать под write-блокировкой. func (q *Queue) Reset() { q.Messages = nil q.deadIdx = nil q.receiptIdx = make(map[string]int) q.uuidIdx = make(map[string]int) q.liveCount = 0 q.deadCount = 0 } // EnsureIndexes — экспортируемая обёртка над ensureIndexes: строит индексы, // если очередь была собрана в обход методов (литералом, из Redis, тестами). // Вызывать под write-блокировкой. func (q *Queue) EnsureIndexes() { q.ensureIndexes() } // IndexOfReceipt — индекс сообщения по ReceiptHandle (только in-flight) за O(1). // Вызывать под write-блокировкой (ensureIndexes может перестроить индексы). func (q *Queue) IndexOfReceipt(receiptHandle string) (int, bool) { q.ensureIndexes() i, ok := q.receiptIdx[receiptHandle] if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] { return 0, false } return i, true } // IndexOfUUID — индекс живого сообщения по Uuid за O(1). // Вызывать под write-блокировкой. func (q *Queue) IndexOfUUID(uuid string) (int, bool) { q.ensureIndexes() i, ok := q.uuidIdx[uuid] if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] { return 0, false } return i, true } // MarkInFlight — регистрирует ReceiptHandle → индекс (для tick по in-flight). // Вызывать под write-блокировкой. func (q *Queue) MarkInFlight(i int, receiptHandle string) { q.ensureIndexes() if receiptHandle != "" { q.receiptIdx[receiptHandle] = i } } // ClearInFlight — снимает регистрацию ReceiptHandle (возврат видимости). // Вызывать под write-блокировкой. func (q *Queue) ClearInFlight(i int, receiptHandle string) { if receiptHandle == "" { return } if idx, ok := q.receiptIdx[receiptHandle]; ok && idx == i { delete(q.receiptIdx, receiptHandle) } } // InFlightIndexes — снимок индексов in-flight сообщений (для visibility-тика). // Возвращает срез индексов в Messages. Вызывать под write-блокировкой. func (q *Queue) InFlightIndexes() []int { idx := make([]int, 0, len(q.receiptIdx)) for _, i := range q.receiptIdx { idx = append(idx, i) } return idx } // IsDead — tombstone-ли слот. Безопасно под RLock (не мутирует состояние). func (q *Queue) IsDead(i int) bool { if i < 0 || i >= len(q.deadIdx) { return false } return q.deadIdx[i] } // LiveCount — число живых (не tombstone) сообщений. // Безопасно под RLock. Эквивалент прежнего len(queue.Messages). func (q *Queue) LiveCount() int { return len(q.Messages) - q.deadCount }