Files
SQS-service/doc/thinking/opus-report-2026-10-10.md
T

232 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Код-ревью shared-sqs (Opus, 2026-10-10)
> Ответ Opus на промпт «сделай код-ревью и посоветуй по архитектуре».
> Дословно, без правок.
Все файлы из списка прочитаны. Строки взяты из листингов, часть пунктов опирается на прочитанное ранее. Пункты, которые стоит перепроверить перед правкой, помечены «проверить». Теги [Ф1–Ф6] указывают на ваши фокусные области.
## 🔴 Критично: потеря данных, падение процесса, обход безопасности
1. **[Ф1] Fatal `concurrent map iteration and map write` при повторном CreateQueue.** `create_queue.go:84` вызывает `SaveQueue` для существующей очереди под глобальным локом, но без `queue.mu`. `SaveQueue` маршалит `Duplicates`, `FIFO*` и `Tags`, а `send_message` пишет в них под `queue.mu`. Это не panic, а fatal runtime, который `recover` не ловит и который убивает весь процесс. Идемпотентный CreateQueue — частый вызов SDK. Тот же класс ошибки: `admin.go:498-535` и `seed.go`.
2. **[Ф1] `list_queue_tags.go:49-60` отдаёт в ответ ссылку на `queue.Tags`.** `RUnlock` срабатывает по `defer` при return, а XML-маршалинг идёт позже, в router. Параллельный `TagQueue` даёт тот же fatal. Нужна копия мапы.
3. **[Ф1] Инверсия порядка локов и дедлок через ожидающего писателя.**
- `queue.mu` → `SyncQueues.RLock`: `set_queue_attributes.go:48,63` → `queue_attributes.go:78`.
- `SyncQueues.RLock` → `queue.RLock`: скан в `admin.go:435-486` (подтверждено), `gauge_updater` (проверить, держит ли глобальный RLock).
- Любой писатель в `SyncQueues` (`create_queue.go:73`, `delete_queue.go:45`, admin) блокирует новых читателей Go RWMutex. Получается цикл, и сервис встаёт целиком.
- Отдельно: `models.go:410` — ABBA и self-дедлок на DLQ (цикл A→B→A или DLQ=self не отсекается в `queue_attributes.go:85`).
- `conf/config.go:126-134` — `return` без `Unlock` и дедлок на старте.
4. **[Ф1, Ф3] Redis-I/O под глобальным локом.** `delete_queue.go:45-56` выполняет синхронный `DeleteQueue` под `SyncQueues.Lock`. Комментарии «асинхронно» (`:24,55`) устарели. Redis-пауза 3–5 с останавливает все тенанты. То же в `admin.go:498` и `gosqs.go:107`.
5. **[Ф3] Подтверждённое сообщение теряется при сбое Redis.**
- `send_message.go:157` и `send_message_batch.go:133-139,153-158`: при ошибке `SaveMessage(s)Sync` откатывается только `RemoveIndex`.
- `Duplicates` и `FIFOSequenceNumbers` не откатываются, `InitDuplicatation` уже сработал.
- Ретрай клиента с тем же dedupId получает 200 без сохранения.
- Таймаут Redis неоднозначен: запись могла пройти, и после рестарта появится фантом.
6. **[Ф3] Receive, visibility-return и DLQ-перенос не персистятся.**
- Затронуты `gosqs.go:124-154`, `change_message_visibility*.go`, `receive_message.go`.
- DLQ-копия не пишется (`models.go:409-413`), а `change_message_visibility.go:72-81` делает только Delete в источнике.
- После рестарта сообщение воскресает в источнике и пропадает из DLQ.
- Порядок при загрузке теряется: `redis.go:504-513` итерирует HGETALL без сортировки, FIFO ломается.
7. **[Ф1, Ф2] Зависание FIFO-групп навсегда.**
- `ResetVisibilityAndTryDLQ` (`models.go:389-417`) не делает `UnlockGroup`, путь из `change_message_visibility.go:64` и batch-версии.
- `purge_queue.go:63-64` сбрасывает `Duplicates`, но не `FIFOMessages`.
- После рестарта `FIFOMessages` из Redis (`redis.go:163,448`) не согласован с in-flight, а тик снимает лок только по `receiptIdx`.
8. **DeleteMessageBatch с пустым `ReceiptHandle` удаляет все неполученные сообщения.**
- `delete_message_batch.go:79-97` сопоставляет handle сканом. У невыданных сообщений handle `""` и совпадает с записью `""`.
- Query-парсер (`requests.go:568`) отсекает пустой handle, JSON-протокол — нет.
- Одиночный delete безопасен: индекс пропускает `""`.
9. **[Ф4] HA — это не HA.**
- `leader.go:106-126`: ошибка Refresh не демотирует лидера, lease-проверки нет.
- `goaws.go:171-191`: пассив выполняет `PeriodicTasks` и пишет в Redis. Handlers лидерство не проверяют, защита — только `/ready`.
- При повышении пассива состояние из Redis не перечитывается: он работает со снимком своего старта.
- Без Redis `TryAcquireLock` и `RefreshLock` возвращают true (`redis.go:666-715`), каждый инстанс становится «лидером».
- Нет fencing-токена.
- `redis.go:61-85`: при неудачном Connect клиент остаётся nil навсегда, reconnect не запускается.
10. **[Ф3, Ф4] Старт с пустым состоянием.** `goaws.go:103-106,125-134` при недоступном Redis или ошибке `LoadAllQueues` продолжает работу. Следом `SaveQueue` перезапишет Redis пустотой. Нужен fail-fast.
11. **[Ф6] Тело подменяется при валидной подписи.** `auth_middleware.go:166-169,234-243` берёт `X-Amz-Content-Sha256` из заголовка и не сверяет с реальным хешем тела.
12. **[Ф6] JWT без проверки подписи.**
- `jwt.go`: идентичность берётся из непроверенного payload, `Exp==0` пропускается.
- Валидность подтверждает GET на любой из трёх шлюзов, включая dev и test.
- `tenant_store.go:213-291`: тенант находится по email из такого токена, возможен захват чужого тенанта.
- `cache.go:68-75`: при ошибке сети grace продлевает отозванный токен.
13. **[Ф3] Удалённый тенант воскресает.** `redis.go:594-609` `DeleteTenant` не чистит `ssq:tenant_ak`, а read-through в auth (`:63-74`) его возвращает. Найденный тенант кэшируется навсегда.
14. **[Ф5] OOM-потолок на уровне процесса.**
- Лимит только по числу сообщений: 120k × 256 КБ = ~30 ГБ на одну очередь (`validation.go:43-44`).
- Лимитов по байтам или памяти нет ни на очередь, ни на тенанта, ни глобально.
- Размер атрибутов не входит в проверку (`MaxMessageAttributeSize` объявлен и не используется).
- Один тенант валит процесс всем.
15. **[Ф3] `redis.go:33,116-133`: `Client` глобален, `reconnect()` подменяет его без синхронизации.** Это data race, старый клиент не закрывается. go-redis и так переподключается сам, `reconnect()` не нужен.
## 🟡 Существенно
**Гонки и конкурентность**
- **[Ф1]** `globals.go:77-100`: `GetLocked` отпускает глобальный лок до `LockMu`, и после взятия лока нет проверки, что очередь ещё в map.
- Запись в удалённую очередь возвращает 200, а сообщение пропало.
- `SaveMessagesSync` пишет `ssq:msg:{key}` уже после `DeleteQueue`, это осиротевшие ключи в Redis.
- Аналог: `send_message_batch.go:46-53` → `:105`.
- **[Ф3]** `redis.go:215-236`: seq-guard проверяется до HSET (TOCTOU). `:394-416`: проверка seq сразу после инкремента — тавтология, запущенная async-`SaveQueue` воскрешает метаданные после `HDel`. Карты seq растут вечно (`:44-46`).
- **[Ф3]** `purge_queue.go:68`: синхронный Redis под `queue.mu`, ошибка не возвращается, память очищена, а Redis нет. Ранее запущенные async `SaveMessage` могут записать сообщение после DEL.
**Tombstone и индексы**
- **[Ф2]** `models.go:201-208`: `ensureIndexes` тавтологичен. Проверка `liveCount == len-deadCount` не ловит рассинхрон map-индексов.
- **[Ф2]** `models.go:262-283`: `RemoveIndex` не обнуляет слот, тело сообщения живёт до компакции. Порог 1024 слота или половина — до 256 МБ мусора на очередь.
- **[Ф2]** `RebuildIndexes` на старте разреженный, после загрузки `deadCount=0`: компакции нет, но и мусора нет. Это нормально, если порядок восстановлен сортировкой (см. 🔴6).
- `models.go:113-141`: `FIFOSequenceNumbers` и `FIFOMessages` растут без чистки.
- `models.go:172-180`: запись в nil-map при `Duplicates==nil`.
**Семантика SQS**
- Dedup:
- `delete_message.go:59` и `delete_message_batch.go:93` делают `delete(Duplicates)` при удалении, окно 5 мин нарушено.
- `requests.go:422-423`: `ContentBasedDeduplication` принимается и игнорируется, FIFO без dedupId не дедуплицируется.
- Дубль возвращает новый MessageId.
- Receive и счётчики:
- `Retry++` на таймауте видимости (`gosqs.go:142`): `MaxReceiveCount` считается по таймаутам.
- `receive_message.go:203,247`: `ApproximateReceiveCount` завышен на 1.
- `ReceiptTime` перезаписывается, `FirstReceiveTimestamp` неверен.
- `models.go:405`: `ChangeMessageVisibility(0)` скрывает сообщение ещё на `q.VisibilityTimeout`, а должно быть видно сразу (проверить по receive).
- `change_message_visibility_batch.go:72`: нет проверки отрицательных значений.
- `queue_attributes.go:41-70`: 0 означает «не передан», и поэтому нельзя вернуть `DelaySeconds`, `ReceiveMessageWaitTimeSeconds` или `VisibilityTimeout` в 0.
- `requests.go:35-37,30`: невалидные значения и пустая строка молча игнорируются вместо ошибки.
- `queue_attributes.go:73-87`:
- нет проверки self-ссылки, цикла, совпадения FIFO-типа и диапазона `maxReceiveCount`;
- `DeadLetterQueueName` здесь не выставляется (проверить, заполняет ли его `SaveQueue`, иначе DLQ теряется после рестарта);
- `delete_queue.go:53` не отвязывает DLQ-ссылки других очередей.
- `create_queue.go:73-85`: повторный CreateQueue с другими атрибутами возвращает 200 вместо `QueueNameExists`.
- `create_queue.go:66`, `set_queue_attributes.go:59`: точное сравнение Content-Type. Для `json-1.1` или с `charset` whitelist обходится.
- Batch:
- `send_message_batch.go:87-103` валидирует пакет целиком, вместо per-entry `Failed`.
- Нет суммарного лимита 256 КБ.
- Per-message `DelaySeconds` не применяется (`:118-127`, проверить паритет с single).
- `delete_message_batch.go:65-72`: дубль handle теряет Id из ответа.
- `delete_message_batch.go:79` — O(N) скан вместо `IndexOfReceipt`, при том что `change_message_visibility_batch.go:84` индекс использует.
- Код ошибки `"1"`, порядок ответа недетерминирован.
- Query-парсеры (`requests.go:235,568,602`) молча обрезают пакет на первой пустой записи.
- `get_queue_attributes.go:89-100`:
- `Approximate…` включает in-flight и delayed;
- timestamp-заглушки `"0000000000"`;
- нет `FifoQueue`, `ContentBasedDeduplication`, `Policy`, `…Delayed`, что ломает Terraform и мониторинг.
- `list_queues.go:34-43`: скан всех очередей сервиса под глобальным RLock при готовом tenant-индексе. Пагинации нет (`MaxResults` и `NextToken` игнорируются), порядок случайный.
- `tag_queue.go:56-60`: нет лимитов на теги (AWS: 50 шт., ключ 128, значение 256). Теги сериализуются на каждый `SaveQueue`, это DoS-вектор.
**Производительность [Ф5]**
- `receive_message.go:164-216`: два полных скана под write-локом. Long-poll опрашивает каждые 100 мс с эксклюзивным локом на каждого поллера.
- `SaveQueue` на каждый send, delete и CMV (`send_message.go:159`, `delete_message*.go`, `change_message_visibility_batch.go:117`): O(Duplicates + FIFO + Tags) под локом.
- `gosqs.go:50-54`: один горутин-тик на все очереди последовательно, с синхронным Redis (`:107`).
- `redis.go:422-543`:
- `LoadAllQueues` грузит всё последовательно, общий таймаут 30 с;
- ошибка очереди → `continue`, очередь грузится пустой;
- время старта и RAM пропорциональны полному состоянию.
**HA и жизненный цикл [Ф4]**
- `router.go:214-228`: `/ready` не учитывает Redis, загрузку состояния и shutdown. `ReadyFunc` — глобальная переменная.
- `goaws.go:248-268`:
- `billing.Close()` идёт до дренажа HTTP;
- async Redis-записи не дренируются;
- не-ready на время дренажа нет;
- `log.Fatalf` в `:245` не отпускает лок;
- `leader.go:63-76`: `Stop` освобождает лок до дренажа.
- `goaws.go:204-211`: нет `ReadTimeout` (медленное тело), лимитов соединений и rate-limit. Тело читается целиком до проверки подписи.
**Безопасность [Ф6]**
- SigV4:
- `auth_middleware.go:296-326` не требует подписи `host`, `x-amz-date`, `x-amz-target`;
- `:158` clock skew 15 мин (в AWS — 5);
- `:215` presigned не проверяет дату из будущего;
- `:290` `canonicalURI` зависит от нормализации nginx и заголовка Host;
- превышение лимита тела даёт 403 `SignatureDoesNotMatch`, а не 413 (`router.go:124-133`);
- rate-limit по неудачным попыткам нет, ошибки раскрывают причину (`:81`).
- `auth_middleware.go:63-74`: каждый запрос с неизвестным accessKey ходит в Redis (HGET до 3 с) без негативного кэша. `jwt.go:90-133` — до трёх последовательных запросов по 10 с на токен, без singleflight.
- `admin.go`:
- статичный Bearer без rate-limit;
- `:223` `jwtMiddleware` пропускает путь по `HasSuffix("/auth")`;
- `:263-267` IDOR-защита только если в `mux.Vars` есть id;
- `:409-420` `deleteTenant`: in-flight handlers пишут в удалённые очереди;
- `:194,207-212` токен и `SecretKey` отдаются в теле ответа;
- `:640-669` `sendMessageToQueue` без лимита `MaxMessagesForQueue` и с async-сохранением.
- `tenant_store.go`: секреты в Redis открытым текстом. Нет проверки уникальности email (`:58-108`), `MaxQueues` перезаписывается при каждом логине (`:240`), `LoadTenant` затирает свежие правки (`:194-202`).
- `router.go:72-74`: маршруты `/{account}` и `/{account}/{queueName}` перекрывают `/admin/*` и `/health` для POST. Bypass в auth по этим путям fail-open, но nil-проверка tenant присутствует во всех 17 просмотренных handlers.
- `goaws.go:148-150`: при `SEED_DEMO` креды демо-тенанта пишутся в лог.
**Прочее**
- `router.go:250-277`: `countBatchEntries` работает только с form, для JSON-протокола возвращает 0 и биллинг батчей занижен. `:297` `msgBytes` — размер запроса, а не ответа.
- `billing.go`:
- канал 1024, переполнение дропает записи;
- `db.Exec` без таймаута;
- `Close()` обнуляет `db` без синхронизации с воркером (data race);
- DSN собирается через Sprintf.
- `metrics`:
- label tenant: accessKey в gauge и `tenant.ID` в counters, accessKey утекает в метрики;
- `Reset()` gauge даёт окно пустых значений;
- label не чистится при удалении тенанта.
- `conf/config.go`:
- `:111` yaml-очереди кладутся без префикса `accessKey:` и обходят tenant-изоляцию;
- `:189-196` при пустой RedrivePolicy `queues[deadLetterQueueName]` ищется без tenant-префикса.
- `seed.go`: ARN собирается с `t.ID` вместо `AccountID`, очереди без FIFO/DLQ-полей, `SaveMessages` асинхронный.
## 🟢 Мелочи
- Расхождение дефолтов:
- `build.go:19-32` (admin) задаёт `VisibilityTimeout`, `MaxSize`, `Retention` напрямую;
- `CreateQueueV1` берёт их из `NewCreateQueueRequest` по yaml;
- при нулях в yaml пути разойдутся.
- Мёртвый код и дубли:
- `validation.go`: `ClampInt`, `ValidateMessageAttributes`, `MaxRequestBodySize=1MB` (router — 3 МБ), `MaxTenantsDefault` дублирует `MaxTenantsGlobal`;
- `AvailableQueueAttributes` и `AttrNameWhitelist` — два списка;
- `DeduplicationPeriod` — `var`;
- `send_message.go:73` — мёртвая ветка `mux.Vars`.
- `BaseResponseMetadata.RequestId` — нулевой UUID во всех ответах: корреляция невозможна.
- `models/helpers.go`: `rand.Intn(max-min)` паникует при `max<min`; `Sprintf` и `ParseDuration` на каждый вызов; `IsReadyForReceipt` пересчитывает latency на каждый вызов.
- Устаревшие комментарии: `redis.go:1-13`, `send_message.go:38`, `goaws.yaml` (retention «not implemented»).
- INFO-логи на каждый вызов: `delete_message.go:43` (с ReceiptHandle), `get_queue_attributes.go:64`, `list_queues.go:30`.
- `models/errors.go`:
- `delete_message.go:48` возвращает `MessageDoesNotExist` при отсутствии очереди;
- `InvalidAttributeValue` и `MissingParameter` имеют жёсткий текст сообщения;
- `QueueExists` не используется.
- `tenant_helpers.go` и `configuration.go` не прогнаны через gofmt. Имя очереди берётся из последнего сегмента URL, `tenantID` в пути игнорируется.
- Версия `xml.Marshal` с `http.Error` после `WriteHeader` (`router.go:175-184`).
## Архитектура: потолок и вынос в Redis (вариант A)
**Потолок сегодня.** Он определяется тремя вещами:
- RAM процесса при отсутствии лимита по байтам;
- время старта: полная загрузка последовательным HGETALL;
- последовательный тик и O(N)-сканы в receive и gauge.
Ориентировочно потолок — десятки тысяч сообщений на горячую очередь. Измерить p99 receive при 100k in-flight и время `LoadAllQueues` на проде.
**Что Redis не гарантирует.** «Синхронно» в коде означает «принято Redis в память», а не fsync и не репликация. Redis на той же ВМ — общий SPOF. Для durability нужны `appendfsync always` или `everysec` с осознанной потерей до 1 с, либо `WAIT`, и отдельный реплицируемый Redis.
**Порядок перехода (по риску):**
1. **Сначала дёшево, без смены архитектуры:**
- сделать `SaveQueue` синхронным или write-behind с batch;
- вынести FIFO-lock из сообщений в метаданные и персистить;
- сортировать при загрузке (по seq или `SentTime`);
- персистить receive, visibility и DLQ;
- fail-fast при старте без Redis;
- лимиты по байтам и по тегам.
2. **Починить конкурентность:**
- единый порядок локов (глобальный до per-queue, не наоборот);
- убрать I/O под глобальным локом;
- проверка живости очереди после `LockMu`;
- копии при отдаче map в ответ.
3. **Интерфейс `QueueStore`** поверх `SyncQueues`. Сейчас handlers лезут в `Messages`, индексы и `Duplicates` напрямую, поэтому без интерфейса вынос невозможен.
4. **Redis как источник истины:**
- Lua-скрипты (или Streams) на send, receive, delete, change-visibility, DLQ-перенос;
- visibility через ZSET по времени;
- dedup через ключ с TTL;
- FIFO-групп-лок как ключ с lease.
5. **Sweeper вместо `PeriodicTasks`**, под отдельным локом и с fencing-токеном.
6. **Снять leader-lock.** До этого этапа HA остаётся «один активный, пассив безопасен только за `/ready`». Для безопасного active/passive нужны fencing-токен, перезагрузка состояния при повышении, запрет записи без лидерства и lease-проверка на лидере.
**Главный риск варианта A** — сетевой round-trip на каждую операцию и сложность FIFO и DLQ в Lua. Дешёвая промежуточная ступень — шардирование по тенантам (вариант B) с оставленным in-memory ядром. Оно не даёт HA, но снимает потолок по RAM и тику.
## Порядок исправления
1. Fatal-гонки: п. 1–2 из 🔴.
2. Lock order и I/O под глобальным локом: п. 3–4.
3. Откат dedup при сбое Redis: п. 5.
4. Пустой handle в DeleteMessageBatch: п. 8.
5. Подпись, тело и JWT: п. 11–12.
6. Fail-fast на старте и lease у лидера: п. 9–10.
7. Лимит по байтам: п. 14.
8. Персист receive, visibility и DLQ вместе с порядком при загрузке: п. 6–7.