P1.2: индексация сообщений — tombstone-удаление O(1), receipt/uuid-индексы, tick по in-flight

- models.Queue: deadIdx/receiptIdx/uuidIdx/liveCount/deadCount + примитивы
  (AppendMessage/RemoveIndex/Compact/CompactIfNeeded/Reset/EnsureIndexes/
  IndexOfReceipt/IndexOfUUID/MarkInFlight/ClearInFlight/InFlightIndexes/LiveCount).
  Удаление — tombstone за O(1), физическая чистка амортизированная.
- DeleteMessage/ChangeMessageVisibility(±Batch): поиск по индексу вместо скана
  среза; удаление без сдвига остатка.
- DeleteMessageBatch: tombstone вместо полной пересборки среза.
- processQueueTick: visibility/DLQ по индексу in-flight (O(in-flight)),
  retention — tombstone, в конце — CompactIfNeeded.
- send-пути: AppendMessage + LiveCount (OOM-лимит по живым).
- admin/metrics/persistence: учёт живых сообщений, индексы после загрузки из Redis.
- Redis-схема не менялась (сообщения и так в per-message HASH).
This commit is contained in:
Repinoid
2026-10-07 08:01:49 +03:00
parent e735563a04
commit c5b2c11f5d
15 changed files with 519 additions and 130 deletions
+257 -11
View File
@@ -61,18 +61,33 @@ type Queue struct {
MaximumMessageSize int
MessageRetentionPeriod int // seconds (реализовано в processQueueTick)
Messages []SqsMessage
DeadLetterQueue *Queue
// ─── Индексация сообщений (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
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 ещё не выполнялся.
@@ -81,10 +96,10 @@ type Queue struct {
// 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() }
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-группы.
//
@@ -163,3 +178,234 @@ func (q *Queue) InitDuplicatation(deduplicationId string) {
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
}