From e735563a04b76fddce9219a41ae941390061e5bf Mon Sep 17 00:00:00 2001 From: Repinoid Date: Wed, 7 Oct 2026 07:56:56 +0300 Subject: [PATCH] =?UTF-8?q?doc(P1.2):=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20(tombstone=20+=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D1=8B?= =?UTF-8?q?=20+=20=D0=B0=D0=BC=D0=BE=D1=80=D1=82=D0=B8=D0=B7=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D1=86=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../p1.2-message-index-design-2026-10-07.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 doc/thinking/p1.2-message-index-design-2026-10-07.md diff --git a/doc/thinking/p1.2-message-index-design-2026-10-07.md b/doc/thinking/p1.2-message-index-design-2026-10-07.md new file mode 100644 index 0000000..cb4640d --- /dev/null +++ b/doc/thinking/p1.2-message-index-design-2026-10-07.md @@ -0,0 +1,97 @@ +# P1.2 — индексация сообщений: дизайн и решение (2026-10-07) + +> Статус: ЗАКРЫВАЕМ отсрочку от 2026-10-06. Задача — сделать DeleteMessage и +> visibility-перерасчёт НЕ зависящими от полного размера очереди. +> Источник: `plan-for-flash-2026-10-05.md` (P1.2), `p1.1-per-queue-lock-analysis-2026-10-06.md`. + +## 1. Постановка + +`Queue.Messages []SqsMessage` — единственное хранилище сообщений. Пути: + +| Путь | Сейчас | +|---|---| +| ReceiveMessage (`receiveMessagesUnderLock`) | скан всего среза (нужен — ищем готовые) | +| DeleteMessage | скан среза по ReceiptHandle, затем сдвиг `append(Messages[:i], Messages[i+1:]...)` | +| DeleteMessageBatch | полная пересборка среза | +| ChangeMessageVisibility(±Batch) | скан по ReceiptHandle | +| `processQueueTick` (видимость + DLQ) | скан всего среза | +| Retention-эвикция | скан всего среза (компакция на месте) | +| `numberOfHiddenMessagesInQueue`, admin/health/peek/metrics | скан | + +## 2. Почему «просто индекс receipt→index» недостаточно + +Индекс поверх среза **не даёт O(1)** на удалении: физическое удаление из середины +среза сдвигает все последующие элементы и ломает все сохранённые индексы +(O(N) на переиндексацию). Именно это было причиной отсрочки 2026-10-06. + +## 3. Решение: tombstone + индексы + амортизированная компакция + +Ключевая идея: **удаление не двигает элементы**. Слот помечается «мёртвым» +(tombstone) за O(1); физическая чистка (компакция) выполняется редко — +раз в секунду в `processQueueTick` и по порогу при удалении. + +Это сохраняет порядок сообщений (важно для FIFO и порядка выдачи), +не меняет Redis-схему (сообщения и так лежат отдельными HASH-полями) и +делает индексы стабильными между компакциями. + +### 3.1. Новые поля `models.Queue` (все `json:"-"`, в Redis не попадают) + +```go +Messages []SqsMessage // физическое хранилище; слот может быть tombstone +deadIdx []bool // параллельный Messages: true = слот удалён +receiptIdx map[string]int // ReceiptHandle → индекс (только in-flight) +uuidIdx map[string]int // Uuid → индекс (только живые) +liveCount int // число живых сообщений +deadCount int // число tombstone-слотов +``` + +Схема Redis не меняется: `queueMetaDTO` сообщения не содержит, а сами сообщения +хранятся отдельными полями `ssq:msg:{key}` → UUID. Tombstone-сообщения из Redis +удаляются сразу (как и сейчас, `HDel` синхронно), поэтому «воскрешения» после +рестарта не будет. + +### 3.2. API `Queue` (примитивы, мутировать `Messages` напрямую запрещено) + +- `AppendMessage(m SqsMessage) int` — добавить (append) + проставить индексы; +- `RemoveIndex(i int)` — tombstone за O(1) + снять с индексов; +- `IndexOfReceipt(rh string) (int, bool)` / `IndexOfUUID(u string) (int, bool)`; +- `MarkInFlight(i int, rh string)` / `ClearInFlight(i int, rh string)`; +- `IsDead(i int) bool`, `LiveCount() int`, `InFlightIndexes() []int`; +- `Compact()` — физическое удаление tombstone + перестройка индексов (O(N)); +- `Reset()` — очистка (purge); +- `RebuildIndexes()` — восстановление индексов (загрузка из Redis, ленивая защита). + +Индексы строятся лениво (`ensureIndexes`), поэтому `Queue`, собранные литералом +в тестах/загрузчике, работают без изменений вызовов. + +### 3.3. Что становится O(1) / O(in-flight) + +- **DeleteMessage** → `IndexOfReceipt` + `RemoveIndex` — O(1) (+ редкая компакция). +- **ChangeMessageVisibility(±Batch)** → `IndexOfReceipt` — O(1) на запись. +- **visibility-перерасчёт в тике** → итерация ТОЛЬКО по `receiptIdx` (in-flight), + а не по всем сообщениям — O(in-flight). +- **DLQ-перенос в тике** — внутри той же итерации по in-flight. + +### 3.4. Что осознанно остаётся O(N) + +- **receive** — нужно найти подходящие сообщения; полный O(1) требует ready-heap + и в объём P1.2 не входит (критерий плана — delete + visibility). +- **retention-эвикция** — раз в секунду, скан (отдельный пункт P1.3, уже сделан). + +Компакция порогом: при `deadCount > 1024 && deadCount > liveCount` — прямо в +`RemoveIndex` (амортизированно O(1) на удаление). + +## 4. Затронутые файлы + +`app/models/models.go`, `app/models/helpers.go`, `app/gosqs/{receive_message, +delete_message, delete_message_batch, change_message_visibility, +change_message_visibility_batch, gosqs, send_message, send_message_batch, +get_queue_attributes}.go`, `app/admin/admin.go`, `app/metrics/gauge_updater.go`, +`app/persistence/redis.go`, `app/gosqs/tenant_flow_test.go`. + +## 5. Критерий готовности + +1. `DeleteMessage`/`ChangeMessageVisibility` не сканируют срез. +2. visibility-перерасчёт в тике идёт по числу in-flight, не по размеру очереди. +3. Все существующие тесты зелёные; добавлены тесты на tombstone/компакцию. +4. Redis-схема не изменилась; рестарт не «воскрешает» удалённые сообщения.