11 KiB
История правок shared-sqs — 2026-10-07 (P1.2 + фикс утечки блокировки)
Запись по требованию «документировать всё, включая собственные ошибки». Предыдущие записи:
doc/thinking/*-2026-10-0[56].md,HISTORY/2026-08-1*-session-log.md.
1. Задача
Пользователь: «доделать ПОЛНОСТЬЮ и протестировать SQS». Незакрытым по плану
plan-for-flash-2026-10-05.md оставался единственный пункт — P1.2
(индексация сообщений), отложенный 2026-10-06 с обоснованием.
2. Что сделано
2.1. Дизайн (коммит b58e958)
doc/thinking/p1.2-message-index-design-2026-10-07.md.
Ключевое решение: tombstone + индексы + амортизированная компакция.
Удаление не сдвигает срез (O(1)), поэтому индексы receipt→index и uuid→index
остаются валидными. Физическая чистка — Compact/CompactIfNeeded в processQueueTick.
Redis-схема не меняется (сообщения и так лежат в per-message HASH).
2.2. Код (коммит 22e9bc8)
models.Queue:deadIdx,receiptIdx,uuidIdx,liveCount,deadCount(всеjson:"-")- примитивы:
AppendMessage,RemoveIndex,Compact,CompactIfNeeded,Reset,EnsureIndexes/RebuildIndexes,IndexOfReceipt,IndexOfUUID,MarkInFlight,ClearInFlight,InFlightIndexes,IsDead,LiveCount.
- примитивы:
DeleteMessage/ChangeMessageVisibility(±Batch)— поиск по индексу вместо скана среза; удаление без сдвига остатка.DeleteMessageBatch— tombstone вместо полной пересборки среза.processQueueTick— visibility/DLQ по индексу in-flight (O(in-flight)), retention — через tombstone, в конце —CompactIfNeeded.- send-пути —
AppendMessage/LiveCount; admin/metrics/persistence — учёт живых сообщений;LoadAllQueues—RebuildIndexesпосле загрузки.
2.3. Тесты (коммиты e05af04, 5b3fb51)
app/models/message_index_test.go— 6 тестов примитивов (tombstone, компакция, индексы, Reset, порог).app/gosqs/delete_middle_test.go— удаление СЕРЕДИНЫ очереди через API: остальные не теряются, удалённое не возвращается.app/gosqs/tenant_flow_test.go— существующие тесты переведены наLiveCount(); добавленTestReceiveEmptyQueue_DoesNotLeakLock.
3. НАЙДЕННЫЙ ДЕФЕКТ (моя регрессия) — утечка per-queue блокировки
3.1. Проявление
Стресс (2000 сообщений × циклы): ReadTimeout, всплеск 1.9 с на delete_batch,
дальше — зависание сервиса (health при этом отвечает).
3.2. Диагностика (факты, не догадки)
- Снят стек горутин по
SIGQUIT: десятки горутин вsync.RWMutex.RLock(models.go:101 Queue.RLockMu), включаяmetrics.updateGauges(ждала 15 минут) и HTTP-хендлерыGetQueueAttributesV1. - Аудит всех
GetLocked/GetRLocked: дефект ровно один. receive_message.go:queue := models.SyncQueues.GetLocked(key) // Lock взят if queue == nil || queue.LiveCount() == 0 { return nil, false // ← возврат БЕЗ Unlock } defer queue.UnlockMu()
3.3. Причина и почему проявилось именно сейчас
Возврат до defer оставлял queue.mu захваченным навсегда (в Go мьютекс не
освобождается при выходе из функции). Прежнее условие len(queue.Messages) == 0
срабатывало только на полностью пустой очереди; после перехода на tombstone
появился новый случай LiveCount()==0 при len(Messages)>0 (все сообщения удалены,
компакция ещё не прошла) — типовой для последовательных циклов «залить → выгрести».
3.4. Фикс
defer queue.UnlockMu() — до любой проверки/возврата. Регрессионный тест
TestReceiveEmptyQueue_DoesNotLeakLock проверен на дефекте (падает за 2 с)
и на фиксе (зелёный).
4. Результаты проверок (после фикса)
| Проверка | Результат |
|---|---|
go build ./..., go vet ./... |
OK |
go test -race ./app/... |
зелёные |
tests/api_test.sh (aws CLI, локальный сервис) |
PASS=22 FAIL=0 |
tests/sdk_test.py (boto3 1.43.108) |
PASS=15 FAIL=0 |
tests/fifo_dlq_probe.py |
fifo_order / fifo_dedup / dlq = PASS |
| Стресс 3×2000 (send-batch → receive → delete-batch) | send ≈0.6 с, drain ≈1.2 с на цикл, left=0/0, вызовов >0.5 с нет |
5. Инфраструктурные заметки (для повторного прогона)
- Локальный сервис:
SHARED_SQS_SEED_DEMO=true /tmp/shared-sqs --admin-token=... --port=4100(memory-only безREDIS_ADDR; креды демо-тенанта печатаются в лог при старте). boto3в системе НЕ установлен (PEP 668) иpython3.12-venvотсутствует → wheels скачаны черезpip downloadи распакованы в/tmp/sqlibs_unpacked, запуск сPYTHONPATH=/tmp/sqlibs_unpacked.- Лимит очередей тенанта — 10: падавшие скрипты оставляют очереди, перед прогоном
чистить (
aws sqs delete-queue). ENDPOINT(api_test.sh) иENDPOINT_URL(python-тесты) — разные имена переменных.
6. Уроки
- Стресс-проверка обязательна: api/sdk/fifo проходили и с дефектом, он проявлялся только на циклах полного опустошения очереди.
SIGQUIT— быстрый и точный способ найти зависание (кто держит мьютекс), дешевле догадок.- При замене условия на новый критерий (
len→LiveCount) надо перечитывать весь блок, включая порядокdefer— старый код содержал латентную утечку лока, которую новый критерий сделал массовой. - Не запускать сервис с
| head— SIGPIPE убивает процесс на середине теста.
7. Код-ревью (роль ПРО) и фиксы — коммит 48c38c4
Проведён архитектурный разбор всех затронутых файлов. Найдено и исправлено:
- 🔥 Батч-видимость не заводила в DLQ (
change_message_visibility_batch.go). ПриVisibilityTimeout=0батч лишь инкрементировалRetryи НЕ переносил сообщение в DLQ — логика была только в одиночной версии, счётчик и перенос расходились. Вынесен единый методmodels.Queue.ResetVisibilityAndTryDLQ(сброс видимости + DLQ-перенос), обе версии приведены к одному пути. ТестTestChangeVisibilityBatch_MovesToDLQ(падает на старой логике, зелёный на новой). - TOCTOU лимит в
SendMessageBatch: проверка OOM-лимита была только по устаревшему снимку (RLock → Lock). Добавлена повторная проверка под per-queue Lock. - Инварианты
InFlightIndexes(снимок может содержать устаревшие индексы — потребитель обязан перепроверять) иensureIndexes(проверяет согласованность чисел, а не валидность map-индексов →Messagesнельзя мутировать вне методов) зафиксированы в док-комментариях. Compactупрощён: одинRebuildIndexesна выходе +ensureIndexesна входе (убран дублирующий блок),deadCountпересчитывается внутри.
Осталось осознанно не тронуто: DeleteMessageBatch итерация с RemoveIndex внутри
безопасна (tombstone не меняет длину) — покрыто тестами.
8. Полный код-ревью сервиса (2026-10-08) — коммит 5b0e89a
Разобран весь сервис: auth (SigV4 + JWT), router, billing, tenant, admin/UI API, создание очередей. Критичных проблем не найдено. Исправлено три замечания:
- TOCTOU лимита тенантов —
TenantStore.Create: проверкаMaxTenantsGlobalбыла только под RLock, вставка — позже под Lock без повторной проверки. Добавлена проверка под Lock (как уже было вCreateFromJWT). - Потеря
NubesSubпри рестарте —CreateFromJWT: обновлённыйNubesSubсохранялся в Redis только внутри ветки сменыMaxQueues; теперь персист при любом реальном изменении, запись только если что-то изменилось. http.Clientна каждый вызов —auth.PingNubesAPIпереведён на общий клиент (keep-alive).
UI/безопасность проверены отдельно: listTenants/getTenant/IDOR для UI-тенанта —
видит только себя, чужие {id} отсекаются, удаление через UI запрещено,
SecretKey отдаётся только по явному /ui/api/credentials.