Files
SQS-service/HISTORY/2026-10-07-session-log.md
T

11 KiB
Raw Blame History

История правок 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. Уроки

  1. Стресс-проверка обязательна: api/sdk/fifo проходили и с дефектом, он проявлялся только на циклах полного опустошения очереди.
  2. SIGQUIT — быстрый и точный способ найти зависание (кто держит мьютекс), дешевле догадок.
  3. При замене условия на новый критерий (len → LiveCount) надо перечитывать весь блок, включая порядок defer — старый код содержал латентную утечку лока, которую новый критерий сделал массовой.
  4. Не запускать сервис с | head — SIGPIPE убивает процесс на середине теста.

7. Код-ревью (роль ПРО) и фиксы — коммит 48c38c4

Проведён архитектурный разбор всех затронутых файлов. Найдено и исправлено:

  1. 🔥 Батч-видимость не заводила в DLQ (change_message_visibility_batch.go). При VisibilityTimeout=0 батч лишь инкрементировал Retry и НЕ переносил сообщение в DLQ — логика была только в одиночной версии, счётчик и перенос расходились. Вынесен единый метод models.Queue.ResetVisibilityAndTryDLQ (сброс видимости + DLQ-перенос), обе версии приведены к одному пути. Тест TestChangeVisibilityBatch_MovesToDLQ (падает на старой логике, зелёный на новой).
  2. TOCTOU лимит в SendMessageBatch: проверка OOM-лимита была только по устаревшему снимку (RLock → Lock). Добавлена повторная проверка под per-queue Lock.
  3. Инварианты InFlightIndexes (снимок может содержать устаревшие индексы — потребитель обязан перепроверять) и ensureIndexes (проверяет согласованность чисел, а не валидность map-индексов → Messages нельзя мутировать вне методов) зафиксированы в док-комментариях.
  4. Compact упрощён: один RebuildIndexes на выходе + ensureIndexes на входе (убран дублирующий блок), deadCount пересчитывается внутри.

Осталось осознанно не тронуто: DeleteMessageBatch итерация с RemoveIndex внутри безопасна (tombstone не меняет длину) — покрыто тестами.

8. Полный код-ревью сервиса (2026-10-08) — коммит 5b0e89a

Разобран весь сервис: auth (SigV4 + JWT), router, billing, tenant, admin/UI API, создание очередей. Критичных проблем не найдено. Исправлено три замечания:

  1. TOCTOU лимита тенантов — TenantStore.Create: проверка MaxTenantsGlobal была только под RLock, вставка — позже под Lock без повторной проверки. Добавлена проверка под Lock (как уже было в CreateFromJWT).
  2. Потеря NubesSub при рестарте — CreateFromJWT: обновлённый NubesSub сохранялся в Redis только внутри ветки смены MaxQueues; теперь персист при любом реальном изменении, запись только если что-то изменилось.
  3. http.Client на каждый вызов — auth.PingNubesAPI переведён на общий клиент (keep-alive).

UI/безопасность проверены отдельно: listTenants/getTenant/IDOR для UI-тенанта — видит только себя, чужие {id} отсекаются, удаление через UI запрещено, SecretKey отдаётся только по явному /ui/api/credentials.