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
+44 -24
View File
@@ -74,28 +74,34 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
// - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение
// в DLQ и удаляем из исходной очереди.
func processQueueTick(key string, queue *models.Queue) {
// Ленивая починка индексов (P1.2): очередь могла быть собрана литералом или
// загружена из Redis — тогда индексы строятся здесь один раз.
queue.EnsureIndexes()
// 0. Эвикция просроченных сообщений по MessageRetentionPeriod.
// Retention == 0 означает «без истечения» (не эвиктируем).
// Удаление — tombstone за O(1); физическая чистка — CompactIfNeeded ниже.
if queue.MessageRetentionPeriod > 0 {
cutoff := time.Now().Add(-time.Duration(queue.MessageRetentionPeriod) * time.Second)
expired := make([]string, 0) // uuid просроченных сообщений для удаления из Redis
// Компакция НА МЕСТЕ: читаем значение в i, пишем в n (n <= i) — без
// алиасинга на переиспользуемом срезе и без лишней аллокации.
n := 0
for i := range queue.Messages {
msg := queue.Messages[i]
if msg.SentTime.Before(cutoff) {
expired = append(expired, msg.Uuid)
if msg.GroupID != "" {
queue.UnlockGroup(msg.GroupID)
}
if queue.IsDead(i) {
continue
}
queue.Messages[n] = msg
n++
msg := queue.Messages[i]
if !msg.SentTime.Before(cutoff) {
continue
}
expired = append(expired, msg.Uuid)
if msg.GroupID != "" {
queue.UnlockGroup(msg.GroupID)
}
if msg.ReceiptHandle != "" {
queue.ClearInFlight(i, msg.ReceiptHandle)
}
queue.RemoveIndex(i)
}
if len(expired) > 0 {
queue.Messages = queue.Messages[:n]
log.Infof("Queue [%s]: evicted %d messages by MessageRetentionPeriod", queue.Name, len(expired))
// Удаляем просроченные из Redis, чтобы не воскресли после рестарта.
persistence.DeleteMessagesPersist(key, expired)
@@ -111,18 +117,25 @@ func processQueueTick(key string, queue *models.Queue) {
}
// 2. Возврат видимости истёкших сообщений + перенос в DLQ.
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
for i := 0; i < len(queue.Messages); i++ {
msg := &queue.Messages[i]
if msg.ReceiptHandle == "" {
continue // сообщение не в полёте
// Итерируем ТОЛЬКО по in-flight (receiptIdx), а не по всему срезу — стоимость
// пропорциональна числу сообщений «в полёте», а не размеру очереди (P1.2).
log.Debugf("Queue [%s] live [%d]", queue.Name, queue.LiveCount())
now := time.Now()
for _, i := range queue.InFlightIndexes() {
if i < 0 || i >= len(queue.Messages) || queue.IsDead(i) {
continue
}
if msg.VisibilityTimeout.After(time.Now()) {
msg := &queue.Messages[i]
rh := msg.ReceiptHandle
if rh == "" {
continue // уже возвращено в видимость
}
if msg.VisibilityTimeout.After(now) {
continue // таймаут видимости ещё действует
}
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
log.Debugf("Making message visible again %s", rh)
queue.ClearInFlight(i, rh)
queue.UnlockGroup(msg.GroupID)
msg.ReceiptHandle = ""
msg.ReceiptTime = time.Now().UTC()
@@ -132,18 +145,25 @@ func processQueueTick(key string, queue *models.Queue) {
if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil &&
msg.Retry >= queue.MaxReceiveCount {
dlqMsg := *msg
queue.DeadLetterQueue.LockMu()
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
queue.DeadLetterQueue.AppendMessage(dlqMsg)
queue.DeadLetterQueue.UnlockMu()
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
i-- // элемент на позиции i удалён — повторно проверяем новый элемент на ней
queue.RemoveIndex(i)
}
}
// 4. Компакция tombstone-слотов (амортизированно): срез не растёт бесконечно.
queue.CompactIfNeeded()
}
func numberOfHiddenMessagesInQueue(queue *models.Queue) int {
num := 0
for _, m := range queue.Messages {
for i := range queue.Messages {
if queue.IsDead(i) {
continue
}
m := &queue.Messages[i]
if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) {
num++
}