25 KiB
Код-ревью shared-sqs (Opus, 2026-10-10)
Ответ Opus на промпт «сделай код-ревью и посоветуй по архитектуре». Дословно, без правок.
Все файлы из списка прочитаны. Строки взяты из листингов, часть пунктов опирается на прочитанное ранее. Пункты, которые стоит перепроверить перед правкой, помечены «проверить». Теги [Ф1–Ф6] указывают на ваши фокусные области.
🔴 Критично: потеря данных, падение процесса, обход безопасности
- [Ф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. - [Ф1]
list_queue_tags.go:49-60отдаёт в ответ ссылку наqueue.Tags.RUnlockсрабатывает поdeferпри return, а XML-маршалинг идёт позже, в router. ПараллельныйTagQueueдаёт тот же fatal. Нужна копия мапы. - [Ф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и дедлок на старте.
- [Ф1, Ф3] Redis-I/O под глобальным локом.
delete_queue.go:45-56выполняет синхронныйDeleteQueueподSyncQueues.Lock. Комментарии «асинхронно» (:24,55) устарели. Redis-пауза 3–5 с останавливает все тенанты. То же вadmin.go:498иgosqs.go:107. - [Ф3] Подтверждённое сообщение теряется при сбое Redis.
send_message.go:157иsend_message_batch.go:133-139,153-158: при ошибкеSaveMessage(s)Syncоткатывается толькоRemoveIndex.DuplicatesиFIFOSequenceNumbersне откатываются,InitDuplicatationуже сработал.- Ретрай клиента с тем же dedupId получает 200 без сохранения.
- Таймаут Redis неоднозначен: запись могла пройти, и после рестарта появится фантом.
- [Ф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 ломается.
- Затронуты
- [Ф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.
- DeleteMessageBatch с пустым
ReceiptHandleудаляет все неполученные сообщения.delete_message_batch.go:79-97сопоставляет handle сканом. У невыданных сообщений handle""и совпадает с записью"".- Query-парсер (
requests.go:568) отсекает пустой handle, JSON-протокол — нет. - Одиночный delete безопасен: индекс пропускает
"".
- [Ф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 не запускается.
- [Ф3, Ф4] Старт с пустым состоянием.
goaws.go:103-106,125-134при недоступном Redis или ошибкеLoadAllQueuesпродолжает работу. СледомSaveQueueперезапишет Redis пустотой. Нужен fail-fast. - [Ф6] Тело подменяется при валидной подписи.
auth_middleware.go:166-169,234-243берётX-Amz-Content-Sha256из заголовка и не сверяет с реальным хешем тела. - [Ф6] JWT без проверки подписи.
jwt.go: идентичность берётся из непроверенного payload,Exp==0пропускается.- Валидность подтверждает GET на любой из трёх шлюзов, включая dev и test.
tenant_store.go:213-291: тенант находится по email из такого токена, возможен захват чужого тенанта.cache.go:68-75: при ошибке сети grace продлевает отозванный токен.
- [Ф3] Удалённый тенант воскресает.
redis.go:594-609DeleteTenantне чиститssq:tenant_ak, а read-through в auth (:63-74) его возвращает. Найденный тенант кэшируется навсегда. - [Ф5] OOM-потолок на уровне процесса.
- Лимит только по числу сообщений: 120k × 256 КБ = ~30 ГБ на одну очередь (
validation.go:43-44). - Лимитов по байтам или памяти нет ни на очередь, ни на тенанта, ни глобально.
- Размер атрибутов не входит в проверку (
MaxMessageAttributeSizeобъявлен и не используется). - Один тенант валит процесс всем.
- Лимит только по числу сообщений: 120k × 256 КБ = ~30 ГБ на одну очередь (
- [Ф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 нет. Ранее запущенные asyncSaveMessageмогут записать сообщение после 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-ссылки других очередей.
- нет проверки self-ссылки, цикла, совпадения FIFO-типа и диапазона
create_queue.go:73-85: повторный CreateQueue с другими атрибутами возвращает 200 вместоQueueNameExists.create_queue.go:66,set_queue_attributes.go:59: точное сравнение Content-Type. Дляjson-1.1или сcharsetwhitelist обходится.- Batch:
send_message_batch.go:87-103валидирует пакет целиком, вместо per-entryFailed.- Нет суммарного лимита 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;:158clock skew 15 мин (в AWS — 5);:215presigned не проверяет дату из будущего;:290canonicalURIзависит от нормализации 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;
:223jwtMiddlewareпропускает путь поHasSuffix("/auth");:263-267IDOR-защита только если вmux.Varsесть id;:409-420deleteTenant: in-flight handlers пишут в удалённые очереди;:194,207-212токен иSecretKeyотдаются в теле ответа;:640-669sendMessageToQueueбез лимита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 и биллинг батчей занижен.:297msgBytes— размер запроса, а не ответа.billing.go:- канал 1024, переполнение дропает записи;
db.Execбез таймаута;Close()обнуляетdbбез синхронизации с воркером (data race);- DSN собирается через Sprintf.
metrics:- label tenant: accessKey в gauge и
tenant.IDв counters, accessKey утекает в метрики; Reset()gauge даёт окно пустых значений;- label не чистится при удалении тенанта.
- label tenant: accessKey в gauge и
conf/config.go::111yaml-очереди кладутся без префиксаaccessKey:и обходят tenant-изоляцию;:189-196при пустой RedrivePolicyqueues[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.
Порядок перехода (по риску):
- Сначала дёшево, без смены архитектуры:
- сделать
SaveQueueсинхронным или write-behind с batch; - вынести FIFO-lock из сообщений в метаданные и персистить;
- сортировать при загрузке (по seq или
SentTime); - персистить receive, visibility и DLQ;
- fail-fast при старте без Redis;
- лимиты по байтам и по тегам.
- сделать
- Починить конкурентность:
- единый порядок локов (глобальный до per-queue, не наоборот);
- убрать I/O под глобальным локом;
- проверка живости очереди после
LockMu; - копии при отдаче map в ответ.
- Интерфейс
QueueStoreповерхSyncQueues. Сейчас handlers лезут вMessages, индексы иDuplicatesнапрямую, поэтому без интерфейса вынос невозможен. - Redis как источник истины:
- Lua-скрипты (или Streams) на send, receive, delete, change-visibility, DLQ-перенос;
- visibility через ZSET по времени;
- dedup через ключ с TTL;
- FIFO-групп-лок как ключ с lease.
- Sweeper вместо
PeriodicTasks, под отдельным локом и с fencing-токеном. - Снять leader-lock. До этого этапа HA остаётся «один активный, пассив безопасен только за
/ready». Для безопасного active/passive нужны fencing-токен, перезагрузка состояния при повышении, запрет записи без лидерства и lease-проверка на лидере.
Главный риск варианта A — сетевой round-trip на каждую операцию и сложность FIFO и DLQ в Lua. Дешёвая промежуточная ступень — шардирование по тенантам (вариант B) с оставленным in-memory ядром. Оно не даёт HA, но снимает потолок по RAM и тику.
Порядок исправления
- Fatal-гонки: п. 1–2 из 🔴.
- Lock order и I/O под глобальным локом: п. 3–4.
- Откат dedup при сбое Redis: п. 5.
- Пустой handle в DeleteMessageBatch: п. 8.
- Подпись, тело и JWT: п. 11–12.
- Fail-fast на старте и lease у лидера: п. 9–10.
- Лимит по байтам: п. 14.
- Персист receive, visibility и DLQ вместе с порядком при загрузке: п. 6–7.