Author SHA1 Message Date
Repinoid 4d4ebb3a7a chore: update ignore rules 2026-10-27 14:08:53 +03:00
Repinoid 716f931544 docs(history): деплой на 213, smoke 22/22, образы в Gitea 2026-10-11 10:05:51 +03:00
Repinoid 7860c7c3f2 build: registry → gitea.services.ngcloud.ru/nail (вместо Docker Hub) 2026-10-11 09:51:08 +03:00
Repinoid 4e648f690e docs(history): зафиксировать решения по оставленным пунктам (34/37/28=A) 2026-10-11 09:19:50 +03:00
Repinoid b864cab5be docs(history): внешнее ревью Claude Sonnet и исправления 2026-10-11 2026-10-11 09:14:12 +03:00
Repinoid f99f49bd8e persistence: порядковая нумерация async SaveMessage (старое состояние не перезапишет новое) + очистка при удалении 2026-10-11 09:12:42 +03:00
Repinoid fa47026400 SQS: dedup внутри цикла SendMessageBatch (NextSequenceNumber только для не-дубликата, откат dedup при ошибке персиста, per-entry DelaySeconds) 2026-10-11 08:58:30 +03:00
Repinoid a08d58cba1 SQS: DeletePersist возвращает ошибку (500 при сбое Redis), fail-fast загрузка очередей, дефолты атрибутов при CreateQueue, защита SqsErrors от nil-deref 2026-10-11 08:56:49 +03:00
Repinoid 5284a56f7a SQS: delete batch ключ по Id, валидация DelaySeconds 0-900 2026-10-11 08:54:21 +03:00
Repinoid 120a44cb6a SQS: sendMessageToQueue лимиты+sync persist, Reset чистит FIFO, nil-map Duplicates, ApproximateReceiveCount без +1, billing.Close после Shutdown, запрет ':' в access_key, recover в PeriodicTasks 2026-10-11 08:54:21 +03:00
Repinoid e8e0879914 SQS: ValidateQueueName в createTenantQueue, JWT требует exp, leader сбрасывает лидерство при длительном сбое refresh 2026-10-11 08:50:12 +03:00
Repinoid 582ad4a832 auth: сверять X-Amz-Content-Sha256 с фактическим хэшем тела 2026-10-11 08:35:57 +03:00
Repinoid 0512ea2389 billing: потокобезопасный db + корректный Close (остановка воркера до db.Close) 2026-10-11 08:35:56 +03:00
Repinoid dd9acb3705 gosqs: персистить перенос в DLQ (SaveMessage в DLQ + DeleteMessagePersist исходной) 2026-10-11 08:34:01 +03:00
Repinoid e720f1ffcb gosqs: запретить self/cyclic RedrivePolicy (иначе deadlock при переносе в DLQ) 2026-10-11 08:34:01 +03:00
Repinoid b179ec6de7 persistence: Client → atomic.Pointer (устранение data race при reconnect) 2026-10-11 08:30:58 +03:00
Repinoid 9e0ad2c0a4 persistence: DeleteTenant чистит и tenant_ak (отзыв access key) 2026-10-11 08:27:22 +03:00
Repinoid 09fe0f7e66 config: разблокировать SyncQueues при ошибке setQueueRedrivePolicy 2026-10-11 08:26:16 +03:00
Repinoid 24dbdaa766 admin: jwtMiddleware сравнивает точный путь /ui/api/auth (не суффикс /auth) 2026-10-11 08:26:16 +03:00
Repinoid 84ef8f1fc9 docs(history): AWS-compatibility work for Terraform provider (2.20) 2026-10-10 17:31:02 +03:00
Repinoid ccd070200a fix(fifo): ContentBasedDeduplication + dedup window survives delete (AWS semantics)
- ContentBasedDeduplication=true: dedupId computed from body SHA-256 when not provided
- do NOT drop dedupId on message delete: AWS keeps the 5-minute window regardless
  (previously re-send after delete delivered the message again)
- utils: GetSHA256Hash
2026-10-10 17:30:02 +03:00
Repinoid 9f1242aca3 feat(aws-compat): queue attributes for Terraform (FifoQueue, ContentBasedDeduplication, Policy, real timestamps)
- GetQueueAttributes now returns FifoQueue, ContentBasedDeduplication, Policy and
  real Created/LastModifiedTimestamp (previously missing/stubbed '0000000000')
- Queue: add CreatedAt, LastModifiedAt, Policy, ContentBasedDeduplication
- parse FifoQueue/ContentBasedDeduplication in both protocols (StringToBool)
- Policy/RedriveAllowPolicy: normalize JSON-string form to object (JSON protocol
  sent them as string -> SetQueueAttributes failed with InvalidParameterValue)
- persistence: keep new fields in meta DTO + backfill empty timestamps on load
- FIFO dedup is inherent: IsDuplicate/InitDuplicatation gate on IsFIFO only
  (env flag EnableDuplicates=false disabled dedup for all FIFO queues)
2026-10-10 17:29:58 +03:00
Repinoid 22fa2b162a docs(history): deploy of review fixes to VM 213 + functional verification of empty-ReceiptHandle fix 2026-10-10 17:14:14 +03:00
Repinoid bb6dbfd349 docs(history): record review fixes applied (2.18) and remaining work 2026-10-10 17:08:28 +03:00
Repinoid 4e2b462379 fix(startup): fail-fast when REDIS_ADDR set but Redis unreachable 2026-10-10 17:08:02 +03:00
Repinoid e21abb8f13 fix(security): JWT validate against prod gateway only + cap grace renewal
- jwt: drop test/dev gateway from nubesAPIEndpoints (test-stand token was accepted as prod)
- cache: grace bounded by graceDeadline from last successful validation (no more indefinite renewal)
2026-10-10 17:07:57 +03:00
Repinoid 521e3d85d0 fix(concurrency,durability): address Opus review critical findings
- create_queue: SaveQueue under per-queue lock, not global SyncQueues.Lock
  (fixes fatal concurrent map iteration/write on idempotent CreateQueue of existing FIFO queue)
- delete_message_batch: reject empty ReceiptHandle (JSON protocol bypass deleted all unreceived msgs)
- send_message/send_message_batch: register dedupId only after successful persist
  (retry with same dedupId no longer returns 200 without persisting)
- memory_guard: global HeapAlloc guard (SHARED_SQS_MAX_HEAP_MB, 0=off) in send paths
2026-10-10 17:07:52 +03:00
Repinoid ea7b539d19 docs(review): Opus code-review report verbatim + critical analysis (confirmed 🔴1/🔴5/🔴8, open JWT and byte-limit decisions) 2026-10-10 17:03:20 +03:00
Repinoid 073977286f docs(history): log Step 3 HA implementation, measurements and open questions 2026-10-10 16:32:21 +03:00
Repinoid 27fa2e6ae3 docs(ha): detailed active/passive HA design, measurements and migration path to option A 2026-10-10 16:30:19 +03:00
Repinoid d7b3c653e0 feat(ha): leader-election via Redis lock + /ready endpoint (active/passive, off by default)
- app/leader: Elector (SETNX lock + PEXPIRE heartbeat via Lua, IsLeader)
- app/persistence: TryAcquireLock/RefreshLock/ReleaseLock (no-op when Redis absent)
- app/router: /ready -> 200 if ReadyFunc nil/true, else 503 (both planes)
- app/cmd: --leader-election / SHARED_SQS_LEADER_ELECTION, --leader-ttl, --leader-interval;
  ReadyFunc wired only when enabled, lock released on graceful shutdown
- default OFF: single-instance behaviour unchanged
2026-10-10 16:30:15 +03:00
Repinoid 607aebb8ef doc: durability SendMessageBatch + billing (предусмотрен, выключен) 2026-10-10 16:19:54 +03:00
Repinoid a341508b6d fix(batch): SendMessageBatch пишет в Redis синхронно (как SendMessage) + не персистит дубли; SaveMessagesSync 2026-10-10 16:18:54 +03:00
Repinoid 064ed6e5d7 doc: Шаг 3 — дизайн выноса состояния + журнал (read-through тенантов) 2026-10-10 16:10:04 +03:00
Repinoid 8e22bc4b9b feat(step3): тенанты read-through Redis (индекс accessKey→tenant, fallback в auth, бэкфилл на старте) 2026-10-10 16:09:52 +03:00
Repinoid 1862f058be doc: Шаг 2 — разбиение data-plane/control-plane 2026-10-10 16:02:02 +03:00
Repinoid a26e6da8b7 feat(split): data-plane (публичный SQS API) и control-plane (админка/метрики/UI на localhost) — Шаг 2 2026-10-10 16:00:57 +03:00
Repinoid a66b908689 doc: прод-хардненинг (seed off, новый admin-token, logrotate) 2026-10-10 15:58:37 +03:00
Repinoid c4cc3c80ea doc: systemd + локальный Redis (устойчивость), дефекты seed, грабли 2026-10-10 14:24:50 +03:00
Repinoid f7fe2f26e5 fix(seed): идемпотентность (не плодить демо-тенанта) + персистентность очередей/сообщений в Redis 2026-10-10 14:23:34 +03:00
Repinoid cf369736a0 doc: QueueUrl исправлен (ExternalURLBase) + ошибки по пути; дефект снят 2026-10-10 14:04:04 +03:00
Repinoid c011099224 fix(queue-url): seed.go через gosqs.TenantQueueURL; env-override в applyEnvironmentDefaults (работает без goaws.yaml) 2026-10-10 14:03:44 +03:00
Repinoid f0e7cc905d feat(queue-url): ExternalURLBase для публичного QueueUrl (+ env SHARED_SQS_EXTERNAL_URL_BASE), тест 2026-10-10 13:44:22 +03:00
Repinoid ee5503f9ac doc: журнал сессии 2026-10-10 + справочник по публичной точке на ВМ (TLS, замеры, ошибки) 2026-10-10 13:42:22 +03:00
Repinoid 6800d1abf3 doc: убрать kube5s.ru (умирающий домен) из актуального migration-to-managed 2026-10-10 13:31:42 +03:00
Repinoid 7a2a7e1610 doc: мануал — уточнена ссылка на tenantQueueKey 2026-10-08 17:33:41 +03:00
Repinoid 3b0815155a doc: мануал для нового чата — состояние, решения, план шагов (ВМ -> разбиение -> хранилище), команды, грабли, карта кода 2026-10-08 17:33:03 +03:00
Repinoid 39b162439f doc: архитектурный анализ — публичный SQS, обход через ВМ, модульный монолит (data-plane/control-plane)
- SQS должен быть публичным (как AWS SQS/YMQ) -> внешний доступ обязателен;
- стратегия сети: сейчас публичная точка на ВМ (обход managed-шлюза), позже —
  девопсам чинить шлюз с доказательствами;
- 'без монолитов' уточнено: модульный монолит, критерии разбиения, data-plane vs
  control-plane, billing/metrics/UI — модули;
- ограничение: in-memory stateful-ядро = одиночка, вынос хранилища важнее;
- IoT: внешний MQTT-канал неизбежен, крупные тела мимо шлюза;
- открытые вопросы.
2026-10-08 17:09:21 +03:00
Repinoid b99905469d doc(HANDOFF): тикет отменён — болезнь платформы, решение через обход (ссылки на разборы) 2026-10-08 16:51:58 +03:00
Repinoid d8bdea75f3 doc: разбор применимости обхода drhider к SQS (прокси на ВМ, pull вместо push)
- почему схему 1:1 скопировать нельзя (клиенты на AWS SDK, синхронный протокол) ->
  аналог: прозрачный прокси на ВМ;
- условия: сохранение Host для SigV4 (auth_middleware.go:315-317), внешние QueueUrl
  (сейчас http://region.host:port из goaws.yaml, tenant_helpers.go:29-37),
  TLS, доступ ВМ->кластер, отключение буферизации;
- 4 варианта с оценкой (прокси / настройки ingress / MTU / ретраи) и таблица
  'что какой лечит';
- обязательные замеры до решения (раздел 5) и открытые вопросы.
2026-10-08 16:51:45 +03:00
Repinoid a1d72b61d4 doc: находки — как та же сетевая болезнь платформы обойдена в drhider (буфер на ВМ, pull вместо push)
Собраны и проверены по источникам все факты из ~/nubes/drhider:
- диагноз: ingress рвёт входящие тела >64КБ, egress не ограничен;
- схема пофайлового транзита через ВМ-буфер + egress-GET (pull);
- применённые фиксы платформы (cilium mtu:1400, keep-alive 10->75,
  proxy-body-size 1024m, HTTP/2 off) через ShturvalServiceConfig;
- ложные следы, лимиты, SSRF/path-traversal защиты, тесты 25/25;
- вывод: тикет в поддержку — не решение, нужен архитектурный обход.
2026-10-08 16:50:36 +03:00
Repinoid b947f1d467 doc: журнал — полный код-ревью сервиса и фиксы замечаний (2026-10-08) 2026-10-08 15:19:36 +03:00
Repinoid 572c617f44 fix(review): TOCTOU лимита тенантов + персист NubesSub + пакетный http.Client
1. TenantStore.Create: повторная проверка глобального лимита тенантов под Lock
   (ранняя была под RLock — гонка могла превысить MaxTenantsGlobal).
2. CreateFromJWT: обновлённый NubesSub теперь персистится в Redis (раньше
   сохранялся только внутри ветки смены MaxQueues и терялся при рестарте);
   запись в Redis только при реальном изменении.
3. auth.PingNubesAPI: общий http.Client (keep-alive) вместо нового на каждый
   вызов.
2026-10-08 15:19:15 +03:00
Repinoid 33a0adc9be refactor(P1.2-revue): Compact — один RebuildIndexes + ensureIndexes; журнал ревью-фиксов
Пункт 4 ревью: убран дублирующий блок перестройки индексов в Compact
(ensureIndexes на входе + один RebuildIndexes на выходе). Поведение то же,
логика чище. Дополнен HISTORY разделом о фиксах код-ревью.
2026-10-07 08:44:02 +03:00
Repinoid 2e1c594892 fix(P1.2-revue): батч-видимость не заводила в DLQ + TOCTOU лимит в SendMessageBatch
1. ChangeMessageVisibilityBatch при VisibilityTimeout=0 лишь инкрементировал
   Retry и НЕ переносил сообщение в DLQ (логика была только в одиночной версии).
   Вынесен единый метод Queue.ResetVisibilityAndTryDLQ — сброс видимости +
   DLQ-перенос. Обе версии привели к одному пути.
2. SendMessageBatch: проверка OOM-лимита была только по устаревшему снимку
   (RLock → Lock) — добавлена повторная проверка под per-queue Lock (TOCTOU).
3. Инварианты InFlightIndexes/ensureIndexes зафиксированы в док-комментариях.

Тест: TestChangeVisibilityBatch_MovesToDLQ (падает на старой логике, зелёный
на новой).
2026-10-07 08:35:52 +03:00
Repinoid f96649b14e doc: P1.2 сделан — результаты, найденный дефект (утечка per-queue блокировки) и прогоны тестов 2026-10-07 08:27:10 +03:00
Repinoid 529d208576 fix(P1.2): утечка per-queue блокировки при ReceiveMessage на пустой очереди
Возврат до defer queue.UnlockMu() оставлял блокировку захваченной навсегда —
очередь «зависала»: все последующие Lock/RLock ждали вечно. Проявлялось при
LiveCount()==0 (все сообщения удалены/tombstone). Найдено стресс-тестом
(2000 сообщений × циклы): стек горутин показал массовое ожидание RLockMu.

- receive: defer до проверки пустоты.
- регрессионный тест TestReceiveEmptyQueue_DoesNotLeakLock (падает на дефекте,
  проходит на фиксе).
2026-10-07 08:26:34 +03:00
Repinoid ad14fd2f7e test(P1.2): примитивы индексации — tombstone O(1), компакция, receipt/uuid-индексы, Reset 2026-10-07 08:06:07 +03:00
Repinoid c5b2c11f5d P1.2: индексация сообщений — tombstone-удаление O(1), receipt/uuid-индексы, tick по in-flight
- models.Queue: deadIdx/receiptIdx/uuidIdx/liveCount/deadCount + примитивы
  (AppendMessage/RemoveIndex/Compact/CompactIfNeeded/Reset/EnsureIndexes/
  IndexOfReceipt/IndexOfUUID/MarkInFlight/ClearInFlight/InFlightIndexes/LiveCount).
  Удаление — tombstone за O(1), физическая чистка амортизированная.
- DeleteMessage/ChangeMessageVisibility(±Batch): поиск по индексу вместо скана
  среза; удаление без сдвига остатка.
- DeleteMessageBatch: tombstone вместо полной пересборки среза.
- processQueueTick: visibility/DLQ по индексу in-flight (O(in-flight)),
  retention — tombstone, в конце — CompactIfNeeded.
- send-пути: AppendMessage + LiveCount (OOM-лимит по живым).
- admin/metrics/persistence: учёт живых сообщений, индексы после загрузки из Redis.
- Redis-схема не менялась (сообщения и так в per-message HASH).
2026-10-07 08:01:49 +03:00
Repinoid e735563a04 doc(P1.2): дизайн индексации сообщений (tombstone + индексы + амортизированная компакция) 2026-10-07 07:56:56 +03:00
Repinoid 31840b9756 fix: DLQ-релинк после загрузки из Redis + безопасная эвикция retention + ParseForm
1. DLQ больше не сериализуется вложенным объектом (была detached-копия после
   загрузки + неограниченная рекурсия при цикле): в метаданных хранится только
   имя (dead_letter_queue_name), после LoadAllQueues указатель релинкуется на
   объект из map. Закрывает потерянные переносы в DLQ и чужой мьютекс.
2. Retention-эвикция: компакция НА МЕСТЕ (чтение value в i, запись в n<=i)
   вместо kept := Messages[:0] — устранён алиасинг на переиспользуемом срезе.
3. countBatchEntries: явный req.ParseForm() — корректный msg_count для батчей.
Тесты: +DLQ-перенос, +эвикция с сохранением порядка; go test -race зелёный.
2026-10-07 07:18:31 +03:00
Repinoid d53aadecfa doc: P1.2 отложен с обоснованием (после P1.1 O(N) ограничен одной очередью) 2026-10-06 18:59:57 +03:00
Repinoid 8988293628 P2.4: единый путь создания очереди/сообщения (admin переиспользует gosqs)
Добавлены gosqs.BuildQueueForTenant (URL/ARN/дефолты/FIFO-детект как в CreateQueueV1)
и gosqs.BuildSqsMessage (UUID+MD5). admin.createTenantQueue и sendMessageToQueue
переведены на них: раньше UI создавал очередь без URL/ARN/IsFIFO и сообщение без MD5,
теперь поведение UI == SQS API + проверка размера при отправке.
2026-10-06 18:59:36 +03:00
Repinoid fd8ffc329d P1.1: per-queue блокировки вместо глобального лока на операции с очередью
Все операции с содержимым очереди (send/receive/delete/visibility/purge/attributes/
tags/admin/gauge/tick) переведены на per-queue RWMutex (QueueStore.GetLocked/
GetRLocked + queue.LockMu). Глобальный SyncQueues-лок остался только для map и
неизменяемых полей. DLQ-переносы берут лок DLQ отдельно (порядок main→DLQ).
marshalQueueMeta больше не мутирует очередь (DTO-снапшот). Устранён дедлок
create_queue (setQueueAttributesV1 вызывается вне глобального лока). Добавлены
конкурентные тесты; go test -race зелёный. Снимает боттлнек: синхронный Redis-I/O
больше не держит глобальный лок.
2026-10-06 18:58:16 +03:00
Repinoid ed17c6cbd0 P1.1 groundwork: per-queue mutex в Queue + анализ блокеров
Добавлены поле mu (не сериализуется) и LockMu/RLockMu-аксессоры в models.Queue;
исправлено копирование Queue by value (numberOfHiddenMessagesInQueue).
Глобальный лок пока используется везде — поведение не менялось.
Задокументированы блокеры полного перехода (DLQ-указатель, мутирующий
marshalQueueMeta, релинк DLQ после загрузки, атомарность перевода).
2026-10-06 18:39:26 +03:00
Repinoid 3a52960d5b P1.4: tenant-индекс очередей (O(1) подсчёт/список вместо скана)
Введён тип QueueStore с индексом accessKey→{queueName}. Мутации очередей идут через
AddQueue/RemoveQueue (create/delete/restore/config/seed/admin), подсчёт и список —
через CountTenantQueues/TenantQueueNames. Устранены O(total) скан-по-префиксу в
countTenantQueues, deleteTenant, listTenantQueues, createTenantQueue. API SyncQueues
(Queues, Lock) сохранён — остальной код не затронут.
2026-10-06 18:36:03 +03:00
Repinoid fb833ef01c P2.5: биллинг — воркер+буфер вместо горутины на операцию; корректный msg_count
RecordUsage теперь кладёт запись в буферизованный канал (1024), один воркер пишет
в PG — убран навал горутин, ждущих коннект пула (было 5). При переполнении буфера
запись дропается с логом (биллинг жертвуем ради стабильности). Для batch-операций
(SendMessageBatch/DeleteMessageBatch/ChangeMessageVisibilityBatch) msg_count теперь
реальное число записей, а не всегда 1.
2026-10-06 18:19:17 +03:00
Repinoid 07eace3c6b P2.8: Redis reconnect/health-check
Connect сохраняет параметры подключения и запускает фоновую проверку (ping каждые
30с). При обрыве Redis-клиент оставался не-nil и записи молча падали в лог; теперь
соединение восстанавливается автоматически через reconnect().
2026-10-06 18:18:06 +03:00
Repinoid 8c39bc5346 P2.1: BYOC Admin API — произвольные креды при создании тенанта
POST /admin/tenants теперь принимает опциональные access_key/secret_key. Если
задано любое из полей — оба обязательны (мин. длина 8/16). С произвольными
кредами tenantID генерируется случайно (CreateFixedAutoID). Без кредов —
поведение прежнее (store.Create).
2026-10-06 18:17:24 +03:00
Repinoid b7f79b388f P2.6: закрыть /metrics Bearer admin-токеном
/metrics отдаёт per-tenant данные (accessKey, число очередей и сообщений), что
является утечкой наружу на публичном endpoint. Теперь /metrics защищён тем же
Bearer admin-токеном (timing-safe сравнение). Скрепперу VM токен задаётся в конфиге.
2026-10-06 18:16:27 +03:00
Repinoid 2aa9a656a7 P2.7: убрать SNS-следы (Topics/Subscriptions) из goaws.yaml
Модели Environment уже не содержат Topics/SnsPort (форк вычистил SNS), но yaml
хранил легаси Topics и SNS-комментарии. Оставлен только SQS-конфиг.
2026-10-06 18:15:28 +03:00
Repinoid 876971eea8 P2.3: timing-safe сравнение admin bearer-токена
bearerAuthMiddleware теперь сравнивает Authorization через subtle.ConstantTimeCompare
(с предварительной проверкой длины), а не через != — устраняет timing side-channel
при подборе admin-токена.
2026-10-06 18:14:27 +03:00
Repinoid 424ec32a6a P1.5: убрать демо-бэкдор (публичный bypass-токен UI)
Удалены authenticateUIDemoToken, uiDemoToken/uiDemoTenantID/uiDemoEmail и их
константы (demo-ui-shared-sqs-ngcloud-2026) — публичный путь входа в UI мимо
JWT-валидации. seed.go теперь создаёт demo-тенанта со СЛУЧАЙНЫМИ кредами
(tenant.Create), без захардкоженного SecretKey в исходнике. Тест
TestJWTAuth_AllowsDemoToken заменён на TestJWTAuth_RejectsMissingToken.
2026-10-06 18:13:40 +03:00
Repinoid db41878951 P1.3: реализован MessageRetentionPeriod (эвикция просроченных сообщений)
processQueueTick теперь эвиктит сообщения старше retention (SentTime + retention < now):
удаляет их из очереди и из Redis (DeleteMessagesPersist), разблокируя FIFO-группы.
Retention == 0 означает отсутствие истечения. PeriodicTasks итерируется по (key, queue),
чтобы передать queueKey для удаления из Redis. Устраняет забивание очередей до OverLimit.
2026-10-06 18:10:57 +03:00
Repinoid 5642b893a8 chore: удалить мёртвые унаследованные тесты gosqs (несовместимы с мультитенантным форком)
13 файлов *_test.go требовали отсутствующие в git пакеты app/fixtures и
app/test, и даже с ними падали бы: написаны под дофорковый GoAws, где не было
тенанта в контексте и tenant-scoped ключей {accessKey}:{queueName}.
Причина зафиксирована в doc/thinking/tests-inherited-broken-2026-10-06.md.
2026-10-06 18:09:29 +03:00
Repinoid ac06572e25 doc: находка — унаследованные Go-тесты gosqs мёртвые (несовместимы с форком)
Тесты требуют тенанта из контекста и tenant-scoped ключей, а написаны под
дофорковый GoAws. Восстановление app/fixtures + app/test сделает их
компилируемыми, но падающими. Нужны новые тесты под мультитенантное поведение.
2026-10-06 18:07:31 +03:00
Repinoid 12b25e393d P0.2: JWT принимается только при явном 2xx от Nubes API
Раньше любой статус, кроме 401/403 (включая 5xx/429/редиректы), считался
подтверждением токена. Теперь валидность подтверждается только явным 2xx;
401/403 — отклонён, прочие статусы — ошибка стенда (пробуем следующий).
2026-10-06 18:04:42 +03:00
Repinoid c19a056de3 P0.3: синхронный персист SendMessage до ответа 200
Добавлен persistence.SaveMessageSync (синхронный HSet, возвращает ошибку).
SendMessage теперь пишет сообщение в Redis ДО возврата клиенту; при ошибке Redis
сообщение откатывается из памяти и возвращается InternalError (новая ошибка в
models/errors.go). Устраняет окно потери подтверждённого сообщения при краше пода
между 200 OK и асинхронной записью.
2026-10-06 18:04:22 +03:00
Repinoid 928a3fb85d P0.1: случайные креды тенанта — убран детерминированный вывод из email
CreateFromJWT больше не выводит ID/AccessKey/SecretKey из email через SHA256
с захардкоженной солью (это позволяло вычислить чужие креды по публичному email).
Креды генерируются случайно через crypto/rand. Удалены tenantIDFromEmail,
accessKeyFromEmail, secretKeyFromEmail, secretKeySalt и ветка legacy-пересчёта.
Идемпотентность по email сохранена (повторный логин возвращает тот же тенант).
2026-10-06 18:03:40 +03:00
Repinoid 6e96a4d007 doc(sqs): подробный план доработок P0/P1/P2 для DeepSeek Flash 2026-10-05 21:38:03 +03:00
Repinoid 9528441c97 doc(sqs): мой критический анализ отчёта Opus (перепроверка по коду) 2026-10-05 21:38:03 +03:00
Repinoid e8ccd8bae1 doc(sqs): промпт для Opus (allowlist файлов) + его отчёт КРАКО и план 2026-10-05 21:38:03 +03:00
“Naeel” 60b15fdb1c doc: HANDOFF-NEW-CHAT.md — резюме сессии для перехода в новый чат 2026-08-16 07:55:43 +04:00
“Naeel” 7897fda9e7 doc: тикет — дополнение: таймера 31с внутри кластера нет, он на внешнем шлюзе 2026-08-15 21:28:55 +04:00
“Naeel” 96f6d64184 doc: черновик тикета в Nubes (явления A/B, 6 доказательств); все фазы плана выполнены 2026-08-15 21:17:17 +04:00
“Naeel” 6d451c33d1 HISTORY: период таймаутов 32.9с (18 интервалов 32.7-33.0с) — таймер доказан; полная проверка скриптов и кредов OK; +tcp_mtu_probe.py 2026-08-15 20:38:01 +04:00
“Naeel” 5c2180d40e doc: финал плана (решения зафиксированы) + сравнение четырёх агентов 2026-08-15 15:05:12 +04:00
“Naeel” cd7f5a7d5a doc: план v4 от Опуса 4.8 + ответы на уточнения (нет второго ISP, NET_RAW вряд ли, echo отложить) 2026-08-15 15:03:33 +04:00
“Naeel” 5f6c0a5bf7 doc: план v3 от Соннета — разделение явлений A/B, port-forward первым; принят за основу 2026-08-15 14:56:13 +04:00
“Naeel” b91f21a50d doc: план v2 от DeepSeek V4 Pro (новый чат) — стратегия изоляции слоёв; сравнение с Flash 2026-08-15 14:50:02 +04:00
“Naeel” bd63c04ea3 doc: план сетевых экспериментов от DeepSeek V4 Flash (не Соннет) с поправками — nubes-network-bug-plan 2026-08-15 14:41:53 +04:00
“Naeel” 7460f59432 tests: 30-мин сравнение с локали — p50 90мс vs 140мс, но 49 таймаутов у shared (5.5%), YMQ 1 ошибка 2026-08-15 13:01:11 +04:00
“Naeel” 9950271259 tests: сравнение shared-SQS vs YMQ — p50 6-8мс vs 60-62мс; у shared выброс send 12-25с на прогон (платформа?) 2026-08-15 12:10:11 +04:00
“Naeel” 904d34904c tests: fifo_dlq_probe — опрос DLQ до 10с вместо мгновенной проверки (гонка с тиком); финальная регрессия зелёная 2026-08-15 09:31:11 +04:00
“Naeel” 2d47e86425 HISTORY: полная регрессия на ВМ — api_test 22/22, sdk_test 15/15; флап fifo_dlq_probe разобран (гонка теста, сервис 5/5) 2026-08-15 09:25:32 +04:00
“Naeel” cc20282ab0 HISTORY: P1.4/P2.5/P3.8 PASS, фикс api_test; +cli_load_test.py 2026-08-15 09:17:04 +04:00
“Naeel” 28f8527f8b tests: change-visibility 5с вместо 1с — гонка с таймером AWS-семантики 2026-08-15 09:07:24 +04:00
“Naeel” 9f69262b8c v0.1.35: фиксы — синхронные удаления в Redis (воскрешение очередей) + RedrivePolicy tenant-scoped DLQ 2026-08-15 08:57:25 +04:00
“Naeel” c66ac28766 HISTORY: FIFO/DLQ e2e — FIFO PASS, найден баг RedrivePolicy (tenant-scoped ключ) 2026-08-15 08:43:10 +04:00
“Naeel” 33735b0deb HISTORY: P0.1 таймауты + P0.2 рестарт PASS + найден дефект воскрешения удалённых очередей 2026-08-15 08:41:58 +04:00
“Naeel” dcafb1ad71 v0.1.35: серверные таймауты по Соннету — ReadHeaderTimeout вместо ReadTimeout, MaxHeaderBytes 2026-08-15 08:34:02 +04:00
95 changed files with 7075 additions and 3948 deletions
+96
View File
@@ -0,0 +1,96 @@
# HANDOFF для нового чата — shared-sqs (состояние на 2026-08-16)
> Этот файл — резюме длинной сессии 13–16.08.2026. Новый чат: прочитать
> этот файл + HISTORY/2026-08-14-session-log.md + doc/thinking/.
## 1. Что за проект
`shared-sqs` — SQS-совместимая очередь (AWS API), Go 1.23, multi-tenant,
форк GoAWS. Живёт на платформе Nubes (managed Kubernetes, realm iot-naeel).
Локально: `/home/naeel/nubes/SQS-service`, зеркало на ВМ:
`naeel@5.172.178.213:~/terra/SQS-service` (ssh-ключ `~/.ssh/naeel_vm_id_ed25519`).
**⛔ ГРАНИЦЫ ПРОЕКТА (записано в память):** работать ТОЛЬКО с локальной папкой
и её зеркалом на ВМ. Никуда больше без прямого указания пути.
## 2. Статус сервиса
- Прод-версия **v0.1.35** задеплоена по digest (Docker Hub `naeel/shared-sqs`).
- Все тесты зелёные: api_test 22/22, sdk_test 15/15, fifo_dlq_probe PASS.
- План Соннета выполнен: P0.1, P0.2, P1.4, P2.5, P2.6, P3.7, P3.8 — все PASS.
- Нагрузка 168 591 операция — 0 сервисных ошибок.
- Память стабильна 2ч (VmRSS 16588→15648 kB, Threads 13).
## 3. Главная открытая проблема — ПЛАТФОРМА, не код
1. **Таймауты каждые 31–33с** на внешнем пути (интернет→шлюз Nubes): ~5.5%
запросов не получают ответ 30с. Доказано серверным tcpdump (8 «дыр» по
29.5с: запросы не доходят до пода), nginx чист (max 68мс, 499=0), cilium
0 дропов, port-forward 24977 раундов 0 сбоев. Таймер — на ВНЕШНЕМ шлюзе,
вне кластера.
2. **MSS=1448** анонсируется шлюзом при underlay MTU 1450 → большие тела
(>~1400 байт) виснут ~51с (PMTUD сломан).
3. Готов тикет: `doc/thinking/nubes-ticket.md` — ОТПРАВИТЬ в поддержку Nubes.
## 4. Что мы починили в кластере (16.08, по команде пользователя)
- kube-vip: `rollout restart ds shturval-vip` — ушли ошибки leaderelection в
удалённый namespace `0a42bef3-...` (хвост июльского сервиса пользователя).
- provider LB: добавлен `cidr-global: 185.247.187.151/32` в ConfigMap
`kubevip` в **kube-system** (provider читает именно оттуда,
env KUBEVIP_NAMESPACE=kube-system). «Ensured load balancer» — OK.
- После починки таймауты НЕ ушли (тест: 19 сбоев, период 31.3–33.5с) —
подтверждение, что таймер на внешнем шлюзе.
## 5. Сравнение с Yandex YMQ (очередь `newsqs`)
- Наш p50: 6–8мс (с ВМ) / 89–90мс (с локали). YMQ: 60–62мс / 138–150мс.
- YMQ стабилен (1 ошибка на 959), у нас — таймауты платформы (см. п.3).
- YMQ-доступ восстановлен: SA `newsqs` (роль ymq.admin), креды в
`secrets/yandex_ymq_newsqs.txt` (имена переменных AWS_ACCESS_KEY_ID/
AWS_SECRET_ACCESS_KEY). Очередь: URL в HISTORY.
- Повторный бенчмарк — ПОСЛЕ фикса шлюза Nubes.
## 6. Ключевые доступы
- Креды shared-SQS (тенант t-fec713a719ad0b33):
AK `SSAK-fec713a719ad0b33c91fa54a`,
SK `e898ed410a20ff166f51a52ba39a2954fb87e2da200b1648c3975772bfe9e2b4`.
- Внешний endpoint: `https://sqs.containerk8s.dev.nubes.ru` (регион us-east-1).
- Внутренний (из подов кластера): сервис `containerk8s` в ns
`f1ffb134-7d16-45bd-8bef-69f6ec8ab33c`, порт 4100.
- Gitea: `gitea.services.ngcloud.ru/Nail/SQS-service` (токен — в secrets/gitea.txt).
- kubeconfig на ВМ протухает ~раз в сутки (~26ч) — обновлять через консоль
Nubes (пользователь копирует в буфер, на ВМ: `xclip -selection clipboard -o
> ~/.kube/config`).
- ⚠️ boto3/aws CLI: ВСЕГДА передавать креды явно (env терминала копит чужие
AWS_ACCESS_KEY_ID — был ложный 403 с кредами Яндекса).
## 7. Хвосты / TODO
1. ~~Отправить тикет Nubes (файл готов).~~ **ОТМЕНЕНО 2026-10-08:** это болезнь
платформы; в соседнем проекте drhider ту же проблему не «починили», а обошли
(внешний буфер + исходящий pull). Разбор: `doc/thinking/nubes-large-body-workaround-findings-2026-10-08.md`,
применимость к SQS: `doc/thinking/nubes-workaround-applicability-to-sqs-2026-10-08.md`.
Тикет оставлен только как история.
2. Удалить дубликат `secrets/yandex_ymq_fork8s.txt` (одинаков с newsqs.txt) —
ждёт команды пользователя.
3. Повторный 30-мин бенчмарк vs YMQ и обновление BENCHMARK-COMPARISON — только
после того, как внешний путь перестанет рваться (обходной прокси, см. п.1),
либо как есть, с оговоркой про таймауты шлюза.
4. Эхо-сервис (Фаза 3 планов) не понадобился — доказательств хватило.
## 8. Тестовая инфраструктура (tests/)
- `run_full_regression.sh` — полная регрессия (api_test + sdk_test + fifo_dlq).
- `long_compare_local.py` — 30-мин сравнение с YMQ с таймстемпами.
- `compare_ymq_vs_shared.py` — короткое сравнение.
- `tcp_mtu_probe.py` — бинарный поиск MTU-порога (порт 443! 32391 снаружи закрыт).
- Планы 4 агентов: doc/thinking/nubes-network-bug-plan*.md.
## 9. Правила пользователя (обязательные)
- Без «делай» — ничего не менять, только отвечать/смотреть.
- Каждый шаг и результат — в HISTORY сразу; коммит после правок.
- Команды только с таймаутами. Никаких оценок — считать/мерить.
- Часовой пояс GMT+03. sed запрещён (только редакторы файлов).
+151
View File
@@ -0,0 +1,151 @@
# История правок 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`:
```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`.
+290
View File
@@ -0,0 +1,290 @@
# История правок shared-sqs — 2026-10-10 (публичная точка на ВМ, TLS, замеры)
> Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24).
> Предыдущие: `HISTORY/2026-10-07-session-log.md`, `doc/thinking/*-2026-10-08.md`.
> Сессия велась двумя моделями: сначала DeepSeek Flash, затем DeepSeek Pro (архитектор) —
> Pro перепроверял выводы Flash, часть из них оказалась неверной (см. раздел 4).
## 1. Задача
Вынести публичную точку SQS на ВМ `5.172.178.213` (обход больного managed-шлюза) и
**проверить гипотезу** из `MANUAL-NEXT-2026-10-08.md` (Шаг 1): нет ли на пути
«интернет → ВМ» той же сетевой болезни (зависания ~51 с, таймауты 31–33 с).
Требования владельца по пути: домен — только свой (`sinefo.ru`); `kube5s` из проекта убрать.
## 2. Хронология (что делалось и что получалось)
### 2.1. Сборка и запуск на ВМ
- `make build` → `shared-sqs` v0.1.35 (статический, x86_64, 17 МБ).
- `scp` → `naeel@5.172.178.213:~/sqs-test/shared-sqs`.
- Запуск memory-only: `SHARED_SQS_SEED_DEMO=true ./shared-sqs --admin-token=dev-token-123 --port=4100`.
- Локально на ВМ: `/health` → `200 {"status":"ok","version":"v0.1.35"}`;
`ListQueues` без подписи → `403 MissingAuthenticationToken` (корректно).
- ⚠️ Предупреждение `Failure to find default config file` — `goaws.yaml` на ВМ нет,
приложение работает на встроенных дефолтах (см. 5.1).
### 2.2. Разведка ВМ
- Публичный адрес — `5.172.178.213` (NAT); внутренний `10.10.102.21` (`ens192`), hostname `naeel-vm`.
- Снаружи прилетают только 80/443. Проверка портов:
- `8443` — занят DNAT: `iptables -t nat`: `--dport 8443 -j DNAT --to-destination 95.179.252.111:443`
(сервис `vpn-transit-dnat`, `enabled/active`) → весь трафик на 8443 уходит на Vultr **до** ВМ.
- `8888`/`9443` — `/dev/tcp` показывает «OPEN», но локально **никто не слушает** и HTTP не отвечает
(curl timeout) → это SYN-proxy провайдерского firewall, порты непригодны.
- `/etc/hosts` на ВМ содержит внутренние имена: `gitea.mgmt.nubes.ru`, `sqs.containerk8s.dev.nubes.ru`
→ `185.247.187.151`, `tf-registry.containerk8s.services.ngcloud.ru`, `s3.msk-1.ngcloud.ru`.
Резолвер ВМ — публичный (`8.8.8.8`, `1.1.1.1`), поэтому имена `*.services.ngcloud.ru` не резолвятся.
### 2.3. Блокер nginx (возник 10-08, снят 10-10)
- 10-08: `sudo nginx -t` **падал**: `host not found in upstream "contractor.pythonk8s.services.ngcloud.ru"
in sites-enabled/check.kube5s.ru:44` — внутреннее имя не резолвится.
- 10-10 (после ребута ВМ): конфиг `check.kube5s.ru` оказался **изменён** (catch-all `location /`
на contractor убран, добавлен `location /rf`) → `nginx -t` **проходит**, nginx перезагружаем.
- Следствие: переход к рабочему пути упростился (сначала была построена схема со вторым nginx, см. 2.4).
### 2.4. Второй nginx на 10443 (сделан, затем убран)
- Владелец пробросил порт: firewall `[22, 80, 443, 4443, 10443]`.
- Проверено: `10443` реально доходит до ВМ (`/health` 200), `4443` — нет.
- Поднят **отдельный** nginx: `/etc/nginx-sqs/nginx.conf`, pid `/run/nginx-sqs.pid`,
`listen 10443` → `proxy_pass http://127.0.0.1:4100`. Запуск `sudo nginx -c /etc/nginx-sqs/nginx.conf`.
### 2.5. Замеры (гипотеза ПОДТВЕРЖДЕНА)
| Тест | Инструмент | Результат |
|---|---|---|
| HTTP, 30 запросов 1КБ/10КБ/100КБ/256КБ | `curl --aws-sigv4` | 30/30 **200**, max **0.99 с** |
| HTTPS, 10 запросов 26КБ…260КБ | `curl --aws-sigv4` | 10/10 **200**, 0.7–3.2 с |
| Long-poll `ReceiveMessage` `WaitTimeSeconds=20` | `curl` | **20.3 с, 200** (корректно) |
Ни зависаний ~51 с, ни таймаутов 31–33 с. **На пути «интернет → ВМ 213» болезни нет.**
### 2.6. DNS
- `sinefo.ru` делегирован в реестре .ru на `ns3.ngcloud.ru` / `ns4.ngcloud.ru` (проверено через `a.dns.ripn.net`).
- `sqs.sinefo.ru A 5.172.178.213` — публично резолвится. Ранее запись была `185.247.187.83`,
что = **IP самого DNS-сервера** `ns3.ngcloud.ru`, а не ВМ (исправлено владельцем).
### 2.7. TLS (Let's Encrypt)
- `sudo certbot certonly --webroot -w /var/www/acme -d sqs.sinefo.ru --non-interactive --agree-tos`
(аккаунт LE на ВМ уже существовал → без нового e-mail) → `/etc/letsencrypt/live/sqs.sinefo.ru/`.
- Срок: до 2027-01-08, автопродление настроено.
- ACME-челлендж обслуживается главным nginx (vhost 80 с `location /.well-known/acme-challenge/`).
### 2.8. Публичная точка (итог)
- Главный nginx, `/etc/nginx/conf.d/sqs-sinefo.conf`:
- **80**: ACME-челлендж + редирект на https;
- **443 ssl**: сертификат `sqs.sinefo.ru` + `proxy_pass http://127.0.0.1:4100`, `Host $http_host`.
- Проверено снаружи: `https://sqs.sinefo.ru/health` → 200 (cert valid), SigV4, 256 КБ за 1.05 с.
### 2.9. Уборка
- Второй nginx (10443) остановлен, `/etc/nginx-sqs` удалён; порт 10443 свободен.
- `conf.d/sqs-sinefo.conf` — заменён на нормальный vhost (было: HTTP-only левак от 10-08).
- Из **актуального** дока `doc/2026-08-13-migration-to-managed.md` убран `kube5s.ru`
(квинтет: `iot.kube5s.ru`→`iot.nubes.ru`, `qu.kube5s.ru`×3→`sqs.containerk8s.dev.nubes.ru`),
коммит `a47743f`. В `doc/legacy/` (история) не трогал.
### 2.10. Фикс QueueUrl (кодинг)
- Проблема: `QueueUrl` = `http://region.host:port/...` (плейсхолдеры) — клиенты без `endpoint_url` сломаны.
- Реализовано: `Environment.ExternalURLBase` + env `SHARED_SQS_EXTERNAL_URL_BASE`.
- **Ошибка по пути №1:** env-override сначала поместил после `models.CurrentEnvironment = envs[env]`
— не сработал: `LoadYamlConfig` делает ранний `return` без `goaws.yaml`. Перенёс в
`applyEnvironmentDefaults` (вызывается через defer → всегда).
- **Ошибка по пути №2:** после переноса всё равно не работало — демо-seed (`app/cmd/seed.go`) собирал
URL **своей копией** логики, а не через `tenantQueueURL`. Вынес `gosqs.TenantQueueURL`, seed переведён на него.
- Коммиты `469132b`, `ca0372b`; тест `app/gosqs/tenant_helpers_test.go`. Проверено снаружи:
`list-queues` → `https://sqs.sinefo.ru/t-.../orders`.
### 2.11. Устойчивость: systemd + локальный Redis
- Установлен `redis-server` (7.0.15) на 213, включён AOF (`appendonly yes`).
- systemd-юнит `shared-sqs.service` (`Restart=always`, `enable`) + `/etc/shared-sqs.env`.
- Проверено: сообщение пережило рестарт; `systemctl is-enabled/active` → enabled/active.
- **Дефекты seed (обнаружены и исправлены, коммит `c0aa618`):** демо-очереди не персистились;
каждый старт плодил нового демо-тенанта (утечка при `Restart=always`). Фикс: `SaveQueue`+
`SaveMessages` + идемпотентность через `store.List()`.
- Управление: `sudo systemctl {status,restart,stop} shared-sqs`.
### 2.12. Прод-хардненинг
- `SHARED_SQS_SEED_DEMO=false`; admin-token сменён на случайный 64-hex (в `/etc/shared-sqs.env`);
logrotate для `sqs.log`.
- Billing/JWT не настроены. Проверено: сервис работает на существующем тенанте из Redis.
### 2.13. Шаг 2 — разбиение data-plane / control-plane
- `router.NewDataPlane` (публичный SQS API) + `router.NewControlPlane` (админка/метрики/UI).
- `main`: два листенера — `0.0.0.0:4100` (публично) и `127.0.0.1:4101` (control-plane, только локально).
- Проверено: публично `/admin`/`/metrics` → 403 (наружу не доступны), SQS API работает; локально
`4101/admin` → 200. Коммит `c1520c0`.
- Примечание: пока один процесс с двумя листенерами (общее in-memory состояние); два процесса —
после Шага 3.
### 2.14. Шаг 3 — начало: дизайн + тенанты read-through Redis
- Дизайн: `doc/thinking/step3-state-extraction-design-2026-10-10.md` (варианты A/B/C, рекомендация
C→A, что замерить).
- Код (коммит `779a71d`): Redis-индекс `ssq:tenant_ak` (accessKey → JSON тенанта) + бэкфилл при старте;
auth-middleware читает тенанта из Redis при промахе локальной памяти.
- Нужно для ЛЮБОГО multi-instance варианта; в одном процессе — страховка.
- Проверено: индекс заполнен (`hlen ssq:tenant_ak` = 1), регрессий нет.
### 2.15. Durability batch + billing
- `SendMessageBatch` переведён на `SaveMessagesSync` (было async) + не персистит дубли (коммит `3559414`).
- Проверено: batch в пустую очередь пережил рестарт.
- Billing — предусмотрен (модуль есть), но выключен (`BILLING_PG_HOST` не задан). Решение: включить позже.
### 2.16. Шаг 3 — HA active/passive (leader-election)
- Выбран **вариант C** (active/passive через Redis-лок) вместо полного выноса состояния (A) —
обоснование: причина простоя — недоступность ВМ, а не CPU; C стоит ~200 строк и не трогает ядро
очередей. Подробно: `doc/thinking/state-extraction-and-ha-2026-10-10.md`.
- Код (коммит `bc00643`):
- `app/persistence/redis.go`: `TryAcquireLock` (SETNX+PX), `RefreshLock`/`ReleaseLock`
(Lua: только если значение наше). При `Client == nil` — всегда `true` (мы одни).
- `app/leader/leader.go`: `Elector` — цикл «продлить/взять лок» каждые 3 с, TTL 10 с, `IsLeader()`.
- `app/router/router.go`: `/ready` (в обоих плоскостях) — 200 если готов, 503 если пассивный;
поведение задаётся пакетной `ReadyFunc`.
- `app/cmd/goaws.go`: `--leader-election` (или env `SHARED_SQS_LEADER_ELECTION=true`),
`--leader-ttl`, `--leader-interval`; `ReadyFunc` ставится только при включении;
лок отпускается при graceful shutdown. **По умолчанию выключено — поведение не изменилось.**
- **Замеры (не гипотезы), ВМ 213, два временных инстанса 4200/4210:**
- одна копия → `/ready` `200`, вторая → `503` (split-brain нет); `redis-cli get ssq:leader`
= `<hostname>-<pid>` лидера;
- `kill -9` лидера → пассивный стал лидером, `/ready` → `200`, перехват занял **~12 с**
(максимум по формуле `ttl+interval` = 13 с);
- прод-API после деплоя: `list-queues` / `send-message` / `receive-message` через
`https://sqs.sinefo.ru` — OK; временные инстансы остановлены, ключ `ssq:leader` удалён.
- **В проде выключено**: для включения нужны общий Redis + вторая ВМ + LB с проверкой `/ready`
(иначе пассивный получит трафик и вернёт «пустые» очереди — главный риск варианта C, описан в доке).
- Док (коммит `865db49`): почему C, механика, замеры, ограничения, пошаговое включение, откат,
путь к варианту A (5 шагов) и триггеры перехода.
### 2.17. Код-ревью Opus + критический разбор
- Дал промпт Opus: полное код-ревью + советы по архитектуре. Дословный ответ:
`doc/thinking/opus-report-2026-10-10.md`; критический разбор + что подтверждено:
`doc/thinking/opus-report-analysis-2026-10-10.md`.
- Opus указал ~50 пунктов (🔴/🟡/🟢). Моя оценка: силён по конкуренции/durability, но смешивает
баги / осознанный дизайн / отклонения от семантики AWS; часть 🔴 надо понизить.
- **Подтверждено по коду (чинится без владельца):**
- 🔴1 фатал `concurrent map iteration and map write`: `create_queue.go:84` зовёт `SaveQueue`
под `SyncQueues.Lock` без `queue.mu`; `SaveQueue`→`json.Marshal` итерирует `Duplicates`/`FIFO*`/`Tags`,
а мутаторы держат только `queue.mu`. Условие: идемпотентный CreateQueue существующей FIFO-очереди + SendMessage.
- 🔴5 неполный откат dedup при сбое `SaveMessageSync` (`send_message.go:151-160`) → ретрай 200 без сохранения.
- 🔴8 пустой `ReceiptHandle` в DeleteMessageBatch удаляет все невыданные сообщения — **только
через JSON-протокол** (`x-amz-json-1.0`); Query-форма защищена (`requests.go:568` не на этом пути).
- Порядок локов + I/O под глобальным локом; fail-fast на старте без Redis отсутствует.
- **Нужны решения владельца:**
- JWT: проверяет ли шлюз подпись (trusted-perimeter из `jwt.go:7-11`)? Независимо чинятся:
три шлюза prod/test/dev → prod; потолок grace в `cache.go:68-75`; идентичность по `sub`, не email.
- Лимит по байтам (OOM, 🔴14): нужны RAM ВМ + число тенантов → cap на тенанта/очередь + глобальный предохранитель по RSS.
- Порядок исправления — §5 дока анализа.
### 2.18. Правки по ревью (применено, 3 коммита)
- `654e315` — 🔴1 (SaveQueue под queue.mu), 🔴8 (пустой ReceiptHandle), 🔴5 (dedup только после
успешной записи, single+batch), глобальный предохранитель HeapAlloc (`memory_guard.go`,
`SHARED_SQS_MAX_HEAP_MB`, 0=выключен).
- `1230149` — JWT: только prod-шлюз (убран test/dev); grace в `cache.go` ограничен `graceDeadline`
от последней успешной проверки (не продлевается бесконечно).
- `2187027` — fail-fast: при заданном `REDIS_ADDR` и недоступном Redis → `log.Fatalf`.
- build + vet + test — зелёные. Не задеплоено на ВМ (жду команды).
- НЕ сделано (нужны решения владельца): цифры лимита по байтам (RAM/тенанты); идентичность
JWT по `sub` вместо email (зависит от того, проверяет ли шлюз подпись). Порядок локов и I/O
под глобальным локом — осталось (более крупный рефакторинг).
### 2.19. Деплой правок на ВМ 213 + проверка
- Обновлён `/home/naeel/sqs-test/shared-sqs` (prev-бинарник сохранён, лог бэкапнут
`sqs.log.before-deploy-*`), сервис `active`, `/health` 200, `/ready` 200.
- В логе подтверждено: `leader-election выключен`, два листенера (4100 public / 4101 localhost),
fail-fast не срабатывал (Redis доступен).
- **Функциональная проверка 🔴8 (изолированная очередь `probe-empty-handle`):**
- отправлено 2 сообщения (не выдавались → `ReceiptHandle == ""`), `ApproximateNumberOfMessages` = 2;
- `delete-message-batch` с `ReceiptHandle: ""` → ошибка
`AWS.SimpleQueueService.InvalidParameterValue`, **сообщения остались (2)** → фикс работает;
- CLI v2 подтверждённо шлёт JSON-протокол (ошибку отдал наш новый валидатор, а не парсер формы);
- легитимный `delete-message-batch` по реальному handle → `Successful`, счётчик 2→1;
- probe-очередь удалена.
- Регрессий нет: `list-queues`, `send-message`, `receive-message` через `https://sqs.sinefo.ru` — OK.
### 2.20. AWS-совместимость для Terraform-провайдера (цель дня уточнена владельцем)
Контекст: это **не глобальный прод с высокой нагрузкой**; цель — рабочие SQS+IoT и **Terraform-провайдер**,
чтобы юзеры Nubes подключали сервисы в своих конфигурациях. Приоритет — **надёжность и соответствие
AWS**, а не масштабирование. Поэтому HA/перф-оптимизации отложены, в фокус — совместимость.
- `95bce88` — атрибуты очереди для провайдера: `GetQueueAttributes` теперь отдаёт `FifoQueue`,
`ContentBasedDeduplication`, `Policy` и **реальные** `CreatedTimestamp`/`LastModifiedTimestamp`
(были отсутствующие/заглушка `0000000000`); в `Queue` добавлены `CreatedAt`/`LastModifiedAt`/`Policy`/
`ContentBasedDeduplication`; разбор FIFO-атрибутов в обоих протоколах (`StringToBool`);
`Policy`/`RedriveAllowPolicy` нормализуются из строки в объект (JSON-протокол присылает строку →
раньше `SetQueueAttributes` падал с `InvalidParameterValue`); бэкфилл пустых timestamps при загрузке.
- `988a6a6` — FIFO-дедупликация по AWS: дедуп — свойство FIFO (гейт по `IsFIFO`, а не по env-флагу
`EnableDuplicates=false`, из-за которого дедуп не работал вообще); `ContentBasedDeduplication`
считает dedupId из тела (SHA-256); dedup-окно **не сбрасывается при удалении** сообщения (5 мин).
- **Проверено локально и на проде** (`aws` CLI v2, JSON-протокол):
- `GetQueueAttributes All` отдаёт полный набор (см. лог);
- FIFO: два send с одним dedupId → 1 сообщение; `ContentBasedDeduplication` (два одинаковых тела) → 1;
- receive+delete, затем повторный send с тем же dedupId → 0 (окно живо) — раньше доставлялся повторно;
- `set-queue-attributes` с `Policy` → OK, `get-queue-attributes Policy` возвращает канонический JSON.
- На ВМ задеплоено (prev-бинарник + бэкап лога сохранены), `active`, `/health` 200.
## 3. Грабли (для повторного использования)
1. **`proxy_set_header Host $host` теряет порт** → SigV4 `SignatureDoesNotMatch`.
Нужен `$http_host`. (nginx `$host` — имя без порта; `$http_host` — заголовок целиком.)
2. **`~/.aws/config [default]` содержит `endpoint_url = https://s3.msk-1.ngcloud.ru`** →
`aws sqs ...` без явного `--endpoint-url` уходит в S3 и получает
`405 MethodNotAllowed` (Ceph RGW, `HostId=...-dc1-msk-1`). **Всегда задавать `--endpoint-url`.**
3. **Креды демо-тенанта рандомны при каждом старте** — брать из `~/sqs-test/sqs.log`
(`seed: создан тенант ... AccessKey=...`). При «address already in use» в логе креды **мёртвого** инстанса.
4. `pkill -f "http.server 10443"` по SSH **самоматчится** на строку ssh-команды и убивает сессию —
использовать `pkill -x <имя>`.
5. Порт `8443` на 213 **нельзя занимать** — там DNAT транзита на Vultr.
6. **Managed Redis недоступен с 213**: его endpoint — внутренний k8s DNS
(`redisk8s.<uid>.svc.cluster.local:6379`), резолвится только изнутри кластера. С ВМ — только
локальный Redis или внешний endpoint от NUBES.
7. Локальный Redis на 213 **не спасает от падения ВМ** (SQS и Redis — на одной машине, SPOF).
## 4. ОШИБКИ (сводка, включая ошибки Flash)
| # | Что утверждалось/сделано | Что на самом деле | Исправление |
|---|---|---|---|
| 1 | «При ребуте ВМ nginx не поднимется» (из-за битого upstream) | После ребута nginx `active`, 80/443 слушают | Неверный вывод; снят при перепроверке |
| 2 | «`.83` — мёртвый IP» | `185.247.187.83` = живой DNS `ns3.ngcloud.ru` (порт 53); проверял только 80/443 | Признано при перепроверке |
| 3 | Запуск второго инстанса при живом первом | `fatal: bind: address already in use`; лог перезаписан кредами мёртвого → `InvalidClientTokenId` | `pkill -x shared-sqs`, один инстанс, креды из лога |
| 4 | Второй nginx: `Host $host` | SigV4 `SignatureDoesNotMatch` | `Host $http_host` |
| 5 | Прямые DNS-выводы по регексу `location / ` | Первый `grep` не нашёл (паттерн с пробелом) — чуть не сделал ложный вывод | Перечитал файл целиком (`cat`) |
| 6 | «Только 80/443 открыты» | 8888/9443 «принимают» TCP (SYN-proxy), но пусты | Уточнено: рабочие — только 80/443 |
## 5. Состояние и воспроизведение
### 5.1. Итоговое состояние
- **Работает:** `https://sqs.sinefo.ru` (443, TLS) → shared-sqs на ВМ 213 (memory-only, `127.0.0.1:4100`).
- SQS: `~/sqs-test/shared-sqs`, лог `~/sqs-test/sqs.log`, pid меняется. Демо-тенант + 5 очередей.
- **QueueUrl — ИСПРАВЛЕНО** (см. 2.10): `https://sqs.sinefo.ru/<tenantID>/<queue>`.
### 5.2. Как поднять заново
```bash
# на ВМ 213
cd ~/sqs-test && setsid nohup env SHARED_SQS_SEED_DEMO=true \
./shared-sqs --admin-token=dev-token-123 --port=4100 --loglevel=info > sqs.log 2>&1 </dev/null &
# креды демо-тенанта — в sqs.log
grep "seed: создан тенант" ~/sqs-test/sqs.log | tail -1
# проверка снаружи
curl -sS https://sqs.sinefo.ru/health
```
### 5.3. Откат
```bash
sudo rm /etc/nginx/conf.d/sqs-sinefo.conf && sudo nginx -t && sudo systemctl reload nginx
pkill -x shared-sqs
# бэкапы nginx на ВМ: /etc/nginx/conf.d.bak-*
```
## 6. Открытые вопросы
1. Клиенты без явного `endpoint_url` — есть ли такие (вопрос №5 мануала). `QueueUrl` уже корректный.
2. Egress ВМ: `curl https://api.ipify.org` с ВМ вернул пусто — влияет на billing (PG) и JWT-API.
3. `SaveQueue` (метаданные очереди) всё ещё пишется **асинхронно** — при failover новый лидер
может не увидеть очередь, созданную незадолго до падения. Первый шаг к варианту A
(см. §8 дока по HA) — сделать синхронно, как `SaveMessagesSync`.
4. Для включения HA нужны: **общий Redis** (сейчас только локальный на 213 — SPOF),
вторая ВМ, LB с проверкой `/ready`. Пока всё это не готово — флаг выключен.
5. Дальше по мануалу: обновить устаревший `MANUAL-NEXT-2026-10-08.md` (ещё говорит «Шаг 1 — проверить
гипотезу» и «вопрос по домену открыт»); решить вопрос push в origin (18 коммитов впереди).
6. **JWT (решение владельца):** проверяет ли шлюз подпись и отдаёт 200 только для валидных токенов?
От этого — 🔴 или «доверяем шлюзу». См. `opus-report-analysis-2026-10-10.md` §3.
7. **Лимит по байтам (решение владельца):** нужны RAM ВМ 213 и целевое число тенантов для цифр cap.
См. `opus-report-analysis-2026-10-10.md` §4.
8. 🔴1/🔴5/🔴8 + fail-fast + JWT + heap-guard — **применены** (коммиты 654e315/1230149/2187027),
не задеплоено. Осталось: порядок локов + I/O под глобальным локом (рефакторинг).
+124
View File
@@ -0,0 +1,124 @@
# История правок shared-sqs — 2026-10-11 (внешнее код-ревью и фиксы)
> Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24).
> Предыдущие: `HISTORY/2026-10-10-session-log.md`, `doc/thinking/*-2026-10-08.md`.
## 1. Задача
Провести **внешнее код-ревью** двух сервисов (SQS-service + IoT) моделью Claude Sonnet
(в режиме Plan — правки не вносит, только находит), затем своими силами исправить
найденное. Промпт для ревью — `CLAUDE-REVIEW-PROMPT.md` в корне монорепо
(со списком файлов, фокус-точками и строгим форматом ответа; явно запрещены
правки, оценка тестов/UI/legacy/стиля).
Ревью дало **40 пунктов** (10 🔴 / 29 🟡 / 1 🟢). Разбор вопросов ревьюера и
уточнение некорректных выводов — в п.3.
## 2. Что исправлено (SQS-service)
### 🔴 Критичные (10/10)
| Пункт | Файл | Суть | Коммит |
|-------|------|------|--------|
| 🔴1 | `app/admin/admin.go` | `jwtMiddleware` пропускал любой путь с суффиксом `/auth` (обход JWT + IDOR на `DELETE .../queues/auth`). Теперь — точное сравнение `/ui/api/auth` | `1241093` |
| 🔴2 | `app/gosqs/queue_attributes.go` | Запрет self-/циклического `RedrivePolicy`: перенос в DLQ берёт второй `mu` под первым → deadlock | `9b9bbe1` |
| 🔴3 | `app/gosqs/gosqs.go`, `models.go`, `change_message_visibility*.go` | Перенос в DLQ не персистился (сообщение «воскрешало» в исходной и пропадало из DLQ) | `1e334c2` |
| 🔴4 | `app/persistence/redis.go`, `app/tenant/tenant_store.go` | `DeleteTenant` не чистил индекс `ssq:tenant_ak` — отозванный тенант возвращался через read-through | `f68b0a4` |
| 🔴5 | `app/persistence/redis.go`, `app/cmd/goaws.go` | Глобальный `Client` перезаписывался в `reconnect()` без синхронизации (data race). → `atomic.Pointer` | `d03ce36` |
| 🔴6 | `app/conf/config.go` | `return ports` без `SyncQueues.Unlock()` при ошибке `setQueueRedrivePolicy` → вечный deadlock | `1520753` |
| 🔴7 | `app/billing/billing.go` | `Close()` писал `db=nil` параллельно с `usageWorker` (nil-паника). → мьютекс + остановка воркера до `db.Close` | `4e36ba0` |
| 🔴10 | `app/auth/auth_middleware.go` | `X-Amz-Content-Sha256` не сверялся с телом — подмена тела под подписанным заголовком | `2918a41` |
(🔴8/🔴9 — на стороне IoT, см. `IoT/HISTORY/2026-10-11-session-log.md`.)
### 🟡 Важные
| Пункт | Файл | Суть | Коммит |
|-------|------|------|--------|
| 🟡20 | `app/admin/admin.go` | `createTenantQueue` не валидировал имя очереди (`:`,`/` ломали ключ) | `efd8c8e` |
| 🟡23 | `app/auth/jwt.go` | Токен без `exp` принимался бессрочно → требуем `exp` | `efd8c8e` |
| 🟡24 | `app/leader/leader.go` | При сбое `RefreshLock` инстанс оставался лидером (split-brain) → сброс лидерства после TTL | `efd8c8e` |
| 🟡21 | `app/tenant/tenant_store.go` | Запрет `:` в BYOC `access_key` (коллизии ключа `{ak}:{name}`) | `dc4815d` |
| 🟡22 | `app/admin/admin.go` | `sendMessageToQueue` без лимитов + async persist → лимит `MaxMessagesForQueue` + `SaveMessageSync` | `dc4815d` |
| 🟡33 | `app/models/models.go` | `InitDuplicatation` падал на nil-map `Duplicates` → ленивая инициализация | `dc4815d` |
| 🟡35 | `app/models/models.go` | `Reset()` не чистил `FIFOMessages` (после purge группы не выдавались) | `dc4815d` |
| 🟡36 | `app/gosqs/receive_message.go` | `ApproximateReceiveCount` был завышен на 1 | `dc4815d` |
| 🟡38 | `app/cmd/goaws.go` | `billing.Close()` вызывался ДО `Shutdown` серверов → перенесён после | `dc4815d` |
| 🟡26 | `app/gosqs/delete_message_batch.go` | Ключ map по `ReceiptHandle` (два entry с одним handle теряли Id) → по `Id` | `e0b5f24` |
| 🟡27 | `app/gosqs/send_message.go` | `DelaySeconds` не валидировался → 0–900 | `e0b5f24` |
| 🟡25 | `app/gosqs/send_message_batch.go` | Dedup регистрировался после цикла (два entry с одним id оба добавлялись); `NextSequenceNumber` до проверки дубликата; per-entry `DelaySeconds` не применялся | `6f69755` |
| 🟡29 | `app/gosqs/delete_message*.go`, `persistence/redis.go` | Ошибка `HDel` только логировалась (клиент получал 200, после рестарта сообщение воскрешало) → `DeleteMessage(s)Persist` возвращает ошибку, 500 клиенту | `5d38118` |
| 🟡31 | `app/persistence/redis.go`, `app/cmd/goaws.go` | Ошибка загрузки сообщений очереди давала `continue` (очередь стартовала пустой) → fail-fast (`log.Fatalf`) | `5d38118` |
| 🟡30 | `app/persistence/redis.go` | Async `SaveMessage` без порядковой нумерации (старое состояние могло перезаписать новое) → seq по `{queueKey}|{uuid}` + очистка при удалении | `7a2340d` |
| 🟡32 | `app/gosqs/gosqs.go` | `PeriodicTasks` без `recover` (паника убивала процесс) | `dc4815d` |
| 🟢 | `app/utils/utils.go` | `SqsErrors[errKey]` без проверки → nil-deref на неизвестном ключе | `5d38118` |
| (🟡 new) | `app/gosqs/create_queue.go` | Очередь из API получала `VisibilityTimeout=0` и пр. (дефолты не подставлялись) | `5d38118` |
### Осознанно НЕ менялось — РЕШЕНИЯ (2026-10-11)
- 🟡34 (атрибуты «0 = не передано»): **решение — не менять** совместимость.
Различать «не передано» и явный 0 потребовало бы перевести `QueueAttributes`
на указатели — ломает JSON-протокол и Terraform-drift. Явный 0 — редкий сценарий.
- 🟡37 (дефолт `SHARED_SQS_MAX_HEAP_MB`): **решение — оставить выключенным (0)**.
Безопасное число зависит от RAM конкретной ВМ и числа тенантов — выбор владельца,
не хардкод в коде.
- 🟡28 (сеть под `queue.mu`): **решение — оставить как есть (вариант A)**. См. п.4.
## 3. Уточнение выводов ревьюера
Ревьюер задал вопросы по коду, ответы найдены в коде (файл:строка):
- **ACL EMQX — единственный барьер для топиков** `{ns}/telemetry/{deviceId}`? Да:
`bridge/handler.go` берёт `ns`/`deviceID` из топика без сверки принадлежности,
барьер — EMQX authz (`emqx/emqx.conf`). Добавлена defense-in-depth проверка
формата (IoT).
- **Self-/циклический `RedrivePolicy` реально задаётся через API?** Да
(`setQueueAttributesV1` не проверял) — отсюда фикс 🔴2.
- **Пустой `adminToken`?** Недопустим — `goaws.go:~74` `log.Fatal` при отсутствии.
- **Дефолты очереди расходятся** (`create_queue.go` vs `conf/config.go:100-109`)?
Да — отсюда фикс (🟡 new).
- **`utils.go` паника на неизвестном ключе?** Текущей паники нет (все ключи есть
в `models.SqsErrors`), но добавлена защита.
Также ревьюер отметил некорректный пункт (удалён из списка по лимиту 40): nil-map
`Duplicates` (🟡33) фактически не воспроизводился в текущем коде — но защита
добавлена как дешёвая страховка.
## 4. 🟡28 — РЕШЕНИЕ (2026-10-11)
- **Проблема**: синхронный `SaveMessageSync` (до 3 с) выполняется под `queue.mu`
(`send_message.go:150`, `delete_message.go`, `admin.go`). Медленный Redis блокирует
всю очередь.
- **Варианты**: A) оставить; B) укоротить таймаут Redis-записи; C) полный рефакторинг
с per-message синхронизацией.
- **Выбрано A — оставить.** Обоснование:
1. in-memory ядро очередей — одиночка, блокировка `queue.mu` локальна (одна очередь).
2. Синхронный персист под `mu` = гарантия «нет потери подтверждённого сообщения
при крахе пода» — приоритет проекта (надёжность, а не масштабирование).
3. Вынос сети за `mu` вносит окно между `Unlock`/`Lock`: два конкурентных
`SendMessage` с одним `deduplicationId` оба пройдут `IsDuplicate` (дубль),
либо при откате `dedup` теряется сообщение — хуже кратковременной блокировки.
4. Redis-авария — глобальная: деградирует всё, 2–3 с на очередь не критично.
- 🟡34/🟡37 — см. п.2.
## 5. Верификация
- `go build ./...`, `go vet ./...`, `go test -race ./...` — зелёные на всех коммитах.
- Push: `2c6d2d7..f80e1b4` в `origin/master`.
## 6. Деплой на прод (ВМ 213) и smoke
- `make build` → `scp shared-sqs → /home/naeel/sqs-test/shared-sqs.new` →
`systemctl stop shared-sqs; mv .new → shared-sqs; start`. Бэкап — `shared-sqs.prev`.
- Проверка: `systemctl is-active` = active; `/health` → 200 `v0.1.35`;
в логе «загружено 5 очередей из Redis» (fail-fast-загрузка сработала, не упала).
- Smoke по `https://sqs.sinefo.ru` (`tests/api_test.sh`, aws cli, тенант `demo-service`):
**PASS=22 FAIL=0** (17 базовых операций + FIFO CreateQueue/Send/Receive/Delete/DeleteQueue).
Вывод: 18 фиксов ревью не сломали AWS-совместимость на проде.
## 7. Образы → Gitea registry
- Реестр — **Gitea** `gitea.services.ngcloud.ru` (НЕ Docker Hub). Makefile/Dockerfile/доки исправлены.
- Залито: `nail/shared-sqs:v0.1.35` (+latest; было только v0.1.25), `nail/iot-service:v0.1.13`,
`nail/iot-emqx:v0.2.4`.
- Push: `e30bf74..f80e1b4`.
+169
View File
@@ -0,0 +1,169 @@
# МАНУАЛ: что делать дальше (состояние на 2026-10-08)
> Файл для НОВОГО чата. Прочитай его первым, затем документы из раздела 2.
> Репозиторий: `/home/naeel/nubes/SQS_und_IoT/SQS-service` (сервис `shared-sqs`).
> Всё, что описано как «сделано», уже в ветке `main` и на сервере (`origin/main`).
---
## 1. Где мы сейчас
**Код SQS готов и запушен.** Ветка `main` = `origin/main`, рабочая копия чистая.
Что сделано за 2026-10-05…08:
- закрыт весь план доработок P0/P1/P2 (`doc/thinking/plan-for-flash-2026-10-05.md`);
- P1.2 — индексация сообщений (tombstone + индексы + амортизированная компакция);
- полный код-ревью сервиса + исправления (утечка блокировки очереди, DLQ в батче,
гонка лимита тенантов, потеря `NubesSub`, общий HTTP-клиент);
- интеграционные прогоны: `api_test.sh` 22/22, `sdk_test.py` 15/15, `fifo_dlq_probe.py` — PASS,
стресс 3×2000 сообщений — без зависаний.
**Прод-сервис никто не использует** (podтверждено владельцем), IoT переделывается с нуля.
Поэтому мы свободны менять архитектуру без обратной совместимости.
## 2. Документы, которые надо прочитать в этом порядке
| Документ | Что внутри |
|---|---|
| `doc/thinking/architecture-analysis-2026-10-08.md` | **ГЛАВНЫЙ**: решения по размещению и разбиению (публичный SQS, обход через ВМ, модульный монолит data-plane/control-plane) |
| `doc/thinking/nubes-large-body-workaround-findings-2026-10-08.md` | как та же болезнь платформы обойдена в `~/nubes/drhider` (буфер на ВМ, pull вместо push) — со ссылками «файл:строка» |
| `doc/thinking/nubes-workaround-applicability-to-sqs-2026-10-08.md` | применимость к SQS: 4 варианта, обязательные замеры, техусловия (Host для подписи, внешние `QueueUrl`, TLS) |
| `HISTORY/2026-10-07-session-log.md` | журнал правок: что и почему менялось, найденные дефекты, уроки |
| `doc/thinking/nubes-ticket.md` | исходная диагностика сетевой болезни (MSS/MTU + таймер шлюза). **Тикет отправлять не нужно** — см. `HANDOFF-NEW-CHAT.md`, раздел 7 |
| `doc/thinking/plan-for-flash-2026-10-05.md` | исходный план доработок (выполнен) |
| `HANDOFF-NEW-CHAT.md` | доступы, креды, конфигурация кластера, история проекта |
## 3. Принятые решения (не пересматривать без причины)
1. **SQS — публичный сервис**, как AWS SQS / YMQ. Внешний доступ обязателен,
совместимость с AWS SDK — обязательна.
2. **Сетевую болезнь платформы не «лечим» тикетом** — обходим: публичная точка входа
на ВМ, минуя managed-шлюз.
3. **Пока — ВМ (сейчас `5.172.178.213`), потом отдельная прод-ВМ.** Девопсов не дёргаем,
пока не отлажено; затем — уже с доказательствами (tcpdump, период 31–33 с, MSS 1448).
4. **«Без монолитов» = модульный монолит**: в отдельные процессы выносим только то,
что живёт по-разному. Для SQS граница одна: **data-plane** (очереди/сообщения/SigV4)
и **control-plane** (тенанты, креды, админ). Биллинг/метрики/UI — модули, не сервисы.
5. **Ограничение, которое важнее разбиения**: ядро очередей держит состояние в памяти
одного пода → одиночка. Пока хранилище не вынесено, «не монолит» ≠ «масштабируется».
## 4. План работ (порядок обязателен)
### Шаг 1. Проверить гипотезу на ВМ — БЕЗ кода (критерий входа: домен и сертификат)
Цель: убедиться, что на ВМ болезни нет, **до** вложений в разбиение.
1. Собрать бинарник: `make build` (или `CGO_ENABLED=0 go build -o shared-sqs ./app/cmd/`).
2. Поднять на ВМ (memory-only достаточно): см. команды в разделе 5.
3. Поставить nginx с TLS на выбранный домен, проксировать на `127.0.0.1:4100`.
**Обязательно** `proxy_set_header Host $host;` — иначе AWS-подпись не сойдётся
(сервис берёт хост из запроса: `app/auth/auth_middleware.go:315-317`).
4. Замерить **с внешней машины** (не с ВМ): малые тела, 1 КБ, 10 КБ, 100 КБ, 256 КБ,
плюс длинный прогон на таймауты. Инструменты: `tests/tcp_mtu_probe.py`,
`tests/long_compare_local.py`, `tests/compare_ymq_vs_shared.py`.
5. **Критерий успеха:** 0 зависаний ~51 с, 0 таймаутов 31–33 с.
Если гипотеза не подтвердится — СТОП, вернуться к разбору (раздел 2, документ о применимости).
### Шаг 2. Код: разбиение на два процесса
- `data-plane` — SQS API и очереди (`app/gosqs/*`, `app/router/router.go`, `app/auth/auth_middleware.go`).
- `control-plane` — админка (`app/admin/admin.go`) и управление тенантами (`app/tenant/*`).
- Что уже учитывать при разбиении:
- адреса очередей формируются жёстко: `app/gosqs/tenant_helpers.go:29-37` →
`http://{region}.{host}:{port}/{tenantID}/{queue}`; хост/порт только из `app/conf/goaws.yaml`
(`app/conf/config.go:87-90`), env-переменных для Host нет → **внешние `QueueUrl` потребуют правки**;
- подпись SigV4 считается по заголовку `Host` (см. выше);
- ключ очереди — `{accessKey}:{queueName}` (`app/gosqs/tenant_helpers.go:16`).
### Шаг 3. Код: вынести состояние (стратегический пункт)
- Сейчас: память — источник истины, Redis — write-through (`app/persistence/redis.go`,
`LoadAllQueues`), восстановление при старте.
- На ВМ поставить Redis и решить, как читать: остаться write-through или перейти к Redis
как источнику истины (тогда появится возможность нескольких экземпляров).
- Это решение определяет потолок сервиса (сейчас — один под).
### Шаг 4. IoT — отдельно, после SQS
- Внешний MQTT-канал неизбежен (устройства), но там только мелкая телеметрия;
критичен разрыв wss ~150 с (лечится переподключением).
- Крупные тела устройств через managed-шлюз не пускать (лимит/дробление/приём на ВМ).
- IoT переделывается с нуля не монолитом: bridge (MQTT), consumer (→storage), API.
## 5. Команды (проверены)
**Локальный запуск (memory-only, без Redis):**
```bash
cd /home/naeel/nubes/SQS_und_IoT/SQS-service
go build -o /tmp/shared-sqs ./app/cmd/
SHARED_SQS_SEED_DEMO=true /tmp/shared-sqs --admin-token=dev-token-123 --port=4100
# креды демо-тенанта печатаются в лог при старте
```
⚠️ Не запускать через `| head` — SIGPIPE убивает процесс на середине теста.
**Проверки кода:**
```bash
go build ./... && go vet ./... && go test -race ./app/...
```
**Деплой на ВМ:**
- зеркало репозитория: `naeel@5.172.178.213:~/terra/SQS-service`;
- SSH: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`;
- ⚠️ порт **443 на 213 занят** (nginx/xray/hysteria), поэтому публичная точка SQS —
отдельный домен на существующем nginx (виртуальный хост/SNI) или отдельный порт;
- ⚠️ в образе `Dockerfile` зашит `REDIS_ADDR` внутреннего кластера — на ВМ переопределить
(или не задавать, чтобы работать memory-only).
**Сборка образа (если через Docker):** `make docker-build` (`Makefile`, образ `naeel/shared-sqs`).
## 6. Грабли и правила (не наступать повторно)
1. `app/cmd` в `.gitignore` → при добавлении файлов `git add -f app/cmd/...`.
2. `deployments/` в этом репозитории **нет** — не искать.
3. `Queue.Messages` — tombstone-хранилище: менять только через `AppendMessage`/`RemoveIndex`/
`Compact`/`Reset`; число живых = `queue.LiveCount()`, а НЕ `len(queue.Messages)`
(`app/models/models.go`).
4. После `GetLocked`/`GetRLocked` — `defer Unlock` **до** любых `return` (иначе очередь
зависает навсегда; было в `receive_message.go`, исправлено).
5. Интеграционные тесты: `tests/api_test.sh` читает `ENDPOINT`, python-тесты — `ENDPOINT_URL`.
`boto3` в системе нет (PEP 668) → скачать колеса `pip download boto3`, распаковать в `/tmp`
и запускать с `PYTHONPATH`.
6. Лимит очередей тенанта = 10 → после упавших прогонов чистить очереди.
7. Диагностика зависаний сервиса: стек горутин по `kill -QUIT` (SIGQUIT).
8. Правки ingress на платформе — только через `ShturvalServiceConfig` (kubectl откатывает ArgoCD).
## 7. Правила работы с владельцем (обязательные)
- Коммитить после каждой логически завершённой правки; **пуш — только по прямой команде**.
- Документировать всё, включая собственные ошибки (HISTORY + `doc/thinking/`).
- Не выполнять `terraform apply`/`destroy`, не менять платформу, не удалять инстансы.
- Не расширять область работ; не лезть в соседние репозитории без прямой команды.
- Рекомендация без замера — не рекомендация, а догадка: сначала мерить, потом предлагать.
- Вопрос от владельца = только ответ (ничего не делать, пока не последует команда).
## 8. Открытые вопросы (нужны решения владельца)
1. **Домен и сертификат** для публичной точки SQS на ВМ (порт 443 занят → виртуальный хост?).
2. **Отдельная прод-ВМ** — когда создавать и с какими параметрами (пока хватит 213 для тестов;
хватит ли 213 — проверить замером: ядра, память, диск, загрузка).
3. **Redis на ВМ** — ставить сейчас или пока memory-only.
4. **Вынос состояния** (Шаг 3) — когда начинать; это определяет масштабируемость.
5. **Клиенты без явного `endpoint_url`** — есть ли такие (влияет на внешние `QueueUrl`).
## 9. Карта кода (что где)
| Путь | Ответственность |
|---|---|
| `app/cmd/goaws.go` | точка входа, флаги, загрузка конфига, periodic tasks, HTTP-сервер |
| `app/router/router.go` | HTTP-роутинг, лимит тела 3 МБ, биллинг/метрики по операциям |
| `app/auth/auth_middleware.go` | SigV4 (header + presigned), извлечение тенанта |
| `app/auth/jwt.go`, `app/auth/cache.go` | валидация JWT через внешний API + кэш |
| `app/gosqs/*` | SQS-операции (send/receive/delete/visibility/queue/attributes/tags) |
| `app/gosqs/receive_message.go` | long-polling, выдача сообщений (в т.ч. per-queue лок) |
| `app/gosqs/gosqs.go` | фоновый тик: retention, дедупликация, видимость, DLQ |
| `app/models/models.go` | `Queue`/`SqsMessage`, индексация (tombstone), FIFO-состояние |
| `app/models/globals.go` | `SyncQueues`: map очередей + tenant-индекс + блокировки |
| `app/persistence/redis.go` | write-through в Redis, загрузка при старте, переподключение |
| `app/admin/admin.go` | админка (тенанты/очереди/сообщения), UI-API по JWT |
| `app/tenant/tenant_store.go` | хранилище тенантов, генерация кредов, авто-провижининг по JWT |
| `app/billing/billing.go` | учёт операций в PostgreSQL (воркер + буфер) |
| `app/metrics/*` | Prometheus-метрики и gauge-обновление |
| `app/conf/goaws.yaml` | Host/Port/Region, дефолты очередей (используется для URL) |
| `tests/*` | интеграционные проверки (aws CLI, boto3, пробы сети/MTU) |
+4 -2
View File
@@ -1,8 +1,10 @@
# Makefile — shared-sqs # Makefile — shared-sqs
# Created: 2026-04-09 # Created: 2026-04-09
# Registry: Docker Hub (naeel/shared-sqs) # Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
# Проверено 2026-10-11: gitea.services.ngcloud.ru/nail/shared-sqs существует (login naeel).
IMAGE_REPO=naeel/shared-sqs REGISTRY ?= gitea.services.ngcloud.ru/nail
IMAGE_REPO=$(REGISTRY)/shared-sqs
VERSION=v0.1.35 VERSION=v0.1.35
LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION) LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION)
BINARY=shared-sqs BINARY=shared-sqs
+135 -157
View File
@@ -8,18 +8,17 @@ import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time" "time"
"shared-sqs/app/auth" "shared-sqs/app/auth"
"shared-sqs/app/gosqs"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence" "shared-sqs/app/persistence"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
"github.com/google/uuid"
"github.com/gorilla/mux" "github.com/gorilla/mux"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -30,17 +29,6 @@ const (
uiTenantContextKey uiContextKey = "ui-tenant" uiTenantContextKey uiContextKey = "ui-tenant"
) )
// ВАЖНО: значения ниже относятся только к временному demo/showcase режиму.
// Перед production rollout без публичного demo-режима этот блок должен быть удалён
// вместе с веткой authenticateUIDemoToken и связанными UI-подсказками.
const (
defaultUIDemoToken = "demo-ui-shared-sqs-ngcloud-2026"
defaultUIDemoTenantID = "t-demo-shared-sqs-ngcloud"
defaultUIDemoEmail = "demo@shared-sqs.ngcloud"
)
var errUIDemoTokenMismatch = errors.New("demo token mismatch")
// ─── вспомогательная функция: найти очередь тенанта по имени ─────────────── // ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено. // findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) { func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
@@ -69,48 +57,6 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint} return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
} }
// uiDemoToken — возвращает публичный demo token для UI.
// Временный showcase-only путь: в production без demo user этот метод нужно удалить.
func (h *Handler) uiDemoToken() string {
if token := os.Getenv("SHARED_SQS_UI_DEMO_TOKEN"); token != "" {
return token
}
return defaultUIDemoToken
}
// uiDemoTenantID — возвращает tenant ID, к которому привязан demo token.
// Существует только для showcase-режима, не для постоянной production auth-модели.
func (h *Handler) uiDemoTenantID() string {
if tenantID := os.Getenv("SHARED_SQS_UI_DEMO_TENANT_ID"); tenantID != "" {
return tenantID
}
return defaultUIDemoTenantID
}
// uiDemoEmail — возвращает отображаемый email для demo UI session.
// Нужен только для публичного демо-логина и должен уйти вместе с demo path.
func (h *Handler) uiDemoEmail() string {
if email := os.Getenv("SHARED_SQS_UI_DEMO_EMAIL"); email != "" {
return email
}
return defaultUIDemoEmail
}
// authenticateUIDemoToken — маппит публичный demo token на заранее сидированный demo tenant.
// Почему так: это быстрый showcase-вход для заказчика. Для production без demo user
// функция должна быть удалена, чтобы в коде не осталось публичного bypass-пути.
func (h *Handler) authenticateUIDemoToken(token string) (*tenant.Tenant, string, error) {
demoToken := h.uiDemoToken()
if demoToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(demoToken)) != 1 {
return nil, "", errUIDemoTokenMismatch
}
demoTenant, ok := h.store.GetByID(h.uiDemoTenantID())
if !ok {
return nil, "", errors.New("demo tenant unavailable — enable SHARED_SQS_SEED_DEMO=true")
}
return demoTenant, h.uiDemoEmail(), nil
}
// currentUITenant — возвращает tenant, авторизованный через UI middleware. // currentUITenant — возвращает tenant, авторизованный через UI middleware.
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) { func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant) t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
@@ -174,8 +120,12 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler { func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
// Timing-safe сравнение: длина префикса и сам токен проверяются отдельно,
// само сравнение значения — через subtle.ConstantTimeCompare, чтобы не
// давать timing side-channel при подборе admin-токена.
expected := "Bearer " + h.adminToken expected := "Bearer " + h.adminToken
if authHeader != expected { if len(authHeader) != len(expected) ||
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
@@ -197,24 +147,6 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
if demoTenant, demoEmail, err := h.authenticateUIDemoToken(req.Token); err == nil {
log.Infof("ui auth: authenticated demo tenant=%s", demoTenant.ID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"email": demoEmail,
"tenant_id": demoTenant.ID,
"access_key": demoTenant.AccessKey,
"secret_key": demoTenant.SecretKey,
"max_queues": demoTenant.MaxQueues,
"token": req.Token,
})
return
} else if !errors.Is(err, errUIDemoTokenMismatch) {
log.Warnf("ui auth: demo login unavailable: %v", err)
jsonErr(w, http.StatusForbidden, err.Error())
return
}
// Парсим JWT claims // Парсим JWT claims
claims, err := auth.ParseJWTClaims(req.Token) claims, err := auth.ParseJWTClaims(req.Token)
if err != nil { if err != nil {
@@ -287,8 +219,10 @@ func (h *Handler) uiCredentials(w http.ResponseWriter, r *http.Request) {
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth. // НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler { func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT // /ui/api/auth — публичный endpoint, пропускаем без проверки JWT.
if strings.HasSuffix(r.URL.Path, "/auth") { // Сравниваем ТОЧНЫЙ путь, а не суффикс "/auth": иначе, например,
// DELETE /ui/api/tenants/{id}/queues/auth пропускался бы без JWT и IDOR-проверки.
if r.URL.Path == "/ui/api/auth" {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@@ -305,19 +239,6 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
} }
token := parts[1] token := parts[1]
if demoTenant, _, err := h.authenticateUIDemoToken(token); err == nil {
if pathTenantID, exists := mux.Vars(r)["id"]; exists && pathTenantID != "" && pathTenantID != demoTenant.ID {
jsonErr(w, http.StatusForbidden, "forbidden tenant access")
return
}
ctx := context.WithValue(r.Context(), uiTenantContextKey, demoTenant)
next.ServeHTTP(w, r.WithContext(ctx))
return
} else if !errors.Is(err, errUIDemoTokenMismatch) {
jsonErr(w, http.StatusForbidden, err.Error())
return
}
claims, err := auth.ParseJWTClaims(token) claims, err := auth.ParseJWTClaims(token)
if err != nil { if err != nil {
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error()) jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
@@ -356,6 +277,10 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
type createTenantRequest struct { type createTenantRequest struct {
Name string `json:"name"` Name string `json:"name"`
MaxQueues int `json:"max_queues"` MaxQueues int `json:"max_queues"`
// BYOC: необязательные произвольные креды. Если задано одно поле —
// оба обязательны.
AccessKey string `json:"access_key,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
} }
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании) // tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
@@ -398,7 +323,23 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"}) json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
return return
} }
t, err := h.store.Create(req.Name, req.MaxQueues)
// BYOC: либо оба поля кредов заданы, либо ни одного.
var t *tenant.Tenant
var err error
if req.AccessKey != "" || req.SecretKey != "" {
if req.AccessKey == "" || req.SecretKey == "" {
jsonErr(w, http.StatusBadRequest, "both access_key and secret_key are required when specifying custom credentials")
return
}
if len(req.AccessKey) < 8 || len(req.SecretKey) < 16 {
jsonErr(w, http.StatusBadRequest, "access_key min 8 chars, secret_key min 16 chars")
return
}
t, err = h.store.CreateFixedAutoID(req.Name, req.MaxQueues, req.AccessKey, req.SecretKey)
} else {
t, err = h.store.Create(req.Name, req.MaxQueues)
}
if err != nil { if err != nil {
log.Errorf("admin: failed to create tenant: %v", err) log.Errorf("admin: failed to create tenant: %v", err)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -466,15 +407,14 @@ func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
return return
} }
// Удаляем все очереди тенанта из SyncQueues // Удаляем все очереди тенанта из SyncQueues (через tenant-индекс, без скана всех очередей)
prefix := t.AccessKey + ":"
deletedQueueKeys := make([]string, 0)
models.SyncQueues.Lock() models.SyncQueues.Lock()
for key := range models.SyncQueues.Queues { tenantQueueNames := models.SyncQueues.TenantQueueNames(t.AccessKey)
if strings.HasPrefix(key, prefix) { deletedQueueKeys := make([]string, 0, len(tenantQueueNames))
deletedQueueKeys = append(deletedQueueKeys, key) for _, name := range tenantQueueNames {
delete(models.SyncQueues.Queues, key) key := t.AccessKey + ":" + name
} deletedQueueKeys = append(deletedQueueKeys, key)
models.SyncQueues.RemoveQueue(key)
} }
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
for _, queueKey := range deletedQueueKeys { for _, queueKey := range deletedQueueKeys {
@@ -507,30 +447,42 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
RetentionPeriod int `json:"retention_period"` RetentionPeriod int `json:"retention_period"`
} }
queues := make([]queueInfo, 0) queues := make([]queueInfo, 0)
type nameQueue struct {
name string
q *models.Queue
}
models.SyncQueues.RLock() models.SyncQueues.RLock()
for key, q := range models.SyncQueues.Queues { pairs := make([]nameQueue, 0)
if strings.HasPrefix(key, prefix) { for _, name := range models.SyncQueues.TenantQueueNames(t.AccessKey) {
name := strings.TrimPrefix(key, prefix) if q := models.SyncQueues.Queues[prefix+name]; q != nil {
visible := 0 pairs = append(pairs, nameQueue{name, q})
notVisible := 0
for _, msg := range q.Messages {
if msg.ReceiptHandle != "" {
notVisible++
} else {
visible++
}
}
queues = append(queues, queueInfo{
Name: name,
Messages: visible,
NotVisible: notVisible,
VisibilityTimeout: q.VisibilityTimeout,
MaxMessageSize: q.MaximumMessageSize,
RetentionPeriod: q.MessageRetentionPeriod,
})
} }
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
for _, p := range pairs {
p.q.RLockMu()
visible := 0
notVisible := 0
for i := range p.q.Messages {
if p.q.IsDead(i) {
continue
}
if p.q.Messages[i].ReceiptHandle != "" {
notVisible++
} else {
visible++
}
}
queues = append(queues, queueInfo{
Name: p.name,
Messages: visible,
NotVisible: notVisible,
VisibilityTimeout: p.q.VisibilityTimeout,
MaxMessageSize: p.q.MaximumMessageSize,
RetentionPeriod: p.q.MessageRetentionPeriod,
})
p.q.RUnlockMu()
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(queues) json.NewEncoder(w).Encode(queues)
} }
@@ -558,6 +510,12 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusBadRequest, "name is required") jsonErr(w, http.StatusBadRequest, "name is required")
return return
} }
// Та же валидация имени, что и в SQS CreateQueue (иначе имена с ':'/'/'
// ломали бы ключ "{accessKey}:{name}").
if err := gosqs.ValidateQueueName(req.Name); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid queue name")
return
}
key := t.AccessKey + ":" + req.Name key := t.AccessKey + ":" + req.Name
models.SyncQueues.Lock() models.SyncQueues.Lock()
if _, exists := models.SyncQueues.Queues[key]; exists { if _, exists := models.SyncQueues.Queues[key]; exists {
@@ -565,27 +523,18 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusConflict, "queue already exists") jsonErr(w, http.StatusConflict, "queue already exists")
return return
} }
// Проверяем лимит очередей тенанта // Проверяем лимит очередей тенанта (через tenant-индекс, O(1))
count := 0 count := models.SyncQueues.CountTenantQueues(t.AccessKey)
for k := range models.SyncQueues.Queues {
if strings.HasPrefix(k, t.AccessKey+":") {
count++
}
}
if t.MaxQueues > 0 && count >= t.MaxQueues { if t.MaxQueues > 0 && count >= t.MaxQueues {
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
jsonErr(w, http.StatusForbidden, "queue limit exceeded") jsonErr(w, http.StatusForbidden, "queue limit exceeded")
return return
} }
models.SyncQueues.Queues[key] = &models.Queue{ // Очередь создаётся через общий билдер — те же URL/ARN/дефолты и FIFO-детект,
Name: req.Name, // что и в SQS CreateQueue (единый путь, без расхождения поведения UI и API).
VisibilityTimeout: 30, newQueue := gosqs.BuildQueueForTenant(t, req.Name)
MaximumMessageSize: 262144, models.SyncQueues.AddQueue(key, newQueue)
MessageRetentionPeriod: 345600, persistence.SaveQueue(key, newQueue)
Messages: []models.SqsMessage{},
Duplicates: make(map[string]time.Time),
}
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID) log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -610,7 +559,7 @@ func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusNotFound, "queue not found") jsonErr(w, http.StatusNotFound, "queue not found")
return return
} }
delete(models.SyncQueues.Queues, key) models.SyncQueues.RemoveQueue(key)
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
persistence.DeleteQueue(key) persistence.DeleteQueue(key)
log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID) log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID)
@@ -659,12 +608,16 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
models.SyncQueues.RLock() q.RLockMu()
result := make([]peekMessageItem, 0, len(q.Messages)) result := make([]peekMessageItem, 0, limit)
for i, msg := range q.Messages { for i := range q.Messages {
if i >= limit { if len(result) >= limit {
break break
} }
if q.IsDead(i) {
continue
}
msg := &q.Messages[i]
result = append(result, peekMessageItem{ result = append(result, peekMessageItem{
ID: msg.Uuid, ID: msg.Uuid,
Body: msg.MessageBody, Body: msg.MessageBody,
@@ -674,7 +627,7 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
InFlight: msg.ReceiptHandle != "", InFlight: msg.ReceiptHandle != "",
}) })
} }
models.SyncQueues.RUnlock() q.RUnlockMu()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(result)
} }
@@ -705,17 +658,30 @@ func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusBadRequest, "body is required") jsonErr(w, http.StatusBadRequest, "body is required")
return return
} }
msg := models.SqsMessage{ // Сообщение строится через общий билдер (UUID + MD5), + проверка размера и
MessageBody: req.Body, // лимита — как в SQS SendMessage (иначе UI обходил бы OOM-защиту).
Uuid: uuid.NewString(), q.LockMu()
SentTime: time.Now(), if q.MaximumMessageSize > 0 && len(req.Body) > q.MaximumMessageSize {
q.UnlockMu()
jsonErr(w, http.StatusBadRequest, "message too big")
return
} }
models.SyncQueues.Lock() if q.LiveCount() >= gosqs.MaxMessagesForQueue(q.IsFIFO) {
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) q.UnlockMu()
// Персистим одно сообщение отдельно jsonErr(w, http.StatusTooManyRequests, "queue is full")
persistence.SaveMessage(key, &msg) return
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) }
models.SyncQueues.Unlock() msg := gosqs.BuildSqsMessage(req.Body)
idx := q.AppendMessage(msg)
// Персистим СИНХРОННО (как SendMessage) — иначе 201 без гарантии записи.
if err := persistence.SaveMessageSync(key, &msg); err != nil {
q.RemoveIndex(idx)
q.UnlockMu()
jsonErr(w, http.StatusInternalServerError, "failed to persist message")
return
}
persistence.SaveQueue(key, q)
q.UnlockMu()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"}) json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"})
@@ -736,12 +702,12 @@ func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusNotFound, "queue not found") jsonErr(w, http.StatusNotFound, "queue not found")
return return
} }
models.SyncQueues.Lock() q.LockMu()
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0] q.Reset()
// Удаляем все сообщения из Redis одной командой // Удаляем все сообщения из Redis одной командой
persistence.PurgeMessagesPersist(key) persistence.PurgeMessagesPersist(key)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, q)
models.SyncQueues.Unlock() q.UnlockMu()
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID) log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -769,13 +735,19 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
queueCount := 0 queueCount := 0
msgCount := 0 msgCount := 0
models.SyncQueues.RLock() models.SyncQueues.RLock()
uiQueues := make([]*models.Queue, 0)
for key, q := range models.SyncQueues.Queues { for key, q := range models.SyncQueues.Queues {
if strings.HasPrefix(key, prefix) { if strings.HasPrefix(key, prefix) {
queueCount++ queueCount++
msgCount += len(q.Messages) uiQueues = append(uiQueues, q)
} }
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
for _, q := range uiQueues {
q.RLockMu()
msgCount += q.LiveCount()
q.RUnlockMu()
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{ json.NewEncoder(w).Encode(adminHealthDetail{
@@ -790,11 +762,17 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
tenants := h.store.List() tenants := h.store.List()
models.SyncQueues.RLock() models.SyncQueues.RLock()
queueCount := len(models.SyncQueues.Queues) queueCount := len(models.SyncQueues.Queues)
msgCount := 0 allQueues := make([]*models.Queue, 0, queueCount)
for _, q := range models.SyncQueues.Queues { for _, q := range models.SyncQueues.Queues {
msgCount += len(q.Messages) allQueues = append(allQueues, q)
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
msgCount := 0
for _, q := range allQueues {
q.RLockMu()
msgCount += q.LiveCount()
q.RUnlockMu()
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{ json.NewEncoder(w).Encode(adminHealthDetail{
+5 -26
View File
@@ -1,7 +1,5 @@
// app/admin/admin_test.go // app/admin/admin_test.go
// Focused tests for UI auth and scoping // Focused tests for UI auth and scoping
// Created: 2026-04-12 09:28 MSK
// Updated: 2026-04-12 09:28 MSK — demo UI token and tenant-scoped UI responses
package admin package admin
import ( import (
@@ -15,37 +13,18 @@ import (
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
// TestJWTAuth_AllowsDemoToken verifies that the public demo token authenticates into the seeded demo tenant. // TestJWTAuth_RejectsMissingToken verifies jwtAuth rejects a request without a token.
func TestJWTAuth_AllowsDemoToken(t *testing.T) { func TestJWTAuth_RejectsMissingToken(t *testing.T) {
t.Setenv("SHARED_SQS_UI_DEMO_TOKEN", "demo-token-for-test")
t.Setenv("SHARED_SQS_UI_DEMO_TENANT_ID", "t-demo-test")
store := tenant.NewTenantStore() store := tenant.NewTenantStore()
demoTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
if err != nil {
t.Fatalf("CreateFixed(): %v", err)
}
h := NewHandler(store, "admin-token") h := NewHandler(store, "admin-token")
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{"token":"demo-token-for-test"}`)) req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.jwtAuth(w, req) h.jwtAuth(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusBadRequest {
t.Fatalf("jwtAuth() status = %d, body = %s", w.Code, w.Body.String()) t.Fatalf("jwtAuth() status = %d, want 400", w.Code)
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := resp["tenant_id"]; got != demoTenant.ID {
t.Fatalf("tenant_id = %v, want %s", got, demoTenant.ID)
}
if got := resp["access_key"]; got != demoTenant.AccessKey {
t.Fatalf("access_key = %v, want %s", got, demoTenant.AccessKey)
} }
} }
+19 -1
View File
@@ -10,6 +10,7 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json"
"encoding/xml" "encoding/xml"
"errors" "errors"
"fmt" "fmt"
@@ -21,6 +22,7 @@ import (
"strings" "strings"
"time" "time"
"shared-sqs/app/persistence"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
@@ -59,6 +61,17 @@ func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler {
} }
t, ok := store.GetByAccessKey(accessKeyID) t, ok := store.GetByAccessKey(accessKeyID)
if !ok {
// Read-through из Redis: тенант мог быть создан control-plane / другим
// инстансом и ещё не попасть в локальную память.
if raw, found := persistence.LoadTenantByAccessKey(accessKeyID); found {
var loaded tenant.Tenant
if err := json.Unmarshal(raw, &loaded); err == nil {
store.LoadTenant(&loaded)
t, ok = store.GetByAccessKey(accessKeyID)
}
}
}
if !ok || !t.Active { if !ok || !t.Active {
writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid") writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid")
return return
@@ -150,9 +163,14 @@ func verifyHeaderSigV4(r *http.Request, t *tenant.Tenant, authHeader string) err
if err != nil { if err != nil {
return err return err
} }
actualHash := sha256Hex(body)
payloadHash := r.Header.Get("X-Amz-Content-Sha256") payloadHash := r.Header.Get("X-Amz-Content-Sha256")
if payloadHash == "" { if payloadHash == "" {
payloadHash = sha256Hex(body) payloadHash = actualHash
} else if !strings.EqualFold(payloadHash, actualHash) && !strings.EqualFold(payloadHash, "UNSIGNED-PAYLOAD") {
// Заголовок заявлен, но не совпадает с фактическим хэшем тела: клиент
// не должен иметь возможность подменить тело под подписанным заголовком.
return errors.New("X-Amz-Content-Sha256 does not match body hash")
} }
canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, true) canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, true)
+15 -8
View File
@@ -27,7 +27,8 @@ const cacheGraceTTL = 2 * time.Minute
const cacheCleanupInterval = 5 * time.Minute const cacheCleanupInterval = 5 * time.Minute
type tokenCacheEntry struct { type tokenCacheEntry struct {
validUntil time.Time validUntil time.Time
graceDeadline time.Time // потолок grace от последней УСПЕШНОЙ проверки (не продлевается)
} }
var ( var (
@@ -66,18 +67,20 @@ func PingNubesAPICached(ctx context.Context, endpoint, token string) error {
return err return err
} }
if err != nil { if err != nil {
// Сетевая ошибка шлюза: если токен ранее был валиден — даём grace. // Сетевая ошибка шлюза: если токен ранее был валиден — даём grace, но
if ok { // НЕ дальше graceDeadline. Раньше validUntil сдвигали вперёд на каждый
tokenCacheMu.Lock() // сетевой сбой — отозванный токен жил бы вечно при повторных ошибках.
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheGraceTTL)} if ok && now.Before(entry.graceDeadline) {
tokenCacheMu.Unlock()
return nil return nil
} }
return err return err
} }
tokenCacheMu.Lock() tokenCacheMu.Lock()
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)} tokenCache[key] = tokenCacheEntry{
validUntil: now.Add(cacheTTL),
graceDeadline: now.Add(cacheGraceTTL),
}
tokenCacheMu.Unlock() tokenCacheMu.Unlock()
return nil return nil
} }
@@ -85,8 +88,12 @@ func PingNubesAPICached(ctx context.Context, endpoint, token string) error {
// WarmTokenCache — записывает токен в кэш как валидный без вызова шлюза. // WarmTokenCache — записывает токен в кэш как валидный без вызова шлюза.
// Используется после успешной свежей проверки при логине (jwtAuth). // Используется после успешной свежей проверки при логине (jwtAuth).
func WarmTokenCache(token string) { func WarmTokenCache(token string) {
now := time.Now()
tokenCacheMu.Lock() tokenCacheMu.Lock()
tokenCache[tokenCacheKey(token)] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)} tokenCache[tokenCacheKey(token)] = tokenCacheEntry{
validUntil: now.Add(cacheTTL),
graceDeadline: now.Add(cacheGraceTTL),
}
tokenCacheMu.Unlock() tokenCacheMu.Unlock()
} }
+21 -9
View File
@@ -59,7 +59,10 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
if claims.Sub == "" { if claims.Sub == "" {
return nil, fmt.Errorf("JWT missing 'sub' claim") return nil, fmt.Errorf("JWT missing 'sub' claim")
} }
if claims.Exp > 0 && claims.Exp < time.Now().Unix() { if claims.Exp == 0 {
return nil, fmt.Errorf("JWT missing 'exp' claim")
}
if claims.Exp < time.Now().Unix() {
return nil, fmt.Errorf("JWT token expired") return nil, fmt.Errorf("JWT token expired")
} }
return &claims, nil return &claims, nil
@@ -71,17 +74,22 @@ var ErrTokenRejected = errors.New("token rejected by nubes API")
// nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен. // nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен.
// Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял. // Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял.
// Юзер может вводить токен от любого стенда — главное, что токен действительный. // Безопасность: только prod-шлюз. Тестовый/dev-шлюзы принимают токены своих
// стендов, и при их наличии токен тестового стенда считался бы валидным на проде.
// Специфичный endpoint можно переопределить через NUBES_ENDPOINT.
var nubesAPIEndpoints = []string{ var nubesAPIEndpoints = []string{
"https://lk-api-gateway.ngcloud.ru/api/v1/svc", "https://lk-api-gateway.ngcloud.ru/api/v1/svc",
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
} }
// nubesHTTPClient — общий клиент для валидации токенов: переиспользует
// соединения (keep-alive) вместо создания нового клиента на каждый запрос.
var nubesHTTPClient = &http.Client{Timeout: 10 * time.Second}
// PingNubesAPI — проверяет валидность токена перебором стендов Nubes API. // PingNubesAPI — проверяет валидность токена перебором стендов Nubes API.
// endpoint (если задан через NUBES_ENDPOINT) пробуется первым. // endpoint (если задан через NUBES_ENDPOINT) пробуется первым.
// Логика: 401/403 → токен отклонён ЭТИМ стендом, пробуем следующий. // Логика: токен принимается ТОЛЬКО при явном HTTP 2xx от стенда.
// Любой другой HTTP статус → токен принят. // 401/403 → токен отклонён ЭТИМ стендом (ErrTokenRejected), пробуем следующий.
// Любой другой статус (4xx/5xx) → стенд НЕ подтвердил токен, пробуем следующий.
func PingNubesAPI(ctx context.Context, endpoint, token string) error { func PingNubesAPI(ctx context.Context, endpoint, token string) error {
candidates := make([]string, 0, len(nubesAPIEndpoints)+1) candidates := make([]string, 0, len(nubesAPIEndpoints)+1)
if endpoint != "" { if endpoint != "" {
@@ -103,19 +111,23 @@ func PingNubesAPI(ctx context.Context, endpoint, token string) error {
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "Mozilla/5.0") req.Header.Set("User-Agent", "Mozilla/5.0")
client := &http.Client{Timeout: 10 * time.Second} resp, err := nubesHTTPClient.Do(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err) lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err)
continue continue
} }
resp.Body.Close() resp.Body.Close()
// Принимаем токен ТОЛЬКО при явном 2xx: никакой другой статус (5xx,
// 429, редирект и т.п.) не должен считаться подтверждением валидности.
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return nil
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode) lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode)
continue continue
} }
return nil lastErr = fmt.Errorf("nubes API %s returned unexpected status HTTP %d", ep, resp.StatusCode)
} }
if lastErr == nil { if lastErr == nil {
lastErr = fmt.Errorf("nubes API rejected token") lastErr = fmt.Errorf("nubes API rejected token")
+117 -28
View File
@@ -9,6 +9,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"os" "os"
"sync"
// PostgreSQL драйвер — регистрируется в database/sql через init() // PostgreSQL драйвер — регистрируется в database/sql через init()
_ "github.com/lib/pq" _ "github.com/lib/pq"
@@ -16,8 +17,26 @@ import (
) )
// db — подключение к PostgreSQL для записи usage-данных. // db — подключение к PostgreSQL для записи usage-данных.
// nil если billing отключён. // nil если billing отключён. Доступ — только через getDB/setDB (мьютекс):
var db *sql.DB // usageWorker читает db параллельно с Close/Init — иначе data race и nil-паника.
var (
db *sql.DB
dbMu sync.Mutex
)
// getDB — потокобезопасное чтение db.
func getDB() *sql.DB {
dbMu.Lock()
defer dbMu.Unlock()
return db
}
// setDB — потокобезопасная установка db.
func setDB(d *sql.DB) {
dbMu.Lock()
db = d
dbMu.Unlock()
}
// Init — подключается к PostgreSQL и создаёт таблицу sqs_usage_records если не существует. // Init — подключается к PostgreSQL и создаёт таблицу sqs_usage_records если не существует.
// Env переменные: BILLING_PG_HOST, BILLING_PG_PORT, BILLING_PG_DATABASE, BILLING_PG_USER, // Env переменные: BILLING_PG_HOST, BILLING_PG_PORT, BILLING_PG_DATABASE, BILLING_PG_USER,
@@ -46,28 +65,28 @@ func Init() {
host, port, dbname, user, password, sslmode) host, port, dbname, user, password, sslmode)
var err error var err error
db, err = sql.Open("postgres", dsn) d, err := sql.Open("postgres", dsn)
if err != nil { if err != nil {
log.Errorf("billing: failed to open PostgreSQL: %v", err) log.Errorf("billing: failed to open PostgreSQL: %v", err)
return return
} }
// Проверяем реальное подключение (Open не подключается) // Проверяем реальное подключение (Open не подключается)
if err = db.Ping(); err != nil { if err = d.Ping(); err != nil {
log.Errorf("billing: failed to connect to PostgreSQL: %v", err) log.Errorf("billing: failed to connect to PostgreSQL: %v", err)
db.Close() d.Close()
db = nil
return return
} }
// Ограничиваем пул — billing не должен отжирать коннекты у основной БД // Ограничиваем пул — billing не должен отжирать коннекты у основной БД
db.SetMaxOpenConns(5) d.SetMaxOpenConns(5)
db.SetMaxIdleConns(2) d.SetMaxIdleConns(2)
setDB(d)
if err = autoMigrate(); err != nil { if err = autoMigrate(); err != nil {
log.Errorf("billing: failed to create table: %v", err) log.Errorf("billing: failed to create table: %v", err)
db.Close() setDB(nil)
db = nil d.Close()
return return
} }
@@ -77,7 +96,7 @@ func Init() {
// autoMigrate — создаёт таблицу и индекс если не существуют. // autoMigrate — создаёт таблицу и индекс если не существуют.
// Идемпотентно — безопасно вызывать при каждом старте. // Идемпотентно — безопасно вызывать при каждом старте.
func autoMigrate() error { func autoMigrate() error {
_, err := db.Exec(` _, err := getDB().Exec(`
CREATE TABLE IF NOT EXISTS sqs_usage_records ( CREATE TABLE IF NOT EXISTS sqs_usage_records (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
@@ -98,35 +117,105 @@ func autoMigrate() error {
return err return err
} }
// RecordUsage — записывает одну SQS-операцию в таблицу биллинга. // usageRecord — одна запись биллинга, накапливается в буфере воркера.
// Вызывается асинхронно (горутина) чтобы не добавлять latency к SQS-ответу. type usageRecord struct {
// Если billing отключён — no-op. Ошибки логируются, SQS-операция не ломается. tenantID string
func RecordUsage(tenantID, operation, queueName string, msgCount int, msgBytes int64) { operation string
if db == nil { queueName string
msgCount int
msgBytes int64
}
// usageChan — канал-буфер для асинхронных записей биллинга.
// Вместо горутины на каждую операцию (навал горутин под нагрузкой) используется
// один воркер, читающий из буферизованного канала.
var usageChan = make(chan usageRecord, 1024)
// done — сигнал остановки воркера при graceful shutdown.
var done = make(chan struct{})
// closeOnce гарантирует однократный close(done).
var closeOnce sync.Once
// workerWg — ожидание завершения воркера перед закрытием db (иначе Close обнулил
// бы db, пока воркер пишет — nil-паника + потеря остатка буфера usageChan).
var workerWg sync.WaitGroup
// init запускает воркер-горутину биллинга. Воркер стартует всегда; если db == nil
// (billing отключён) — он просто дропает записи.
func init() {
workerWg.Add(1)
go usageWorker()
}
// usageWorker — единственная горутина, пишущая usage-записи в PostgreSQL.
func usageWorker() {
defer workerWg.Done()
for {
select {
case rec := <-usageChan:
processUsage(rec)
case <-done:
// Дренируем остаток буфера перед выходом (не теряем usage).
for {
select {
case rec := <-usageChan:
processUsage(rec)
default:
return
}
}
}
}
}
// processUsage — одна запись usage в PostgreSQL (db == nil → дроп).
func processUsage(rec usageRecord) {
d := getDB()
if d == nil {
return return
} }
_, err := d.Exec(
`INSERT INTO sqs_usage_records (tenant_id, operation, queue_name, msg_count, msg_bytes) VALUES ($1, $2, $3, $4, $5)`,
rec.tenantID, rec.operation, rec.queueName, rec.msgCount, rec.msgBytes,
)
if err != nil {
log.Errorf("billing: failed to record usage [%s/%s]: %v", rec.tenantID, rec.operation, err)
}
}
go func() { // RecordUsage — ставит запись в очередь биллинга (асинхронно, без блокировки SQS).
_, err := db.Exec( // Если billing отключён — no-op. Если буфер переполнен — запись дропается (биллинг
`INSERT INTO sqs_usage_records (tenant_id, operation, queue_name, msg_count, msg_bytes) VALUES ($1, $2, $3, $4, $5)`, // жертвуем ради стабильности основного пути), ошибка логируется.
tenantID, operation, queueName, msgCount, msgBytes, func RecordUsage(tenantID, operation, queueName string, msgCount int, msgBytes int64) {
) if getDB() == nil {
if err != nil { return
log.Errorf("billing: failed to record usage [%s/%s]: %v", tenantID, operation, err) }
} select {
}() case usageChan <- usageRecord{tenantID, operation, queueName, msgCount, msgBytes}:
case <-done:
// Воркер остановлен (shutdown) — запись не примется.
default:
log.Warnf("billing: буфер полон, запись [%s/%s] дропнута", tenantID, operation)
}
} }
// Enabled — возвращает true если billing подключён к PostgreSQL // Enabled — возвращает true если billing подключён к PostgreSQL
func Enabled() bool { func Enabled() bool {
return db != nil return getDB() != nil
} }
// Close — закрывает подключение к PostgreSQL. Вызывается при graceful shutdown. // Close — останавливает воркер, дожидается его завершения и закрывает db.
// Вызывается при graceful shutdown. Порядок обязателен: сначала воркер,
// потом db.Close — иначе гонка db=nil с Exec воркера (nil-паника).
func Close() { func Close() {
closeOnce.Do(func() { close(done) })
workerWg.Wait()
dbMu.Lock()
if db != nil { if db != nil {
db.Close() db.Close()
db = nil db = nil
log.Info("billing: PostgreSQL connection closed")
} }
dbMu.Unlock()
log.Info("billing: PostgreSQL connection closed")
} }
+111 -22
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -16,6 +17,7 @@ import (
"shared-sqs/app/billing" "shared-sqs/app/billing"
"shared-sqs/app/conf" "shared-sqs/app/conf"
"shared-sqs/app/gosqs" "shared-sqs/app/gosqs"
"shared-sqs/app/leader"
"shared-sqs/app/metrics" "shared-sqs/app/metrics"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence" "shared-sqs/app/persistence"
@@ -29,14 +31,24 @@ func main() {
var configFile string var configFile string
var adminToken string var adminToken string
var port string var port string
var controlPort string
var debug bool var debug bool
var loglevel string var loglevel string
var leaderElection bool
var leaderTTL time.Duration
var leaderInterval time.Duration
flag.StringVar(&configFile, "config", "", "config file location") flag.StringVar(&configFile, "config", "", "config file location")
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token") flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
flag.StringVar(&port, "port", "4100", "listen port") flag.StringVar(&port, "port", "4100", "data-plane listen port")
flag.StringVar(&controlPort, "control-port", "4101", "control-plane listen port (localhost only: admin/metrics/ui)")
flag.BoolVar(&debug, "debug", false, "set debug log level") flag.BoolVar(&debug, "debug", false, "set debug log level")
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)") flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
// Leader-election (active/passive HA): выключено по умолчанию — поведение
// одиночного инстанса не меняется. Подробно — doc/thinking/state-extraction-and-ha-2026-10-10.md
flag.BoolVar(&leaderElection, "leader-election", false, "enable leader election via Redis lock (active/passive HA); off = always ready")
flag.DurationVar(&leaderTTL, "leader-ttl", 10*time.Second, "leader lock TTL (should be > 3x leader-interval)")
flag.DurationVar(&leaderInterval, "leader-interval", 3*time.Second, "leader lock refresh/acquire interval")
flag.Parse() flag.Parse()
log.SetFormatter(&log.JSONFormatter{}) log.SetFormatter(&log.JSONFormatter{})
@@ -89,12 +101,15 @@ func main() {
redisPass := os.Getenv("REDIS_PASSWORD") redisPass := os.Getenv("REDIS_PASSWORD")
if redisAddr != "" { if redisAddr != "" {
if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil { if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil {
log.Warnf("Не удалось подключиться к Redis: %v — работаем в memory-only режиме", err) // Fail-fast: при явно заданном REDIS_ADDR, но недоступном Redis,
// продолжать опасно — SaveQueue перезапишет Redis пустотой, read-through
// и failover сломаются. Падаем, чтобы systemd перезапустил.
log.Fatalf("Не удалось подключиться к Redis (%s): %v", redisAddr, err)
} }
} }
// Восстановление состояния из Redis (тенанты + очереди) // Восстановление состояния из Redis (тенанты + очереди)
if persistence.Client != nil { if persistence.Client.Load() != nil {
// Загружаем тенантов // Загружаем тенантов
tenantsRaw, err := persistence.LoadAllTenantsRaw() tenantsRaw, err := persistence.LoadAllTenantsRaw()
if err != nil { if err != nil {
@@ -112,16 +127,26 @@ func main() {
// Загружаем очереди // Загружаем очереди
queues, err := persistence.LoadAllQueues() queues, err := persistence.LoadAllQueues()
if err != nil { if err != nil {
log.Warnf("Ошибка загрузки очередей из Redis: %v", err) log.Fatalf("Ошибка загрузки очередей из Redis: %v", err)
} else { } else {
models.SyncQueues.Lock() models.SyncQueues.Lock()
for k, q := range queues { for k, q := range queues {
models.SyncQueues.Queues[k] = q models.SyncQueues.AddQueue(k, q)
} }
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
} }
} }
// Бэкфилл индекса accessKey→tenant для read-through (идемпотентно; нужен для
// тенантов, созданных до появления индекса, и для multi-instance сценария).
if persistence.Client.Load() != nil {
for _, t := range tenantStore.List() {
if data, err := json.Marshal(t); err == nil {
persistence.SaveTenantRaw(t.ID, data)
}
}
}
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true // Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" { if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
seedDemoData(tenantStore) seedDemoData(tenantStore)
@@ -130,8 +155,39 @@ func main() {
// Billing: подключение к PostgreSQL для учёта использования. // Billing: подключение к PostgreSQL для учёта использования.
// Если BILLING_PG_HOST не задан — billing отключён, SQS работает без него. // Если BILLING_PG_HOST не задан — billing отключён, SQS работает без него.
billing.Init() billing.Init()
// Роутер с tenant auth и admin API // Разбиение data-plane/control-plane (Шаг 2):
r := router.New(tenantStore, adminToken) // data-plane — публичный SQS API; control-plane — админка/метрики/UI (только localhost).
dataRouter := router.NewDataPlane(tenantStore)
controlRouter := router.NewControlPlane(tenantStore, adminToken)
// Leader-election (active/passive, Шаг 3 — вариант C).
// Включается флагом --leader-election или SHARED_SQS_LEADER_ELECTION=true.
// Только лидер отдаёт /ready → 200; пассивный — 503 → LB/failover держит
// трафик на активном. Без флага ReadyFunc = nil (всегда готов) — одиночный
// инстанс работает ровно как раньше.
//
// Ограничение (осознанное, см. док): in-memory состояние НЕ шарится. Пока
// второй инстанс запущен «горячим», очереди живут в Redis, но изменения
// второго (пассивного) инстанса не увидит активный до рестарта. Поэтому
// на фазе C пассивный НЕ должен принимать SQS-трафик: nginx/LB обязан
// проверять /ready и не отправлять API-запросы на 503.
if !leaderElection && os.Getenv("SHARED_SQS_LEADER_ELECTION") == "true" {
leaderElection = true
}
var elector *leader.Elector
if leaderElection {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
instanceID := fmt.Sprintf("%s-%d", hostname, os.Getpid())
elector = leader.New("ssq:leader", instanceID, leaderTTL, leaderInterval)
elector.Start()
router.ReadyFunc = elector.IsLeader
log.Infof("leader-election включён (id=%s, ttl=%s, interval=%s)", instanceID, leaderTTL, leaderInterval)
} else {
log.Info("leader-election выключен — инстанс всегда ready (одиночный режим)")
}
// PeriodicTasks — visibility timeout, DLQ, deduplication // PeriodicTasks — visibility timeout, DLQ, deduplication
quit := make(chan bool) quit := make(chan bool)
@@ -140,20 +196,43 @@ func main() {
// Metrics: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд // Metrics: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд
metrics.StartGaugeUpdater(15*time.Second, quit) metrics.StartGaugeUpdater(15*time.Second, quit)
// HTTP сервер с таймаутами // HTTP сервер с таймаутами (рекомендации ревью Соннета, план v0.1.35):
srv := &http.Server{ // - ReadHeaderTimeout 10с — лимит ТОЛЬКО на чтение заголовков (slowloris).
Addr: "0.0.0.0:" + port, // ReadTimeout НЕ ставим: он обрывал бы запросы с медленной/большой
Handler: r, // загрузкой тела (в т.ч. большие SendMessage) и ломал бы long-poll.
ReadTimeout: 30 * time.Second, // - WriteTimeout 35с — ответ; чуть больше max WaitTimeSeconds (20с long-poll).
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s) // - IdleTimeout 90с — keep-alive; больше интервала ретраев клиентских SDK.
IdleTimeout: 60 * time.Second, // - MaxHeaderBytes 8192 — защита от гигантских заголовков
// (платформа режет >16KB, наш лимит строже).
dataSrv := &http.Server{
Addr: "0.0.0.0:" + port,
Handler: dataRouter,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 35 * time.Second,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 8192,
}
// control-plane слушает ТОЛЬКО localhost — админка/метрики/UI не должны быть публичными.
controlSrv := &http.Server{
Addr: "127.0.0.1:" + controlPort,
Handler: controlRouter,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 35 * time.Second,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 8192,
} }
// Запуск в горутине для graceful shutdown // Запуск в горутинах для graceful shutdown
serverErr := make(chan error, 1) serverErr := make(chan error, 2)
go func() { go func() {
log.Infof("shared-sqs listening on 0.0.0.0:%s", port) log.Infof("shared-sqs data-plane listening on 0.0.0.0:%s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := dataSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
go func() {
log.Infof("shared-sqs control-plane listening on 127.0.0.1:%s", controlPort)
if err := controlSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err serverErr <- err
} }
}() }()
@@ -172,14 +251,24 @@ func main() {
// Остановить PeriodicTasks // Остановить PeriodicTasks
close(quit) close(quit)
// Закрыть billing (если был подключён) // Отпустить лидерский лок (если были лидером) до закрытия слушателей,
billing.Close() // чтобы пассивный взял его быстрее, чем через TTL.
if elector != nil {
elector.Stop()
}
// Дать 10 секунд на завершение текущих HTTP запросов // Дать 10 секунд на завершение текущих HTTP запросов
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := srv.Shutdown(ctx); err != nil { if err := dataSrv.Shutdown(ctx); err != nil {
log.Errorf("Server shutdown error: %v", err) log.Errorf("Data-plane shutdown error: %v", err)
} }
if err := controlSrv.Shutdown(ctx); err != nil {
log.Errorf("Control-plane shutdown error: %v", err)
}
// Закрыть billing ПОСЛЕ Shutdown — иначе in-flight запросы писали бы usage
// в уже закрытую БД (ревью 2026-10-11).
billing.Close()
log.Info("shared-sqs stopped") log.Info("shared-sqs stopped")
} }
+28 -23
View File
@@ -1,12 +1,11 @@
// app/cmd/seed.go // app/cmd/seed.go
// Created: 2026-04-09
// Updated: 2026-04-09 — фиксированные credentials для demo-tenant (BYOC)
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true. // Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
// Создаёт тенанта demo-service с 5 очередями и демо-сообщениями. // Создаёт тенанта demo-service со случайными кредами (генерируются через
// tenant.Create → crypto/rand) с 5 очередями и демо-сообщениями.
// //
// DEMO CREDENTIALS — только для тестового стенда.
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к // Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
// admin API и к очередям других тенантов. Credentials открыты намеренно — стенд публичный. // admin API и к очередям других тенантов. Креды генерируются случайно и печатаются
// в лог при старте (только при явном включении SHARED_SQS_SEED_DEMO).
package main package main
import ( import (
@@ -17,30 +16,36 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"shared-sqs/app/gosqs"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
// Фиксированные credentials демо-тенанта.
// Открыты намеренно — тестовый стенд.
// Тенант ограничен 10 очередями и не имеет прав admin API.
const ( const (
demoTenantID = "t-demo-shared-sqs-ngcloud"
demoAccessKey = "SSAK-demo-shared-sqs"
demoSecretKey = "demo-secret-key-shared-sqs-ngcloud-2026"
demoTenantName = "demo-service" demoTenantName = "demo-service"
demoMaxQueues = 10 demoMaxQueues = 10
) )
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями. // seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера. // Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
// Креды генерируются случайно (tenant.Create) — никаких захардкоженных
// AccessKey/SecretKey в исходнике.
func seedDemoData(store *tenant.TenantStore) { func seedDemoData(store *tenant.TenantStore) {
t, err := store.CreateFixed(demoTenantName, demoMaxQueues, demoTenantID, demoAccessKey, demoSecretKey) // Идемпотентность: если демо-тенант уже есть (восстановлен из Redis) — не плодим копии.
for _, existing := range store.List() {
if existing.Name == demoTenantName {
log.Infof("seed: тенант %s уже существует (%s) — сид пропущен", demoTenantName, existing.ID)
return
}
}
t, err := store.Create(demoTenantName, demoMaxQueues)
if err != nil { if err != nil {
log.Warnf("seed: не удалось создать demo-tenant: %v", err) log.Warnf("seed: не удалось создать demo-tenant: %v", err)
return return
} }
log.Infof("seed: создан тенант %s (AccessKey=%s)", t.ID, t.AccessKey) log.Infof("seed: создан тенант %s (AccessKey=%s, SecretKey=%s)", t.ID, t.AccessKey, t.SecretKey)
// Демо-очереди с набором сообщений // Демо-очереди с набором сообщений
queues := []struct { queues := []struct {
@@ -87,14 +92,9 @@ func seedDemoData(store *tenant.TenantStore) {
// URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN // URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN
// Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью. // Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью.
env := models.CurrentEnvironment // URL — единый источник (gosqs.TenantQueueURL), учитывает ExternalURLBase.
var queueURL string queueURL := gosqs.TenantQueueURL(t, q.name)
if env.Region != "" { queueARN := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + q.name
queueURL = "http://" + env.Region + "." + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
} else {
queueURL = "http://" + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
}
queueARN := "arn:aws:sqs:" + env.Region + ":" + t.ID + ":" + q.name
msgs := make([]models.SqsMessage, 0, len(q.msgs)) msgs := make([]models.SqsMessage, 0, len(q.msgs))
for _, body := range q.msgs { for _, body := range q.msgs {
@@ -108,8 +108,7 @@ func seedDemoData(store *tenant.TenantStore) {
}) })
} }
models.SyncQueues.Lock() queue := &models.Queue{
models.SyncQueues.Queues[key] = &models.Queue{
Name: q.name, Name: q.name,
URL: queueURL, URL: queueURL,
Arn: queueARN, Arn: queueARN,
@@ -119,8 +118,14 @@ func seedDemoData(store *tenant.TenantStore) {
Messages: msgs, Messages: msgs,
Duplicates: make(map[string]time.Time), Duplicates: make(map[string]time.Time),
} }
models.SyncQueues.Lock()
models.SyncQueues.AddQueue(key, queue)
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
// Персистим очередь и сообщения в Redis (иначе после рестарта демо-очереди исчезают).
persistence.SaveQueue(key, queue)
persistence.SaveMessages(key, msgs)
log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs)) log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs))
} }
+12 -2
View File
@@ -90,6 +90,9 @@ func LoadYamlConfig(filename string, env string) []string {
queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" + queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" +
models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
} }
if base := strings.TrimRight(models.CurrentEnvironment.ExternalURLBase, "/"); base != "" {
queueUrl = base + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
}
queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name
if queue.ReceiveMessageWaitTimeSeconds == 0 { if queue.ReceiveMessageWaitTimeSeconds == 0 {
@@ -105,7 +108,7 @@ func LoadYamlConfig(filename string, env string) []string {
queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod
} }
models.SyncQueues.Queues[queue.Name] = &models.Queue{ models.SyncQueues.AddQueue(queue.Name, &models.Queue{
Name: queue.Name, Name: queue.Name,
VisibilityTimeout: queue.VisibilityTimeout, VisibilityTimeout: queue.VisibilityTimeout,
Arn: queueArn, Arn: queueArn,
@@ -116,7 +119,7 @@ func LoadYamlConfig(filename string, env string) []string {
IsFIFO: utils.HasFIFOQueueName(queue.Name), IsFIFO: utils.HasFIFOQueueName(queue.Name),
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time), Duplicates: make(map[string]time.Time),
} })
} }
// Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка // Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка
@@ -126,6 +129,9 @@ func LoadYamlConfig(filename string, env string) []string {
err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy) err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy)
if err != nil { if err != nil {
log.Errorf("err: %s", err) log.Errorf("err: %s", err)
// Разблокировать глобальный мьютекс перед выходом — иначе все
// операции с очередями зависнут навсегда (deadlock).
models.SyncQueues.Unlock()
return ports return ports
} }
} }
@@ -140,6 +146,10 @@ func LoadYamlConfig(filename string, env string) []string {
// AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты // AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты
// устанавливались при любом раннем return (например, если конфиг не найден). // устанавливались при любом раннем return (например, если конфиг не найден).
func applyEnvironmentDefaults() { func applyEnvironmentDefaults() {
// Публичный адрес очередей — override через env (работает и когда goaws.yaml не найден).
if v := os.Getenv("SHARED_SQS_EXTERNAL_URL_BASE"); v != "" {
models.CurrentEnvironment.ExternalURLBase = v
}
if models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout <= 0 { if models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout <= 0 {
models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30 models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30
} }
+1 -15
View File
@@ -1,13 +1,10 @@
Local: # Environment name that can be passed on the command line Local: # Environment name that can be passed on the command line
# (i.e.: ./goaws [Local | Dev] -- defaults to 'Local') # (i.e.: ./goaws [Local | Dev] -- defaults to 'Local')
Host: goaws.com # hostname of the goaws system (for docker-compose this is the tag name of the container) Host: goaws.com # hostname of the goaws system (for docker-compose this is the tag name of the container)
# you can now use either 1 port for both sns and sqs or alternatively you can comment out Port and use SqsPort + SnsPort for compatibilyt with
# yopa and (fage-sns + face-sqs). If both ways are in the config file on the one "Port" will be used by GoAws
Port: 4100 # port to listen on. Port: 4100 # port to listen on.
# SqsPort: 9324 # alterante Sqs Port
# SnsPort: 9292 # alternate Sns Port
Region: us-east-1 Region: us-east-1
AccountId: "100010001000" AccountId: "100010001000"
ExternalURLBase: "" # публичный адрес очередей (напр. https://sqs.sinefo.ru); пусто → http://{region}.{host}:{port}
LogToFile: false # Log messages (true/false) LogToFile: false # Log messages (true/false)
LogFile: .st/goaws_messages.log # Log filename (for message logging LogFile: .st/goaws_messages.log # Log filename (for message logging
EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId
@@ -17,7 +14,6 @@ Local: # Environment name that can be passed on the
MaximumMessageSize: 262144 # maximum message size (bytes) MaximumMessageSize: 262144 # maximum message size (bytes)
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented # MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
Queues: [] # No default queues — created via Admin API / AWS CLI by tenants Queues: [] # No default queues — created via Admin API / AWS CLI by tenants
Topics: [] # No default topics
RandomLatency: # Parameters for introducing random latency into message queuing RandomLatency: # Parameters for introducing random latency into message queuing
Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied. Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied.
Max: 0 # Desired latency in milliseconds Max: 0 # Desired latency in milliseconds
@@ -25,20 +21,10 @@ Local: # Environment name that can be passed on the
Dev: # Another environment Dev: # Another environment
Host: localhost Host: localhost
Port: 4100 Port: 4100
# SqsPort: 9324
# SnsPort: 9292
AccountId: "794373491471" AccountId: "794373491471"
LogToFile: false LogToFile: false
LogFile: ./goaws_messages.log LogFile: ./goaws_messages.log
Queues: Queues:
- Name: dev-queue1 - Name: dev-queue1
- Name: dev-queue2 - Name: dev-queue2
Topics:
- Name: dev-topic1
Subscriptions:
- QueueName: dev-queue3
Raw: false
- QueueName: dev-queue4
Raw: true
- Name: dev-topic2
+43
View File
@@ -0,0 +1,43 @@
// app/gosqs/build.go
// Общие билдеры очереди и сообщения — чтобы admin/UI-путь создавал объекты
// ровно так же, как SQS API (единый путь, без расхождения дефолтов).
package gosqs
import (
"time"
"github.com/google/uuid"
"shared-sqs/app/models"
"shared-sqs/app/tenant"
"shared-sqs/app/utils"
)
// BuildQueueForTenant — создаёт очередь тенанта с теми же URL/ARN/дефолтами и
// FIFO-детектом, что и CreateQueueV1. Используется admin-хендлерами, чтобы
// поведение UI совпадало с SQS API (URL/ARN заполнены, .fifo → IsFIFO).
func BuildQueueForTenant(t *tenant.Tenant, queueName string) *models.Queue {
return &models.Queue{
Name: queueName,
URL: tenantQueueURL(t, queueName),
Arn: tenantQueueARN(t, queueName),
VisibilityTimeout: 30,
MaximumMessageSize: MaxMessageSizeDefault,
MessageRetentionPeriod: 345600, // 4 дня
IsFIFO: utils.HasFIFOQueueName(queueName),
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Messages: []models.SqsMessage{},
Duplicates: make(map[string]time.Time),
}
}
// BuildSqsMessage — создаёт сообщение с UUID, MD5 тела и временем отправки —
// так же, как это делает SendMessageV1 (для admin/UI-пути).
func BuildSqsMessage(body string) models.SqsMessage {
return models.SqsMessage{
MessageBody: body,
Uuid: uuid.NewString(),
MD5OfMessageBody: utils.GetMD5Hash(body),
SentTime: time.Now(),
}
}
+22 -44
View File
@@ -47,62 +47,40 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// ВСЯ работа с очередью — под одним Lock. queue := models.SyncQueues.GetLocked(key)
// Раньше проверка существования шла БЕЗ блокировки (чтение map) — if queue == nil {
// concurrent map read and map write при параллельном DeleteQueue
// (fatal error, под падал под нагрузкой).
// defer Unlock: при любой панике в секции мьютекс не останется захваченным.
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
messageFound := false messageFound := false
var changedMsgUuid string var changedMsgUuid string
var msgRemoved bool var msgRemoved bool
for i := 0; i < len(queue.Messages); i++ { var dlqMsg *models.SqsMessage
msgs := queue.Messages // O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза.
if msgs[i].ReceiptHandle == receiptHandle { if i, ok := queue.IndexOfReceipt(receiptHandle); ok {
timeout := queue.VisibilityTimeout if visibilityTimeout == 0 {
if visibilityTimeout == 0 { // Возврат видимости (+ DLQ-перенос при исчерпании MaxReceiveCount) —
msgs[i].ReceiptTime = time.Now().UTC() // единый путь с батчевой версией.
msgs[i].ReceiptHandle = "" changedMsgUuid, msgRemoved, dlqMsg = queue.ResetVisibilityAndTryDLQ(i, receiptHandle)
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(timeout) * time.Second) } else {
msgs[i].Retry++ queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
if queue.MaxReceiveCount > 0 && changedMsgUuid = queue.Messages[i].Uuid
queue.DeadLetterQueue != nil &&
msgs[i].Retry >= queue.MaxReceiveCount {
changedMsgUuid = msgs[i].Uuid
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i])
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
msgRemoved = true
} else {
changedMsgUuid = msgs[i].Uuid
}
} else {
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
changedMsgUuid = msgs[i].Uuid
}
messageFound = true
break
} }
messageFound = true
} }
// Персистим изменения в Redis под Lock (сетевые записи асинхронны). // Персистим изменения в Redis под Lock (сетевые записи асинхронны).
if messageFound { if messageFound {
if msgRemoved { if msgRemoved {
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis // Сообщение удалено (перемещено в DLQ) — удаляем из Redis исходной
persistence.DeleteMessagePersist(key, changedMsgUuid) // очереди и сохраняем в DLQ, иначе после рестарта оно пропадёт из DLQ.
} else { _ = persistence.DeleteMessagePersist(key, changedMsgUuid)
// Сообщение обновлено — перезаписываем в Redis if dlqMsg != nil {
for i := range queue.Messages { persistence.SaveMessage(dlqKeyFor(key, queue.DeadLetterQueue.Name), dlqMsg)
if queue.Messages[i].Uuid == changedMsgUuid {
persistence.SaveMessage(key, &queue.Messages[i])
break
}
} }
} else if i, ok := queue.IndexOfUUID(changedMsgUuid); ok {
// Сообщение обновлено — перезаписываем в Redis
persistence.SaveMessage(key, &queue.Messages[i])
} }
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, queue)
} }
+22 -19
View File
@@ -59,15 +59,11 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
ids[entry.Id] = true ids[entry.Id] = true
} }
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0) successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0)
failedEntries := make([]models.BatchResultErrorEntry, 0) failedEntries := make([]models.BatchResultErrorEntry, 0)
@@ -84,22 +80,29 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
} }
messageFound := false messageFound := false
for i := 0; i < len(queue.Messages); i++ { // O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза.
if queue.Messages[i].ReceiptHandle == entry.ReceiptHandle { if i, ok := queue.IndexOfReceipt(entry.ReceiptHandle); ok {
if entry.VisibilityTimeout == 0 { if entry.VisibilityTimeout == 0 {
// Сброс: сообщение снова видимо, счётчик retry++ // Сброс + DLQ-перенос при исчерпании MaxReceiveCount — единый путь
queue.Messages[i].ReceiptTime = time.Now().UTC() // с одиночной версией (раньше батч лишь инкрементировал Retry и не
queue.Messages[i].ReceiptHandle = "" // переносил в DLQ — счётчик и перенос могли расходиться).
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second) uuid, moved, dlqMsg := queue.ResetVisibilityAndTryDLQ(i, entry.ReceiptHandle)
queue.Messages[i].Retry++ if moved {
} else { // Сообщение перенесено в DLQ и удалено из очереди — чистим Redis
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second) // исходной и сохраняем в DLQ (иначе пропадёт после рестарта).
_ = persistence.DeleteMessagePersist(key, uuid)
if dlqMsg != nil {
persistence.SaveMessage(dlqKeyFor(key, queue.DeadLetterQueue.Name), dlqMsg)
}
} else if j, ok := queue.IndexOfUUID(uuid); ok {
persistence.SaveMessage(key, &queue.Messages[j])
} }
} else {
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second)
// Персистим изменённое сообщение отдельно // Персистим изменённое сообщение отдельно
persistence.SaveMessage(key, &queue.Messages[i]) persistence.SaveMessage(key, &queue.Messages[i])
messageFound = true
break
} }
messageFound = true
} }
if messageFound { if messageFound {
+44 -22
View File
@@ -49,37 +49,59 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
queueUrl := tenantQueueURL(t, queueName) queueUrl := tenantQueueURL(t, queueName)
queueArn := tenantQueueARN(t, queueName) queueArn := tenantQueueARN(t, queueName)
// Готовим очередь и применяем атрибуты ВНЕ глобального лока: setQueueAttributesV1
// берёт SyncQueues.RLock (поиск DLQ), а RWMutex не реентрантен — вызов под
// уже захваченным SyncQueues.Lock дал бы дедлок.
queue := &models.Queue{
Name: queueName,
URL: queueUrl,
Arn: queueArn,
IsFIFO: utils.HasFIFOQueueName(queueName),
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time),
// Дефолты (как в InitQueues): без них очередь из API получала бы
// VisibilityTimeout=0 и т.д. — сообщение сразу видимо, retention 0 = нет
// эвикции. Переданные атрибуты перезапишут дефолты в setQueueAttributesV1.
VisibilityTimeout: models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout,
MaximumMessageSize: models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize,
MessageRetentionPeriod: models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod,
ReceiveMessageWaitTimeSeconds: models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds,
// CreatedTimestamp/LastModifiedTimestamp в GetQueueAttributes (AWS их отдаёт).
CreatedAt: time.Now().UTC(),
LastModifiedAt: time.Now().UTC(),
}
// Список имён переданных атрибутов — для whitelist-валидации
// (Query-протокол: Attribute.N.Name; JSON: nil — проверка пропускается).
var provided map[string]string
if req.Header.Get("Content-Type") != "application/x-amz-json-1.0" {
provided = utils.ExtractQueueAttributes(req.PostForm)
}
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
return utils.CreateErrorResponseV1(err.Error(), true)
}
models.SyncQueues.Lock() models.SyncQueues.Lock()
if _, exists := models.SyncQueues.Queues[key]; !exists { existing, exists := models.SyncQueues.Queues[key]
if !exists {
// Проверка лимита очередей тенанта // Проверка лимита очередей тенанта
if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues { if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues {
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("LimitExceeded", true) return utils.CreateErrorResponseV1("LimitExceeded", true)
} }
log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID) log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID)
queue := &models.Queue{ models.SyncQueues.AddQueue(key, queue)
Name: queueName, existing = queue
URL: queueUrl,
Arn: queueArn,
IsFIFO: utils.HasFIFOQueueName(queueName),
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time),
}
// Список имён переданных атрибутов — для whitelist-валидации
// (Query-протокол: Attribute.N.Name; JSON: nil — проверка пропускается).
var provided map[string]string
if req.Header.Get("Content-Type") != "application/x-amz-json-1.0" {
provided = utils.ExtractQueueAttributes(req.PostForm)
}
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided); err != nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1(err.Error(), true)
}
models.SyncQueues.Queues[key] = queue
} }
// Сохраняем очередь в Redis пока держим Lock — консистентный снапшот
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
// SaveQueue маршалит map'ы очереди (Duplicates/FIFO*/Tags) в JSON. Это нужно
// делать под per-queue блокировкой, а НЕ под глобальным SyncQueues.Lock:
// мутаторы (send/tag/CMV) пишут в эти map под queue.mu, и идемпотентный
// CreateQueue существующей очереди без queue.mu даёт fatal
// "concurrent map iteration and map write" (краш всего процесса).
existing.LockMu()
persistence.SaveQueue(key, existing)
existing.UnlockMu()
respStruct := models.CreateQueueResponse{ respStruct := models.CreateQueueResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
Result: models.CreateQueueResult{QueueUrl: queueUrl}, Result: models.CreateQueueResult{QueueUrl: queueUrl},
-254
View File
@@ -1,254 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"time"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/mitchellh/copystructure"
"github.com/stretchr/testify/assert"
)
func TestCreateQueueV1_success(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.CreateQueueRequest)
*v = fixtures.CreateQueueRequest
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := CreateQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, fixtures.CreateQueueResponse, response)
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
assert.Equal(t, fixtures.FullyPopulatedQueue, actualQueue)
}
func TestCreateQueueV1_success_with_redrive_policy(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
c, _ := dupe.(models.CreateQueueRequest)
c.Attributes.RedrivePolicy = models.RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", fixtures.DeadLetterQueueName),
}
v := resultingStruct.(*models.CreateQueueRequest)
*v = c
return true
}
dlq := &models.Queue{
Name: fixtures.DeadLetterQueueName,
}
models.SyncQueues.Queues[fixtures.DeadLetterQueueName] = dlq
expectedQueue := &models.Queue{
Name: fixtures.QueueName,
URL: fmt.Sprintf("http://%s.%s:%s/%s/%s",
fixtures.LOCAL_ENVIRONMENT.Region,
fixtures.LOCAL_ENVIRONMENT.Host,
fixtures.LOCAL_ENVIRONMENT.Port,
fixtures.LOCAL_ENVIRONMENT.AccountID,
fixtures.QueueName,
),
Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s",
fixtures.LOCAL_ENVIRONMENT.Region,
fixtures.LOCAL_ENVIRONMENT.AccountID,
fixtures.QueueName,
),
VisibilityTimeout: 5,
ReceiveMessageWaitTimeSeconds: 4,
DelaySeconds: 1,
MaximumMessageSize: 2,
MessageRetentionPeriod: 3,
DeadLetterQueue: dlq,
MaxReceiveCount: 100,
Duplicates: make(map[string]time.Time),
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := CreateQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, fixtures.CreateQueueResponse, response)
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
assert.Equal(t, expectedQueue, actualQueue)
}
func TestCreateQueueV1_success_with_existing_queue(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.CreateQueueRequest)
*v = fixtures.CreateQueueRequest
return true
}
q := &models.Queue{
Name: fixtures.QueueName,
}
models.SyncQueues.Queues[fixtures.QueueName] = q
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := CreateQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, fixtures.CreateQueueResponse, response)
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
assert.Equal(t, q, actualQueue)
}
func TestCreateQueueV1_success_with_no_request_attributes_falls_back_to_default(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
c, _ := dupe.(models.CreateQueueRequest)
c.Attributes = models.QueueAttributes{}
v := resultingStruct.(*models.CreateQueueRequest)
*v = c
return true
}
expectedQueue := &models.Queue{
Name: fixtures.QueueName,
URL: fmt.Sprintf("http://%s.%s:%s/%s/%s",
fixtures.LOCAL_ENVIRONMENT.Region,
fixtures.LOCAL_ENVIRONMENT.Host,
fixtures.LOCAL_ENVIRONMENT.Port,
fixtures.LOCAL_ENVIRONMENT.AccountID,
fixtures.QueueName,
),
Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s",
fixtures.LOCAL_ENVIRONMENT.Region,
fixtures.LOCAL_ENVIRONMENT.AccountID,
fixtures.QueueName,
),
VisibilityTimeout: 0,
ReceiveMessageWaitTimeSeconds: 0,
DelaySeconds: 0,
MaximumMessageSize: 0,
MessageRetentionPeriod: 0,
Duplicates: make(map[string]time.Time),
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := CreateQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, fixtures.CreateQueueResponse, response)
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
assert.Equal(t, expectedQueue, actualQueue)
}
func TestCreateQueueV1_success_no_configured_region_for_queue_url(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
models.CurrentEnvironment.Region = ""
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
c, _ := dupe.(models.CreateQueueRequest)
c.Attributes = models.QueueAttributes{}
v := resultingStruct.(*models.CreateQueueRequest)
*v = c
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := CreateQueueV1(r)
assert.Equal(t, http.StatusOK, code)
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
assert.Equal(t,
fmt.Sprintf("http://%s:%s/%s/%s",
fixtures.LOCAL_ENVIRONMENT.Host,
fixtures.LOCAL_ENVIRONMENT.Port,
fixtures.LOCAL_ENVIRONMENT.AccountID,
fixtures.QueueName,
),
actualQueue.URL,
)
}
func TestCreateQueueV1_request_transformer_error(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := CreateQueueV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestCreateQueueV1_invalid_dead_letter_queue_error(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
c, _ := dupe.(models.CreateQueueRequest)
c.Attributes.RedrivePolicy = models.RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"),
}
v := resultingStruct.(*models.CreateQueueRequest)
*v = c
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := CreateQueueV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
+30 -22
View File
@@ -42,30 +42,38 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle) log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
if _, ok := models.SyncQueues.Queues[key]; ok {
for i, msg := range models.SyncQueues.Queues[key].Messages {
if msg.ReceiptHandle == receiptHandle {
msgUuid := msg.Uuid
models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID)
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...)
delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID)
// Удаляем одно сообщение из Redis — O(1)
persistence.DeleteMessagePersist(key, msgUuid)
// Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.DeleteMessageResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
return 200, &respStruct
}
}
log.Warning("Receipt Handle not found")
} else {
log.Warning("Queue not found") log.Warning("Queue not found")
return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
} }
defer queue.UnlockMu()
// O(1)-поиск сообщения по индексу ReceiptHandle → индекс в Messages (P1.2).
// Раньше — линейный скан всего среза + сдвиг остатка (O(N) на большое тело).
if i, ok := queue.IndexOfReceipt(receiptHandle); ok {
msg := queue.Messages[i] // копия ДО мутации: RemoveIndex может сдвинуть срез
queue.ClearInFlight(i, receiptHandle)
queue.UnlockGroup(msg.GroupID)
queue.RemoveIndex(i) // tombstone за O(1) + подрезка хвоста/амортизированная компакция
// ВАЖНО: запись в Duplicates НЕ удаляем — AWS держит dedup-окно 5 минут
// независимо от удаления сообщения (иначе повторный send с тем же
// dedupId доставлялся бы повторно). Чистит только истечение окна (PeriodicTasks).
// Удаляем одно сообщение из Redis — O(1). Ошибка → 500: иначе клиент
// получил бы 200, а после рестарта сообщение «воскресло» бы.
if err := persistence.DeleteMessagePersist(key, msg.Uuid); err != nil {
log.Errorf("DeleteMessage: не удалось удалить сообщение из Redis: %v", err)
return utils.CreateErrorResponseV1("InternalError", true)
}
// Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, queue)
respStruct := models.DeleteMessageResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
return 200, &respStruct
}
log.Warning("Receipt Handle not found")
return utils.CreateErrorResponseV1("MessageDoesNotExist", true) return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
} }
+43 -23
View File
@@ -54,52 +54,72 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true) return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true)
} }
ids[v.Id] = true ids[v.Id] = true
// Пустой ReceiptHandle опасен: в JSON-протоколе (x-amz-json-1.0) он не
// отсекается парсером и совпадает с ReceiptHandle НЕвыданных сообщений (""),
// что удаляло бы все такие сообщения очереди одним запросом.
if v.ReceiptHandle == "" {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
} }
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
deleteMessageMap := make(map[string]*deleteEntry) // Ключ — Id, а НЕ ReceiptHandle: два entry с одинаковым ReceiptHandle и разными
// Id не должны перезаписывать друг друга (первый исчезал бы и не попадал ни в
// Successful, ни в Failed — ревью 2026-10-11). entriesByHandle — обратный индекс.
entriesByID := make(map[string]*deleteEntry)
entriesByHandle := make(map[string][]*deleteEntry)
for _, entry := range requestBody.Entries { for _, entry := range requestBody.Entries {
deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{ de := &deleteEntry{
Id: entry.Id, Id: entry.Id,
ReceiptHandle: entry.ReceiptHandle, ReceiptHandle: entry.ReceiptHandle,
Deleted: false, Deleted: false,
} }
entriesByID[entry.Id] = de
entriesByHandle[entry.ReceiptHandle] = append(entriesByHandle[entry.ReceiptHandle], de)
} }
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0) deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
deletedUuids := make([]string, 0) deletedUuids := make([]string, 0)
remainingMessages := make([]models.SqsMessage, 0, len(queue.Messages))
for _, message := range queue.Messages { // Идём по индексам, не пересобирая срез (P1.2): удаление = tombstone за O(1),
if de, found := deleteMessageMap[message.ReceiptHandle]; found { // индексы не сдвигаются, поэтому итерация остаётся корректной.
log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID) for i := range queue.Messages {
queue.UnlockGroup(message.GroupID) if queue.IsDead(i) {
delete(queue.Duplicates, message.DeduplicationID) continue
}
message := &queue.Messages[i]
des, found := entriesByHandle[message.ReceiptHandle]
if !found {
continue
}
log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID)
// Читаем поля ДО RemoveIndex: компакция может перезаписать слот.
groupID, uuid := message.GroupID, message.Uuid
queue.ClearInFlight(i, message.ReceiptHandle)
queue.UnlockGroup(groupID)
// Duplicates НЕ чистим — dedup-окно 5 минут живёт независимо от удаления (AWS).
for _, de := range des {
de.Deleted = true de.Deleted = true
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id}) deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
deletedUuids = append(deletedUuids, message.Uuid)
} else {
remainingMessages = append(remainingMessages, message)
} }
deletedUuids = append(deletedUuids, uuid)
queue.RemoveIndex(i)
}
// Удаляем сообщения из Redis отдельно — O(batch_size). Ошибка → InternalError.
if err := persistence.DeleteMessagesPersist(key, deletedUuids); err != nil {
log.Errorf("DeleteMessageBatch: не удалось удалить сообщения из Redis: %v", err)
return utils.CreateErrorResponseV1("InternalError", true)
} }
queue.Messages = remainingMessages
// Удаляем сообщения из Redis отдельно — O(batch_size)
persistence.DeleteMessagesPersist(key, deletedUuids)
// Метаданные очереди (duplicates, FIFO state) // Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, queue)
notFoundEntries := make([]models.BatchResultErrorEntry, 0) notFoundEntries := make([]models.BatchResultErrorEntry, 0)
for _, de := range deleteMessageMap { for _, de := range entriesByID {
if !de.Deleted { if !de.Deleted {
notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{ notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{
Code: "1", Code: "1",
-329
View File
@@ -1,329 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/test"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestDeleteMessageBatchV1_success_all_message(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
q := &models.Queue{
Name: "testing",
Messages: []models.SqsMessage{
{
MessageBody: "test%20message%20body%201",
ReceiptHandle: "test1",
},
{
MessageBody: "test%20message%20body%202",
ReceiptHandle: "test2",
},
{
MessageBody: "test%20message%20body%203",
ReceiptHandle: "test3",
},
},
}
models.SyncQueues.Queues["testing"] = q
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteMessageBatchRequest)
*v = models.DeleteMessageBatchRequest{
Entries: []models.DeleteMessageBatchRequestEntry{
{
Id: "delete-test-1",
ReceiptHandle: "test1",
},
{
Id: "delete-test-2",
ReceiptHandle: "test2",
},
{
Id: "delete-test-3",
ReceiptHandle: "test3",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"),
}
return true
}
_, request2 := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
status, response2 := DeleteMessageBatchV1(request2)
deleteMessageBatchResponse := response2.(models.DeleteMessageBatchResponse)
assert.Equal(t, status, http.StatusOK)
assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id)
assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Successful[1].Id)
assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[2].Id)
assert.Empty(t, deleteMessageBatchResponse.Result.Failed)
assert.Empty(t, models.SyncQueues.Queues["testing"].Messages)
}
func TestDeleteMessageBatchV1_success_not_found_message(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
q := &models.Queue{
Name: "testing",
Messages: []models.SqsMessage{
{
MessageBody: "test%20message%20body%201",
ReceiptHandle: "test1",
},
{
MessageBody: "test%20message%20body%203",
ReceiptHandle: "test3",
},
},
}
models.SyncQueues.Queues["testing"] = q
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteMessageBatchRequest)
*v = models.DeleteMessageBatchRequest{
Entries: []models.DeleteMessageBatchRequestEntry{
{
Id: "delete-test-1",
ReceiptHandle: "test1",
},
{
Id: "delete-test-2",
ReceiptHandle: "test2",
},
{
Id: "delete-test-3",
ReceiptHandle: "test3",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"),
}
return true
}
_, request := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
status, response := DeleteMessageBatchV1(request)
deleteMessageBatchResponse := response.(models.DeleteMessageBatchResponse)
assert.Equal(t, status, http.StatusOK)
assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id)
assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[1].Id)
assert.Equal(t, "1", deleteMessageBatchResponse.Result.Failed[0].Code)
assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Failed[0].Id)
assert.Equal(t, "Message not found", deleteMessageBatchResponse.Result.Failed[0].Message)
assert.True(t, deleteMessageBatchResponse.Result.Failed[0].SenderFault)
assert.Empty(t, models.SyncQueues.Queues["testing"].Messages)
}
func TestDeleteMessageBatchV1_error_not_found_queue(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteMessageBatchRequest)
*v = models.DeleteMessageBatchRequest{
Entries: []models.DeleteMessageBatchRequestEntry{
{
Id: "delete-test-1",
ReceiptHandle: "test1",
},
{
Id: "delete-test-2",
ReceiptHandle: "test2",
},
{
Id: "delete-test-3",
ReceiptHandle: "test3",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue"),
}
return true
}
_, r := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
status, _ := DeleteMessageBatchV1(r)
assert.Equal(t, status, http.StatusBadRequest)
}
func TestDeleteMessageBatchV1_error_no_entry(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteMessageBatchRequest)
*v = models.DeleteMessageBatchRequest{
Entries: make([]models.DeleteMessageBatchRequestEntry, 0),
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
return true
}
_, r := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
status, _ := DeleteMessageBatchV1(r)
assert.Equal(t, status, http.StatusBadRequest)
}
func TestDeleteMessageBatchV1_error_too_many_entries(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteMessageBatchRequest)
*v = models.DeleteMessageBatchRequest{
Entries: []models.DeleteMessageBatchRequestEntry{
{
Id: "test-1",
ReceiptHandle: "test-1",
},
{
Id: "test-2",
ReceiptHandle: "test-2",
},
{
Id: "test-3",
ReceiptHandle: "test-3",
},
{
Id: "test-4",
ReceiptHandle: "test-4",
},
{
Id: "test-5",
ReceiptHandle: "test-5",
},
{
Id: "test-6",
ReceiptHandle: "test-6",
},
{
Id: "test-7",
ReceiptHandle: "test-7",
},
{
Id: "test-8",
ReceiptHandle: "test-8",
},
{
Id: "test-9",
ReceiptHandle: "test-9",
},
{
Id: "test-10",
ReceiptHandle: "test-10",
},
{
Id: "test-11",
ReceiptHandle: "test-11",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
return true
}
_, r := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
status, _ := DeleteMessageBatchV1(r)
assert.Equal(t, status, http.StatusBadRequest)
}
func TestDeleteMessageBatchV1_Error_IdNotDistinct(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteMessageBatchRequest)
*v = models.DeleteMessageBatchRequest{
Entries: []models.DeleteMessageBatchRequestEntry{
{
Id: "delete-test-1",
ReceiptHandle: "test1",
},
{
Id: "delete-test-1",
ReceiptHandle: "test2",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
return true
}
_, r := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
status, _ := DeleteMessageBatchV1(r)
assert.Equal(t, http.StatusBadRequest, status)
}
func TestDeleteMessageBatchV1_Error_transformer(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, _ := DeleteMessageBatchV1(r)
assert.Equal(t, http.StatusBadRequest, status)
}
-38
View File
@@ -1,38 +0,0 @@
package gosqs
import (
"net/http"
"testing"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/models"
"github.com/stretchr/testify/assert"
)
func TestDeleteMessage(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
q := &models.Queue{
Name: "testing",
Messages: []models.SqsMessage{{
MessageBody: "test1",
ReceiptHandle: "123",
}},
}
models.SyncQueues.Queues["testing"] = q
_, r := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
QueueUrl: "http://localhost:4100/queue/testing",
ReceiptHandle: "123",
}, true)
status, _ := DeleteMessageV1(r)
assert.Equal(t, status, http.StatusOK)
assert.Empty(t, q.Messages)
}
+101
View File
@@ -0,0 +1,101 @@
// app/gosqs/delete_middle_test.go
// Тест P1.2: удаление сообщения из СЕРЕДИНЫ очереди не ломает остальные —
// индексы receipt/uuid корректны (tombstone), удалённое не возвращается,
// оставшиеся находятся по своим ReceiptHandle.
package gosqs
import (
"net/http"
"testing"
"shared-sqs/app/models"
)
func TestDeleteMessageV1_MiddleKeepsOthersReceivable(t *testing.T) {
resetTestEnv(t)
tnt := testTenant("SSAK-aaa")
injectRequest(t, func(v interface{}) { v.(*models.CreateQueueRequest).QueueName = "q" })
if code, _ := CreateQueueV1(reqWithTenant("/", tnt)); code != http.StatusOK {
t.Fatal("create failed")
}
q := models.SyncQueues.Queues["SSAK-aaa:q"]
q.MaximumMessageSize = 262144
// Три сообщения.
for _, body := range []string{"m1", "m2", "m3"} {
b := body
injectRequest(t, func(v interface{}) {
r := v.(*models.SendMessageRequest)
r.QueueUrl = "http://x/tenant/q"
r.MessageBody = b
})
if code, _ := SendMessageV1(reqWithTenant("/", tnt)); code != http.StatusOK {
t.Fatal("send failed")
}
}
// Получаем все три разом.
injectRequest(t, func(v interface{}) {
r := v.(*models.ReceiveMessageRequest)
r.QueueUrl = "http://x/tenant/q"
r.MaxNumberOfMessages = 10
})
_, resp := ReceiveMessageV1(reqWithTenant("/", tnt))
rr := resp.(models.ReceiveMessageResponse)
if len(rr.Result.Messages) != 3 {
t.Fatalf("receive вернул %d сообщений, want 3", len(rr.Result.Messages))
}
// Удаляем СРЕДНЕЕ.
middle := rr.Result.Messages[1]
injectRequest(t, func(v interface{}) {
r := v.(*models.DeleteMessageRequest)
r.QueueUrl = "http://x/tenant/q"
r.ReceiptHandle = middle.ReceiptHandle
})
if code, _ := DeleteMessageV1(reqWithTenant("/", tnt)); code != http.StatusOK {
t.Fatal("delete failed")
}
if q.LiveCount() != 2 {
t.Fatalf("LiveCount = %d, want 2", q.LiveCount())
}
if _, ok := q.IndexOfUUID(middle.MessageId); ok {
t.Fatal("удалённое сообщение осталось в uuid-индексе")
}
// Оставшиеся по-прежнему in-flight и находятся по своим handle (индексы не «съехали»).
for _, m := range []*models.ResultMessage{rr.Result.Messages[0], rr.Result.Messages[2]} {
if _, ok := q.IndexOfReceipt(m.ReceiptHandle); !ok {
t.Fatalf("handle оставшегося сообщения потерян: %s", m.ReceiptHandle)
}
}
// Возвращаем видимость оставшимся и получаем их снова — удалённого быть не должно.
for _, m := range []*models.ResultMessage{rr.Result.Messages[0], rr.Result.Messages[2]} {
handle := m.ReceiptHandle
injectRequest(t, func(v interface{}) {
r := v.(*models.ChangeMessageVisibilityRequest)
r.QueueUrl = "http://x/tenant/q"
r.ReceiptHandle = handle
r.VisibilityTimeout = 0
})
if code, _ := ChangeMessageVisibilityV1(reqWithTenant("/", tnt)); code != http.StatusOK {
t.Fatal("change visibility failed")
}
}
injectRequest(t, func(v interface{}) {
r := v.(*models.ReceiveMessageRequest)
r.QueueUrl = "http://x/tenant/q"
r.MaxNumberOfMessages = 10
})
_, resp2 := ReceiveMessageV1(reqWithTenant("/", tnt))
rr2 := resp2.(models.ReceiveMessageResponse)
if len(rr2.Result.Messages) != 2 {
t.Fatalf("повторный receive вернул %d, want 2", len(rr2.Result.Messages))
}
for _, m := range rr2.Result.Messages {
if m.MessageId == middle.MessageId {
t.Fatal("удалённое сообщение вернулось в receive")
}
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
log.Warnf("Delete Queue: %s does not exist (tenant: %s)", queueName, t.ID) log.Warnf("Delete Queue: %s does not exist (tenant: %s)", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
delete(models.SyncQueues.Queues, key) models.SyncQueues.RemoveQueue(key)
// Удаляем из Redis асинхронно // Удаляем из Redis асинхронно
persistence.DeleteQueue(key) persistence.DeleteQueue(key)
-91
View File
@@ -1,91 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"shared-sqs/app/test"
"github.com/stretchr/testify/assert"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
)
func TestDeleteQueueV1_success(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteQueueRequest)
*v = models.DeleteQueueRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
return true
}
expectedResponse := models.DeleteQueueResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := DeleteQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
_, ok := models.SyncQueues.Queues["unit-queue1"]
assert.False(t, ok)
}
func TestDeleteQueueV1_success_unknown_queue(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.DeleteQueueRequest)
*v = models.DeleteQueueRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unknown-queue1"),
}
return true
}
expectedResponse := models.DeleteQueueResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := DeleteQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
}
func TestDeleteQueueV1_error_invalid_request(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := DeleteQueueV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
+28 -8
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"shared-sqs/app/interfaces" "shared-sqs/app/interfaces"
"shared-sqs/app/models" "shared-sqs/app/models"
@@ -64,13 +65,12 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID) log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID)
queueAttributes := make([]models.Attribute, 0) queueAttributes := make([]models.Attribute, 0)
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
defer models.SyncQueues.RUnlock() if queue == nil {
queue, ok := models.SyncQueues.Queues[key]
if !ok {
log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID) log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.RUnlockMu()
if shouldInclude("DelaySeconds") { if shouldInclude("DelaySeconds") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)})
@@ -87,17 +87,24 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
if shouldInclude("VisibilityTimeout") { if shouldInclude("VisibilityTimeout") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)}) queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)})
} }
// FIFO-атрибуты: читаются Terraform (aws_sqs_queue) и aws cli; без них — drift.
if shouldInclude("FifoQueue") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "FifoQueue", Value: strconv.FormatBool(queue.IsFIFO)})
}
if shouldInclude("ContentBasedDeduplication") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ContentBasedDeduplication", Value: strconv.FormatBool(queue.ContentBasedDeduplication)})
}
if shouldInclude("ApproximateNumberOfMessages") { if shouldInclude("ApproximateNumberOfMessages") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))}) queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(queue.LiveCount())})
} }
if shouldInclude("ApproximateNumberOfMessagesNotVisible") { if shouldInclude("ApproximateNumberOfMessagesNotVisible") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(*queue))}) queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(queue))})
} }
if shouldInclude("CreatedTimestamp") { if shouldInclude("CreatedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"}) queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: epochSeconds(queue.CreatedAt)})
} }
if shouldInclude("LastModifiedTimestamp") { if shouldInclude("LastModifiedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"}) queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: epochSeconds(queue.LastModifiedAt)})
} }
if shouldInclude("QueueArn") { if shouldInclude("QueueArn") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn}) queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn})
@@ -108,6 +115,10 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn), Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn),
}) })
} }
// Policy — отдаём сохранённую JSON-строку как есть (совместимость с Terraform).
if shouldInclude("Policy") && queue.Policy != "" {
queueAttributes = append(queueAttributes, models.Attribute{Name: "Policy", Value: queue.Policy})
}
respStruct := models.GetQueueAttributesResponse{ respStruct := models.GetQueueAttributesResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
@@ -116,3 +127,12 @@ func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
} }
return http.StatusOK, respStruct return http.StatusOK, respStruct
} }
// epochSeconds — timestamp в формате AWS (unix-секунды строкой).
// Нулевое время отдаём как "0" (стабильно, без drift при чтении).
func epochSeconds(t time.Time) string {
if t.IsZero() {
return "0"
}
return strconv.FormatInt(t.Unix(), 10)
}
-180
View File
@@ -1,180 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"shared-sqs/app/test"
"github.com/mitchellh/copystructure"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestGetQueueAttributesV1_success_all(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueAttributesRequest)
*v = fixtures.GetQueueAttributesRequest
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := GetQueueAttributesV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, fixtures.GetQueueAttributesResponse, response)
}
func TestGetQueueAttributesV1_success_no_request_attrs_returns_all(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueAttributesRequest)
*v = models.GetQueueAttributesRequest{
QueueUrl: "unit-queue1",
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := GetQueueAttributesV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, fixtures.GetQueueAttributesResponse, response)
}
func TestGetQueueAttributesV1_success_all_with_redrive_queue(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueAttributesRequest)
*v = models.GetQueueAttributesRequest{
QueueUrl: "unit-queue2",
AttributeNames: []string{"All"},
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := GetQueueAttributesV1(r)
dupe, _ := copystructure.Copy(fixtures.GetQueueAttributesResponse)
expectedResponse, _ := dupe.(models.GetQueueAttributesResponse)
expectedResponse.Result.Attrs[9].Value = fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, "unit-queue2")
expectedResponse.Result.Attrs = append(expectedResponse.Result.Attrs,
models.Attribute{
Name: "RedrivePolicy",
Value: fmt.Sprintf(`{"maxReceiveCount":"1", "deadLetterTargetArn":"%s:%s"}`, fixtures.BASE_SQS_ARN, "dead-letter-queue1"),
},
)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
}
func TestGetQueueAttributesV1_success_specific_fields(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueAttributesRequest)
*v = models.GetQueueAttributesRequest{
QueueUrl: fmt.Sprintf("%s/unit-queue1", fixtures.BASE_URL),
AttributeNames: []string{"DelaySeconds"},
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := GetQueueAttributesV1(r)
expectedResponse := models.GetQueueAttributesResponse{
Xmlns: models.BaseXmlns,
Result: models.GetQueueAttributesResult{Attrs: []models.Attribute{
models.Attribute{
Name: "DelaySeconds",
Value: "0",
},
}},
Metadata: models.BaseResponseMetadata,
}
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
}
func TestGetQueueAttributesV1_request_transformer_error(t *testing.T) {
defer func() {
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := GetQueueAttributesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestGetQueueAttributesV1_missing_queue_url_in_request_returns_error(t *testing.T) {
defer func() {
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueAttributesRequest)
*v = models.GetQueueAttributesRequest{
QueueUrl: "",
AttributeNames: []string{},
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := GetQueueAttributesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestGetQueueAttributesV1_missing_queue_returns_error(t *testing.T) {
defer func() {
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueAttributesRequest)
*v = fixtures.GetQueueAttributesRequest
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := GetQueueAttributesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
-104
View File
@@ -1,104 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"shared-sqs/app/test"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestGetQueueUrlV1_success(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueUrlRequest)
*v = models.GetQueueUrlRequest{
QueueName: "unit-queue1",
QueueOwnerAWSAccountId: "fugafuga",
}
return true
}
_, r := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
code, response := GetQueueUrlV1(r)
get_queue_url_response := response.(models.GetQueueUrlResponse)
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, get_queue_url_response.Result.QueueUrl, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"))
}
func TestGetQueueUrlV1_error_no_queue(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.GetQueueUrlRequest)
*v = models.GetQueueUrlRequest{
QueueName: "not-exist-unit-queue1",
QueueOwnerAWSAccountId: "fugafuga",
}
return true
}
_, r := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
code, response := GetQueueUrlV1(r)
expected := models.ErrorResult{
Type: "Not Found",
Code: "AWS.SimpleQueueService.NonExistentQueue",
Message: "The specified queue does not exist for this wsdl version.",
}
assert.Equal(t, http.StatusBadRequest, code)
assert.Equal(t, response.GetResult().(models.ErrorResult), expected)
}
func TestGetQueueUrlV1_error_request_transformer(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo(
"POST",
"/",
nil,
true)
code, _ := GetQueueUrlV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
+94 -23
View File
@@ -5,6 +5,7 @@ import (
"time" "time"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/persistence"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -30,22 +31,35 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
// Снапшот указателей на очереди под коротким RLock. // Снапшот (key, указатель) очередей под коротким RLock.
// Указатели остаются валидными после снятия RLock: очереди из map // Указатели остаются валидными после снятия RLock: очереди из map
// не удаляются во время итерации, а объекты живут в куче. // не удаляются во время итерации, а объекты живут в куче.
models.SyncQueues.RLock() models.SyncQueues.RLock()
queues := make([]*models.Queue, 0, len(models.SyncQueues.Queues)) type qEntry struct {
for _, queue := range models.SyncQueues.Queues { key string
queues = append(queues, queue) queue *models.Queue
}
queues := make([]qEntry, 0, len(models.SyncQueues.Queues))
for key, queue := range models.SyncQueues.Queues {
queues = append(queues, qEntry{key, queue})
} }
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
// Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной // Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной
// очереди не мешает обработке остальных и HTTP-запросам. // очереди не мешает обработке остальных и HTTP-запросам.
for _, queue := range queues { for _, qi := range queues {
models.SyncQueues.Lock() func() {
processQueueTick(queue) // recover: паника в processQueueTick одной очереди не должна
models.SyncQueues.Unlock() // убивать весь процесс; mutex освобождается в defer.
defer qi.queue.UnlockMu()
defer func() {
if r := recover(); r != nil {
log.Errorf("gosqs: panic in processQueueTick for queue %q: %v", qi.key, r)
}
}()
qi.queue.LockMu()
processQueueTick(qi.key, qi.queue)
}()
} }
case <-quit: case <-quit:
ticker.Stop() ticker.Stop()
@@ -56,9 +70,10 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
// processQueueTick — обработка ОДНОЙ очереди за один такт. // processQueueTick — обработка ОДНОЙ очереди за один такт.
// //
// ВЫЗЫВАТЬ ТОЛЬКО под удержанием SyncQueues.Lock. // ВЫЗЫВАТЬ под per-queue блокировкой этой очереди (queue.LockMu).
// //
// Логическая схема: // Логическая схема:
// 0. Эвикция сообщений по MessageRetentionPeriod (старше retention удаляются).
// 1. Deduplication-окно (FIFO): удаляем записи старше DeduplicationPeriod. // 1. Deduplication-окно (FIFO): удаляем записи старше DeduplicationPeriod.
// 2. Проходим по сообщениям очереди: // 2. Проходим по сообщениям очереди:
// - пустой ReceiptHandle (не in-flight) — пропускаем; // - пустой ReceiptHandle (не in-flight) — пропускаем;
@@ -67,7 +82,41 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
// FIFO-группы, обновление ReceiptTime), инкрементируем Retry; // FIFO-группы, обновление ReceiptTime), инкрементируем Retry;
// - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение // - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение
// в DLQ и удаляем из исходной очереди. // в DLQ и удаляем из исходной очереди.
func processQueueTick(queue *models.Queue) { func processQueueTick(key string, queue *models.Queue) {
// Ленивая починка индексов (P1.2): очередь могла быть собрана литералом или
// загружена из Redis — тогда индексы строятся здесь один раз.
queue.EnsureIndexes()
// 0. Эвикция просроченных сообщений по MessageRetentionPeriod.
// Retention == 0 означает «без истечения» (не эвиктируем).
// Удаление — tombstone за O(1); физическая чистка — CompactIfNeeded ниже.
if queue.MessageRetentionPeriod > 0 {
cutoff := time.Now().Add(-time.Duration(queue.MessageRetentionPeriod) * time.Second)
expired := make([]string, 0) // uuid просроченных сообщений для удаления из Redis
for i := range queue.Messages {
if queue.IsDead(i) {
continue
}
msg := queue.Messages[i]
if !msg.SentTime.Before(cutoff) {
continue
}
expired = append(expired, msg.Uuid)
if msg.GroupID != "" {
queue.UnlockGroup(msg.GroupID)
}
if msg.ReceiptHandle != "" {
queue.ClearInFlight(i, msg.ReceiptHandle)
}
queue.RemoveIndex(i)
}
if len(expired) > 0 {
log.Infof("Queue [%s]: evicted %d messages by MessageRetentionPeriod", queue.Name, len(expired))
// Удаляем просроченные из Redis, чтобы не воскресли после рестарта.
_ = persistence.DeleteMessagesPersist(key, expired)
}
}
// 1. Просроченные записи deduplication-окна. // 1. Просроченные записи deduplication-окна.
for dedupId, startTime := range queue.Duplicates { for dedupId, startTime := range queue.Duplicates {
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) { if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
@@ -77,18 +126,25 @@ func processQueueTick(queue *models.Queue) {
} }
// 2. Возврат видимости истёкших сообщений + перенос в DLQ. // 2. Возврат видимости истёкших сообщений + перенос в DLQ.
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages)) // Итерируем ТОЛЬКО по in-flight (receiptIdx), а не по всему срезу — стоимость
for i := 0; i < len(queue.Messages); i++ { // пропорциональна числу сообщений «в полёте», а не размеру очереди (P1.2).
msg := &queue.Messages[i] log.Debugf("Queue [%s] live [%d]", queue.Name, queue.LiveCount())
now := time.Now()
if msg.ReceiptHandle == "" { for _, i := range queue.InFlightIndexes() {
continue // сообщение не в полёте if i < 0 || i >= len(queue.Messages) || queue.IsDead(i) {
continue
} }
if msg.VisibilityTimeout.After(time.Now()) { msg := &queue.Messages[i]
rh := msg.ReceiptHandle
if rh == "" {
continue // уже возвращено в видимость
}
if msg.VisibilityTimeout.After(now) {
continue // таймаут видимости ещё действует continue // таймаут видимости ещё действует
} }
log.Debugf("Making message visible again %s", msg.ReceiptHandle) log.Debugf("Making message visible again %s", rh)
queue.ClearInFlight(i, rh)
queue.UnlockGroup(msg.GroupID) queue.UnlockGroup(msg.GroupID)
msg.ReceiptHandle = "" msg.ReceiptHandle = ""
msg.ReceiptTime = time.Now().UTC() msg.ReceiptTime = time.Now().UTC()
@@ -98,16 +154,31 @@ func processQueueTick(queue *models.Queue) {
if queue.MaxReceiveCount > 0 && if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil && queue.DeadLetterQueue != nil &&
msg.Retry >= queue.MaxReceiveCount { msg.Retry >= queue.MaxReceiveCount {
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg) dlqMsg := *msg
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...) msgUuid := msg.Uuid
i-- // элемент на позиции i удалён — повторно проверяем новый элемент на ней dlqName := queue.DeadLetterQueue.Name
queue.DeadLetterQueue.LockMu()
queue.DeadLetterQueue.AppendMessage(dlqMsg)
queue.DeadLetterQueue.UnlockMu()
queue.RemoveIndex(i)
// Персист переноса: иначе после рестарта сообщение «воскреснет»
// в исходной очереди (не удалено из Redis) и пропадёт из DLQ.
_ = persistence.DeleteMessagePersist(key, msgUuid)
persistence.SaveMessage(dlqKeyFor(key, dlqName), &dlqMsg)
} }
} }
// 4. Компакция tombstone-слотов (амортизированно): срез не растёт бесконечно.
queue.CompactIfNeeded()
} }
func numberOfHiddenMessagesInQueue(queue models.Queue) int { func numberOfHiddenMessagesInQueue(queue *models.Queue) int {
num := 0 num := 0
for _, m := range queue.Messages { for i := range queue.Messages {
if queue.IsDead(i) {
continue
}
m := &queue.Messages[i]
if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) { if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) {
num++ num++
} }
-668
View File
@@ -1,668 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"net/url"
"sync"
"testing"
"time"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
// TODO - Admiral-Piett these are better but still screwy. It's easy to have race conditions in here, so
// we have to name all the queues uniquely and leave them around so we're not resetting ourselves.
// Stupid. Handle the global memory issues and this can be easily resolved.
func Test_PeriodicTasks_deletes_deduplication_period_upon_expiration(t *testing.T) {
models.DeduplicationPeriod = 20 * time.Millisecond
quit := make(chan bool)
defer func() {
models.ResetApp()
quit <- true
models.DeduplicationPeriod = 5 * time.Minute
}()
qName := "gosqs-dedupe-queue1"
mainQueue := &models.Queue{
Name: qName,
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName),
Duplicates: map[string]time.Time{
"12345": time.Now(),
},
}
models.SyncQueues.Lock()
models.SyncQueues.Queues[qName] = mainQueue
models.SyncQueues.Unlock()
go PeriodicTasks(10*time.Millisecond, quit)
assertions := func() bool {
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
ok := 0 == len(mainQueue.Duplicates)
if !ok {
return false
}
return true
}
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
}
func Test_PeriodicTasks_VisibilityTimeout_expires(t *testing.T) {
quit := make(chan bool)
defer func() {
models.ResetApp()
quit <- true
}()
qName := "gosqs-visibility-queue1"
mainQueue := &models.Queue{
Name: qName,
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName),
}
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
MessageBody: "1",
ReceiptHandle: "12345",
VisibilityTimeout: time.Now().Add(30 * time.Millisecond),
})
models.SyncQueues.Lock()
models.SyncQueues.Queues[qName] = mainQueue
models.SyncQueues.Unlock()
go PeriodicTasks(10*time.Millisecond, quit)
assertions := func() bool {
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
ok := !mainQueue.Messages[0].ReceiptTime.IsZero()
if !ok {
return false
}
ok = "1" == mainQueue.Messages[0].MessageBody
if !ok {
return false
}
ok = "" == mainQueue.Messages[0].ReceiptHandle
if !ok {
return false
}
ok = 1 == mainQueue.Messages[0].Retry
if !ok {
return false
}
return true
}
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
}
func Test_PeriodicTasks_moves_single_message_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) {
quit := make(chan bool)
defer func() {
models.ResetApp()
quit <- true
}()
qName := "gosqs-main-queue1"
dlqName := "gosqs-dead-letter-queue1"
dlqQueue := &models.Queue{
Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, dlqName),
Name: dlqName,
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, dlqName),
}
mainQueue := &models.Queue{
Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, qName),
DeadLetterQueue: dlqQueue,
MaxReceiveCount: 1,
Name: qName,
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
}
go PeriodicTasks(10*time.Millisecond, quit)
models.SyncQueues.Lock()
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
MessageBody: "1",
Retry: 100,
ReceiptHandle: "12345",
VisibilityTimeout: time.Now().Add(10 * time.Millisecond),
})
models.SyncQueues.Queues[qName] = mainQueue
models.SyncQueues.Queues[dlqName] = dlqQueue
models.SyncQueues.Unlock()
assertions := func() bool {
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
ok := len(dlqQueue.Messages) == 1
if !ok {
return false
}
ok = "1" == dlqQueue.Messages[0].MessageBody
if !ok {
return false
}
return true
}
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
}
func Test_PeriodicTasks_moves_multiple_messages_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) {
quit := make(chan bool)
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
quit <- true
}()
mainQueue := models.SyncQueues.Queues["unit-queue2"]
dlqQueue := models.SyncQueues.Queues["dead-letter-queue1"]
assert.Len(t, dlqQueue.Messages, 0)
go PeriodicTasks(10*time.Millisecond, quit)
models.SyncQueues.Lock()
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
MessageBody: "1",
Retry: 100,
ReceiptHandle: "12345",
})
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
MessageBody: "2",
Retry: 100,
ReceiptHandle: "23456",
})
models.SyncQueues.Unlock()
assertions := func() bool {
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
ok := len(dlqQueue.Messages) == 2
if !ok {
return false
}
ok = "1" == dlqQueue.Messages[0].MessageBody
if !ok {
return false
}
ok = "2" == dlqQueue.Messages[1].MessageBody
if !ok {
return false
}
return true
}
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
}
// TODO - I think all these below belong in handler tests, not in here. Double check the relevant
// handlers for coverage and delete.
func TestSendingAndReceivingFromFIFOQueueReturnsSameMessageOnError(t *testing.T) {
done := make(chan bool)
go PeriodicTasks(1*time.Second, done)
// create a queue
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "requeue-reset.fifo")
form.Add("Attribute.1.Name", "VisibilityTimeout")
form.Add("Attribute.1.Value", "2")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ := CreateQueueV1(req)
assert.Equal(t, status, http.StatusOK)
// send a message
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
form.Add("MessageBody", "1")
form.Add("MessageGroupId", "GROUP-X")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
// send a message
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
form.Add("MessageBody", "2")
form.Add("MessageGroupId", "GROUP-X")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
// receive message
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "ReceiveMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, resp := ReceiveMessageV1(req)
assert.Equal(t, status, http.StatusOK)
result := resp.GetResult().(models.ReceiveMessageResult)
receiptHandleFirst := result.Messages[0].ReceiptHandle
if string(result.Messages[0].Body) != "1" {
t.Fatalf("should have received body 1: %s", err)
}
// try to receive another message and we should get none
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "ReceiveMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = ReceiveMessageV1(req)
assert.Equal(t, status, http.StatusOK)
if len(models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages) != 1 {
t.Fatal("there should be only 1 group locked")
}
if models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages["GROUP-X"] != 0 {
t.Fatal("there should be GROUP-X locked")
}
// remove message
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "DeleteMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
form.Add("ReceiptHandle", receiptHandleFirst)
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = DeleteMessageV1(req)
assert.Equal(t, status, http.StatusOK)
if len(models.SyncQueues.Queues["requeue-reset.fifo"].Messages) != 1 {
t.Fatal("there should be only 1 message in queue")
}
// receive message - loop until visibility timeouts
for {
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "ReceiveMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, resp := ReceiveMessageV1(req)
assert.Equal(t, status, http.StatusOK)
result := resp.GetResult().(models.ReceiveMessageResult)
if len(result.Messages) == 0 {
continue
}
if string(result.Messages[0].Body) != "2" {
t.Fatalf("should have received body 2: %s", err)
}
break
}
done <- true
}
func TestSendMessage_POST_DuplicatationNotAppliedToStandardQueue(t *testing.T) {
done := make(chan bool)
go PeriodicTasks(1*time.Second, done)
// create a queue
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "stantdard-testing")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ := CreateQueueV1(req)
assert.Equal(t, status, http.StatusOK)
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing")
form.Add("MessageBody", "Test1")
form.Add("MessageDeduplicationId", "123")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
// Check the status code is what we expect.
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 0 {
t.Fatal("there should be 1 message in queue")
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing")
form.Add("MessageBody", "Test2")
form.Add("MessageDeduplicationId", "123")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
// Check the status code is what we expect.
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 1 {
t.Fatal("there should be 2 messages in queue")
}
done <- true
}
func TestSendMessage_POST_DuplicatationDisabledOnFifoQueue(t *testing.T) {
done := make(chan bool)
go PeriodicTasks(1*time.Second, done)
// create a queue
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "no-dup-testing.fifo")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ := CreateQueueV1(req)
assert.Equal(t, status, http.StatusOK)
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo")
form.Add("MessageBody", "Test1")
form.Add("MessageDeduplicationId", "123")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
// Check the status code is what we expect.
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) == 0 {
t.Fatal("there should be 1 message in queue")
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo")
form.Add("MessageBody", "Test2")
form.Add("MessageDeduplicationId", "123")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
// Check the status code is what we expect.
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) != 2 {
t.Fatal("there should be 2 message in queue")
}
done <- true
}
func TestSendMessage_POST_DuplicatationEnabledOnFifoQueue(t *testing.T) {
done := make(chan bool)
go PeriodicTasks(1*time.Second, done)
// create a queue
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "dup-testing.fifo")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ := CreateQueueV1(req)
assert.Equal(t, status, http.StatusOK)
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
models.SyncQueues.Queues["dup-testing.fifo"].EnableDuplicates = true
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo")
form.Add("MessageBody", "Test1")
form.Add("MessageDeduplicationId", "123")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
// Check the status code is what we expect.
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) == 0 {
t.Fatal("there should be 1 message in queue")
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo")
form.Add("MessageBody", "Test2")
form.Add("MessageDeduplicationId", "123")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
// Check the status code is what we expect.
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) != 1 {
t.Fatal("there should be 1 message in queue")
}
if body := models.SyncQueues.Queues["dup-testing.fifo"].Messages[0].MessageBody; string(body) == "Test2" {
t.Fatal("duplicate message should not be added to queue")
}
done <- true
}
func TestSendMessage_POST_DelaySeconds(t *testing.T) {
// create a queue
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "sendmessage-delay")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ := CreateQueueV1(req)
assert.Equal(t, status, http.StatusOK)
// send a message
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "SendMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
form.Add("MessageBody", "1")
form.Add("DelaySeconds", "2")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = SendMessageV1(req)
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v",
status, http.StatusOK)
}
// receive message before delay is up
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "ReceiveMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
form.Add("Version", "2012-11-05")
req.PostForm = form
status, _ = ReceiveMessageV1(req)
assert.Equal(t, status, http.StatusOK)
// receive message with wait should return after delay
req, err = http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form = url.Values{}
form.Add("Action", "ReceiveMessage")
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
form.Add("WaitTimeSeconds", "10")
form.Add("Version", "2012-11-05")
req.PostForm = form
start := time.Now()
status, _ = ReceiveMessageV1(req)
elapsed := time.Since(start)
assert.Equal(t, status, http.StatusOK)
if elapsed < 1*time.Second {
t.Errorf("handler didn't wait at all")
}
if elapsed > 4*time.Second {
t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2")
}
}
func TestCreateErrorResponseV1(t *testing.T) {
expectedResponse := models.ErrorResponse{
Result: models.ErrorResult{
Type: "Not Found",
Code: "AWS.SimpleQueueService.NonExistentQueue",
Message: "The specified queue does not exist for this wsdl version.",
},
RequestId: "00000000-0000-0000-0000-000000000000",
}
status, response := utils.CreateErrorResponseV1("QueueNotFound", true)
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, expectedResponse, response)
}
// waitTimeout waits for the waitgroup for the specified max timeout.
// Returns true if waiting timed out.
// credits: https://stackoverflow.com/questions/32840687/timeout-for-waitgroup-wait
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
c := make(chan struct{})
go func() {
defer close(c)
wg.Wait()
}()
select {
case <-c:
return false // completed normally
case <-time.After(timeout):
return true // timed out
}
}
+3 -5
View File
@@ -40,13 +40,11 @@ func ListQueueTagsV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
defer models.SyncQueues.RUnlock() if queue == nil {
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.RUnlockMu()
tags := queue.Tags tags := queue.Tags
if tags == nil { if tags == nil {
-116
View File
@@ -1,116 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"shared-sqs/app/test"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestListQueuesV1_success(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.ListQueueRequest)
*v = models.ListQueueRequest{}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := ListQueuesV1(r)
r1 := response.(models.ListQueuesResponse)
assert.Equal(t, http.StatusOK, code)
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"))
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue2"))
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1"))
}
func TestListQueuesV1_success_no_queues(t *testing.T) {
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.ListQueueRequest)
*v = models.ListQueueRequest{}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := ListQueuesV1(r)
r1 := response.(models.ListQueuesResponse)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, r1.Result.QueueUrls, []string{})
}
func TestListQueuesV1_success_with_queue_name_prefix(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.ListQueueRequest)
*v = models.ListQueueRequest{QueueNamePrefix: "dead-letter"}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := ListQueuesV1(r)
r1 := response.(models.ListQueuesResponse)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, []string{fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1")}, r1.Result.QueueUrls)
}
func TestListQueuesV1_success_with_queue_name_prefix_no_matching_queues(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.ListQueueRequest)
*v = models.ListQueueRequest{QueueNamePrefix: "garbage"}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := ListQueuesV1(r)
r1 := response.(models.ListQueuesResponse)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, []string{}, r1.Result.QueueUrls)
}
func TestListQueuesV1_request_transformer_error(t *testing.T) {
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := ListQueuesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
+37
View File
@@ -0,0 +1,37 @@
// app/gosqs/memory_guard.go
// Глобальный предохранитель памяти процесса (защита от OOM одним тенантом).
//
// Лимиты по числу сообщений (MaxMessagesForQueue) не ограничивают СУММАРНЫЙ
// объём: 120k × 256 КБ ≈ 30 ГБ на одну очередь. Этот предохранитель отказывает
// в SendMessage/SendMessageBatch по HeapAlloc выше порога — единственная защита
// процесса от суммы всех тенантов.
//
// Задаётся env `SHARED_SQS_MAX_HEAP_MB` (0 = выключен). По умолчанию выключен:
// конкретное число зависит от RAM ВМ и целевого числа тенантов (решение владельца).
// Рекомендация на старт: ~70% от RAM ВМ, напр. 1024 для 1.5 ГБ ВМ.
package gosqs
import (
"os"
"runtime"
"strconv"
)
// maxHeapBytes — порог HeapAlloc в байтах; 0 = предохранитель выключен.
var maxHeapBytes = func() int64 {
mb, err := strconv.ParseInt(os.Getenv("SHARED_SQS_MAX_HEAP_MB"), 10, 64)
if err != nil || mb <= 0 {
return 0
}
return mb * 1024 * 1024
}()
// heapOverLimit — true, если текущий HeapAlloc превышает порог.
func heapOverLimit() bool {
if maxHeapBytes <= 0 {
return false
}
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
return int64(ms.HeapAlloc) > maxHeapBytes
}
+9 -9
View File
@@ -45,29 +45,29 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
queueName := uriSegments[len(uriSegments)-1] queueName := uriSegments[len(uriSegments)-1]
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
if _, ok := models.SyncQueues.Queues[key]; !ok {
log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID) log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
// AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь. // AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь.
// Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает. // Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает.
if time.Since(models.SyncQueues.Queues[key].LastPurgeTime) < 60*time.Second { if time.Since(queue.LastPurgeTime) < 60*time.Second {
log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, models.SyncQueues.Queues[key].LastPurgeTime.Format(time.RFC3339)) log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, queue.LastPurgeTime.Format(time.RFC3339))
return utils.CreateErrorResponseV1("PurgeQueueInProgress", true) return utils.CreateErrorResponseV1("PurgeQueueInProgress", true)
} }
log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID) log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Queues[key].Messages = nil queue.Reset()
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time) queue.Duplicates = make(map[string]time.Time)
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд. // Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
models.SyncQueues.Queues[key].LastPurgeTime = time.Now() queue.LastPurgeTime = time.Now()
// Удаляем все сообщения из Redis одной командой DEL // Удаляем все сообщения из Redis одной командой DEL
persistence.PurgeMessagesPersist(key) persistence.PurgeMessagesPersist(key)
// Сохраняем пустые метаданные очереди // Сохраняем пустые метаданные очереди
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, queue)
respStruct := models.PurgeQueueResponse{ respStruct := models.PurgeQueueResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
-126
View File
@@ -1,126 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"time"
"shared-sqs/app/test"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestPurgeQueueV1_success(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.PurgeQueueRequest)
*v = models.PurgeQueueRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
return true
}
// Put a message on the queue
targetQueue := models.SyncQueues.Queues["unit-queue1"]
models.SyncQueues.Lock()
targetQueue.Messages = []models.SqsMessage{models.SqsMessage{}}
targetQueue.Duplicates = map[string]time.Time{
"dedupe-id": time.Now(),
}
models.SyncQueues.Unlock()
expectedResponse := models.PurgeQueueResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := PurgeQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
assert.Nil(t, targetQueue.Messages)
assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates)
}
func TestPurgeQueueV1_success_no_messages_on_queue(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.PurgeQueueRequest)
*v = models.PurgeQueueRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
return true
}
expectedResponse := models.PurgeQueueResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := PurgeQueueV1(r)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
targetQueue := models.SyncQueues.Queues["unit-queue1"]
assert.Nil(t, targetQueue.Messages)
assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates)
}
func TestPurgeQueueV1_request_transformer_error(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := PurgeQueueV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestPurgeQueueV1_requested_queue_does_not_exist(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.PurgeQueueRequest)
*v = models.PurgeQueueRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "garbage"),
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := PurgeQueueV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
+46 -4
View File
@@ -1,8 +1,10 @@
package gosqs package gosqs
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -22,9 +24,11 @@ import (
// обнулял все остальные атрибуты очереди. // обнулял все остальные атрибуты очереди.
// 3. Значения вне диапазонов AWS → ошибка InvalidParameterValue // 3. Значения вне диапазонов AWS → ошибка InvalidParameterValue
// (раньше значения молча клэмпились в допустимый диапазон). // (раньше значения молча клэмпились в допустимый диапазон).
// 4. RedrivePolicy: ARN DLQ разбирается, DLQ должна существовать, иначе // 4. RedrivePolicy: ARN DLQ разбирается, DLQ ищется по TENANT-SCOPED ключу
// InvalidAttributeValue. // "{accessKey}:{queueName}" (раньше — по голому имени, а ключи в map
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided map[string]string) error { // tenant-scoped → DLQ никогда не находилась, RedrivePolicy не работал).
// DLQ должна принадлежать тому же тенанту.
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided map[string]string, tenantAccessKey string) error {
// Шаг 1: whitelist имён атрибутов (единый список — models.AttrNameWhitelist). // Шаг 1: whitelist имён атрибутов (единый список — models.AttrNameWhitelist).
for name := range provided { for name := range provided {
if !models.AttrNameWhitelist[name] { if !models.AttrNameWhitelist[name] {
@@ -71,13 +75,51 @@ func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided
if attr.RedrivePolicy != (models.RedrivePolicy{}) { if attr.RedrivePolicy != (models.RedrivePolicy{}) {
arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":") arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":")
queueName := arnArray[len(arnArray)-1] queueName := arnArray[len(arnArray)-1]
deadLetterQueue, ok := models.SyncQueues.Queues[queueName] // DLQ ищем по tenant-scoped ключу — как хранятся все очереди.
dlqKey := tenantAccessKey + ":" + queueName
models.SyncQueues.RLock()
deadLetterQueue, ok := models.SyncQueues.Queues[dlqKey]
models.SyncQueues.RUnlock()
if !ok { if !ok {
log.Error("Invalid RedrivePolicy Attribute") log.Error("Invalid RedrivePolicy Attribute")
return fmt.Errorf("InvalidAttributeValue") return fmt.Errorf("InvalidAttributeValue")
} }
// Запрет self-DLQ (q → q) и циклов (A → B → A): при переносе в DLQ
// переносящий код берёт DeadLetterQueue.LockMu() под уже взятым q.mu —
// self/цикл даёт немедленный self-deadlock (или AB/BA-deadlock).
if deadLetterQueue == q {
log.Error("Invalid RedrivePolicy Attribute: self DLQ")
return fmt.Errorf("InvalidAttributeValue")
}
// Обход цепочки DLQ для детекта циклов. Лимит 100 — защита от уже
// существующего в данных цикла (если бы он там был).
for cur, steps := deadLetterQueue, 0; cur != nil && steps < 100; cur, steps = cur.DeadLetterQueue, steps+1 {
if cur == q {
log.Error("Invalid RedrivePolicy Attribute: cyclic DLQ")
return fmt.Errorf("InvalidAttributeValue")
}
}
q.DeadLetterQueue = deadLetterQueue q.DeadLetterQueue = deadLetterQueue
q.MaxReceiveCount = attr.RedrivePolicy.MaxReceiveCount.Int() q.MaxReceiveCount = attr.RedrivePolicy.MaxReceiveCount.Int()
} }
// Шаг 4: FIFO-атрибуты и Policy — храним как есть (совместимость с Terraform).
// FifoQueue только включаем (выключить FIFO у существующей очереди нельзя —
// как и в AWS). ContentBasedDeduplication и Policy не исполняются,
// но должны возвращаться в GetQueueAttributes, иначе drift в Terraform.
if attr.FifoQueue.Bool() {
q.IsFIFO = true
}
if attr.ContentBasedDeduplication.Bool() {
q.ContentBasedDeduplication = true
}
if attr.Policy != nil {
// Сохраняем канонический JSON (map → marshal сортирует ключи) —
// детерминированно, без drift при чтении.
if data, err := json.Marshal(attr.Policy); err == nil {
q.Policy = string(data)
}
}
q.LastModifiedAt = time.Now().UTC()
return nil return nil
} }
-123
View File
@@ -1,123 +0,0 @@
package gosqs
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"shared-sqs/app/models"
)
func TestSetQueueAttributesV1_success_no_redrive_policy(t *testing.T) {
var emptyQueue *models.Queue
q := &models.Queue{}
attrs := models.QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 2,
MessageRetentionPeriod: 3,
ReceiveMessageWaitTimeSeconds: 4,
VisibilityTimeout: 5,
}
err := setQueueAttributesV1(q, attrs)
assert.Nil(t, err)
assert.Equal(t, 1, q.DelaySeconds)
assert.Equal(t, 2, q.MaximumMessageSize)
assert.Equal(t, 3, q.MessageRetentionPeriod)
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 5, q.VisibilityTimeout)
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
assert.Equal(t, 0, q.MaxReceiveCount)
}
func TestSetQueueAttributesV1_success_no_request_attributes(t *testing.T) {
var emptyQueue *models.Queue
q := &models.Queue{}
attrs := models.QueueAttributes{}
err := setQueueAttributesV1(q, attrs)
assert.Nil(t, err)
assert.Equal(t, 0, q.DelaySeconds)
assert.Equal(t, 0, q.MaximumMessageSize)
assert.Equal(t, 0, q.MessageRetentionPeriod)
assert.Equal(t, 0, q.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 0, q.VisibilityTimeout)
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
assert.Equal(t, 0, q.MaxReceiveCount)
}
func TestSetQueueAttributesV1_success_can_set_0_values_where_applicable(t *testing.T) {
var emptyQueue *models.Queue
q := &models.Queue{
DelaySeconds: 1,
MaximumMessageSize: 2,
MessageRetentionPeriod: 3,
ReceiveMessageWaitTimeSeconds: 4,
VisibilityTimeout: 5,
}
attrs := models.QueueAttributes{}
err := setQueueAttributesV1(q, attrs)
assert.Nil(t, err)
assert.Equal(t, 0, q.DelaySeconds)
assert.Equal(t, 0, q.MaximumMessageSize)
assert.Equal(t, 3, q.MessageRetentionPeriod)
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 0, q.VisibilityTimeout)
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
assert.Equal(t, 0, q.MaxReceiveCount)
}
func TestSetQueueAttributesV1_success_with_redrive_policy(t *testing.T) {
defer func() {
models.ResetApp()
}()
existingQueueName := "existing-queue"
existingQueue := &models.Queue{Name: existingQueueName}
models.SyncQueues.Queues[existingQueueName] = existingQueue
q := &models.Queue{}
attrs := models.QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 2,
MessageRetentionPeriod: 3,
ReceiveMessageWaitTimeSeconds: 4,
VisibilityTimeout: 5,
RedrivePolicy: models.RedrivePolicy{
MaxReceiveCount: 10,
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName),
},
}
err := setQueueAttributesV1(q, attrs)
assert.Nil(t, err)
assert.Equal(t, 1, q.DelaySeconds)
assert.Equal(t, 2, q.MaximumMessageSize)
assert.Equal(t, 3, q.MessageRetentionPeriod)
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 5, q.VisibilityTimeout)
assert.Equal(t, existingQueue, q.DeadLetterQueue)
assert.Equal(t, 10, q.MaxReceiveCount)
}
func TestSetQueueAttributesV1_error_redrive_policy_targets_missing_queue(t *testing.T) {
existingQueueName := "existing-queue"
q := &models.Queue{}
attrs := models.QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 2,
MessageRetentionPeriod: 3,
ReceiveMessageWaitTimeSeconds: 4,
VisibilityTimeout: 5,
RedrivePolicy: models.RedrivePolicy{
MaxReceiveCount: 10,
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName),
},
}
err := setQueueAttributesV1(q, attrs)
assert.Error(t, err)
}
+22 -11
View File
@@ -86,17 +86,15 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
// ОДИН RLock на проверку существования И чтение атрибута очереди: // ОДИН RLock на проверку существования И чтение атрибута очереди:
// раньше было два отдельных RLock — между ними очередь могла быть удалена // раньше было два отдельных RLock — между ними очередь могла быть удалена
// (nil-deref при чтении ReceiveMessageWaitTimeSeconds). // (nil-deref при чтении ReceiveMessageWaitTimeSeconds).
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
queue, queueExists := models.SyncQueues.Queues[key] if queue == nil {
if !queueExists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
waitTimeSeconds := requestBody.WaitTimeSeconds waitTimeSeconds := requestBody.WaitTimeSeconds
if waitTimeSeconds == 0 { if waitTimeSeconds == 0 {
waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds
} }
models.SyncQueues.RUnlock() queue.RUnlockMu()
if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds { if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds {
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
@@ -155,11 +153,15 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
// (ApproximateReceiveCount в ответе); // (ApproximateReceiveCount в ответе);
// - для FIFO-очереди блокируется группа (LockGroup) до DeleteMessage/таймаута. // - для FIFO-очереди блокируется группа (LockGroup) до DeleteMessage/таймаута.
func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages int) ([]*models.ResultMessage, bool) { func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages int) ([]*models.ResultMessage, bool) {
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
return nil, false
queue, queueFound := models.SyncQueues.Queues[key] }
if !queueFound || len(queue.Messages) == 0 { // ВАЖНО: defer — ДО любых return ниже. Иначе возврат на пустой очереди
// оставлял бы per-queue блокировку захваченной навсегда (очередь «зависала»:
// все последующие Lock/RLock ждали бы вечно).
defer queue.UnlockMu()
if queue.LiveCount() == 0 {
return nil, false return nil, false
} }
// Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение. // Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
@@ -173,6 +175,9 @@ func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages
if numMsg >= maxNumberOfMessages { if numMsg >= maxNumberOfMessages {
break break
} }
if queue.IsDead(i) {
continue // tombstone: сообщение логически удалено (P1.2)
}
msg := &queue.Messages[i] msg := &queue.Messages[i]
if msg.ReceiptHandle != "" { if msg.ReceiptHandle != "" {
continue // сообщение уже в полёте у другого получателя continue // сообщение уже в полёте у другого получателя
@@ -196,6 +201,9 @@ func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages
msg.VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second) msg.VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second)
} }
msg.NumberOfReceives++ msg.NumberOfReceives++
// Регистрируем handle→index: по нему DeleteMessage/ChangeMessageVisibility
// работают за O(1), а visibility-тик итерирует только in-flight (P1.2).
queue.MarkInFlight(i, msg.ReceiptHandle)
messages = append(messages, buildResultMessage(msg)) messages = append(messages, buildResultMessage(msg))
numMsg++ numMsg++
@@ -236,7 +244,7 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
Attributes: map[string]string{ Attributes: map[string]string{
"ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)), "ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)),
"SenderId": models.CurrentEnvironment.AccountID, "SenderId": models.CurrentEnvironment.AccountID,
"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives+1), "ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives),
"SentTimestamp": fmt.Sprintf("%d", m.SentTime.UnixNano()/int64(time.Millisecond)), // Фикс: реальное время отправки "SentTimestamp": fmt.Sprintf("%d", m.SentTime.UnixNano()/int64(time.Millisecond)), // Фикс: реальное время отправки
}, },
} }
@@ -250,6 +258,9 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
// читает общее состояние очереди. // читает общее состояние очереди.
func queueHasReceivableMessages(queue *models.Queue) bool { func queueHasReceivableMessages(queue *models.Queue) bool {
for i := range queue.Messages { for i := range queue.Messages {
if queue.IsDead(i) {
continue // tombstone (P1.2)
}
msg := &queue.Messages[i] msg := &queue.Messages[i]
if msg.ReceiptHandle != "" { if msg.ReceiptHandle != "" {
continue continue
-404
View File
@@ -1,404 +0,0 @@
package gosqs
import (
"context"
"fmt"
"net/http"
"sync"
"testing"
"time"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/models"
"github.com/stretchr/testify/assert"
)
// TODO Admiral-Piett - fix all these tests, we don't want to be waiting for 5 seconds plus like this.
func TestReceiveMessageV1_with_WaitTimeEnforced(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
q := &models.Queue{
Name: "waiting-queue",
ReceiveMessageWaitTimeSeconds: 2,
//MaximumMessageSize: 262144,
}
models.SyncQueues.Queues["waiting-queue"] = q
// receive message ensure delay
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: "http://localhost:4100/queue/waiting-queue",
}, true)
start := time.Now()
status, response := ReceiveMessageV1(r)
elapsed := time.Since(start)
assert.Equal(t, http.StatusOK, status)
if elapsed < 2*time.Second {
t.Fatalf("handler didn't wait ReceiveMessageWaitTimeSeconds %s", response)
}
// mock sending a message
q.Messages = append(q.Messages, models.SqsMessage{MessageBody: "1"})
// receive message
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: "http://localhost:4100/queue/waiting-queue",
}, true)
start = time.Now()
status, resp := ReceiveMessageV1(r)
elapsed = time.Since(start)
assert.Equal(t, http.StatusOK, status)
if elapsed > 1*time.Second {
t.Fatal("handler waited when message was available, expected not to wait")
}
assert.Equal(t, "1", string(resp.GetResult().(models.ReceiveMessageResult).Messages[0].Body))
}
func TestReceiveMessageV1_CanceledByClient(t *testing.T) {
// create a queue
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
q := &models.Queue{
Name: "cancel-queue",
ReceiveMessageWaitTimeSeconds: 20,
}
models.SyncQueues.Queues["cancel-queue"] = q
var wg sync.WaitGroup
ctx, cancelReceive := context.WithCancel(context.Background())
wg.Add(1)
go func() {
defer wg.Done()
// receive message (that will be canceled)
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: "http://localhost:4100/queue/cancel-queue",
}, true)
r = r.WithContext(ctx)
status, resp := ReceiveMessageV1(r)
assert.Equal(t, http.StatusOK, status)
if len(resp.GetResult().(models.ReceiveMessageResult).Messages) != 0 {
t.Fatal("expecting this ReceiveMessage() to not pickup this message as it should canceled before the Send()")
}
}()
time.Sleep(100 * time.Millisecond) // let enought time for the Receive go to wait mode
cancelReceive() // cancel the first ReceiveMessage(), make sure it will not pickup the sent message below
time.Sleep(5 * time.Millisecond)
// send a message
_, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{
QueueUrl: "http://localhost:4100/queue/cancel-queue",
MessageBody: "12345",
}, true)
status, _ := SendMessageV1(r)
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK)
}
// receive message
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: "http://localhost:4100/queue/cancel-queue",
}, true)
start := time.Now()
status, resp := ReceiveMessageV1(r)
assert.Equal(t, http.StatusOK, status)
elapsed := time.Since(start)
result, ok := resp.GetResult().(models.ReceiveMessageResult)
if !ok {
t.Fatal("handler should return a message")
}
if len(result.Messages) == 0 || string(result.Messages[0].Body) == "12345\n" {
t.Fatal("handler should return a message")
}
if elapsed > 1*time.Second {
t.Fatal("handler waited when message was available, expected not to wait")
}
if timedout := waitTimeout(&wg, 2*time.Second); timedout {
t.Errorf("expected ReceiveMessage() in goroutine to exit quickly due to cancelReceive() called")
}
}
func TestReceiveMessageV1_with_DelaySeconds(t *testing.T) {
// create a queue
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
q := &models.Queue{
Name: "delay-seconds-queue",
DelaySeconds: 2,
}
models.SyncQueues.Queues["delay-seconds-queue"] = q
// send a message
_, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{
QueueUrl: "http://localhost:4100/queue/delay-seconds-queue",
MessageBody: "1",
}, true)
status, _ := SendMessageV1(r)
if status != http.StatusOK {
t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK)
}
// receive message before delay is up
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/delay-seconds-queue"}, true)
status, _ = ReceiveMessageV1(r)
assert.Equal(t, http.StatusOK, status)
// receive message with wait should return after delay
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: "http://localhost:4100/queue/delay-seconds-queue",
WaitTimeSeconds: 10,
}, true)
start := time.Now()
status, _ = ReceiveMessageV1(r)
elapsed := time.Since(start)
assert.Equal(t, http.StatusOK, status)
if elapsed < 1*time.Second {
t.Errorf("handler didn't wait at all")
}
if elapsed > 4*time.Second {
t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2")
}
}
func TestReceiveMessageV1_with_MessageAttributes(t *testing.T) {
// create a queue
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
q := &models.Queue{Name: "waiting-queue"}
models.SyncQueues.Queues["waiting-queue"] = q
// send a message
q.Messages = append(q.Messages, models.SqsMessage{
MessageBody: "1",
MessageAttributes: map[string]models.MessageAttribute{
"TestMessageAttrName": {
DataType: "String",
StringValue: "TestMessageAttrValue",
},
},
})
// receive message
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/waiting-queue"}, true)
status, resp := ReceiveMessageV1(r)
result := resp.GetResult().(models.ReceiveMessageResult)
assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateFirstReceiveTimestamp"])
assert.NotEmpty(t, result.Messages[0].Attributes["SenderId"])
assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateReceiveCount"])
assert.NotEmpty(t, result.Messages[0].Attributes["SentTimestamp"])
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, "1", string(result.Messages[0].Body))
assert.Equal(t, 1, len(result.Messages[0].MessageAttributes))
assert.Equal(t, "String", result.Messages[0].MessageAttributes["TestMessageAttrName"].DataType)
assert.Equal(t, "TestMessageAttrValue", result.Messages[0].MessageAttributes["TestMessageAttrName"].StringValue)
}
func TestReceiveMessageV1_request_transformer_error(t *testing.T) {
// TODO
}
func TestReceiveMessageV1_with_CustomVisibilityTimeout(t *testing.T) {
// create a queue
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
// Create a queue with a default visibility timeout of 30 seconds
q := &models.Queue{
Name: "custom-visibility-queue",
VisibilityTimeout: 30,
}
models.SyncQueues.Queues["custom-visibility-queue"] = q
// Add a message to the queue
q.Messages = append(q.Messages, models.SqsMessage{
MessageBody: "test-message",
Uuid: "test-uuid",
})
// Test 1: Receive message with custom visibility timeout
customTimeout := 60 // 60 seconds
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: "http://localhost:4100/queue/custom-visibility-queue",
VisibilityTimeout: customTimeout,
}, true)
status, resp := ReceiveMessageV1(r)
assert.Equal(t, http.StatusOK, status)
result := resp.GetResult().(models.ReceiveMessageResult)
assert.Equal(t, 1, len(result.Messages))
assert.Equal(t, "test-message", string(result.Messages[0].Body))
// Verify the message in the queue has the custom visibility timeout
// We can't directly check the exact time, but we can verify it's not using the queue's default
// by checking that the visibility timeout is greater than now + default timeout - 1 second
// and less than now + custom timeout + 1 second
now := time.Now()
defaultExpiry := now.Add(time.Duration(q.VisibilityTimeout) * time.Second)
customExpiry := now.Add(time.Duration(customTimeout) * time.Second)
// The first message should have the custom visibility timeout
msgVisibilityTimeout := q.Messages[0].VisibilityTimeout
assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)),
"Message visibility timeout should be greater than default timeout")
assert.True(t, msgVisibilityTimeout.Before(customExpiry.Add(1*time.Second)),
"Message visibility timeout should be less than custom timeout + 1 second")
// Test 2: Reset the queue and test with zero visibility timeout (should use queue default)
models.SyncQueues.Queues["custom-visibility-queue"] = &models.Queue{
Name: "custom-visibility-queue",
VisibilityTimeout: 30,
}
q = models.SyncQueues.Queues["custom-visibility-queue"]
q.Messages = append(q.Messages, models.SqsMessage{
MessageBody: "test-message-2",
Uuid: "test-uuid-2",
})
// Receive message with zero visibility timeout (should use queue default)
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: "http://localhost:4100/queue/custom-visibility-queue",
VisibilityTimeout: 0, // Zero should use queue default
}, true)
status, resp = ReceiveMessageV1(r)
assert.Equal(t, http.StatusOK, status)
// Verify the message in the queue has the default visibility timeout
now = time.Now()
defaultExpiry = now.Add(time.Duration(q.VisibilityTimeout) * time.Second)
// The message should have the default visibility timeout
msgVisibilityTimeout = q.Messages[0].VisibilityTimeout
assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)),
"Message visibility timeout should be greater than default timeout - 1 second")
assert.True(t, msgVisibilityTimeout.Before(defaultExpiry.Add(1*time.Second)),
"Message visibility timeout should be less than default timeout + 1 second")
}
func TestReceiveMessageV1_FIFOSecondMessageAvailableAfterDelete(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
queueName := "fifo-delay-queue"
queueURL := fmt.Sprintf("http://localhost:4100/queue/%s", queueName)
now := time.Now().Add(-1 * time.Minute)
// create a queue with a visibility timeout of 10 seconds
q := &models.Queue{
Name: queueName,
VisibilityTimeout: 10,
IsFIFO: true,
FIFOMessages: map[string]int{},
FIFOSequenceNumbers: map[string]int{},
Duplicates: map[string]time.Time{},
Messages: []models.SqsMessage{
{
MessageBody: "first",
Uuid: "first-uuid",
GroupID: "company#worker",
SentTime: now,
},
{
MessageBody: "second",
Uuid: "second-uuid",
GroupID: "company#worker",
SentTime: now,
},
},
}
models.SyncQueues.Queues[queueName] = q
// receive the first FIFO message only
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: queueURL,
MaxNumberOfMessages: 1,
}, true)
status, resp := ReceiveMessageV1(r)
assert.Equal(t, http.StatusOK, status)
result := resp.GetResult().(models.ReceiveMessageResult)
if len(result.Messages) != 1 {
t.Fatalf("expected to receive the first FIFO message, got %d", len(result.Messages))
}
assert.Equal(t, "first", result.Messages[0].Body)
firstReceipt := result.Messages[0].ReceiptHandle
// verify the second FIFO message is blocked while the first is in flight
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: queueURL,
}, true)
status, resp = ReceiveMessageV1(r)
assert.Equal(t, http.StatusOK, status)
result = resp.GetResult().(models.ReceiveMessageResult)
if len(result.Messages) != 0 {
t.Fatalf("expected no FIFO message while the first is outstanding, got %d", len(result.Messages))
}
// delete the first FIFO message
_, deleteReq := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
QueueUrl: queueURL,
ReceiptHandle: firstReceipt,
}, true)
deleteStatus, _ := DeleteMessageV1(deleteReq)
assert.Equal(t, http.StatusOK, deleteStatus)
// receive the second FIFO message and ensure it does not wait for full 10 second visibility timeout
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
QueueUrl: queueURL,
}, true)
start := time.Now()
status, resp = ReceiveMessageV1(r)
elapsed := time.Since(start)
assert.Equal(t, http.StatusOK, status)
result = resp.GetResult().(models.ReceiveMessageResult)
if len(result.Messages) != 1 {
t.Fatalf("expected second FIFO message to be available immediately, got %d", len(result.Messages))
}
if elapsed > time.Second {
t.Fatalf("expected second FIFO message without waiting on visibility timeout, took %s", elapsed)
}
assert.Equal(t, "second", result.Messages[0].Body)
// delete the second FIFO message
_, deleteReq = test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
QueueUrl: queueURL,
ReceiptHandle: result.Messages[0].ReceiptHandle,
}, true)
deleteStatus, _ = DeleteMessageV1(deleteReq)
assert.Equal(t, http.StatusOK, deleteStatus)
if len(q.Messages) != 0 {
t.Fatalf("expected all FIFO messages to be deleted, remaining %d", len(q.Messages))
}
}
// TODO - other tests
+45 -28
View File
@@ -83,17 +83,17 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.RLock() queue := models.SyncQueues.GetLocked(key)
queue, exists := models.SyncQueues.Queues[key] if queue == nil {
if !exists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
maxMessageSize := queue.MaximumMessageSize maxMessageSize := queue.MaximumMessageSize
currentMsgCount := len(queue.Messages) currentMsgCount := queue.LiveCount()
queueIsFIFO := queue.IsFIFO queueIsFIFO := queue.IsFIFO
contentBasedDedup := queue.ContentBasedDeduplication
delaySecs := queue.DelaySeconds delaySecs := queue.DelaySeconds
models.SyncQueues.RUnlock()
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой // FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы). // отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
@@ -101,6 +101,12 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
if queueIsFIFO && messageGroupID == "" { if queueIsFIFO && messageGroupID == "" {
return utils.CreateErrorResponseV1("MissingParameter", true) return utils.CreateErrorResponseV1("MissingParameter", true)
} }
// ContentBasedDeduplication: если MessageDeduplicationId не передан, AWS сам
// вычисляет его из тела сообщения (SHA-256). Без этого включённый флаг
// ничего не делал бы и FIFO-дедупликация не работала.
if queueIsFIFO && contentBasedDedup && messageDeduplicationID == "" {
messageDeduplicationID = utils.GetSHA256Hash(messageBody)
}
if maxMessageSize > 0 && len(messageBody) > maxMessageSize { if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
return utils.CreateErrorResponseV1("MessageTooBig", true) return utils.CreateErrorResponseV1("MessageTooBig", true)
@@ -111,6 +117,9 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
return utils.CreateErrorResponseV1("OverLimit", true) return utils.CreateErrorResponseV1("OverLimit", true)
} }
if requestBody.DelaySeconds < 0 || requestBody.DelaySeconds > 900 {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
if requestBody.DelaySeconds != 0 { if requestBody.DelaySeconds != 0 {
delaySecs = requestBody.DelaySeconds delaySecs = requestBody.DelaySeconds
} }
@@ -128,38 +137,46 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
msg.SentTime = time.Now() msg.SentTime = time.Now()
msg.DelaySecs = delaySecs msg.DelaySecs = delaySecs
models.SyncQueues.Lock() // Повторная проверка лимита под per-queue Lock (между снимком выше и текущим
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой // моментом другие горутины могли добавить сообщения — мягкий OOM-лимит).
// выше и захватом Lock (иначе nil-pointer deref). if queue.LiveCount() >= MaxMessagesForQueue(queue.IsFIFO) {
queue = models.SyncQueues.Queues[key] return utils.CreateErrorResponseV1("OverLimit", true)
if queue == nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
// Повторная проверка лимита под Lock: между RLock-снимком и захватом Lock // Глобальный предохранитель памяти (HeapAlloc) — защита от OOM суммой тенантов.
// другие горутины могли добавить сообщения (мягкий OOM-лимит). if heapOverLimit() {
if len(queue.Messages) >= MaxMessagesForQueue(queue.IsFIFO) {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("OverLimit", true) return utils.CreateErrorResponseV1("OverLimit", true)
} }
fifoSeqNumber := "" fifoSeqNumber := ""
if queue.IsFIFO { persistErr := false
fifoSeqNumber = queue.NextSequenceNumber(messageGroupID)
}
if !queue.IsDuplicate(messageDeduplicationID) { if !queue.IsDuplicate(messageDeduplicationID) {
queue.Messages = append(queue.Messages, msg) idx := queue.AppendMessage(msg)
// Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size) // Персистим сообщение СИНХРОННО до ответа клиенту: краш пода в окне
persistence.SaveMessage(key, &msg) // между 200 OK и async-записью приводил бы к потере подтверждённого
// сообщения. При ошибке — откатываем append (не оставляем в памяти
// сообщение, которого нет в Redis).
if err := persistence.SaveMessageSync(key, &msg); err != nil {
log.Errorf("SendMessage: не удалось персистить сообщение %s в очередь %s: %v", msg.Uuid, key, err)
queue.RemoveIndex(idx)
persistErr = true
} else {
// Регистрируем dedupId и порядковый номер ТОЛЬКО при успешной записи.
// Иначе ретрай с тем же dedupId получил бы 200 без сохранённого сообщения
// (InitDuplicatation после отката append помечал бы id как обработанный).
queue.InitDuplicatation(messageDeduplicationID)
if queue.IsFIFO {
fifoSeqNumber = queue.NextSequenceNumber(messageGroupID)
}
// Метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue)
}
} else { } else {
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName) log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
} }
queue.InitDuplicatation(messageDeduplicationID) if persistErr {
// Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages return utils.CreateErrorResponseV1("InternalError", true)
persistence.SaveQueue(key, queue) }
models.SyncQueues.Unlock()
// Логируем только метаданные — тело сообщения не логируется (perf + security) // Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody)) log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody))
+61 -24
View File
@@ -43,16 +43,14 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.RLock() queue := models.SyncQueues.GetRLocked(key)
queue, exists := models.SyncQueues.Queues[key] if queue == nil {
if !exists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
maxMsgSize := queue.MaximumMessageSize maxMsgSize := queue.MaximumMessageSize
currentMsgCount := len(queue.Messages) currentMsgCount := queue.LiveCount()
queueIsFIFO := queue.IsFIFO queueIsFIFO := queue.IsFIFO
models.SyncQueues.RUnlock() queue.RUnlockMu()
sendEntries := requestBody.Entries sendEntries := requestBody.Entries
@@ -104,15 +102,23 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
return utils.CreateErrorResponseV1("OverLimit", true) return utils.CreateErrorResponseV1("OverLimit", true)
} }
models.SyncQueues.Lock() queue.LockMu()
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой defer queue.UnlockMu()
// выше и захватом Lock (иначе nil-pointer deref).
queue = models.SyncQueues.Queues[key] // Повторная проверка лимита под per-queue Lock: снимок currentMsgCount выше
if queue == nil { // был сделан под RLock, между ним и взятием Lock очередь могла измениться
models.SyncQueues.Unlock() // (гонка TOCTOU). Здесь — обязательная проверка по свежему числу живых.
return utils.CreateErrorResponseV1("QueueNotFound", true) if queue.LiveCount()+len(sendEntries) > MaxMessagesForQueue(queue.IsFIFO) {
return utils.CreateErrorResponseV1("OverLimit", true)
} }
// Глобальный предохранитель памяти (HeapAlloc) — защита от OOM суммой тенантов.
if heapOverLimit() {
return utils.CreateErrorResponseV1("OverLimit", true)
}
newMsgs := make([]models.SqsMessage, 0, len(sendEntries)) newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
appendedIdx := make([]int, 0, len(sendEntries))
appendedDedupIDs := make([]string, 0, len(sendEntries))
for _, sendEntry := range sendEntries { for _, sendEntry := range sendEntries {
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody} msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
if len(sendEntry.MessageAttributes) > 0 { if len(sendEntry.MessageAttributes) > 0 {
@@ -121,20 +127,41 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
} }
msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody) msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody)
msg.GroupID = sendEntry.MessageGroupId msg.GroupID = sendEntry.MessageGroupId
msg.DeduplicationID = sendEntry.MessageDeduplicationId msg.DelaySecs = sendEntry.DelaySeconds
// ContentBasedDeduplication: dedupId вычисляется из тела (SHA-256),
// если не передан явно (AWS-семантика FIFO).
dedupID := sendEntry.MessageDeduplicationId
if queue.IsFIFO && queue.ContentBasedDeduplication && dedupID == "" {
dedupID = utils.GetSHA256Hash(sendEntry.MessageBody)
}
msg.DeduplicationID = dedupID
msg.Uuid = uuid.NewString() msg.Uuid = uuid.NewString()
msg.SentTime = time.Now() msg.SentTime = time.Now()
// Дубликат проверяем ДО NextSequenceNumber/Append: иначе seq инкрементировался
// бы и для отброшенного дубликата, а два entry с одним dedupID в одном батче
// оба добавились бы (InitDuplicatation был после цикла — ревью 2026-10-11).
if queue.IsDuplicate(dedupID) {
log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", dedupID, queueName)
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
Id: sendEntry.Id,
MessageId: msg.Uuid,
MD5OfMessageBody: msg.MD5OfMessageBody,
MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
SequenceNumber: "",
})
continue
}
fifoSeqNumber := "" fifoSeqNumber := ""
if queue.IsFIFO { if queue.IsFIFO {
fifoSeqNumber = queue.NextSequenceNumber(sendEntry.MessageGroupId) fifoSeqNumber = queue.NextSequenceNumber(sendEntry.MessageGroupId)
} }
if !queue.IsDuplicate(sendEntry.MessageDeduplicationId) { // Регистрируем dedupId сразу — второй entry с тем же id увидит его.
queue.Messages = append(queue.Messages, msg) queue.InitDuplicatation(dedupID)
} else { appendedIdx = append(appendedIdx, queue.AppendMessage(msg))
log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", sendEntry.MessageDeduplicationId, queueName) newMsgs = append(newMsgs, msg)
} appendedDedupIDs = append(appendedDedupIDs, dedupID)
queue.InitDuplicatation(sendEntry.MessageDeduplicationId)
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{ sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
Id: sendEntry.Id, Id: sendEntry.Id,
@@ -143,15 +170,25 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
MD5OfMessageAttributes: msg.MD5OfMessageAttributes, MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
SequenceNumber: fifoSeqNumber, SequenceNumber: fifoSeqNumber,
}) })
newMsgs = append(newMsgs, msg)
// Логируем только метаданные — тело сообщения не логируется (perf + security) // Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody)) log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody))
} }
// Персистим новые сообщения отдельно — O(batch_size*msg_size) вместо O(N*msg_size) // Персистим СИНХРОННО до ответа (как одиночный SendMessage): иначе краш в окне
persistence.SaveMessages(key, newMsgs) // между 200 OK и async-записью потеряет подтверждённые сообщения.
if err := persistence.SaveMessagesSync(key, newMsgs); err != nil {
log.Errorf("SendMessageBatch: не удалось персистить батч в %s: %v", key, err)
for _, idx := range appendedIdx {
queue.RemoveIndex(idx)
}
// Откат dedup-регистраций: иначе retry с тем же dedupId получил бы
// «duplicate» без сохранённых сообщений.
for _, id := range appendedDedupIDs {
delete(queue.Duplicates, id)
}
return utils.CreateErrorResponseV1("InternalError", true)
}
// Метаданные очереди (FIFO state, duplicates) — без Messages // Метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue) persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock()
respStruct := models.SendMessageBatchResponse{ respStruct := models.SendMessageBatchResponse{
Xmlns: models.BaseXmlns, Xmlns: models.BaseXmlns,
-346
View File
@@ -1,346 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/test"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestSendMessageBatchV1_Success(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageBatchRequest{
Entries: []models.SendMessageBatchRequestEntry{
{
Id: "test-msg-with-non-attribute",
MessageBody: "test%20message%20body%201",
},
{
Id: "test-msg-with-single-attirbute",
MessageBody: "test%20message%20body%202",
MessageAttributes: map[string]models.MessageAttribute{
"my-attribute-name": {
DataType: "String",
StringValue: "my-attribute-string-value",
},
},
},
{
Id: "test-msg-with-multi-attirbute",
MessageBody: "test%20message%20body%203",
MessageAttributes: map[string]models.MessageAttribute{
"my-attribute-name-1": {
BinaryValue: "binary-value-1",
DataType: "Binary",
},
"my-attribute-name-2": {
DataType: "String",
StringValue: "my-attribute-string-value-2",
},
},
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageBatchRequest)
*v = sendMessageRequest_success
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageBatchV1(r)
sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse)
assert.Equal(t, http.StatusOK, status)
assert.True(t, ok)
resultEntry := sendMessageBatchResponse.Result.Entry
assert.Equal(t, 3, len(resultEntry))
assert.Contains(t, resultEntry[0].Id, "test-msg-with-non-attribute")
assert.Contains(t, resultEntry[1].Id, "test-msg-with-single-attirbute")
assert.Contains(t, resultEntry[2].Id, "test-msg-with-multi-attirbute")
assert.Empty(t, resultEntry[0].SequenceNumber)
assert.Empty(t, resultEntry[1].SequenceNumber)
assert.Empty(t, resultEntry[2].SequenceNumber)
}
func TestSendMessageBatchV1_Success_Fifo_Queue(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageBatchRequest{
Entries: []models.SendMessageBatchRequestEntry{
{
Id: "test_msg_001",
MessageBody: "test%20message%20body%201",
},
{
Id: "test_msg_002",
MessageBody: "test%20message%20body%202",
},
{
Id: "test_msg_003",
MessageBody: "test%20message%20body%203",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "fifo-queue-1"),
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageBatchRequest)
*v = sendMessageRequest_success
return true
}
q := &models.Queue{
Name: "fifo-queue-1",
MaximumMessageSize: 1024,
IsFIFO: true,
}
models.SyncQueues.Queues["fifo-queue-1"] = q
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageBatchV1(r)
sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse)
assert.Equal(t, http.StatusOK, status)
assert.True(t, ok)
resultEntry := sendMessageBatchResponse.Result.Entry
assert.Equal(t, 3, len(resultEntry))
assert.Contains(t, resultEntry[0].Id, "test_msg_001")
assert.NotEmpty(t, resultEntry[0].SequenceNumber)
assert.Contains(t, resultEntry[1].Id, "test_msg_002")
assert.NotEmpty(t, resultEntry[1].SequenceNumber)
assert.Contains(t, resultEntry[2].Id, "test_msg_003")
assert.NotEmpty(t, resultEntry[2].SequenceNumber)
}
func TestSendMessageBatchV1_Error_QueueNotFound(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_not_found_queue := models.SendMessageBatchRequest{
Entries: []models.SendMessageBatchRequestEntry{
{
Id: "test_msg_001",
MessageBody: "test%20message%20body%201",
},
{
Id: "test_msg_002",
MessageBody: "test%20message%20body%202",
},
{
Id: "test_msg_003",
MessageBody: "test%20message%20body%203",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue1"),
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageBatchRequest)
*v = sendMessageRequest_not_found_queue
return true
}
expected := models.ErrorResult{
Type: "Not Found",
Code: "AWS.SimpleQueueService.NonExistentQueue",
Message: "The specified queue does not exist for this wsdl version.",
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageBatchV1(r)
errorResult := response.GetResult().(models.ErrorResult)
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, expected, errorResult)
}
func TestSendMessageBatchV1_Error_NoEntry(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_no_entry := models.SendMessageBatchRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageBatchRequest)
*v = sendMessageRequest_no_entry
return true
}
expected := models.ErrorResult{
Type: "EmptyBatchRequest",
Code: "AWS.SimpleQueueService.EmptyBatchRequest",
Message: "The batch request doesn't contain any entries.",
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageBatchV1(r)
errorResult := response.GetResult().(models.ErrorResult)
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, expected, errorResult)
}
func TestSendMessageBatchV1_Error_IdNotDistinct(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_no_entry := models.SendMessageBatchRequest{
Entries: []models.SendMessageBatchRequestEntry{
{
Id: "test_msg_001",
MessageBody: "test%20message%20body%201",
},
{
Id: "test_msg_001",
MessageBody: "test%20message%20body%202",
},
{
Id: "test_msg_001",
MessageBody: "test%20message%20body%203",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageBatchRequest)
*v = sendMessageRequest_no_entry
return true
}
expected := models.ErrorResult{
Type: "BatchEntryIdsNotDistinct",
Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct",
Message: "Two or more batch entries in the request have the same Id.",
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageBatchV1(r)
errorResult := response.GetResult().(models.ErrorResult)
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, expected, errorResult)
}
func TestSendMessageBatchV1_Error_TooManyEntries(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageBatchRequest{
Entries: []models.SendMessageBatchRequestEntry{
{
Id: "test_msg_001",
MessageBody: "test%20message%20body%201",
},
{
Id: "test_msg_002",
MessageBody: "test%20message%20body%202",
},
{
Id: "test_msg_003",
MessageBody: "test%20message%20body%203",
},
{
Id: "test_msg_004",
MessageBody: "test%20message%20body%204",
},
{
Id: "test_msg_005",
MessageBody: "test%20message%20body%205",
},
{
Id: "test_msg_006",
MessageBody: "test%20message%20body%206",
},
{
Id: "test_msg_007",
MessageBody: "test%20message%20body%207",
},
{
Id: "test_msg_008",
MessageBody: "test%20message%20body%208",
},
{
Id: "test_msg_009",
MessageBody: "test%20message%20body%209",
},
{
Id: "test_msg_010",
MessageBody: "test%20message%20body%210",
},
{
Id: "test_msg_011",
MessageBody: "test%20message%20body%211",
},
},
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageBatchRequest)
*v = sendMessageRequest_success
return true
}
expected := models.ErrorResult{
Type: "TooManyEntriesInBatchRequest",
Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest",
Message: "Maximum number of entries per request are 10.",
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageBatchV1(r)
errorResult := response.GetResult().(models.ErrorResult)
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, expected, errorResult)
}
func TestSendMessageBatchV1_Error_transformer(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := SendMessageBatchV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
-220
View File
@@ -1,220 +0,0 @@
package gosqs
import (
"net/http"
"testing"
"time"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestSendMessageV1_Success(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageRequest{
QueueUrl: "http://localhost:4200/new-queue-1",
MessageBody: "Test Message",
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageRequest)
*v = sendMessageRequest_success
return true
}
q := &models.Queue{
Name: "new-queue-1",
MaximumMessageSize: 1024,
}
models.SyncQueues.Queues["new-queue-1"] = q
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageV1(r)
// Check the queue
assert.Equal(t, 1, len(q.Messages))
msg := q.Messages[0]
assert.Equal(t, "Test Message", string(msg.MessageBody))
// Check the response
assert.Equal(t, http.StatusOK, status)
sendMessageResponse, ok := response.(models.SendMessageResponse)
assert.True(t, ok)
assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody)
// No FIFO Sequence
assert.Empty(t, sendMessageResponse.Result.SequenceNumber)
}
func TestSendMessageV1_Success_FIFOQueue(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageRequest{
QueueUrl: "http://localhost:4200/new-queue-1",
MessageBody: "Test Message",
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageRequest)
*v = sendMessageRequest_success
return true
}
q := &models.Queue{
Name: "new-queue-1",
MaximumMessageSize: 1024,
IsFIFO: true,
}
models.SyncQueues.Queues["new-queue-1"] = q
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageV1(r)
// Check the queue
assert.Equal(t, 1, len(q.Messages))
msg := q.Messages[0]
assert.Equal(t, "Test Message", string(msg.MessageBody))
// Check the response
assert.Equal(t, http.StatusOK, status)
sendMessageResponse, ok := response.(models.SendMessageResponse)
assert.True(t, ok)
assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody)
// Should have FIFO Sequence
assert.NotEmpty(t, sendMessageResponse.Result.SequenceNumber)
}
func TestSendMessageV1_Success_Deduplication(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageRequest{
QueueUrl: "http://localhost:4200/new-queue-1",
MessageBody: "Test Message",
MessageDeduplicationId: "1",
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageRequest)
*v = sendMessageRequest_success
return true
}
q := &models.Queue{
Name: "new-queue-1",
MaximumMessageSize: 1024,
IsFIFO: true,
EnableDuplicates: true,
Duplicates: make(map[string]time.Time),
}
models.SyncQueues.Queues["new-queue-1"] = q
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, _ := SendMessageV1(r)
// Check the queue
assert.Equal(t, 1, len(q.Messages))
// Check the response
assert.Equal(t, http.StatusOK, status)
// Send the same message (have DeduplicationId)
status, _ = SendMessageV1(r)
// Response is "success"
assert.Equal(t, http.StatusOK, status)
// Only 1 message should be in the queue
assert.Equal(t, 1, len(q.Messages))
}
func TestSendMessageV1_request_transformer_error(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := SendMessageV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestSendMessageV1_MaximumMessageSize_MessageTooBig(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageRequest{
QueueUrl: "http://localhost:4200/new-queue-1",
MessageBody: "Test Message",
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageRequest)
*v = sendMessageRequest_success
return true
}
q := &models.Queue{
Name: "new-queue-1",
MaximumMessageSize: 1,
}
models.SyncQueues.Queues["new-queue-1"] = q
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageV1(r)
// Check the response
assert.Equal(t, http.StatusBadRequest, status)
errorResponse, ok := response.(models.ErrorResponse)
assert.True(t, ok)
assert.Equal(t, "MessageTooBig", errorResponse.Result.Type)
}
func TestSendMessageV1_POST_QueueNonExistant(t *testing.T) {
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
sendMessageRequest_success := models.SendMessageRequest{
QueueUrl: "http://localhost:4200/new-queue-1",
MessageBody: "Test Message",
}
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SendMessageRequest)
*v = sendMessageRequest_success
return true
}
// No test queue is added to app.SyncQueues
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
status, response := SendMessageV1(r)
// Check the status code is what we expect.
assert.Equal(t, http.StatusBadRequest, status)
// Check the response body is what we expect.
errorResponse, ok := response.(models.ErrorResponse)
assert.True(t, ok)
assert.Equal(t, "Not Found", errorResponse.Result.Type)
}
+4 -5
View File
@@ -45,13 +45,12 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID) log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
queue, ok := models.SyncQueues.Queues[key]
if !ok {
log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID) log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
// Список имён переданных атрибутов — для whitelist-валидации. // Список имён переданных атрибутов — для whitelist-валидации.
// Query-протокол: имена видны в form (Attribute.N.Name). // Query-протокол: имена видны в form (Attribute.N.Name).
// JSON-протокол: структура уже разобрана из тела, список имён недоступен → nil // JSON-протокол: структура уже разобрана из тела, список имён недоступен → nil
@@ -61,7 +60,7 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
provided = utils.ExtractQueueAttributes(req.PostForm) provided = utils.ExtractQueueAttributes(req.PostForm)
} }
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided); err != nil { if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
return utils.CreateErrorResponseV1(err.Error(), true) return utils.CreateErrorResponseV1(err.Error(), true)
} }
// Сохраняем атрибуты в Redis пока держим Lock (через defer) // Сохраняем атрибуты в Redis пока держим Lock (через defer)
-169
View File
@@ -1,169 +0,0 @@
package gosqs
import (
"fmt"
"net/http"
"testing"
"shared-sqs/app/test"
"shared-sqs/app/conf"
"shared-sqs/app/fixtures"
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/stretchr/testify/assert"
)
func TestSetQueueAttributesV1_success_multiple_attributes(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SetQueueAttributesRequest)
*v = fixtures.SetQueueAttributesRequest
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := SetQueueAttributesV1(r)
expectedResponse := models.SetQueueAttributesResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
actualQueue := models.SyncQueues.Queues["unit-queue1"]
assert.Equal(t, 5, actualQueue.VisibilityTimeout)
assert.Equal(t, 4, actualQueue.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 1, actualQueue.DelaySeconds)
assert.Equal(t, 2, actualQueue.MaximumMessageSize)
assert.Equal(t, 3, actualQueue.MessageRetentionPeriod)
}
func TestSetQueueAttributesV1_success_single_attribute(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SetQueueAttributesRequest)
*v = models.SetQueueAttributesRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
Attributes: models.QueueAttributes{
VisibilityTimeout: 5,
},
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, response := SetQueueAttributesV1(r)
expectedResponse := models.SetQueueAttributesResponse{
Xmlns: models.BaseXmlns,
Metadata: models.BaseResponseMetadata,
}
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, expectedResponse, response)
actualQueue := models.SyncQueues.Queues["unit-queue1"]
assert.Equal(t, 5, actualQueue.VisibilityTimeout)
assert.Equal(t, 0, actualQueue.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 0, actualQueue.DelaySeconds)
assert.Equal(t, 0, actualQueue.MaximumMessageSize)
assert.Equal(t, 345600, actualQueue.MessageRetentionPeriod)
}
func TestSetQueueAttributesV1_invalid_request_body(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
return false
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := SetQueueAttributesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestSetQueueAttributesV1_missing_queue_url(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SetQueueAttributesRequest)
*v = models.SetQueueAttributesRequest{
Attributes: models.QueueAttributes{},
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := SetQueueAttributesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestSetQueueAttributesV1_missing_expected_queue(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SetQueueAttributesRequest)
*v = models.SetQueueAttributesRequest{
QueueUrl: "garbage",
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := SetQueueAttributesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
func TestSetQueueAttributesV1_invalid_redrive_queue(t *testing.T) {
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
defer func() {
models.ResetApp()
utils.REQUEST_TRANSFORMER = utils.TransformRequest
}()
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
v := resultingStruct.(*models.SetQueueAttributesRequest)
*v = models.SetQueueAttributesRequest{
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
Attributes: models.QueueAttributes{
RedrivePolicy: models.RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"),
},
},
}
return true
}
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
code, _ := SetQueueAttributesV1(r)
assert.Equal(t, http.StatusBadRequest, code)
}
+3 -5
View File
@@ -41,13 +41,11 @@ func TagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
// Инициализируем Tags если nil (для старых очередей, созданных без Tags) // Инициализируем Tags если nil (для старых очередей, созданных без Tags)
if queue.Tags == nil { if queue.Tags == nil {
+26 -16
View File
@@ -4,11 +4,12 @@
package gosqs package gosqs
import ( import (
"net/http" "net/http"
"strings"
"shared-sqs/app/auth" "shared-sqs/app/auth"
"shared-sqs/app/models" "shared-sqs/app/models"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
) )
// tenantQueueKey — внутренний ключ очереди в SyncQueues в формате "{accessKey}:{queueName}". // tenantQueueKey — внутренний ключ очереди в SyncQueues в формате "{accessKey}:{queueName}".
@@ -17,8 +18,15 @@ func tenantQueueKey(tenantAccessKey, queueName string) string {
return tenantAccessKey + ":" + queueName return tenantAccessKey + ":" + queueName
} }
// getTenantFromContext — извлекает тенанта из request context. // dlqKeyFor — ключ DLQ по ключу исходной очереди (формат "{accessKey}:{name}")
// Возвращает nil если тенант не найден (не должно быть — auth middleware должен это поймать раньше). // и имени DLQ. Используется для персиста перенесённых в DLQ сообщений.
func dlqKeyFor(srcKey, dlqName string) string {
idx := strings.LastIndex(srcKey, ":")
if idx < 0 {
return srcKey + ":" + dlqName
}
return srcKey[:idx] + ":" + dlqName
}
func getTenantFromContext(r *http.Request) *tenant.Tenant { func getTenantFromContext(r *http.Request) *tenant.Tenant {
t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant) t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant)
return t return t
@@ -27,6 +35,10 @@ return t
// tenantQueueURL — формирует URL очереди для тенанта. // tenantQueueURL — формирует URL очереди для тенанта.
// Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive. // Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive.
func tenantQueueURL(t *tenant.Tenant, queueName string) string { func tenantQueueURL(t *tenant.Tenant, queueName string) string {
// Публичный адрес (напр. https://sqs.sinefo.ru) — приоритетнее локального формата.
if base := strings.TrimRight(models.CurrentEnvironment.ExternalURLBase, "/"); base != "" {
return base + "/" + t.ID + "/" + queueName
}
host := models.CurrentEnvironment.Host host := models.CurrentEnvironment.Host
port := models.CurrentEnvironment.Port port := models.CurrentEnvironment.Port
region := models.CurrentEnvironment.Region region := models.CurrentEnvironment.Region
@@ -36,21 +48,19 @@ return "http://" + region + "." + host + ":" + port + "/" + t.ID + "/" + queueNa
return "http://" + host + ":" + port + "/" + t.ID + "/" + queueName return "http://" + host + ":" + port + "/" + t.ID + "/" + queueName
} }
// TenantQueueURL — экспортированная обёртка над tenantQueueURL (нужна app/cmd).
func TenantQueueURL(t *tenant.Tenant, queueName string) string {
return tenantQueueURL(t, queueName)
}
// tenantQueueARN — формирует ARN очереди для тенанта. // tenantQueueARN — формирует ARN очереди для тенанта.
func tenantQueueARN(t *tenant.Tenant, queueName string) string { func tenantQueueARN(t *tenant.Tenant, queueName string) string {
return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName
} }
// countTenantQueues — считает количество очередей тенанта в SyncQueues. // countTenantQueues — считает количество очередей тенанта (через tenant-индекс, O(1)).
// Используется для проверки лимита MaxQueues. // Используется для проверки лимита MaxQueues.
// Вызывать под SyncQueues.RLock(). // Вызывать под SyncQueues.RLock()/Lock().
func countTenantQueues(tenantAccessKey string) int { func countTenantQueues(tenantAccessKey string) int {
prefix := tenantAccessKey + ":" return models.SyncQueues.CountTenantQueues(tenantAccessKey)
count := 0
for key := range models.SyncQueues.Queues {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
count++
}
}
return count
} }
+35
View File
@@ -0,0 +1,35 @@
package gosqs
import (
"testing"
"shared-sqs/app/models"
"shared-sqs/app/tenant"
)
// TestTenantQueueURL — проверяет формирование QueueUrl: публичный ExternalURLBase
// приоритетнее локального формата, пустое значение даёт старый формат.
func TestTenantQueueURL(t *testing.T) {
old := models.CurrentEnvironment
defer func() { models.CurrentEnvironment = old }()
tt := &tenant.Tenant{ID: "t-abc123"}
// Публичный адрес; хвостовой слеш обрезается.
models.CurrentEnvironment = models.Environment{ExternalURLBase: "https://sqs.sinefo.ru/"}
if got, want := tenantQueueURL(tt, "orders"), "https://sqs.sinefo.ru/t-abc123/orders"; got != want {
t.Fatalf("external base: got %q, want %q", got, want)
}
// Пусто → старый формат с region.
models.CurrentEnvironment = models.Environment{Host: "goaws.com", Port: "4100", Region: "us-east-1"}
if got, want := tenantQueueURL(tt, "orders"), "http://us-east-1.goaws.com:4100/t-abc123/orders"; got != want {
t.Fatalf("local format (region): got %q, want %q", got, want)
}
// Пусто, без region.
models.CurrentEnvironment = models.Environment{Host: "localhost", Port: "4100"}
if got, want := tenantQueueURL(tt, "orders"), "http://localhost:4100/t-abc123/orders"; got != want {
t.Fatalf("local format (no region): got %q, want %q", got, want)
}
}
+3 -5
View File
@@ -41,13 +41,11 @@ func UntagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
key := tenantQueueKey(t.AccessKey, queueName) key := tenantQueueKey(t.AccessKey, queueName)
models.SyncQueues.Lock() queue := models.SyncQueues.GetLocked(key)
defer models.SyncQueues.Unlock() if queue == nil {
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
defer queue.UnlockMu()
if queue.Tags != nil { if queue.Tags != nil {
for _, tagKey := range requestBody.TagKeys { for _, tagKey := range requestBody.TagKeys {
+151
View File
@@ -0,0 +1,151 @@
// app/leader/leader.go
// Простейший leader-election для active/passive (Шаг 3, вариант C).
//
// Лидер держит Redis-лок (ssq:leader) с TTL и продлевает его каждые interval.
// Пассивные периодически пытаются взять лок; когда лидер падает, TTL истекает и
// один из пассивных становится лидером. Готовность отдаётся через IsLeader().
//
// Почему так, а не полный вынос состояния (вариант A): дешевле в разы и не требует
// переписывания ядра очередей; снимает главную боль «одна ВМ = SPOF». Потолок
// (горизонтальное масштабирование) при этом остаётся — см.
// doc/thinking/state-extraction-and-ha-2026-10-10.md.
//
// ВАЖНО: при memory-only (нет Redis) — считаем себя лидером (мы единственные).
package leader
import (
"sync"
"time"
"shared-sqs/app/persistence"
log "github.com/sirupsen/logrus"
)
// Elector — выборы лидера через Redis-лок.
type Elector struct {
key string
id string
ttl time.Duration
interval time.Duration
mu sync.RWMutex
isLeader bool
stopCh chan struct{}
stopped bool
// lastRefresh — время последнего успешного refresh/acquire лока.
// Нужен для сброса лидерства при длительном сбое refresh (split-brain).
lastRefresh time.Time
}
// New — создаёт Elector. key — ключ лока, id — уникальный id инстанса.
func New(key, id string, ttl, interval time.Duration) *Elector {
return &Elector{
key: key,
id: id,
ttl: ttl,
interval: interval,
stopCh: make(chan struct{}),
lastRefresh: time.Now(),
}
}
// Start — запускает цикл выборов в фоне (первая попытка — сразу).
func (e *Elector) Start() {
log.Infof("leader: старт выборов (key=%s, id=%s, ttl=%s)", e.key, e.id, e.ttl)
go e.loop()
}
// IsLeader — текущий статус (готовность обслуживать).
func (e *Elector) IsLeader() bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.isLeader
}
// Stop — останавливает цикл и освобождает лок (если были лидером).
func (e *Elector) Stop() {
e.mu.Lock()
if e.stopped {
e.mu.Unlock()
return
}
e.stopped = true
close(e.stopCh)
was := e.isLeader
e.mu.Unlock()
if was {
persistence.ReleaseLock(e.key, e.id)
}
}
func (e *Elector) setLeader(v bool) {
e.mu.Lock()
changed := e.isLeader != v
e.isLeader = v
e.mu.Unlock()
if changed {
if v {
log.Info("leader: инстанс стал ЛИДЕРОМ (активный)")
} else {
log.Warn("leader: потерян лидерский лок (пассивный)")
}
}
}
func (e *Elector) loop() {
t := time.NewTicker(e.interval)
defer t.Stop()
e.step()
for {
select {
case <-e.stopCh:
return
case <-t.C:
e.step()
}
}
}
func (e *Elector) step() {
if e.IsLeader() {
ok, err := persistence.RefreshLock(e.key, e.id, e.ttl)
if err != nil {
log.Errorf("leader: refresh: %v", err)
// Refresh не удаётся: если с последнего успеха прошло > TTL — лок
// гарантированно истёк, и другой инстанс мог стать лидером (split-brain).
// Сбрасываем лидерство (fail-safe), а не остаёмся «лидером» без лока.
if time.Since(e.lastRefreshAt()) > e.ttl {
e.setLeader(false)
}
return
}
e.markRefreshed()
if !ok {
e.setLeader(false)
}
return
}
ok, err := persistence.TryAcquireLock(e.key, e.id, e.ttl)
if err != nil {
log.Errorf("leader: acquire: %v", err)
return
}
if ok {
e.setLeader(true)
e.markRefreshed()
}
}
func (e *Elector) lastRefreshAt() time.Time {
e.mu.RLock()
defer e.mu.RUnlock()
return e.lastRefresh
}
func (e *Elector) markRefreshed() {
e.mu.Lock()
e.lastRefresh = time.Now()
e.mu.Unlock()
}
+11 -4
View File
@@ -37,18 +37,25 @@ func StartGaugeUpdater(interval time.Duration, quit <-chan bool) {
// updateGauges — пересчитывает sqs_queues_count и sqs_messages_count per tenant. // updateGauges — пересчитывает sqs_queues_count и sqs_messages_count per tenant.
// Формат ключа очереди: "{accessKey}:{queueName}" — берём часть до первого ":". // Формат ключа очереди: "{accessKey}:{queueName}" — берём часть до первого ":".
func updateGauges() { func updateGauges() {
// Собираем данные под RLock — не блокируем запись // Снимок указателей под коротким RLock, затем содержимое — под per-queue RLock
models.SyncQueues.RLock() models.SyncQueues.RLock()
snapshot := make(map[string]*models.Queue, len(models.SyncQueues.Queues))
for key, queue := range models.SyncQueues.Queues {
snapshot[key] = queue
}
models.SyncQueues.RUnlock()
tenantQueues := make(map[string]int) tenantQueues := make(map[string]int)
tenantMessages := make(map[string]int) tenantMessages := make(map[string]int)
for key, queue := range models.SyncQueues.Queues { for key, queue := range snapshot {
// Ключ формата "accessKey:queueName" — tenant определяется по accessKey // Ключ формата "accessKey:queueName" — tenant определяется по accessKey
parts := strings.SplitN(key, ":", 2) parts := strings.SplitN(key, ":", 2)
tenantKey := parts[0] tenantKey := parts[0]
tenantQueues[tenantKey]++ tenantQueues[tenantKey]++
tenantMessages[tenantKey] += len(queue.Messages) queue.RLockMu()
tenantMessages[tenantKey] += queue.LiveCount()
queue.RUnlockMu()
} }
models.SyncQueues.RUnlock()
// Сбрасываем старые значения и записываем новые // Сбрасываем старые значения и записываем новые
QueuesGauge.Reset() QueuesGauge.Reset()
+1
View File
@@ -23,6 +23,7 @@ Port string
SqsPort string SqsPort string
Region string Region string
AccountID string AccountID string
ExternalURLBase string
LogToFile bool LogToFile bool
LogFile string LogFile string
EnableDuplicates bool EnableDuplicates bool
+28
View File
@@ -34,3 +34,31 @@ func (s *StringToInt) UnmarshalJSON(data []byte) error {
func (s *StringToInt) Int() int { func (s *StringToInt) Int() int {
return int(*s) return int(*s)
} }
// StringToBool — аналог StringToInt для булевых атрибутов: принимает и JSON-bool
// (true/false), и строку ("true"/"false"). Query-протокол шлёт строки,
// JSON-протокол — булевы значения; без этого FifoQueue/ContentBasedDeduplication
// из JSON-запроса молча терялись бы.
type StringToBool bool
func (s *StringToBool) UnmarshalJSON(data []byte) error {
var b bool
if err := json.Unmarshal(data, &b); err == nil {
*s = StringToBool(b)
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
tmp, err := strconv.ParseBool(str)
if err != nil {
return err
}
*s = StringToBool(tmp)
return nil
}
func (s *StringToBool) Bool() bool {
return bool(*s)
}
+2
View File
@@ -30,6 +30,8 @@ func init() {
"PurgeQueueInProgress": {HttpError: http.StatusForbidden, Type: "Sender", Code: "AWS.SimpleQueueService.PurgeQueueInProgress", Message: "Only one PurgeQueue operation on a queue is allowed every 60 seconds."}, "PurgeQueueInProgress": {HttpError: http.StatusForbidden, Type: "Sender", Code: "AWS.SimpleQueueService.PurgeQueueInProgress", Message: "Only one PurgeQueue operation on a queue is allowed every 60 seconds."},
// OverLimit — превышен лимит сообщений в очереди (OOM-защита) // OverLimit — превышен лимит сообщений в очереди (OOM-защита)
"OverLimit": {HttpError: http.StatusBadRequest, Type: "OverLimit", Code: "OverLimit", Message: "The queue contains the maximum number of messages."}, "OverLimit": {HttpError: http.StatusBadRequest, Type: "OverLimit", Code: "OverLimit", Message: "The queue contains the maximum number of messages."},
// InternalError — внутренняя ошибка сервера (например, не удалось персистить сообщение)
"InternalError": {HttpError: http.StatusInternalServerError, Type: "InternalError", Code: "AWS.SimpleQueueService.InternalError", Message: "We encountered an internal error. Please try again."},
} }
} }
+103 -3
View File
@@ -1,6 +1,7 @@
package models package models
import ( import (
"strings"
"sync" "sync"
) )
@@ -17,7 +18,106 @@ var CurrentEnvironment = Environment{
var LogMessages bool var LogMessages bool
var LogFile string var LogFile string
var SyncQueues = struct { // QueueStore — хранилище очередей: блокировка + map ключ→очередь + индекс tenant→очереди.
//
// Ключ очереди в Queues — "{accessKey}:{queueName}" (tenant-scoped изоляция).
// Индекс tenantQueues (accessKey → набор имён очередей) позволяет считать и
// перечислять очереди тенанта за O(число его очередей), без скана всех очередей сервиса.
//
// ВАЖНО: методы AddQueue/RemoveQueue/CountTenantQueues/TenantQueueNames НЕ берут
// блокировку — вызывать под уже захваченным SyncQueues.Lock()/RLock()
// (соответствует существующей дисциплине блокировок в коде).
type QueueStore struct {
sync.RWMutex sync.RWMutex
Queues map[string]*Queue Queues map[string]*Queue
}{Queues: make(map[string]*Queue)} tenantQueues map[string]map[string]struct{}
}
var SyncQueues = QueueStore{
Queues: make(map[string]*Queue),
tenantQueues: make(map[string]map[string]struct{}),
}
// splitQueueKey — разбирает tenant-scoped ключ "{accessKey}:{queueName}".
// Легаси-ключи без ":" (yaml-очереди) tenant-независимы → ok=false.
func splitQueueKey(key string) (accessKey, queueName string, ok bool) {
idx := strings.Index(key, ":")
if idx <= 0 || idx >= len(key)-1 {
return "", "", false
}
return key[:idx], key[idx+1:], true
}
// AddQueue — добавляет очередь и обновляет tenant-индекс.
// Вызывать под SyncQueues.Lock().
func (s *QueueStore) AddQueue(key string, q *Queue) {
s.Queues[key] = q
if ak, name, ok := splitQueueKey(key); ok {
if s.tenantQueues[ak] == nil {
s.tenantQueues[ak] = make(map[string]struct{})
}
s.tenantQueues[ak][name] = struct{}{}
}
}
// RemoveQueue — удаляет очередь и обновляет tenant-индекс.
// Вызывать под SyncQueues.Lock().
func (s *QueueStore) RemoveQueue(key string) {
delete(s.Queues, key)
if ak, name, ok := splitQueueKey(key); ok {
if set, exists := s.tenantQueues[ak]; exists {
delete(set, name)
if len(set) == 0 {
delete(s.tenantQueues, ak)
}
}
}
}
// GetLocked — возвращает очередь с захваченной per-queue write-блокировкой.
// nil если очереди нет. Вызывающий ОБЯЗАН вызвать q.UnlockMu().
func (s *QueueStore) GetLocked(key string) *Queue {
s.RLock()
q := s.Queues[key]
s.RUnlock()
if q == nil {
return nil
}
q.LockMu()
return q
}
// GetRLocked — то же, но read-блокировка. Вызывающий ОБЯЗАН вызвать q.RUnlockMu().
func (s *QueueStore) GetRLocked(key string) *Queue {
s.RLock()
q := s.Queues[key]
s.RUnlock()
if q == nil {
return nil
}
q.RLockMu()
return q
}
// CountTenantQueues — число очередей тенанта (по accessKey) за O(1).
// Вызывать под SyncQueues.RLock() или Lock().
func (s *QueueStore) CountTenantQueues(accessKey string) int {
return len(s.tenantQueues[accessKey])
}
// TenantQueueNames — копия списка имён очередей тенанта.
// Вызывать под SyncQueues.RLock() или Lock().
func (s *QueueStore) TenantQueueNames(accessKey string) []string {
set := s.tenantQueues[accessKey]
names := make([]string, 0, len(set))
for n := range set {
names = append(names, n)
}
return names
}
// ResetQueueIndex — очищает tenant-индекс (для ResetResources/тестов).
// Вызывать под SyncQueues.Lock().
func (s *QueueStore) ResetQueueIndex() {
s.tenantQueues = make(map[string]map[string]struct{})
}
+1
View File
@@ -16,6 +16,7 @@ func ResetApp() {
func ResetResources() { func ResetResources() {
SyncQueues.Lock() SyncQueues.Lock()
SyncQueues.Queues = make(map[string]*Queue) SyncQueues.Queues = make(map[string]*Queue)
SyncQueues.ResetQueueIndex()
SyncQueues.Unlock() SyncQueues.Unlock()
} }
+163
View File
@@ -0,0 +1,163 @@
// app/models/message_index_test.go
// Тесты индексации сообщений (P1.2): tombstone-удаление, компакция, индексы
// receipt/uuid. Проверяют ключевые свойства, на которых держится O(1)-удаление
// и O(in-flight) visibility-перерасчёт.
package models
import (
"fmt"
"testing"
"time"
)
// newTestQueue — очередь с последовательно добавленными сообщениями.
func newTestQueue(uuids ...string) *Queue {
q := &Queue{Name: "t", Duplicates: map[string]time.Time{}}
for _, u := range uuids {
q.AppendMessage(SqsMessage{Uuid: u, MessageBody: u})
}
return q
}
// TestQueue_RemoveIsTombstoneO1 — удаление не двигает срез и снимает индексы.
func TestQueue_RemoveIsTombstoneO1(t *testing.T) {
q := newTestQueue("a", "b", "c")
if q.LiveCount() != 3 {
t.Fatalf("LiveCount = %d, want 3", q.LiveCount())
}
if i, ok := q.IndexOfUUID("b"); !ok || i != 1 {
t.Fatalf("IndexOfUUID(b) = %d,%v; want 1,true", i, ok)
}
q.RemoveIndex(1)
if q.LiveCount() != 2 {
t.Fatalf("LiveCount после удаления = %d, want 2", q.LiveCount())
}
if !q.IsDead(1) {
t.Fatal("слот 1 должен быть tombstone")
}
if len(q.Messages) != 3 {
t.Fatalf("физический размер среза изменился (%d) — удаление должно быть tombstone O(1)", len(q.Messages))
}
if _, ok := q.IndexOfUUID("b"); ok {
t.Fatal("удалённый Uuid не должен находиться в индексе")
}
// Оставшиеся индексы не должны «съехать».
if i, ok := q.IndexOfUUID("c"); !ok || i != 2 {
t.Fatalf("IndexOfUUID(c) = %d,%v; want 2,true", i, ok)
}
}
// TestQueue_CompactKeepsOrder — компакция сохраняет порядок живых и перестраивает индексы.
func TestQueue_CompactKeepsOrder(t *testing.T) {
q := newTestQueue("a", "b", "c", "d")
// Помечаем сообщение "c" in-flight, чтобы проверить сохранение receipt-индекса.
q.Messages[2].ReceiptHandle = "c#1"
q.MarkInFlight(2, "c#1")
q.RemoveIndex(1) // b
q.RemoveIndex(3) // d
q.Compact()
if q.LiveCount() != 2 || len(q.Messages) != 2 {
t.Fatalf("после компакции LiveCount=%d len=%d, want 2/2", q.LiveCount(), len(q.Messages))
}
if q.deadCount != 0 {
t.Fatalf("deadCount после компакции = %d, want 0", q.deadCount)
}
if q.Messages[0].Uuid != "a" || q.Messages[1].Uuid != "c" {
t.Fatalf("порядок нарушен: %q, %q", q.Messages[0].Uuid, q.Messages[1].Uuid)
}
if i, ok := q.IndexOfUUID("c"); !ok || i != 1 {
t.Fatalf("IndexOfUUID(c) после компакции = %d,%v; want 1,true", i, ok)
}
if i, ok := q.IndexOfReceipt("c#1"); !ok || i != 1 {
t.Fatalf("IndexOfReceipt(c#1) после компакции = %d,%v; want 1,true", i, ok)
}
}
// TestQueue_ReceiptIndexLifecycle — регистрация/снятие in-flight.
func TestQueue_ReceiptIndexLifecycle(t *testing.T) {
q := newTestQueue("a")
q.Messages[0].ReceiptHandle = "a#1"
q.MarkInFlight(0, "a#1")
if i, ok := q.IndexOfReceipt("a#1"); !ok || i != 0 {
t.Fatalf("IndexOfReceipt = %d,%v; want 0,true", i, ok)
}
if got := len(q.InFlightIndexes()); got != 1 {
t.Fatalf("InFlightIndexes len = %d, want 1", got)
}
q.ClearInFlight(0, "a#1")
if _, ok := q.IndexOfReceipt("a#1"); ok {
t.Fatal("снятый handle не должен находиться в индексе")
}
if got := len(q.InFlightIndexes()); got != 0 {
t.Fatalf("InFlightIndexes len = %d, want 0", got)
}
}
// TestQueue_RebuildIndexesAfterDirectReplace — защита от прямой замены среза
// (загрузка из Redis / тесты): индексы восстанавливаются по содержимому.
func TestQueue_RebuildIndexesAfterDirectReplace(t *testing.T) {
q := newTestQueue("a")
q.Messages = []SqsMessage{{Uuid: "x"}, {Uuid: "y", ReceiptHandle: "y#1"}}
q.RebuildIndexes()
if q.LiveCount() != 2 {
t.Fatalf("LiveCount = %d, want 2", q.LiveCount())
}
if i, ok := q.IndexOfUUID("y"); !ok || i != 1 {
t.Fatalf("IndexOfUUID(y) = %d,%v; want 1,true", i, ok)
}
if i, ok := q.IndexOfReceipt("y#1"); !ok || i != 1 {
t.Fatalf("IndexOfReceipt(y#1) = %d,%v; want 1,true", i, ok)
}
}
// TestQueue_CompactIfNeededThreshold — пороговая компакция чистит tombstone
// и сохраняет число живых.
func TestQueue_CompactIfNeededThreshold(t *testing.T) {
q := &Queue{Name: "t", Duplicates: map[string]time.Time{}}
const total = 5000
for i := 0; i < total; i++ {
q.AppendMessage(SqsMessage{Uuid: fmt.Sprintf("u%d", i)})
}
for i := 0; i < 2000; i++ {
q.RemoveIndex(i)
}
if q.deadCount != 2000 {
t.Fatalf("deadCount = %d, want 2000", q.deadCount)
}
q.CompactIfNeeded()
if q.deadCount != 0 || len(q.Messages) != total-2000 {
t.Fatalf("после CompactIfNeeded deadCount=%d len=%d, want 0/%d", q.deadCount, len(q.Messages), total-2000)
}
if q.LiveCount() != total-2000 {
t.Fatalf("LiveCount = %d, want %d", q.LiveCount(), total-2000)
}
// Порядок живых сохранён: первый живой — u2000.
if q.Messages[0].Uuid != "u2000" {
t.Fatalf("первый живой = %q, want u2000", q.Messages[0].Uuid)
}
}
// TestQueue_Reset — purge полностью очищает сообщения и индексы.
func TestQueue_Reset(t *testing.T) {
q := newTestQueue("a", "b")
q.RemoveIndex(0)
q.Reset()
if q.LiveCount() != 0 || len(q.Messages) != 0 || q.deadCount != 0 {
t.Fatalf("после Reset: LiveCount=%d len=%d deadCount=%d", q.LiveCount(), len(q.Messages), q.deadCount)
}
if len(q.InFlightIndexes()) != 0 {
t.Fatal("после Reset in-flight индекс должен быть пуст")
}
// Очередь остаётся работоспособной.
if idx := q.AppendMessage(SqsMessage{Uuid: "c"}); idx != 0 || q.LiveCount() != 1 {
t.Fatalf("после Reset append: idx=%d LiveCount=%d", idx, q.LiveCount())
}
}
+342 -16
View File
@@ -2,6 +2,7 @@ package models
import ( import (
"strconv" "strconv"
"sync"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -46,6 +47,11 @@ func (m *SqsMessage) IsReadyForReceipt() bool {
} }
type Queue struct { type Queue struct {
// mu — per-queue блокировка содержимого очереди (Messages, Duplicates, FIFO-
// состояние, LastPurgeTime). Заменяет удержание глобального SyncQueues.Lock
// на время операций с одной очередью. sync.RWMutex сериализации не подлежит.
mu sync.RWMutex `json:"-"`
Name string Name string
URL string URL string
Arn string Arn string
@@ -53,22 +59,58 @@ type Queue struct {
ReceiveMessageWaitTimeSeconds int ReceiveMessageWaitTimeSeconds int
DelaySeconds int DelaySeconds int
MaximumMessageSize int MaximumMessageSize int
MessageRetentionPeriod int // seconds // TODO - not used in the code yet MessageRetentionPeriod int // seconds (реализовано в processQueueTick)
Messages []SqsMessage Messages []SqsMessage
DeadLetterQueue *Queue // ─── Индексация сообщений (P1.2) ────────────────────────────────────────
MaxReceiveCount int // deadIdx — параллельный Messages слайс tombstone-флагов: true = слот
IsFIFO bool // логически удалён. Удаление НЕ сдвигает элементы (O(1)), поэтому индексы
FIFOMessages map[string]int // (receiptIdx/uuidIdx) остаются валидными; физическая чистка — Compact.
FIFOSequenceNumbers map[string]int // Менять Messages напрямую НЕЛЬЗЯ: только через AppendMessage/RemoveIndex/
EnableDuplicates bool // Compact/Reset (см. ниже). Прямая замена допустима лишь для полной очистки
Duplicates map[string]time.Time // и загрузки из Redis (с последующим RebuildIndexes).
Tags map[string]string deadIdx []bool `json:"-"`
// receiptIdx — ReceiptHandle → индекс в Messages (только in-flight сообщения).
receiptIdx map[string]int `json:"-"`
// uuidIdx — Uuid → индекс в Messages (только живые сообщения).
uuidIdx map[string]int `json:"-"`
// liveCount — число живых сообщений; deadCount — число tombstone-слотов.
liveCount int `json:"-"`
deadCount int `json:"-"`
DeadLetterQueue *Queue
// DeadLetterQueueName — имя DLQ. В Redis-метаданных хранится ТОЛЬКО имя
// (а не вложенный объект): после загрузки указатель DeadLetterQueue
// релинкуется на объект из общей map (см. persistence.LoadAllQueues).
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
MaxReceiveCount int
IsFIFO bool
FIFOMessages map[string]int
FIFOSequenceNumbers map[string]int
EnableDuplicates bool
Duplicates map[string]time.Time
Tags map[string]string
// LastPurgeTime — время последнего успешного PurgeQueue. // LastPurgeTime — время последнего успешного PurgeQueue.
// Используется для запрета повторного purge ранее чем через 60 секунд // Используется для запрета повторного purge ранее чем через 60 секунд
// (требование AWS: PurgeQueueInProgress). Нулевое значение = purge ещё не выполнялся. // (требование AWS: PurgeQueueInProgress). Нулевое значение = purge ещё не выполнялся.
LastPurgeTime time.Time LastPurgeTime time.Time
// CreatedAt/LastModifiedAt — для атрибутов CreatedTimestamp/LastModifiedTimestamp
// (AWS отдаёт их в GetQueueAttributes; Terraform читает и без них видел бы drift).
CreatedAt time.Time
LastModifiedAt time.Time
// Policy — resource policy (JSON-строка), храним и отдаём как есть для
// совместимости с Terraform. НЕ исполняется (нет enforcement).
Policy string
// ContentBasedDeduplication — FIFO-атрибут. Храним и отдаём значение,
// переданное клиентом (для совместимости), см. раздел про dedup.
ContentBasedDeduplication bool
} }
// LockMu/UnlockMu/RLockMu/RUnlockMu — доступ к per-queue блокировке извне пакета models
// (поле mu неэкспортируемо).
func (q *Queue) LockMu() { q.mu.Lock() }
func (q *Queue) UnlockMu() { q.mu.Unlock() }
func (q *Queue) RLockMu() { q.mu.RLock() }
func (q *Queue) RUnlockMu() { q.mu.RUnlock() }
// NextSequenceNumber — возвращает следующий порядковый номер сообщения FIFO-группы. // NextSequenceNumber — возвращает следующий порядковый номер сообщения FIFO-группы.
// //
// Логическая схема: каждый вызов инкрементирует счётчик группы и возвращает // Логическая схема: каждый вызов инкрементирует счётчик группы и возвращает
@@ -118,13 +160,12 @@ func (q *Queue) UnlockGroup(groupId string) {
} }
// IsDuplicate — проверяет, было ли сообщение с таким deduplicationId уже // IsDuplicate — проверяет, было ли сообщение с таким deduplicationId уже
// добавлено в очередь в пределах deduplication-окна. // добавлено в очередь в пределах deduplication-окна (5 минут).
// // Дедупликация — НЕОТЪЕМЛЕМОЕ свойство FIFO-очереди (AWS): раньше она зависела
// Логическая схема: дубликаты отслеживаются ТОЛЬКО для FIFO-очередей с // от env-флага EnableDuplicates=false по умолчанию, и FIFO-очереди не
// включённой дупликацией (EnableDuplicates) и непустым deduplicationId. // дедуплицировали вообще. Теперь — только по IsFIFO и непустому id.
// Для остальных случаев дубликаты не отслеживаются (AWS-семантика).
func (q *Queue) IsDuplicate(deduplicationId string) bool { func (q *Queue) IsDuplicate(deduplicationId string) bool {
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { if !q.IsFIFO || deduplicationId == "" {
return false return false
} }
@@ -138,11 +179,296 @@ func (q *Queue) IsDuplicate(deduplicationId string) bool {
// фоновой задачей PeriodicTasks (deduplication period expired). // фоновой задачей PeriodicTasks (deduplication period expired).
// Для не-FIFO очередей или пустого deduplicationId — no-op. // Для не-FIFO очередей или пустого deduplicationId — no-op.
func (q *Queue) InitDuplicatation(deduplicationId string) { func (q *Queue) InitDuplicatation(deduplicationId string) {
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { if !q.IsFIFO || deduplicationId == "" {
return return
} }
// Ленивая инициализация: очередь, собранная литералом или загруженная из
// Redis до появления поля, может иметь nil-map — иначе запись паникует.
if q.Duplicates == nil {
q.Duplicates = make(map[string]time.Time)
}
if _, ok := q.Duplicates[deduplicationId]; !ok { if _, ok := q.Duplicates[deduplicationId]; !ok {
q.Duplicates[deduplicationId] = time.Now() q.Duplicates[deduplicationId] = time.Now()
} }
} }
// ─── P1.2: индексация сообщений (tombstone + индексы) ──────────────────────
//
// Все методы ниже мутируют состояние очереди → вызывать ТОЛЬКО под
// per-queue write-блокировкой (queue.LockMu). Читающие методы (LiveCount,
// IsDead) безопасны под RLock.
// compactDeadThreshold — минимальное число tombstone-слотов, после которого
// компакция при удалении разрешена. Защищает от O(N)-чистки на мелких очередях:
// при удалении слот подрезается с хвоста, остальная чистка — в processQueueTick.
const compactDeadThreshold = 1024
// ensureIndexes — ленивая инициализация/починка индексов.
// При корректном состоянии — две дешёвые проверки; O(N) — только если состояние
// рассинхронизировалось (прямая замена Messages вне методов, загрузка из Redis,
// старый JSON-формат). ВАЖНО (инвариант): оценка «в порядке» проверяет лишь
// согласованность чисел и не перестраивает уже корректные map-индексы — поэтому
// мутировать Messages ВНЕ методов (AppendMessage/RemoveIndex/Compact/Reset),
// например менять ReceiptHandle на месте, НЕЛЬЗЯ.
// Вызывать под write-блокировкой.
func (q *Queue) ensureIndexes() {
if len(q.deadIdx) == len(q.Messages) &&
q.receiptIdx != nil && q.uuidIdx != nil &&
q.liveCount == len(q.Messages)-q.deadCount {
return
}
q.RebuildIndexes()
}
// RebuildIndexes — перестраивает deadIdx/receiptIdx/uuidIdx/liveCount/deadCount
// по текущему Messages. Существующие tombstone СОХРАНЯЮТСЯ по позициям (если
// deadIdx длиннее Messages — обрезается, короче — добивается false).
// Вызывать под write-блокировкой.
func (q *Queue) RebuildIndexes() {
if len(q.deadIdx) > len(q.Messages) {
q.deadIdx = q.deadIdx[:len(q.Messages)]
}
for len(q.deadIdx) < len(q.Messages) {
q.deadIdx = append(q.deadIdx, false)
}
q.receiptIdx = make(map[string]int, len(q.Messages))
q.uuidIdx = make(map[string]int, len(q.Messages))
q.liveCount = 0
q.deadCount = 0
for i := range q.Messages {
if q.deadIdx[i] {
q.deadCount++
continue
}
q.liveCount++
if u := q.Messages[i].Uuid; u != "" {
q.uuidIdx[u] = i
}
if rh := q.Messages[i].ReceiptHandle; rh != "" {
q.receiptIdx[rh] = i
}
}
}
// AppendMessage — добавляет сообщение в конец очереди, поддерживая индексы.
// Возвращает индекс добавленного сообщения. Вызывать под write-блокировкой.
func (q *Queue) AppendMessage(m SqsMessage) int {
q.ensureIndexes()
q.Messages = append(q.Messages, m)
q.deadIdx = append(q.deadIdx, false)
idx := len(q.Messages) - 1
q.liveCount++
if m.Uuid != "" {
q.uuidIdx[m.Uuid] = idx
}
if m.ReceiptHandle != "" {
q.receiptIdx[m.ReceiptHandle] = idx
}
return idx
}
// RemoveIndex — логически удаляет сообщение по индексу (tombstone) за O(1).
// ВАЖНО: длина q.Messages НЕ меняется (слот остаётся tombstone) — поэтому
// индексы, взятые до вызова, остаются валидными, а итерации по срезу
// безопасны. Физическая чистка — Compact/CompactIfNeeded (в processQueueTick).
// Вызывать под write-блокировкой.
func (q *Queue) RemoveIndex(i int) {
q.ensureIndexes()
if i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
return
}
msg := &q.Messages[i]
q.deadIdx[i] = true
q.deadCount++
if q.liveCount > 0 {
q.liveCount--
}
if msg.Uuid != "" {
if idx, ok := q.uuidIdx[msg.Uuid]; ok && idx == i {
delete(q.uuidIdx, msg.Uuid)
}
}
if rh := msg.ReceiptHandle; rh != "" {
if idx, ok := q.receiptIdx[rh]; ok && idx == i {
delete(q.receiptIdx, rh)
}
}
}
// CompactIfNeeded — компакция при заметной доле tombstone-слотов.
// Амортизированно: копия среза делается не на каждое удаление, а когда число
// «мёртвых» слотов становится заметным (≥ 1024 или ≥ половины среза).
// Вызывать под write-блокировкой ВНЕ итераций по Messages (меняет индексы).
func (q *Queue) CompactIfNeeded() {
if q.deadCount == 0 {
return
}
if q.deadCount >= compactDeadThreshold || q.deadCount*2 >= len(q.Messages) {
q.Compact()
}
}
// Compact — физически удаляет tombstone-слоты, сохраняя порядок живых, и
// перестраивает индексы. O(N). Вызывать под write-блокировкой.
func (q *Queue) Compact() {
// ensureIndexes гарантирует len(deadIdx)==len(Messages) (иначе слоты разъедутся)
// и корректность deadCount. При согласованном состоянии — две дешёвые проверки.
q.ensureIndexes()
if q.deadCount == 0 {
return
}
n := 0
for i := range q.Messages {
if q.deadIdx[i] {
continue
}
if n != i {
q.Messages[n] = q.Messages[i]
}
n++
}
// Обнуляем «хвост», чтобы не держать ссылки на удалённые сообщения (GC).
tail := q.Messages[n:]
for i := range tail {
tail[i] = SqsMessage{}
}
q.Messages = q.Messages[:n]
// После чистки tombstone нет: обнуляем флаги и перестраиваем индексы по живым
// (RebuildIndexes заново посчитает liveCount/deadCount и заполнит receipt/uuid).
q.deadIdx = q.deadIdx[:0]
q.RebuildIndexes()
}
// Reset — полностью очищает сообщения и индексы (purge).
// Вызывать под write-блокировкой.
func (q *Queue) Reset() {
q.Messages = nil
q.deadIdx = nil
q.receiptIdx = make(map[string]int)
q.uuidIdx = make(map[string]int)
q.liveCount = 0
q.deadCount = 0
// Чистим FIFO-состояние: иначе блокировки групп и порядковые номера остались
// бы, и сообщения этих групп не выдавались после purge (ревью 2026-10-11).
q.FIFOMessages = make(map[string]int)
q.FIFOSequenceNumbers = make(map[string]int)
}
// EnsureIndexes — экспортируемая обёртка над ensureIndexes: строит индексы,
// если очередь была собрана в обход методов (литералом, из Redis, тестами).
// Вызывать под write-блокировкой.
func (q *Queue) EnsureIndexes() {
q.ensureIndexes()
}
// IndexOfReceipt — индекс сообщения по ReceiptHandle (только in-flight) за O(1).
// Вызывать под write-блокировкой (ensureIndexes может перестроить индексы).
func (q *Queue) IndexOfReceipt(receiptHandle string) (int, bool) {
q.ensureIndexes()
i, ok := q.receiptIdx[receiptHandle]
if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
return 0, false
}
return i, true
}
// IndexOfUUID — индекс живого сообщения по Uuid за O(1).
// Вызывать под write-блокировкой.
func (q *Queue) IndexOfUUID(uuid string) (int, bool) {
q.ensureIndexes()
i, ok := q.uuidIdx[uuid]
if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
return 0, false
}
return i, true
}
// MarkInFlight — регистрирует ReceiptHandle → индекс (для tick по in-flight).
// Вызывать под write-блокировкой.
func (q *Queue) MarkInFlight(i int, receiptHandle string) {
q.ensureIndexes()
if receiptHandle != "" {
q.receiptIdx[receiptHandle] = i
}
}
// ClearInFlight — снимает регистрацию ReceiptHandle (возврат видимости).
// Вызывать под write-блокировкой.
func (q *Queue) ClearInFlight(i int, receiptHandle string) {
if receiptHandle == "" {
return
}
if idx, ok := q.receiptIdx[receiptHandle]; ok && idx == i {
delete(q.receiptIdx, receiptHandle)
}
}
// ResetVisibilityAndTryDLQ — сбрасывает видимость сообщения по индексу и, если
// исчерпан MaxReceiveCount при настроенной DLQ, переносит его в DLQ и удаляет
// из исходной очереди (tombstone). Возвращает false, если сообщение на самом
// деле перенесено в DLQ (удалено из очереди).
//
// Единый путь для одиночной и батчевой ChangeMessageVisibility: раньше логика
// DLQ была только в одиночной версии, а батч лишь инкрементировал Retry — из-за
// этого retry-счётчик и DLQ-перенос могли расходиться.
// Вызывать под write-блокировкой q. Перенос в DLQ выполняется под её блокировкой.
// Возвращает: msgUuid, movedToDLQ.
// ResetVisibilityAndTryDLQ — сбрасывает видимость сообщения по индексу и, если
// исчерпан MaxReceiveCount, переносит его в DLQ. При переносе удаляет сообщение
// из исходной очереди (tombstone).
//
// Единый путь для одиночной и батчевой ChangeMessageVisibility: раньше логика
// DLQ была только в одиночной версии, а батч лишь инкрементировал Retry — из-за
// этого retry-счётчик и DLQ-перенос могли расходиться.
// Вызывать под write-блокировкой q. Перенос в DLQ выполняется под её блокировкой.
// Возвращает: msgUuid, movedToDLQ, dlqMsg (копия перенесённого сообщения, чтобы
// вызывающий мог персистить его в DLQ; nil если переноса не было).
func (q *Queue) ResetVisibilityAndTryDLQ(i int, receiptHandle string) (msgUuid string, movedToDLQ bool, dlqMsg *SqsMessage) {
q.ensureIndexes()
q.ClearInFlight(i, receiptHandle)
msg := &q.Messages[i]
msg.ReceiptTime = time.Now().UTC()
msg.ReceiptHandle = ""
msg.VisibilityTimeout = time.Now().Add(time.Duration(q.VisibilityTimeout) * time.Second)
msg.Retry++
msgUuid = msg.Uuid
if q.MaxReceiveCount > 0 && q.DeadLetterQueue != nil && msg.Retry >= q.MaxReceiveCount {
dlqMsg := *msg
q.DeadLetterQueue.LockMu()
q.DeadLetterQueue.AppendMessage(dlqMsg)
q.DeadLetterQueue.UnlockMu()
q.RemoveIndex(i)
return msgUuid, true, &dlqMsg
}
return msgUuid, false, nil
}
// InFlightIndexes — снимок индексов in-flight сообщений (для visibility-тика).
// Возвращает срез индексов в Messages. Вызывать под write-блокировкой.
//
// ВАЖНО: индексы — снимок на момент вызова. Некоторые могут стать неактуальными
// (handle переехал/сообщение удалено), поэтому потребитель ОБЯЗАН перепроверять
// каждое сообщение по индексу (живость: в границах среза, не tombstone, handle
// не пуст) — как это делает processQueueTick.
func (q *Queue) InFlightIndexes() []int {
idx := make([]int, 0, len(q.receiptIdx))
for _, i := range q.receiptIdx {
idx = append(idx, i)
}
return idx
}
// IsDead — tombstone-ли слот. Безопасно под RLock (не мутирует состояние).
func (q *Queue) IsDead(i int) bool {
if i < 0 || i >= len(q.deadIdx) {
return false
}
return q.deadIdx[i]
}
// LiveCount — число живых (не tombstone) сообщений.
// Безопасно под RLock. Эквивалент прежнего len(queue.Messages).
func (q *Queue) LiveCount() int {
return len(q.Messages) - q.deadCount
}
+61 -7
View File
@@ -113,6 +113,20 @@ func (r *CreateQueueRequest) SetAttributesFromForm(values url.Values) {
continue continue
} }
r.Attributes.RedriveAllowPolicy = tmp r.Attributes.RedriveAllowPolicy = tmp
case "FifoQueue":
tmp, err := strconv.ParseBool(attrValue)
if err != nil {
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
continue
}
r.Attributes.FifoQueue = StringToBool(tmp)
case "ContentBasedDeduplication":
tmp, err := strconv.ParseBool(attrValue)
if err != nil {
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
continue
}
r.Attributes.ContentBasedDeduplication = StringToBool(tmp)
} }
} }
return return
@@ -381,6 +395,20 @@ func (r *SetQueueAttributesRequest) SetAttributesFromForm(values url.Values) {
continue continue
} }
r.Attributes.RedriveAllowPolicy = tmp r.Attributes.RedriveAllowPolicy = tmp
case "FifoQueue":
tmp, err := strconv.ParseBool(attrValue)
if err != nil {
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
continue
}
r.Attributes.FifoQueue = StringToBool(tmp)
case "ContentBasedDeduplication":
tmp, err := strconv.ParseBool(attrValue)
if err != nil {
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
continue
}
r.Attributes.ContentBasedDeduplication = StringToBool(tmp)
} }
} }
return return
@@ -393,12 +421,17 @@ type QueueAttributes struct {
DelaySeconds StringToInt `json:"DelaySeconds"` DelaySeconds StringToInt `json:"DelaySeconds"`
MaximumMessageSize StringToInt `json:"MaximumMessageSize"` MaximumMessageSize StringToInt `json:"MaximumMessageSize"`
MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented
Policy map[string]interface{} `json:"Policy"` // NOTE: not implemented Policy map[string]interface{} `json:"Policy"` // хранится, не исполняется
ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"` ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"`
VisibilityTimeout StringToInt `json:"VisibilityTimeout"` VisibilityTimeout StringToInt `json:"VisibilityTimeout"`
// Dead Letter Queues Only // Dead Letter Queues Only
RedrivePolicy RedrivePolicy `json:"RedrivePolicy"` RedrivePolicy RedrivePolicy `json:"RedrivePolicy"`
RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented
// FIFO-атрибуты. FifoQueue у нас также выводится из суффикса .fifo имени,
// но значение от клиента нужно СОХРАНИТЬ и вернуть в GetQueueAttributes —
// иначе Terraform (aws_sqs_queue) видел бы постоянный drift.
FifoQueue StringToBool `json:"FifoQueue"`
ContentBasedDeduplication StringToBool `json:"ContentBasedDeduplication"`
} }
// AttrNameWhitelist — имена атрибутов, допустимые в SetQueueAttributes/CreateQueue. // AttrNameWhitelist — имена атрибутов, допустимые в SetQueueAttributes/CreateQueue.
@@ -444,8 +477,29 @@ func (q *QueueAttributes) UnmarshalJSON(data []byte) error {
return fmt.Errorf("invalid attribute name: %s", name) return fmt.Errorf("invalid attribute name: %s", name)
} }
} }
// Policy/RedriveAllowPolicy в SQS API — СТРОКА с JSON внутри, а поля у нас —
// map. JSON-протокол присылает их именно строкой, и unmarshal в map падал бы
// (SetQueueAttributes → InvalidParameterValue). Разворачиваем строку в объект.
// Также принимаем вариант, где уже пришёл объект — он и так валиден.
for _, name := range []string{"Policy", "RedriveAllowPolicy"} {
rv, ok := raw[name]
if !ok {
continue
}
var s string
if err := json.Unmarshal(rv, &s); err == nil {
if !json.Valid([]byte(s)) {
return fmt.Errorf("invalid JSON in attribute %s", name)
}
raw[name] = json.RawMessage(s)
}
}
normalized, err := json.Marshal(raw)
if err != nil {
return err
}
type plain QueueAttributes type plain QueueAttributes
return json.Unmarshal(data, (*plain)(q)) return json.Unmarshal(normalized, (*plain)(q))
} }
type RedrivePolicy struct { type RedrivePolicy struct {
+433 -91
View File
@@ -18,7 +18,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -29,17 +31,33 @@ import (
// Client — глобальный Redis клиент. // Client — глобальный Redis клиент.
// nil означает режим "только память" — все функции тихо no-op. // nil означает режим "только память" — все функции тихо no-op.
var Client *redis.Client // atomic.Pointer: reconnect() пишет новый клиент из health-check горутины,
// а остальные горутины читают — без атомарности это data race.
var Client atomic.Pointer[redis.Client]
// Параметры подключения — сохраняются для переподключения после обрыва.
var (
redisAddr string
redisUsername string
redisPassword string
)
var ( var (
seqMu sync.Mutex seqMu sync.Mutex
queueWriteSeq = map[string]uint64{} queueWriteSeq = map[string]uint64{}
tenantWriteSeq = map[string]uint64{} tenantWriteSeq = map[string]uint64{}
// msgWriteSeq — порядковая нумерация async-записей ОТДЕЛЬНЫХ сообщений
// (ключ "{queueKey}|{uuid}"). Без неё два SaveMessage одного сообщения из
// разных горутин могли завершиться вразнобой: старое состояние перезаписало
// бы новое. Записи удаляются при удалении сообщения (DeleteMessagePersist и др.).
msgWriteSeq = map[string]uint64{}
) )
const ( const (
// redisHashTenants — HASH: tenantID → JSON тенанта // redisHashTenants — HASH: tenantID → JSON тенанта
redisHashTenants = "ssq:tenants" redisHashTenants = "ssq:tenants"
// redisHashTenantAK — HASH: accessKey → JSON тенанта (индекс read-through в auth)
redisHashTenantAK = "ssq:tenant_ak"
// redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2) // redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2)
redisHashQueues = "ssq:queues" redisHashQueues = "ssq:queues"
// redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage) // redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage)
@@ -53,6 +71,10 @@ func Connect(addr, username, password string) error {
log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме") log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме")
return nil return nil
} }
redisAddr = addr
redisUsername = username
redisPassword = password
rdb := redis.NewClient(&redis.Options{ rdb := redis.NewClient(&redis.Options{
Addr: addr, Addr: addr,
Username: username, Username: username,
@@ -64,8 +86,57 @@ func Connect(addr, username, password string) error {
if err := rdb.Ping(ctx).Err(); err != nil { if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("redis ping %s: %w", addr, err) return fmt.Errorf("redis ping %s: %w", addr, err)
} }
Client = rdb Client.Store(rdb)
log.Infof("persistence: подключились к Redis %s", addr) log.Infof("persistence: подключились к Redis %s", addr)
startRedisHealthCheck()
return nil
}
// startRedisHealthCheck — фоновая проверка соединения и переподключение.
// После обрыва Redis-клиент остаётся не-nil, но записи молча падают. Здесь мы
// периодически пингуем Redis и при сбое создаём новый клиент (reconnect), чтобы
// персистентность восстановилась автоматически.
func startRedisHealthCheck() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if Client.Load() == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := Client.Load().Ping(ctx).Err()
cancel()
if err == nil {
continue
}
log.Warnf("persistence: Redis недоступен (%v), пробуем переподключиться", err)
if reErr := reconnect(); reErr != nil {
log.Errorf("persistence: переподключение не удалось: %v", reErr)
} else {
log.Infof("persistence: переподключились к Redis %s", redisAddr)
}
}
}()
}
// reconnect — создаёт новый Redis-клиент с сохранёнными параметрами подключения.
func reconnect() error {
if redisAddr == "" {
return fmt.Errorf("redis addr пуст")
}
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
Username: redisUsername,
Password: redisPassword,
DB: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return err
}
Client.Store(rdb)
return nil return nil
} }
@@ -82,23 +153,76 @@ func asyncWrite(fn func()) {
}() }()
} }
// marshalQueueMeta — сериализует очередь БЕЗ Messages (и без Messages в DLQ). // queueMetaDTO — сериализуемая копия очереди БЕЗ Messages (и без сообщений DLQ).
// DeadLetterQueue сохраняется как ссылка (Name/URL/Arn), но без сообщений. // Отдельный тип, т.к. models.Queue содержит sync.RWMutex и его нельзя копировать/
// ВАЖНО: вызывать под SyncQueues.Lock() — временно nil'ит Messages. // сериализовать целиком, а временное зануление Messages мутировало бы живую очередь.
// Имена полей совпадают с models.Queue → json совместим при загрузке.
type queueMetaDTO struct {
Name string
URL string
Arn string
VisibilityTimeout int
ReceiveMessageWaitTimeSeconds int
DelaySeconds int
MaximumMessageSize int
MessageRetentionPeriod int
MaxReceiveCount int
IsFIFO bool
FIFOMessages map[string]int
FIFOSequenceNumbers map[string]int
EnableDuplicates bool
Duplicates map[string]time.Time
Tags map[string]string
LastPurgeTime time.Time
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
// Создание/изменение + совместимые атрибуты (GetQueueAttributes/Terraform).
CreatedAt time.Time
LastModifiedAt time.Time
Policy string
ContentBasedDeduplication bool
}
// queueToMetaDTO — строит снимок очереди без сообщений, НЕ мутируя исходную очередь.
// DLQ сохраняется как ИМЯ (не вложенный объект): иначе — detached-копия после
// загрузки и неограниченная рекурсия при циклической DLQ.
func queueToMetaDTO(q *models.Queue) *queueMetaDTO {
if q == nil {
return nil
}
dto := &queueMetaDTO{
Name: q.Name,
URL: q.URL,
Arn: q.Arn,
VisibilityTimeout: q.VisibilityTimeout,
ReceiveMessageWaitTimeSeconds: q.ReceiveMessageWaitTimeSeconds,
DelaySeconds: q.DelaySeconds,
MaximumMessageSize: q.MaximumMessageSize,
MessageRetentionPeriod: q.MessageRetentionPeriod,
MaxReceiveCount: q.MaxReceiveCount,
IsFIFO: q.IsFIFO,
FIFOMessages: q.FIFOMessages,
FIFOSequenceNumbers: q.FIFOSequenceNumbers,
EnableDuplicates: q.EnableDuplicates,
Duplicates: q.Duplicates,
Tags: q.Tags,
LastPurgeTime: q.LastPurgeTime,
DeadLetterQueueName: q.DeadLetterQueueName,
CreatedAt: q.CreatedAt,
LastModifiedAt: q.LastModifiedAt,
Policy: q.Policy,
ContentBasedDeduplication: q.ContentBasedDeduplication,
}
if q.DeadLetterQueue != nil {
dto.DeadLetterQueueName = q.DeadLetterQueue.Name
}
return dto
}
// marshalQueueMeta — сериализует метаданные очереди БЕЗ сообщений,
// НЕ мутируя исходную очередь (см. queueMetaDTO). Безопасно вызывать
// под per-queue блокировкой только этой очереди.
func marshalQueueMeta(queue *models.Queue) ([]byte, error) { func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
savedMsgs := queue.Messages return json.Marshal(queueToMetaDTO(queue))
queue.Messages = nil
var savedDLQMsgs []models.SqsMessage
if queue.DeadLetterQueue != nil {
savedDLQMsgs = queue.DeadLetterQueue.Messages
queue.DeadLetterQueue.Messages = nil
}
data, err := json.Marshal(queue)
queue.Messages = savedMsgs
if queue.DeadLetterQueue != nil {
queue.DeadLetterQueue.Messages = savedDLQMsgs
}
return data, err
} }
// SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно. // SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно.
@@ -106,7 +230,7 @@ func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
// создаёт консистентный снапшот. Горутина только делает сетевой вызов. // создаёт консистентный снапшот. Горутина только делает сетевой вызов.
// Для сохранения сообщений используй SaveMessage/SaveMessages. // Для сохранения сообщений используй SaveMessage/SaveMessages.
func SaveQueue(key string, queue *models.Queue) { func SaveQueue(key string, queue *models.Queue) {
if Client == nil { if Client.Load() == nil {
return return
} }
// Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот // Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот
@@ -122,7 +246,7 @@ func SaveQueue(key string, queue *models.Queue) {
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil { if err := Client.Load().HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet queue meta %q: %v", key, err) log.Errorf("persistence: HSet queue meta %q: %v", key, err)
} }
}) })
@@ -132,7 +256,7 @@ func SaveQueue(key string, queue *models.Queue) {
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage). // Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
// Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения. // Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения.
func SaveMessage(queueKey string, msg *models.SqsMessage) { func SaveMessage(queueKey string, msg *models.SqsMessage) {
if Client == nil { if Client.Load() == nil {
return return
} }
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
@@ -141,19 +265,47 @@ func SaveMessage(queueKey string, msg *models.SqsMessage) {
return return
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
msgKey := queueKey + "|" + msg.Uuid
writeSeq := nextMsgWriteSeq(msgKey)
asyncWrite(func() { asyncWrite(func() {
if !isLatestMsgWriteSeq(msgKey, writeSeq) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil { if err := Client.Load().HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet message %s/%s: %v", queueKey, msg.Uuid, err) log.Errorf("persistence: HSet message %s/%s: %v", queueKey, msg.Uuid, err)
} }
}) })
} }
// SaveMessageSync — сохраняет ОДНО сообщение в Redis СИНХРОННО и возвращает ошибку.
// Используется на пути SendMessage: ответ 200 клиенту должен выдаваться ТОЛЬКО
// после фактической записи сообщения в Redis, иначе краш пода в окне между
// ответом и async-записью приведёт к потере подтверждённого сообщения.
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
// Если Client == nil (memory-only режим) — возвращает nil (персистить некуда).
func SaveMessageSync(queueKey string, msg *models.SqsMessage) error {
if Client.Load() == nil {
return nil
}
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msg.Uuid, err)
}
hashKey := redisMsgHashPrefix + queueKey
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.Load().HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
return fmt.Errorf("HSet message %s/%s: %w", queueKey, msg.Uuid, err)
}
return nil
}
// SaveMessages — сохраняет несколько сообщений в Redis одним pipeline. // SaveMessages — сохраняет несколько сообщений в Redis одним pipeline.
// Вызывать под Lock. // Вызывать под Lock.
func SaveMessages(queueKey string, msgs []models.SqsMessage) { func SaveMessages(queueKey string, msgs []models.SqsMessage) {
if Client == nil || len(msgs) == 0 { if Client.Load() == nil || len(msgs) == 0 {
return return
} }
// Сериализуем все сообщения синхронно под Lock // Сериализуем все сообщения синхронно под Lock
@@ -178,85 +330,116 @@ func SaveMessages(queueKey string, msgs []models.SqsMessage) {
for k, v := range fields { for k, v := range fields {
args = append(args, k, v) args = append(args, k, v)
} }
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil { if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err) log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err)
} }
}) })
} }
// DeleteMessagePersist — удаляет одно сообщение из Redis асинхронно. // SaveMessagesSync — сохраняет несколько сообщений в Redis СИНХРОННО (pipeline).
// Вызывать при DeleteMessage (после удаления из in-memory). // Используется на пути SendMessageBatch: ответ 200 выдаётся только после записи
func DeleteMessagePersist(queueKey string, msgUuid string) { // всех сообщений (как в одиночном SendMessage). Возвращает ошибку для отката.
if Client == nil || msgUuid == "" { func SaveMessagesSync(queueKey string, msgs []models.SqsMessage) error {
return if Client.Load() == nil || len(msgs) == 0 {
return nil
}
fields := make(map[string]string, len(msgs))
for i := range msgs {
data, err := json.Marshal(&msgs[i])
if err != nil {
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msgs[i].Uuid, err)
}
fields[msgs[i].Uuid] = string(data)
}
if len(fields) == 0 {
return nil
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel()
defer cancel() args := make([]interface{}, 0, len(fields)*2)
if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil { for k, v := range fields {
log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err) args = append(args, k, v)
} }
}) if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
return fmt.Errorf("HSet messages %s (%d msgs): %w", queueKey, len(fields), err)
}
return nil
} }
// DeleteMessagesPersist — удаляет несколько сообщений из Redis. // DeleteMessagePersist — удаляет одно сообщение из Redis СИНХРОННО.
// Вызывать при DeleteMessageBatch. // Синхронность обязательна: если удаление потеряется при рестарте пода,
func DeleteMessagesPersist(queueKey string, uuids []string) { // сообщение «воскреснет» из Redis (факт: удалённые очереди возвращались
if Client == nil || len(uuids) == 0 { // после рестарта, когда удаления были асинхронными).
return func DeleteMessagePersist(queueKey string, msgUuid string) error {
if Client.Load() == nil || msgUuid == "" {
return nil
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
// Копируем uuids — вызывающий может переиспользовать slice ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ids := make([]string, len(uuids)) defer cancel()
copy(ids, uuids) if err := Client.Load().HDel(ctx, hashKey, msgUuid).Err(); err != nil {
asyncWrite(func() { return fmt.Errorf("HDel message %s/%s: %w", queueKey, msgUuid, err)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) }
defer cancel() clearMsgWriteSeq(queueKey, msgUuid)
if err := Client.HDel(ctx, hashKey, ids...).Err(); err != nil { return nil
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(ids), err)
}
})
} }
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis (для PurgeQueue). // DeleteMessagesPersist — удаляет несколько сообщений из Redis СИНХРОННО
// Удаляет весь HASH ssq:msg:{queueKey}. // (иначе удалённые сообщения воскреснут после рестарта пода).
func DeleteMessagesPersist(queueKey string, uuids []string) error {
if Client.Load() == nil || len(uuids) == 0 {
return nil
}
hashKey := redisMsgHashPrefix + queueKey
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.Load().HDel(ctx, hashKey, uuids...).Err(); err != nil {
return fmt.Errorf("HDel messages %s (%d): %w", queueKey, len(uuids), err)
}
clearMsgWriteSeq(queueKey, uuids...)
return nil
}
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis СИНХРОННО.
// Синхронность обязательна: при рестарте пода удалённые сообщения не должны
// воскреснуть (факт: асинхронное удаление очередей приводило к их возврату).
func PurgeMessagesPersist(queueKey string) { func PurgeMessagesPersist(queueKey string) {
if Client == nil { if Client.Load() == nil {
return return
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
defer cancel() if err := Client.Load().Del(ctx, hashKey).Err(); err != nil {
if err := Client.Del(ctx, hashKey).Err(); err != nil { log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err) }
} clearAllMsgWriteSeq(queueKey)
})
} }
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно. // DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
func DeleteQueue(key string) { func DeleteQueue(key string) {
if Client == nil { if Client.Load() == nil {
return return
} }
writeSeq := nextQueueWriteSeq(key) writeSeq := nextQueueWriteSeq(key)
msgHashKey := redisMsgHashPrefix + key msgHashKey := redisMsgHashPrefix + key
asyncWrite(func() { // СИНХРОННО: удаление очереди обязано попасть в Redis до ответа клиенту.
if !isLatestQueueWriteSeq(key, writeSeq) { // Асинхронное удаление приводило к «воскрешению» удалённых очередей после
return // рестарта пода (факт 2026-08-15: 50 мусорных очередей вернулись из Redis).
} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
defer cancel() if !isLatestQueueWriteSeq(key, writeSeq) {
// Удаляем метаданные из общего HASH return
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil { }
log.Errorf("persistence: HDel queue meta %q: %v", key, err) // Удаляем метаданные из общего HASH
} if err := Client.Load().HDel(ctx, redisHashQueues, key).Err(); err != nil {
// Удаляем весь HASH с сообщениями log.Errorf("persistence: HDel queue meta %q: %v", key, err)
if err := Client.Del(ctx, msgHashKey).Err(); err != nil { }
log.Errorf("persistence: DEL messages hash %q: %v", key, err) // Удаляем весь HASH с сообщениями
} if err := Client.Load().Del(ctx, msgHashKey).Err(); err != nil {
}) log.Errorf("persistence: DEL messages hash %q: %v", key, err)
}
} }
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса. // LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
@@ -264,14 +447,14 @@ func DeleteQueue(key string) {
// Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages, // Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages,
// они извлекаются в отдельный HASH и метаданные пересохраняются без Messages. // они извлекаются в отдельный HASH и метаданные пересохраняются без Messages.
func LoadAllQueues() (map[string]*models.Queue, error) { func LoadAllQueues() (map[string]*models.Queue, error) {
if Client == nil { if Client.Load() == nil {
return nil, nil return nil, nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
// 1. Загружаем метаданные очередей // 1. Загружаем метаданные очередей
raw, err := Client.HGetAll(ctx, redisHashQueues).Result() raw, err := Client.Load().HGetAll(ctx, redisHashQueues).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("redis HGetAll queues: %w", err) return nil, fmt.Errorf("redis HGetAll queues: %w", err)
} }
@@ -296,6 +479,16 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
q.FIFOSequenceNumbers = make(map[string]int) q.FIFOSequenceNumbers = make(map[string]int)
} }
// Бэкфилл timestamp-ов: у очередей, созданных до появления полей, они нулевые.
// Без этого Terraform видел бы CreatedTimestamp=0 и постоянный drift.
// Заполняем сейчас и сохраним при ближайшем SaveQueue.
if q.CreatedAt.IsZero() {
q.CreatedAt = time.Now().UTC()
}
if q.LastModifiedAt.IsZero() {
q.LastModifiedAt = q.CreatedAt
}
// Миграция v1→v2: если в JSON есть Messages — это старый формат // Миграция v1→v2: если в JSON есть Messages — это старый формат
if len(q.Messages) > 0 { if len(q.Messages) > 0 {
migrateKeys = append(migrateKeys, k) migrateKeys = append(migrateKeys, k)
@@ -321,7 +514,7 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
args = append(args, q.Messages[i].Uuid, string(data)) args = append(args, q.Messages[i].Uuid, string(data))
} }
if len(args) > 0 { if len(args) > 0 {
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil { if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: migrate HSet messages %q: %v", k, err) log.Errorf("persistence: migrate HSet messages %q: %v", k, err)
} }
} }
@@ -329,7 +522,7 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// Пересохраняем метаданные без Messages // Пересохраняем метаданные без Messages
metaData, err := marshalQueueMeta(q) metaData, err := marshalQueueMeta(q)
if err == nil { if err == nil {
if err := Client.HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil { if err := Client.Load().HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil {
log.Errorf("persistence: migrate HSet meta %q: %v", k, err) log.Errorf("persistence: migrate HSet meta %q: %v", k, err)
} }
} }
@@ -339,10 +532,11 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди // 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди
for k, q := range queues { for k, q := range queues {
hashKey := redisMsgHashPrefix + k hashKey := redisMsgHashPrefix + k
msgRaw, err := Client.HGetAll(ctx, hashKey).Result() msgRaw, err := Client.Load().HGetAll(ctx, hashKey).Result()
if err != nil { if err != nil {
log.Errorf("persistence: HGetAll messages %q: %v", k, err) // Fail-fast: скрытая пустая очередь при недоступных сообщениях
continue // хуже явной ошибки — сообщения остались бы невидимыми в Redis.
return nil, fmt.Errorf("redis HGetAll messages %q: %w", k, err)
} }
if len(msgRaw) > 0 { if len(msgRaw) > 0 {
msgs := make([]models.SqsMessage, 0, len(msgRaw)) msgs := make([]models.SqsMessage, 0, len(msgRaw))
@@ -359,6 +553,27 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// Пустая очередь — Messages уже nil, оставляем // Пустая очередь — Messages уже nil, оставляем
q.Messages = nil q.Messages = nil
} }
// Строим индексы (P1.2): receiptIdx/uuidIdx/deadIdx по загруженным
// сообщениям. Без этого первый же receive/delete перестроил бы их лениво.
q.RebuildIndexes()
}
// 4. Релинк DLQ: в метаданных хранится только имя DLQ — связываем указатель
// с реальным объектом из map. Иначе DeadLetterQueue оказался бы detached-копией
// (переносы в DLQ писали бы в неё, а не в живую очередь; мьютекс — чужой).
for k, q := range queues {
if q.DeadLetterQueueName == "" {
continue
}
idx := strings.Index(k, ":")
if idx <= 0 {
continue
}
if dlq, ok := queues[k[:idx+1]+q.DeadLetterQueueName]; ok {
q.DeadLetterQueue = dlq
} else {
log.Warnf("persistence: DLQ %q для очереди %q не найдена при загрузке", q.DeadLetterQueueName, k)
}
} }
log.Infof("persistence: загружено %d очередей из Redis (миграций: %d)", len(queues), len(migrateKeys)) log.Infof("persistence: загружено %d очередей из Redis (миграций: %d)", len(queues), len(migrateKeys))
@@ -369,12 +584,17 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
// Принимает []byte чтобы избежать циклического импорта с пакетом tenant. // Принимает []byte чтобы избежать циклического импорта с пакетом tenant.
// Сериализацию делает вызывающий (tenant_store.go). // Сериализацию делает вызывающий (tenant_store.go).
func SaveTenantRaw(id string, jsonData []byte) { func SaveTenantRaw(id string, jsonData []byte) {
if Client == nil { if Client.Load() == nil {
return return
} }
// Копируем bytes — вызывающий может переиспользовать буфер // Копируем bytes — вызывающий может переиспользовать буфер
dataCopy := make([]byte, len(jsonData)) dataCopy := make([]byte, len(jsonData))
copy(dataCopy, jsonData) copy(dataCopy, jsonData)
// Извлекаем accessKey для индекса read-through (без импорта пакета tenant)
var meta struct {
AccessKey string
}
_ = json.Unmarshal(dataCopy, &meta)
writeSeq := nextTenantWriteSeq(id) writeSeq := nextTenantWriteSeq(id)
asyncWrite(func() { asyncWrite(func() {
if !isLatestTenantWriteSeq(id, writeSeq) { if !isLatestTenantWriteSeq(id, writeSeq) {
@@ -382,15 +602,37 @@ func SaveTenantRaw(id string, jsonData []byte) {
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil { if err := Client.Load().HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil {
log.Errorf("persistence: HSet tenant %q: %v", id, err) log.Errorf("persistence: HSet tenant %q: %v", id, err)
} }
if meta.AccessKey != "" {
if err := Client.Load().HSet(ctx, redisHashTenantAK, meta.AccessKey, string(dataCopy)).Err(); err != nil {
log.Errorf("persistence: HSet tenant_ak %q: %v", meta.AccessKey, err)
}
}
}) })
} }
// LoadTenantByAccessKey — read-through тенанта по accessKey (для auth-middleware).
// Возвращает сырой JSON; десериализацию делает вызывающий.
func LoadTenantByAccessKey(accessKey string) ([]byte, bool) {
if Client.Load() == nil || accessKey == "" {
return nil, false
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
v, err := Client.Load().HGet(ctx, redisHashTenantAK, accessKey).Result()
if err != nil {
return nil, false
}
return []byte(v), true
}
// DeleteTenant — удаляет тенанта из Redis асинхронно. // DeleteTenant — удаляет тенанта из Redis асинхронно.
func DeleteTenant(id string) { // Удаляет из ОБОИХ индексов: ssq:tenants (по id) и ssq:tenant_ak (по accessKey),
if Client == nil { // иначе LoadTenantByAccessKey вернёт удалённого тенанта и отзыв ключей не сработает.
func DeleteTenant(id, accessKey string) {
if Client.Load() == nil {
return return
} }
writeSeq := nextTenantWriteSeq(id) writeSeq := nextTenantWriteSeq(id)
@@ -400,9 +642,14 @@ func DeleteTenant(id string) {
} }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
if err := Client.HDel(ctx, redisHashTenants, id).Err(); err != nil { if err := Client.Load().HDel(ctx, redisHashTenants, id).Err(); err != nil {
log.Errorf("persistence: HDel tenant %q: %v", id, err) log.Errorf("persistence: HDel tenant %q: %v", id, err)
} }
if accessKey != "" {
if err := Client.Load().HDel(ctx, redisHashTenantAK, accessKey).Err(); err != nil {
log.Errorf("persistence: HDel tenant_ak %q: %v", accessKey, err)
}
}
}) })
} }
@@ -419,6 +666,41 @@ func isLatestQueueWriteSeq(key string, seq uint64) bool {
return queueWriteSeq[key] == seq return queueWriteSeq[key] == seq
} }
func nextMsgWriteSeq(key string) uint64 {
seqMu.Lock()
defer seqMu.Unlock()
msgWriteSeq[key]++
return msgWriteSeq[key]
}
func isLatestMsgWriteSeq(key string, seq uint64) bool {
seqMu.Lock()
defer seqMu.Unlock()
return msgWriteSeq[key] == seq
}
// clearMsgWriteSeq — удаляет записи нумерации для удалённых сообщений
// (иначе msgWriteSeq рос бы бесконечно).
func clearMsgWriteSeq(queueKey string, uuids ...string) {
seqMu.Lock()
defer seqMu.Unlock()
for _, u := range uuids {
delete(msgWriteSeq, queueKey+"|"+u)
}
}
// clearAllMsgWriteSeq — удаляет ВСЕ записи нумерации для очереди (purge).
func clearAllMsgWriteSeq(queueKey string) {
prefix := queueKey + "|"
seqMu.Lock()
defer seqMu.Unlock()
for k := range msgWriteSeq {
if strings.HasPrefix(k, prefix) {
delete(msgWriteSeq, k)
}
}
}
func nextTenantWriteSeq(id string) uint64 { func nextTenantWriteSeq(id string) uint64 {
seqMu.Lock() seqMu.Lock()
defer seqMu.Unlock() defer seqMu.Unlock()
@@ -435,12 +717,12 @@ func isLatestTenantWriteSeq(id string, seq uint64) bool {
// LoadAllTenantsRaw — загружает всех тенантов из Redis при старте. // LoadAllTenantsRaw — загружает всех тенантов из Redis при старте.
// Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go. // Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go.
func LoadAllTenantsRaw() (map[string][]byte, error) { func LoadAllTenantsRaw() (map[string][]byte, error) {
if Client == nil { if Client.Load() == nil {
return nil, nil return nil, nil
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
raw, err := Client.HGetAll(ctx, redisHashTenants).Result() raw, err := Client.Load().HGetAll(ctx, redisHashTenants).Result()
if err != nil { if err != nil {
return nil, fmt.Errorf("redis HGetAll tenants: %w", err) return nil, fmt.Errorf("redis HGetAll tenants: %w", err)
} }
@@ -451,3 +733,63 @@ func LoadAllTenantsRaw() (map[string][]byte, error) {
log.Infof("persistence: загружено %d тенантов из Redis", len(result)) log.Infof("persistence: загружено %d тенантов из Redis", len(result))
return result, nil return result, nil
} }
// ---- Leader election (active/passive) ----
//
// Простейший HA-механизм (вариант C): один инстанс — активный (держит лок),
// остальные — пассивные. При падении активного лок истекает по TTL и пассивный
// его перехватывает (см. app/leader). Инстанс сообщает готовность через /ready.
//
// ВАЖНО: при Client == nil (memory-only) — считаем себя лидером (мы единственные).
// TryAcquireLock — пытается взять лок (SETNX с TTL). true — лок наш.
func TryAcquireLock(key, value string, ttl time.Duration) (bool, error) {
if Client.Load() == nil {
return true, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return Client.Load().SetNX(ctx, key, value, ttl).Result()
}
// refreshLockScript — продлить TTL, ТОЛЬКО если лок всё ещё наш (атомарно).
var refreshLockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end`)
// releaseLockScript — удалить лок, ТОЛЬКО если он наш.
var releaseLockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end`)
// RefreshLock — продлевает TTL лока, если он всё ещё наш. false — лок потерян.
func RefreshLock(key, value string, ttl time.Duration) (bool, error) {
if Client.Load() == nil {
return true, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := refreshLockScript.Run(ctx, Client.Load(), []string{key}, value, ttl.Milliseconds()).Int()
if err != nil {
return false, err
}
return n == 1, nil
}
// ReleaseLock — освобождает лок (если он наш). Вызывается при graceful shutdown.
func ReleaseLock(key, value string) {
if Client.Load() == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := releaseLockScript.Run(ctx, Client.Load(), []string{key}, value).Int(); err != nil {
log.Errorf("persistence: release lock %q: %v", key, err)
}
}
+131 -28
View File
@@ -4,6 +4,7 @@
package router package router
import ( import (
"crypto/subtle"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
@@ -27,8 +28,13 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// New — создаёт HTTP router с tenant auth и admin API // NewDataPlane — публичный SQS API (data-plane): SQS actions, /health, статика, описание.
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler { // НЕ содержит админ-консоль, /metrics и /ui — они в control-plane (NewControlPlane).
//
// Разбиение data-plane/control-plane (Шаг 2): публично доступен только этот роутер.
// Пока — два листенера в одном процессе (общее in-memory состояние); отдельные процессы
// станут возможны после выноса состояния в Redis (Шаг 3).
func NewDataPlane(tenantStore *tenant.TenantStore) http.Handler {
r := mux.NewRouter() r := mux.NewRouter()
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB // Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB
@@ -38,38 +44,17 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
// /health — публичный, без auth // /health — публичный, без auth
r.HandleFunc("/health", health).Methods("GET") r.HandleFunc("/health", health).Methods("GET")
// /ready — готовность инстанса (для failover/балансировщика)
r.HandleFunc("/ready", readyHandler).Methods("GET")
// /static — логотип, favicon для публичных страниц // /static — логотип, favicon для публичных страниц
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET") r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
// /metrics — Prometheus endpoint для Victoria Metrics scrape
r.Handle("/metrics", promhttp.Handler()).Methods("GET")
// Страница примеров команд — публично // Страница примеров команд — публично
r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) { r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) {
ui.ExamplesHandler(models.Version).ServeHTTP(w, req) ui.ExamplesHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET") }).Methods("GET")
// Админ-консоль — статика. Регистрируем ДО admin subrouter,
// иначе PathPrefix("/admin") с bearer-auth перехватит GET /admin.
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
r.HandleFunc("/admin/", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
// Admin API — Bearer token auth, регистрируется через AdminHandler
adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r)
// UI public API — JWT auth, для встроенной console
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
adminHandler.RegisterPublicRoutes(r)
// UI console — встроенный SPA, публичный доступ
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
// SQS API — tenant auth middleware оборачивает каждый handler отдельно. // SQS API — tenant auth middleware оборачивает каждый handler отдельно.
// r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0 // r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку. // при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
@@ -91,6 +76,51 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
return r return r
} }
// NewControlPlane — админка, метрики и UI (control-plane).
// ВАЖНО: этот роутер должен слушать ТОЛЬКО localhost (не проксируется наружу).
func NewControlPlane(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
r := mux.NewRouter()
r.Use(requestBodyLimitMiddleware)
// /health — для проверки control-plane локально
r.HandleFunc("/health", health).Methods("GET")
// /ready — готовность (лидер ли)
r.HandleFunc("/ready", readyHandler).Methods("GET")
// /static — нужен UI-консоли (логотип/favicon)
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
// /metrics — Prometheus endpoint для Victoria Metrics scrape.
// Защищён Bearer admin-токеном: метрики содержат per-tenant данные
// (accessKey/число очередей и сообщений), которые нельзя светить наружу.
r.Handle("/metrics", metricsAuth(adminToken, promhttp.Handler())).Methods("GET")
// Админ-консоль — статика. Регистрируем ДО admin subrouter,
// иначе PathPrefix("/admin") с bearer-auth перехватит GET /admin.
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
r.HandleFunc("/admin/", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
// Admin API — Bearer token auth, регистрируется через AdminHandler
adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r)
// UI public API — JWT auth, для встроенной console
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
adminHandler.RegisterPublicRoutes(r)
// UI console — встроенный SPA
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
return r
}
// requestBodyLimitMiddleware — ограничивает размер тела HTTP запроса до 3MB. // requestBodyLimitMiddleware — ограничивает размер тела HTTP запроса до 3MB.
// Защита от OOM при отправке гигантских messages/batch (Fix #3.5). // Защита от OOM при отправке гигантских messages/batch (Fix #3.5).
func requestBodyLimitMiddleware(next http.Handler) http.Handler { func requestBodyLimitMiddleware(next http.Handler) http.Handler {
@@ -181,6 +211,71 @@ func health(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, `{"status":"ok","version":%q}`, models.Version) fmt.Fprintf(w, `{"status":"ok","version":%q}`, models.Version)
} }
// ReadyFunc — если задана, /ready отдаёт 200 при true и 503 иначе (для failover/LB).
// Задаётся в main при включённом leader-election. nil → всегда готовы.
var ReadyFunc func() bool
// readyHandler — готовность инстанса обслуживать трафик (для балансировщика/failover).
// Пассивный инстанс отдаёт 503, чтобы LB не направлял на него трафик.
func readyHandler(w http.ResponseWriter, req *http.Request) {
if ReadyFunc != nil && !ReadyFunc() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ready"}`))
}
// metricsAuth — оборачивает /metrics в проверку Bearer admin-токена.
// В отличие от публичного /health, метрики содержат per-tenant данные
// (accessKey, число очередей/сообщений) и не должны быть доступны без auth.
func metricsAuth(adminToken string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
expected := "Bearer " + adminToken
if len(authHeader) != len(expected) ||
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// countBatchEntries — подсчитывает число записей в batch-запросе SQS.
// Для query-протокола записи идут как SendMessageBatchRequestEntry.N.Id (N=1..).
// Для JSON-протокола считаем по количеству записей в массиве Entries (requestBody).
// Возвращает 0 если определить не удалось (тогда биллинг запишет msg_count=1).
func countBatchEntries(req *http.Request, action string) int {
// Query-формат: считаем уникальные N в SendMessageBatchRequestEntry.N.Id и т.п.
prefix := "SendMessageBatchRequestEntry."
if action == "DeleteMessageBatch" {
prefix = "DeleteMessageBatchRequestEntry."
} else if action == "ChangeMessageVisibilityBatch" {
prefix = "ChangeMessageVisibilityBatchRequestEntry."
}
// Разбираем form, если ещё не разобран (иначе PostForm пуст для lazy-запросов).
_ = req.ParseForm()
if req.PostForm != nil || req.Form != nil {
form := req.Form
if form == nil {
form = req.PostForm
}
count := 0
for key := range form {
if strings.HasPrefix(key, prefix) && strings.HasSuffix(key, ".Id") {
count++
}
}
if count > 0 {
return count
}
}
return 0
}
func actionHandler(w http.ResponseWriter, req *http.Request) { func actionHandler(w http.ResponseWriter, req *http.Request) {
action := extractAction(req) action := extractAction(req)
start := time.Now() start := time.Now()
@@ -208,8 +303,16 @@ func actionHandler(w http.ResponseWriter, req *http.Request) {
if queueName == "" { if queueName == "" {
queueName = req.FormValue("QueueName") queueName = req.FormValue("QueueName")
} }
// Billing: запись в PostgreSQL (async, no-op если billing выключен) // Billing: запись в PostgreSQL (async, no-op если billing выключен).
billing.RecordUsage(t.ID, action, queueName, 1, msgBytes) // msgCount для batch-операций — число сообщений в батче; для прочих — 1.
msgCount := 1
if action == "SendMessageBatch" || action == "DeleteMessageBatch" ||
action == "ChangeMessageVisibilityBatch" {
if n := countBatchEntries(req, action); n > 0 {
msgCount = n
}
}
billing.RecordUsage(t.ID, action, queueName, msgCount, msgBytes)
// Prometheus counters: requests + bytes // Prometheus counters: requests + bytes
metrics.RequestsTotal.WithLabelValues(t.ID, action).Inc() metrics.RequestsTotal.WithLabelValues(t.ID, action).Inc()
metrics.RequestBytesTotal.WithLabelValues(t.ID, action).Add(float64(msgBytes)) metrics.RequestBytesTotal.WithLabelValues(t.ID, action).Add(float64(msgBytes))
+66 -57
View File
@@ -4,7 +4,6 @@ package tenant
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -26,7 +25,7 @@ const DefaultTenantMaxQueues = 50
// Tenant — модель тенанта shared-sqs. // Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header. // AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct { type Tenant struct {
ID string // уникальный идентификатор тенанта (t-<hex>: детерминированный из email для JWT-тенантов, случайный для manual) ID string // уникальный идентификатор тенанта (t-<hex>: случайный, генерируется через crypto/rand)
Name string // человекочитаемое имя Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>) AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars) SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
@@ -88,6 +87,12 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
} }
s.mu.Lock() s.mu.Lock()
// Повторная проверка лимита ПОД Lock: ранняя проверка выше была под RLock,
// между ней и взятием Lock другой поток мог создать тенанта (TOCTOU).
if len(s.byID) >= MaxTenantsGlobal {
s.mu.Unlock()
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
}
s.byID[t.ID] = t s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t s.byAccessKey[t.AccessKey] = t
if t.Email != "" { if t.Email != "" {
@@ -105,7 +110,23 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo). // CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo).
// Используется только при инициализации демо-данных; не вызывается из user-facing API. // Используется только при инициализации демо-данных; не вызывается из user-facing API.
// CreateFixedAutoID — создаёт тенанта с заранее известными креды (BYOC),
// но СЛУЧАЙНЫМ tenantID (генерируется через crypto/rand). Используется Admin API
// при задании произвольных access_key/secret_key без явного tenantID.
func (s *TenantStore) CreateFixedAutoID(name string, maxQueues int, accessKey, secretKey string) (*Tenant, error) {
id, err := generateTenantID()
if err != nil {
return nil, fmt.Errorf("generate tenant id: %w", err)
}
return s.CreateFixed(name, maxQueues, id, accessKey, secretKey)
}
func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) { func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) {
// access_key используется как префикс ключа "{accessKey}:{queueName}" —
// ':' в нём ломает изоляцию тенантов (ListQueues/коллизии ключей).
if strings.Contains(accessKey, ":") {
return nil, fmt.Errorf("access key must not contain ':'")
}
s.mu.Lock() s.mu.Lock()
if _, exists := s.byID[tenantID]; exists { if _, exists := s.byID[tenantID]; exists {
s.mu.Unlock() s.mu.Unlock()
@@ -168,8 +189,8 @@ func (s *TenantStore) Delete(id string) bool {
} }
s.mu.Unlock() s.mu.Unlock()
// Удаляем из Redis асинхронно // Удаляем из Redis асинхронно (оба индекса: по id и по accessKey)
persistence.DeleteTenant(id) persistence.DeleteTenant(id, t.AccessKey)
return true return true
} }
@@ -196,54 +217,63 @@ func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) {
} }
// CreateFromJWT — auto-provisioning тенанта из JWT claims. // CreateFromJWT — auto-provisioning тенанта из JWT claims.
// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email. // Идентичность — email: тенант ищется/создаётся по email (индекс byEmail).
// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле. // Креды (ID/AccessKey/SecretKey) генерируются СЛУЧАЙНО через crypto/rand и
// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается: // НЕ выводятся из email — иначе креды можно было бы вычислить по публичному email
// старые индексы и Redis-запись удаляются, создаётся детерминированная запись. // (SHA256(email+соль) при известной соли = полный доступ к чужим очередям).
// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются // Тот же email при повторном логине → тот же тенант и те же креды (из индекса/Redis).
// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей).
func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) { func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) {
if email == "" { if email == "" {
return nil, fmt.Errorf("JWT email is required for tenant provisioning") return nil, fmt.Errorf("JWT email is required for tenant provisioning")
} }
tenantID := tenantIDFromEmail(email)
accessKey := accessKeyFromEmail(email)
secretKey := secretKeyFromEmail(email)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
// Идемпотентность: тенант с таким email уже есть // Идемпотентность: тенант с таким email уже есть — возвращаем как есть,
// креды НЕ пересчитываем и НЕ меняем (они случайны и неизменны).
if existing, ok := s.byEmail[email]; ok { if existing, ok := s.byEmail[email]; ok {
// Полное совпадение — возвращаем как есть // Персистим только если что-то реально изменилось: иначе лишние записи
if existing.ID == tenantID && existing.AccessKey == accessKey { // в Redis на каждый логин, а обновлённый NubesSub раньше вообще терялся
if sub != "" { // (сохранялся только внутри ветки смены MaxQueues).
existing.NubesSub = sub changed := false
} if sub != "" && existing.NubesSub != sub {
// Обновляем лимит очередей на актуальное значение из кода при каждом existing.NubesSub = sub
// логине и персистим в Redis — иначе после рестарта пода из Redis changed = true
// восстановится старый лимит (было: тенант навсегда оставался с лимитом,
// заданным при первом логине).
if existing.MaxQueues != maxQueues {
existing.MaxQueues = maxQueues
if data, err := json.Marshal(existing); err == nil {
persistence.SaveTenantRaw(existing.ID, data)
}
}
return existing, nil
} }
// Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую // Обновляем лимит очередей на актуальное значение из кода при каждом
delete(s.byID, existing.ID) // логине — иначе после рестарта пода из Redis восстановится старый лимит.
delete(s.byAccessKey, existing.AccessKey) if existing.MaxQueues != maxQueues {
delete(s.byEmail, existing.Email) existing.MaxQueues = maxQueues
persistence.DeleteTenant(existing.ID) changed = true
}
if changed {
if data, err := json.Marshal(existing); err == nil {
persistence.SaveTenantRaw(existing.ID, data)
}
}
return existing, nil
} }
if len(s.byID) >= MaxTenantsGlobal { if len(s.byID) >= MaxTenantsGlobal {
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal) return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
} }
id, err := generateTenantID()
if err != nil {
return nil, fmt.Errorf("generate tenant id: %w", err)
}
accessKey, err := generateAccessKey()
if err != nil {
return nil, fmt.Errorf("generate access key: %w", err)
}
secretKey, err := generateSecretKey()
if err != nil {
return nil, fmt.Errorf("generate secret key: %w", err)
}
t := &Tenant{ t := &Tenant{
ID: tenantID, ID: id,
Name: email, Name: email,
AccessKey: accessKey, AccessKey: accessKey,
SecretKey: secretKey, SecretKey: secretKey,
@@ -266,27 +296,6 @@ func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant,
return t, nil return t, nil
} }
// tenantIDFromEmail — детерминированный ID тенанта из email: "t-{16 hex}".
func tenantIDFromEmail(email string) string {
hash := sha256.Sum256([]byte(strings.ToLower(email)))
return "t-" + hex.EncodeToString(hash[:8])
}
// accessKeyFromEmail — детерминированный AccessKey: "SSAK-{24 hex}".
func accessKeyFromEmail(email string) string {
hash := sha256.Sum256([]byte(strings.ToLower(email)))
return "SSAK-" + hex.EncodeToString(hash[:12])
}
// secretKeySalt — соль для детерминированного SecretKey email-тенантов.
const secretKeySalt = "shared-sqs:secret-key:v1"
// secretKeyFromEmail — детерминированный SecretKey: 64 hex = SHA256(email + соль).
func secretKeyFromEmail(email string) string {
h := sha256.Sum256([]byte(strings.ToLower(email) + ":" + secretKeySalt))
return hex.EncodeToString(h[:])
}
// List — список всех тенантов (для admin GET /tenants). // List — список всех тенантов (для admin GET /tenants).
func (s *TenantStore) List() []*Tenant { func (s *TenantStore) List() []*Tenant {
s.mu.RLock() s.mu.RLock()
+13 -1
View File
@@ -2,6 +2,7 @@ package utils
import ( import (
"crypto/md5" "crypto/md5"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
@@ -82,7 +83,11 @@ func ExtractQueueAttributes(u url.Values) map[string]string {
// CreateErrorResponseV1 — формирует SQS error response по ключу ошибки. // CreateErrorResponseV1 — формирует SQS error response по ключу ошибки.
// Параметр isSqs оставлен для обратной совместимости (всегда true). // Параметр isSqs оставлен для обратной совместимости (всегда true).
func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) { func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) {
err := models.SqsErrors[errKey] err, ok := models.SqsErrors[errKey]
if !ok {
// Неизвестный ключ → InternalError вместо nil-deref (ревью 2026-10-11).
err = models.SqsErrors["InternalError"]
}
respStruct := models.ErrorResponse{ respStruct := models.ErrorResponse{
Result: err.Response(), Result: err.Response(),
@@ -97,6 +102,13 @@ func GetMD5Hash(text string) string {
return hex.EncodeToString(hasher.Sum(nil)) return hex.EncodeToString(hasher.Sum(nil))
} }
// GetSHA256Hash — SHA-256 (hex) от значения. Используется для
// ContentBasedDeduplication: AWS сам вычисляет MessageDeduplicationId из тела.
func GetSHA256Hash(text string) string {
sum := sha256.Sum256([]byte(text))
return hex.EncodeToString(sum[:])
}
func HashAttributes(attributes map[string]models.MessageAttribute) string { func HashAttributes(attributes map[string]models.MessageAttribute) string {
hasher := md5.New() hasher := md5.New()
+4 -4
View File
@@ -36,7 +36,7 @@ REST API (iot-operator:9090) → Пользователь (UI/Terraform)
``` ```
### Ключевые компоненты k8s ### Ключевые компоненты k8s
- **CRD IoTDevice** (`iot.kube5s.ru/v1alpha1`) — регистрация устройств - **CRD IoTDevice** (`iot.nubes.ru/v1alpha1`) — регистрация устройств
- **k8s Secret** `iot-{deviceId}` — хранит MQTT пароль (64 hex, crypto/rand) - **k8s Secret** `iot-{deviceId}` — хранит MQTT пароль (64 hex, crypto/rand)
- **OwnerReference** — каскадное удаление Secret при удалении IoTDevice - **OwnerReference** — каскадное удаление Secret при удалении IoTDevice
- **RBAC** — ClusterRole для чтения/записи IoTDevice + Secrets - **RBAC** — ClusterRole для чтения/записи IoTDevice + Secrets
@@ -161,9 +161,9 @@ Managed PostgreSQL — IoT данные
### Что сейчас ### Что сейчас
- SQS сервис живёт в `namespace: shared-sqs` в кластере `iot-naeel` - SQS сервис живёт в `namespace: shared-sqs` в кластере `iot-naeel`
- Endpoint: `https://qu.kube5s.ru` - Endpoint: `https://sqs.containerk8s.dev.nubes.ru`
- Multi-tenant: tenant `iot-service` (id: `t-96afe7e9f781f6ca`), очередь `iot-telemetry` - Multi-tenant: tenant `iot-service` (id: `t-96afe7e9f781f6ca`), очередь `iot-telemetry`
- IoT использует: `SQS_ENDPOINT=https://qu.kube5s.ru`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY` - IoT использует: `SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`
- Протокол: AWS SQS-совместимый (SendMessage, ReceiveMessage, DeleteMessage, GetQueueUrl, GetQueueAttributes) - Протокол: AWS SQS-совместимый (SendMessage, ReceiveMessage, DeleteMessage, GetQueueUrl, GetQueueAttributes)
### Что нужно от нового SQS сервиса ### Что нужно от нового SQS сервиса
@@ -180,7 +180,7 @@ Managed PostgreSQL — IoT данные
### Env vars для IoT → SQS ### Env vars для IoT → SQS
``` ```
SQS_ENDPOINT=https://qu.kube5s.ru (поменяется на новый managed URL) SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru (поменяется на новый managed URL)
SQS_ACCESS_KEY=... SQS_ACCESS_KEY=...
SQS_SECRET_KEY=... SQS_SECRET_KEY=...
SQS_QUEUE_NAME=iot-telemetry (default) SQS_QUEUE_NAME=iot-telemetry (default)
@@ -0,0 +1,89 @@
# Архитектурный анализ размещения и разбиения shared-sqs (2026-10-08)
**Роль:** архитектор. **Статус:** решения согласованы с владельцем, записаны для исполнения.
---
## 1. Исходные требования (согласовано)
1. **SQS должен быть полноценным публичным сервисом** — как AWS SQS и Yandex YMQ.
Клиенты ходят к нему снаружи по HTTPS через AWS SDK. Внешний доступ — обязателен,
а не опция.
2. **Без монолитов** — но с уточнением (раздел 3): не «микросервисы ради микросервисов»,
а «разные процессы там, где разные требования».
3. **Девопсов не дёргать сейчас** — у них и так заботы.
## 2. Сетевая стратегия (две беды платформы, обход)
См. `nubes-large-body-workaround-findings-2026-10-08.md` и
`nubes-workaround-applicability-to-sqs-2026-10-08.md`.
**Две независимые беды managed-шлюза:**
- A. Большие пакеты/тела не проходят (MSS 1448 при underlay 1450, PMTUD мёртв) — ~51 с;
- B. Таймауты/RST на ЛЮБЫХ телах из-за таймеров шлюза (период 31–33 с; keep-alive 10 в drhider).
Обе — на стороне платформы, тикет не решает (доказано опытом drhider).
**Принятая стратегия:**
1. Сейчас — публичная точка входа на **ВМ** (наш nginx, TLS, MTU, keep-alive), минуя
managed-шлюз. Полный контроль, девопсы не нужны.
2. Когда сервис отлажен и появились реальные клиенты — к девопсам с накопленными
доказательствами (tcpdump, период 31–33 с, MSS 1448, опыт drhider), просить чинить
managed-шлюз уже предметно.
**Уточнение про «в кубере та же хуйня»:** она есть только на пути через managed
ingress извне. Внутри кластера сеть чистая (6–8 мс, без таймаутов).
## 3. Разбиение: «модульный монолит», а не микросервисы
Вывод обсуждения: монолит плох там, где смешивает разное; микросервисы — плата
(сеть, распределённые транзакции, оркестрация, дебаг), которая для стадии
«никто не использует, один человек» преждевременна.
**Критерии, по которым решаем, выносить ли в отдельный процесс:**
1. разный профиль нагрузки/масштабирования;
2. разный жизненный цикл / частота релизов;
3. stateful vs stateless.
**Целевое разбиение SQS:**
| Компонент | Отдельный процесс? | Обоснование |
|---|---|---|
| SQS **data-plane** (очереди, сообщения, SigV4) | **да** | горячий путь, stateful, свой профиль |
| **control-plane** (тенанты, креды, очереди, админ) | **да** | редкие операции, другой жизненный цикл и периметр |
| биллинг | модуль внутри | выносить = сеть ради одной горутины, пишущей в PG |
| метрики (Prometheus) | модуль внутри | это эндпоинт, не сервис |
| UI | статика | не сервис |
## 4. Ограничение, которое разбиение НЕ снимает
Ядро SQS держит очередь в памяти одного пода (`globals.go` `SyncQueues`,
`redis.go` `LoadAllQueues`). «Не монолит» ≠ «масштабируется»: пока хранилище не
вынесено наружу (чтение тоже из Redis, либо иной сторедж, либо шардирование),
data-plane всё равно одиночка. Это отдельный, более важный вопрос — решать до
набора тенантов.
## 5. Размещение IoT (для полноты картины)
- К IoT внешний доступ нужен по определению (устройства). Managed-шлюз в цепочке
IoT неизбежен.
- Но: телеметрия — мелкие MQTT-сообщения, для них критичен только разрыв wss ~150 с
(лечится переподключением — уже реализовано в IoT).
- Большие payload'ы от устройств через MQTT-шлюз **не пускать**: лимит размера,
дробление на уровне протокола, либо приём крупных данных на ВМ с вытягиванием
кластером (схема drhider).
- IoT переделывается с нуля — не монолитом: bridge (MQTT), consumer (→storage),
API отдельными сервисами.
## 6. Открытые вопросы (ждут решений)
1. Домен и сертификат для публичной точки SQS на ВМ.
2. Есть ли клиенты без явного `endpoint_url` (влияет на внешние `QueueUrl`).
3. Вынос хранилища data-plane (главный стратегический вопрос) — когда начинаем.
4. Крупные тела у IoT-устройств нужны или только мелкая телеметрия.
## 7. Следующий шаг
Подготовить развёртывание SQS на ВМ: разбиение на data-plane и control-plane,
nginx с правильным MTU/keep-alive/TLS, внешние `QueueUrl`. Старт — по команде
владельца.
@@ -0,0 +1,91 @@
# Критический анализ отчёта Opus (мой разбор, 2026-10-05)
> Кем: я (DeepSeek). Что: критический разбор отчёта Opus
> (`opus-report-2026-10-05.md`) по архитектуре shared-sqs.
> Метод: ключевые утверждения Опуса перепроверялись по коду напрямую, а не
> принимались на веру. Источники — «файл:строка».
---
## Что я перепроверил по коду (факты)
| Утверждение Опуса | Вердикт | Источник в коде |
|---|---|---|
| P0.1: SecretKey детерминирован из email + соль в исходнике | ✅ подтверждено | `tenant_store.go:289-292` `secretKeyFromEmail` = `SHA256(lower(email)+":shared-sqs:secret-key:v1")`; соль — константа `secretKeySalt` (`tenant_store.go:286`); `accessKeyFromEmail` = `SHA256(email)[:12]` |
| P0.2: JWT без проверки подписи, доверие на PingNubesAPI | ✅ подтверждено | `jwt.go` — парсинг структуры + `exp`; `PingNubesAPI` принимает, если хоть один стенд не 401/403 |
| Кэш: отозванный токен живёт до ~7 мин | ✅ подтверждено | `cache.go`: `cacheTTL=5m`, `cacheGraceTTL=2m` |
| Глобальный мьютекс на весь сервис | ✅ подтверждено | `globals.go:20-23` — один `SyncQueues.RWMutex` + `Queues map[string]*Queue` |
| Сиротство очередей при пересчёте legacy-тенанта | ✅ подтверждено | `tenant_store.go:202-205` — комментарий в коде сам это описывает |
| MessageRetentionPeriod не реализован | ✅ подтверждено | `models.go` — `// TODO - not used` |
Ложных утверждений не найдено.
---
## Сильные стороны отчёта
1. **P0.1 — самая ценная находка.** Детерминированный вывод SecretKey из
публичного email — реальный, доказуемый по коду дефект. Указан не только
механизм, но и точные функции. Именно это реально блокирует «готовность SQS».
2. **Верно разделены SigV4 и JWT.** SigV4 проверяется полноценно
(`auth_middleware.go`: canonical request, `subtle.ConstantTimeCompare`,
clock skew, presigned); JWT — нет. Большинство поверхностных ревью их путают.
3. **Классификация P0/P1/P2 адекватна** — безопасность+durability отделены от
масштабируемости и гигиены.
4. **Пункты 3–7** (глобальный лок, SPOF, async-Redis, нет RetentionPeriod,
плоский слайс) — архитектурно точны.
5. **Раздел «противоречия док↔код» конкретен** (особенно №1: док про
`sub`/`bySub`/`sless-`, код про `email`/`byEmail`/`t-`).
---
## Слабые места и оговорки
1. **P0.2 переоценён как самостоятельный «критичный».** Вектор «подмена email
→ доступ» опасен **только в связке с P0.1**: неверный email даёт чужие креды,
потому что креды детерминированы из email. Исправь P0.1 (случайные креды в
Redis) — и неверный email из JWT уже не даст чужих ключей, только
провиженинг под чужим именем (менее критично). Опус не связал P0.1 и P0.2
явно, хотя атака работает попарно. Стоит оформить как один инцидент.
2. **П.4 «SPOF/нет горизонтального масштабирования»** — справедливо
архитектурно, но сервис и проектировался single-instance managed
HTTP-контейнером (README). Это осознанное ограничение, а не баг. В разряд
«высокого риска сейчас» ставить преждевременно — это долгосрочная
архитектурная развилка, а не текущий блокер.
3. **П.5 (async-Redis = потеря подтверждённых сообщений) — полуверно.**
Сообщение в памяти уже есть (клиент получил 200 = сообщение в памяти,
источнике правды). Теряется только при краше пода **до** async-`HSet`, и
только это узкое окно. Окно реально, но «нарушение durability» подано
сильнее, чем есть: теряются не все сообщения, а только при краше в окне.
4. **П.10 (timing side-channel admin-токена)** — технически верно, но admin-токен
приходит как высокоэнтропийный bearer; практическая эксплуатируемость timing-атаки
через сеть у одного короткого сравнения ничтожна. Формально в P2 и есть, но
вес в подаче чуть завышен.
5. **П.14 (`/metrics` публичен)** — стоит оговорка: лейбл `tenant` = tenantID
(`t-...`), а НЕ SecretKey. Утечка реальна (число очередей/сообщений по
тенанту), но это не утечка кредов. Формулировка корректна, но без оговорки
читается страшнее.
6. **Пробел контекста (не ошибка Опуса, а следствие моего allowlist-запрета):**
отчёт не отделяет «код-блокировку» от «платформенной блокировки» — периодические
таймауты ~31–33с и MSS-баг для тел >~1400 байт (разобрано в
`doc/thinking/nubes-ticket.md`). Даже идеальный код их не уберёт. Итоговый
вывод о «готовности SQS» надо читать с этой поправкой.
---
## Итоговая оценка
Отчёт — **добротный, по фактам точный**. Из 16 пунктов рисков ложных нет;
спорные — только **вес/приоритет**:
- P0.2 связать с P0.1 (один инцидент);
- п.4 (SPOF) и п.10 (timing) слегка переоценены для текущей single-instance роли.
Главная ценность: **P0.1 (детерминированный SecretKey)** — подтверждённый по
коду блокер готовности, который до этого так жёстко не формулировался.
Единственный реальный пробел — платформенный контекст (таймауты/MSS), но он
возник из моего же allowlist и устраняется дополнением, не исправлением.
@@ -0,0 +1,176 @@
# Находки: как та же сетевая болезнь платформы уже решена в drhider (буфер на ВМ)
**Дата:** 2026-10-08
**Источник находок:** репозиторий `~/nubes/drhider` (+ `~/nubes/drhider/upload-platform`)
**Зачем:** у shared-sqs та же проблема (таймауты внешнего пути, большие тела), и
наивный путь «отправить тикет в Nubes» оказался тупиком — явление признано
болезнью платформы. В drhider ту же болезнь **обошли архитектурно**, а не починили.
> ## ⚠️ ГЛАВНЫЙ ВЫВОД
> **Ingress managed-кластера обрывает ВХОДЯЩИЕ тела больше порога, а ИСХОДЯЩИЙ
> трафик (egress) из кластера не ограничен.** Отсюда рабочий приём: не «толкать»
> большие данные в кластер (push), а **«вытягивать» их извне (pull)** — данные
> кладутся на внешний хост-буфер, а приложение в кластере забирает их исходящим
> запросом. Тикет в поддержку проблему не решает.
---
## 1. Диагноз проблемы (цитаты из drhider)
`upload-platform/HISTORY/2026-09-06-layer2-architecture-and-api-boundaries.md:18`:
> «**Причина существования**: Ingress-контроллер k8s-кластера (`pythonk8s`)
> обрывает входящие запросы с телом более 64 КБ. Прямой `POST` больших файлов в
> кластер невозможен; **исходящий трафик (egress) из кластера не ограничен**.»
`PLAN-upload-layers.md:27` (контекст, «что уже проверено»):
> «Паттерн загрузки (**шлюз managed-кластера рвёт тела >64КБ, egress не
> ограничен**): браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` →
> egress `GET` (pull) → сессия.»
Это ровно тот же класс, что явление A у shared-sqs (большие POST), только у
drhider порог «тело >64 КБ» на ingress, а у SQS — MSS/MTU-порог ~1.4 КБ на
внешнем шлюзе (`doc/thinking/nubes-ticket.md`, явление A). Причина на стороне
платформы в обоих случаях.
## 2. Схема обхода, применённая в drhider (пошагово)
`upload-platform/HISTORY/2026-09-06-layer2-architecture-and-api-boundaries.md:19-27`
— «Пофайловый транзит (Per-File Transit)»:
1. Файл №k браузер отправляет методом `PUT` **в буфер на ВМ** (вне кластера).
2. Браузер сразу шлёт в приложение (Flask в кластере) `POST /api/upload_refs` —
тело запроса маленькое (только ссылка на файл), поэтому ingress его пропускает.
3. Приложение **само забирает** файл исходящим потоковым `GET` прямо в память
сессии — egress не режется.
4. Приложение немедленно отправляет `DELETE` на ВМ — буфер очищен.
5. Браузер переходит к файлу №k+1. **В буфере на ВМ в любой момент максимум один файл.**
Дополнительные принципы оттуда же:
- **RAM-only**: без постоянной записи на диск ни в кластере, ни на ВМ.
- **Zero hardcode**: адрес буфера и префикс — параметры конфигурации
(`vmUploadUrl` на фронте, `vmUploadPrefix` на бэке для обязательной SSRF-проверки).
- **Автономный mock-буфер** (in-memory WebDAV) для тестов без внешней сети.
`upload/README.md:13` подтверждает ту же механику:
> «Пофайловый стриминг: для каждого файла выполняется `PUT` в буфер на ВМ,
> немедленный `POST /api/upload_refs` в бэкенд, и после подтверждения приёма
> буфер на ВМ очищается.»
## 3. Что нельзя потерять при переносе (накоплено в drhider)
`PLAN-upload-layers.md` (раздел «Требования/что НЕЛЬЗЯ потерять»):
- дедупликация «имя+размер» (иначе суффикс `_2`);
- раскрытие ZIP с путями и фильтр расширений (`.pdf .doc .docx .txt .md`);
- рекурсивный обход папок (`webkitdirectory`);
- **SSRF-валидация URL** (`url.startswith(VM_UPLOAD_PREFIX)`);
- `_safe_name` — защита от path traversal с сохранением подпапок;
- **повторы pull** (3 попытки, пауза 2 с);
- лимиты **50 МБ на файл / 500 МБ на сессию**;
- различение «сессия не найдена» и «превышен лимит»;
- статусы «пропущен (лимит)» / «не извлечён»;
- экранирование имён для защиты от self-XSS.
## 4. Фиксы платформы, которые уже применили (и их границы)
`PROBLEM-AND-SOLUTION.md` (drhider, v0.0.31, 2026-07-16):
**Проблема 1 — пауза 51 с (MTU).** Underlay MTU 1450, Cilium Geneve добавляет
50 байт, дефолт пода 1500 → пакет 1550 → фрагментация → дроп → ядро ждёт
повторной передачи 51 с. **Решение:** `cilium-config` → `mtu: 1400`
(1400+50 = 1450). Задокументировано 2026-07-13, применено 2026-07-14.
**Проблема 2 — `ERR_CONNECTION_RESET` в браузере.** Истинная причина —
`keep-alive: 10` вместо дефолта 75: пока пользователь выбирал файлы (>10 с),
nginx закрывал idle-соединение, браузер не замечал FIN и слал POST в закрытый
сокет → RST. **Решение** (в платформе Штурвала, 2026-07-15) — через
`ShturvalServiceConfig`, а не через kubectl:
```yaml
controller:
config:
keep-alive: "75"
proxy-body-size: "1024m"
client-body-timeout: "60"
client-header-timeout: "30"
hostPort:
enabled: true
replicaCount: 2
service:
type: LoadBalancer
```
> Важно: правки ingress на платформе делаются через `ShturvalServiceConfig` —
> **через kubectl нельзя, ArgoCD откатывает**.
**Ложные следы, которые опровергли** (та же таблица): kube-vip ARP flapping,
hostPort на 2 из 4 нод, upstream keepalive, conntrack, MTU (для проблемы 2),
HTTP/2.
**Результаты после фиксов (v0.0.29, 2026-07-15):** 10× POST 63 КБ — 10/10 OK;
полный UI-цикл (5 файлов + SSE + ZIP + CSV) — OK; 5× POST с интервалом 75 с —
5/5 OK; PDF 5 МБ через curl — 200 OK за 0.62 с. **Итого 25/25, 0 RST.**
⚠️ Но это вылечило ingress-таймер и MTU-фрагментацию, **а не порог размера
тела**: обходной слой через ВМ-буфер остался нужен и является рабочим решением.
## 5. Обходы «не по сети» — тот же принцип pull/вынос
`PROBLEM-AND-SOLUTION.md:71`: `doc_to_markdown()` переписали — вместо локального
`soffice` (LibreOffice на ВМ, тяжёлый и не тянул) **HTTP-вызов внешнего сервиса**
`liberta.containerk8s.dev.nubes.ru/convert` через `httpx`. То есть тяжёлую/медленную
работу тоже вынесли наружу и ходят к ней по HTTP.
Смежное (из кода drhider): конвертация `.doc` через `liberta` выполняется
**последовательно** (`_LLM_CONCURRENCY = 1`) — сервис не выдерживает
конкурентность (`drhider/extractor.py:160-194`, `drhider/scanner.py:122`).
## 6. Готовый переиспользуемый модуль (для справки)
`PLAN-upload-layers.md` — слои выделены так, чтобы их можно было перенести в
любой проект копированием (без общей репы):
| Слой | Где | Ответственность |
|---|---|---|
| 0. Конфиг | общий | адрес ВМ-буфера, расширения, лимиты |
| 1. Выбор файлов | фронт | таблица, дедуп, ZIP, дерево папок, статусы |
| 2. Закачка через ВМ | фронт + бэк | `PUT` на ВМ → `upload_refs` pull → сессия |
| 3. Логика приложения | бэк | обработка (у каждого своя) — **не выносится** |
Реализация-эталон: `~/nubes/drhider/upload-platform/` (Слой 1 v0.1.13 готов,
Слой 2 в работе на 2026-09-06), а также рабочий образец `~/nubes/drhider/upload/`.
## 7. Инфраструктурные факты
- **ВМ-буфер:** `5.172.178.213` (вне кластера). Nginx WebDAV, каталоги
`/var/www/drhider-upload/`, `/var/www/contracts-upload/`, cron-очистка каждые
5 минут (`find … -mmin +30 -delete`). Для целевого прода — перевод в
RAM/in-memory буфер.
- **SSH к ВМ:** `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
(`README.md:141`).
- **Кластеры/неймспейсы:** `pythonk8s` (ingress режет >64 КБ); сервисы drhider,
contractor, uploader-dev.
## 8. Выводы (что берём из чужого опыта)
1. **Тикет в Nubes — не решение.** Это болезнь платформы; в drhider её не «починили»,
а обошли. Тикет (`doc/thinking/nubes-ticket.md`) имеет смысл только как
фиксация для истории, не как способ получить рабочий сервис.
2. **Рабочий приём — pull вместо push:** внешний буфер + исходящий `GET` из
кластера. Egress свободен, ingress режется.
3. **Правки ingress на платформе** — только через `ShturvalServiceConfig`
(kubectl откатывается ArgoCD).
4. **MTU-фикс (`cilium mtu: 1400`)** нужен как гигиена, но порога больших тел не
снимает.
5. **Тяжёлые/медленные операции** тоже выносятся наружу и вызываются по HTTP
(пример: `liberta`).
## Источники (файл:строка)
| Источник | Что |
|---|---|
| `~/nubes/drhider/PROBLEM-AND-SOLUTION.md` | MTU-фикс, keep-alive 10→75, ложные следы, тесты 25/25 |
| `~/nubes/drhider/PLAN-upload-layers.md:27` | паттерн «шлюз рвёт >64 КБ, egress свободен» |
| `~/nubes/drhider/upload/README.md:13` | пофайловый стриминг через ВМ-буфер |
| `~/nubes/drhider/upload-platform/HISTORY/2026-09-06-layer2-architecture-and-api-boundaries.md:18-27` | диагноз + пошаговый пофайловый транзит, RAM-only, mock-буфер |
| `~/nubes/drhider/docs/MSS-CLAMPING-PLAN.md`, `docs/about1500.md`, `docs/INGRESS-SOLUTIONS.md` | сопутствующие материалы по MTU/MSS/ingress |
| `~/nubes/drhider/drhider/extractor.py:160-194`, `drhider/scanner.py:122` | вынос конвертации в сервис `liberta`, последовательные вызовы |
@@ -0,0 +1,137 @@
# Стратегия доказательства сетевого бага Nubes — v2
> ⚠️ **ПОМЕТКА: анализ от DeepSeek V4 Pro (основная модель) в НОВОМ чате — НЕ Соннет.**
> Пользователь задал тот же вопрос в новом чате DeepSeek V4 Pro.
> Сохранено 2026-08-15. Сравнение с планом DeepSeek V4 Flash — в HISTORY
> и в конце этого файла.
## Стратегия доказательства
Доказывается изоляцией слоёв. Каждый тест исключает один слой пути:
```
клиент (локальная машина) → интернет → шлюз платформы (VIP 185.247.187.151)
→ kube-vip → NodePort 32391 → kube-proxy → nginx-ingress → Cilium → под shared-sqs
```
«Виновата платформа» доказано, если ВСЕ три условия:
1. Таймауты воспроизводятся на пути БЕЗ нашего кода (сырой TCP, эхо) с тем же паттерном.
2. tcpdump фиксирует потерю сегмента клиент↔кластер при том, что под/nginx
ответил за миллисекунды (по логам).
3. Потери коррелируют с MTU-порогом (~1448) или платформенным таймером
(conntrack/RST), а не с логикой приложения.
## 1. Эхо или сырые TCP
Делать ОБА, в порядке: сырые TCP, потом эхо.
**Сырые TCP на 185.247.187.151:32391** — бесплатный первый тест. Покрывает
путь до nginx-пода без HTTP и без нашего кода. Отправлять сегменты 512, 1380,
1448, 1488+ байт. Критерий доставки — TCP ACK, не HTTP-ответ (nginx на мусор
ответит 400/RST — нормально).
**Эхо-сервис** — второй шаг, ~20 строк Go, Deployment + Service с отдельным
NodePort (без ingress). Параметризуемый размер тела ответа (`?bytes=512|1400`) —
большие POST тестируют прямой путь, большие ОТВЕТЫ — обратный. Гонять тем же
клиентом. Таймауты воспроизвелись → nginx-ingress и наше приложение исключены.
## 2. Как поймать таймаут
tcpdump на клиенте: `tcpdump -i any -s 0 -w /tmp/cap.pcap host 185.247.187.151 and tcp port 32391`
| Симптом | Что смотреть |
|---|---|
| 51-с зависание большого POST | ACK на сегмент с полным MSS? ретрасмиты и интервалы? чем сессия завершилась (RST/FIN, кто первый) |
| 30-с ReadTimeout на 512 байт | запрос отправлен полностью? ACK получен? ответ не пришёл? RST после idle (conntrack)? keep-alive пул? |
| MTU-гипотеза | ICMP type 3 code 4 (Fragmentation Needed)? Не приходят → PMTUD сломан → механизм зависаний |
Замечание: 51 с не похоже на TCP-backoff Linux (tcp_retries2=15 → сотни секунд).
Скорее прикладной таймер (~50–60 с клиента/nginx/шлюза). Точный интервал в pcap
покажет, чей таймер.
Серверный захват — только через kubectl: `kubectl debug -it <pod> --image=<tcpdump-образ>`
(нужны NET_RAW/NET_ADMIN — на managed может быть запрещено). Если запрещено —
роль серверного захвата выполняют nginx access log и эхо-А/Б.
Ключевой приём: двусторонний захват, корреляция по TCP seq/ack (не по времени):
- запрос ушёл с клиента, не появился у пода → потеря на прямом пути;
- ответ ушёл из пода, клиент не получил → потеря на обратном;
- в обоих случаях приложение исключено.
## 3. kube-vip / NodePort / nginx через kubectl
nginx access log + `$request_time $upstream_response_time $status $bytes_sent`:
| В логе | Вывод |
|---|---|
| 499 + большой request_time | клиент ушёл до ответа |
| 200 + малый upstream, клиент без ответа | потеря между nginx и клиентом |
| записи нет | потеря до nginx (kube-vip/NodePort/шлюз) |
kube-vip: `kubectl get pods -A | grep -i vip`, логи (ARP/BGP-анонсы, флап VIP),
сопоставить ноду-держателя VIP с реально принимающей нодой.
kube-proxy/NodePort: `kubectl get ds -n kube-system kube-proxy`, логи;
`externalTrafficPolicy: Cluster` → лишний хоп + SNAT; iptables/conntrack ноды
не видны (нет доступа к нодам) — debug-под с hostNetwork+privileged+nodeSelector
или тикет.
Cilium: `kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`
(в cilium-под, НЕ в operator). Дроп с причиной — прямое доказательство.
Ограничение: внешний шлюз (VIP, MSS=1448) из kubectl не виден. Если кластерные
точки чистые, а снаружи потери есть — виноват шлюз/underlay, оформляется тикет.
## 4. Пошаговый план: дешевле → дороже
1. Инвентаризация логов: nginx access/error за тестовый период (499, request_time
30+/51), логи приложения (фактическое время обработки). Параллельно открыть
тикет Nubes.
2. PMTU-зонд с клиента: `ping -M do -s 1372/1422/1460/1472` до VIP (пороги MTU
1400/1450/1488/1500); при запрете ICMP — `mtr --tcp -P 443`, tcptraceroute.
MSS=1448 → сегменты 1488 при underlay 1450 — шлюз анонсирует неверный MSS.
3. Второй клиент из другого сегмента (VPS/другой ISP): тот же скрипт.
Воспроизвелось → исключены ISP и локальная сеть пользователя.
4. Сырые TCP на 32391 с обеих точек: потери на больших сегментах без HTTP →
исключены HTTP и приложение.
5. kubectl port-forward с ВМ на shared-sqs (localhost:4100): чистый тест →
приложение и под исправны (в обход шлюза через API-туннель).
6. tcpdump на клиенте + сверка с nginx access log: классификация таймаутов.
7. Эхо-сервис NodePort без ingress: А/Б 512/1400 тем же клиентом. Остались
таймауты → nginx и приложение исключены; исчезли → завести эхо через ingress
и повторить (изоляция nginx).
8. Захват в кластере: debug-контейнеры tcpdump, cilium monitor drop, логи
kube-vip/kube-proxy; двусторонняя корреляция по seq.
9. Матрица размеров (опционально): 100–1500 байт на эхо и на 32391. Резкий
порог 1380–1448 → MTU; плавный рост → шлюз/перегрузка.
## Итог: что считается доказательством
| Наблюдение | Вывод |
|---|---|
| Таймауты на эхо-NodePort (без ingress) с тем же паттерном | исключены nginx и наш код — виноват kube-vip/NodePort/шлюз/underlay |
| nginx: 200 + upstream <100 мс, клиент без ответа | потеря между nginx и клиентом |
| нет записи в access log | потеря до nginx |
| pcap: сегмент не дошёл до пода / ответ не дошёл до клиента | потеря в сетевом пути |
| ping -M do: резкий порог размера | MTU-дефект пути |
| cilium monitor показывает drop | внутрикластерная потеря |
| port-forward чистый, внешний IP таймаутит | виноват шлюз/NodePort/kube-vip |
Итоговая цепочка для тикета: pcap с клиента + access log nginx (200/upstream
малый) + воспроизведение на эхо без нашего кода + PMTU-порог ≈1448.
---
## Сравнение с планом DeepSeek V4 Flash (основной агент)
Flash: силён декомпозицией по nginx-логам, но с ошибкой (cilium monitor через
cilium-operator), переоценён echo, нет PMTU-зонда, нет порога ping -M do, нет
port-forward, нет второго ISP.
Pro (этот план): сильнее методологически — сырые TCP с критерием ACK,
PMTU-зонд ping -M do, второй клиент, port-forward, таблица «что считается
доказательством». Грубых ошибок нет.
Синтез (рекомендация): дешёвые тесты обоих планов + финал echo.
Порядок: nginx-логи → PMTU-зонд → сырые TCP → port-forward → tcpdump клиента →
второй ISP (если есть) → cilium monitor → echo → тикет.
@@ -0,0 +1,150 @@
# Plan: Доказательство сетевого бага Nubes — v3 (СОННЕТ)
> ✅ **ПОМЕТКА: это план от Соннета** (пользователь передал ответ Соннета).
> Сохранено 2026-08-15. Сравнение с v1 (DeepSeek Flash) и v2 (DeepSeek Pro) —
> в конце файла и в HISTORY.
## TL;DR
Два РАЗНЫХ явления с разными причинами — нельзя объяснять одной гипотезой и
нельзя ловить одним методом. Цепочка: nginx-логи → port-forward (бесплатное
доказательство невиновности приложения) → PMTUD-зонд → tcpdump → cilium
monitor → echo-сервис (только если предыдущих данных не хватает).
## Разделение двух явлений (критически важно)
**Явление A — большие POST (~51с).** Причина: MSS=1448, underlay MTU=1450,
сегменты 1488 → дроп. PMTUD сломан (ICMP Type 3 Code 4 заблокирован шлюзом) →
TCP ретрасмитит по экспоненте: 1с,2с,4с,8с,16с ≈ 31с, ещё попытка → ~51с.
Задача: подтвердить ping-зондом и зафиксировать в тикете.
**Явление B — малые POST 512 байт (~1/37с).** MTU-моделью НЕ объясняется.
Ключевая улика: РЕГУЛЯРНОСТЬ 1/37с — это таймер, не случайные потери.
Кандидаты: сессионный таймер gateway на VIP 185.247.187.151 (вероятнее всего),
kube-vip ARP keepalive цикл (флап при смене лидера), conntrack GC с агрессивным
idle-таймером.
## 1. Echo vs сырые TCP
Сырые TCP на 32391 — недостаточны (проверяют только фазу установления; проблемы
в фазе ДАННЫХ; nginx ответит RST/400 на мусор — неинформативно).
Правильный порядок:
1. kubectl port-forward (бесплатно, 5 минут) — чище и быстрее echo.
2. Echo NodePort без ingress — если port-forward и логи не дали ответа.
Echo: принимает POST, возвращает тело; `?bytes=N` для размера ответа
(обратный путь отдельно от прямого). Деплой БЕЗ ingress, прямой NodePort.
## 2. Как поймать таймаут: tcpdump
Клиент (запустить ДО теста, параллельно):
`tcpdump -i <iface> -s 0 -w /tmp/sqs.pcap 'host 185.247.187.151 and port 32391'`
Явление A (51с, большой POST): ICMP Type 3 Code 4 от 185.x → PMTUD работает;
нет ICMP, ретрасмиты 1/2/4/8/16с → PMTUD сломан (основная гипотеза); кто шлёт
RST в конце и через сколько — чей таймаут (клиент vs nginx/шлюз).
Явление B (30с, 512 байт): ACK на запрос получен → запрос дошёл, ответ потерян
на обратном пути; ACK нет → потеря до nginx (kube-vip/NodePort/gateway);
точный интервал между таймаутами (~37с ± 1с → таймер подтверждён); RST от
сервера — чей (nginx idle keepalive или gateway).
Ключевой приём: корреляция по TCP seq/ack, не по времени.
## 3. kube-vip / NodePort / nginx — только kubectl
nginx access log: `kubectl logs -n ingress <nginx-pod> --since=35m | grep -E " (499|5[0-9][0-9]) "`.
Нужны поля `$request_time $upstream_response_time`. Если нет — проверить
ConfigMap nginx.
| Запись в nginx log | Вывод |
|---|---|
| 499 + большой request_time | клиент ушёл до ответа (наш ReadTimeout) |
| 200 + upstream <100мс, клиент получил таймаут | ответ потерян между nginx и клиентом |
| записи нет | потеря ДО nginx: kube-vip/NodePort/gateway |
kube-vip: `kubectl get pods -A | grep -i vip`, логи (leader/ARP/flap/error).
ARP-флап во время теста → кратковременная потеря VIP → дропы.
Cilium (на ПОДЕ, не на operator): `kubectl get pods -n kube-system -l k8s-app=cilium -o wide`;
`kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`
параллельно с тестом.
NodePort/SNAT: `externalTrafficPolicy` — Cluster → любая нода + SNAT (лишний хоп,
conntrack); Local → только нода-держатель VIP (миграция VIP → brief outage).
port-forward: `kubectl port-forward -n <ns> svc/<sqs-svc> 14100:4100` — тот же
скрипт на localhost:14100. 0 таймаутов → приложение невиновно.
## 4. Пошаговый план: дешевле → дороже
**Фаза 0 — бесплатно, уже есть данные:**
1. nginx access log за период прошлого теста (499, request_time/upstream_response_time).
2. kube-vip логи: флапы в тот же промежуток.
3. Тикет Nubes: два явления отдельно — (A) MTU/PMTUD порог 1448, (B) таймер ~37с.
**Фаза 1 — клиентские тесты, 30 минут:**
4. PMTUD-зонд: `ping -M do -s N 185.247.187.151` для N=1300,1400,1420,1422,
1448,1450,1460,1472 — точный порог. Нет ответа ≥1448 → ICMP заблокирован →
тикет с числами.
5. tcpdump + тот же 30-мин тест. Wireshark: `tcp.analysis.retransmission || icmp`.
Точный интервал между таймаутами.
**Фаза 2 — kubectl, без деплоя:**
6. port-forward тест ПЕРВЫМ: 15-мин прогон через туннель. 0 таймаутов →
приложение доказанно невиновно.
7. cilium monitor параллельно со свежим тестом против внешнего IP.
8. Если нет upstream_response_time в формате — обновить ConfigMap, повторить.
**Фаза 3 — echo (только если 0–2 не дали ответа):**
9. Minimal Go echo (NodePort, без ingress), тот же тест. Воспроизвелось →
nginx и приложение исключены → kube-vip/NodePort/gateway. Нет → echo через
ingress → изоляция nginx.
10. Матрица размеров 512/1000/1400/1448/1460/1500 × 10 мин, порог бинарным поиском.
**Фаза 4 — финальная корреляция для тикета:**
11. Одновременно tcpdump + nginx access log + cilium monitor. Корреляция по
seq: «запрос ушёл, ACK получен, ответа нет» = потеря между nginx и клиентом.
Артефакты: pcap + логи + ping-зонд → тикет.
**Решения о приоритетах:**
- port-forward — самый дешёвый и чистый способ изолировать приложение. ДО echo.
- Сырые TCP на 32391 — пропустить.
- Echo — только если port-forward + nginx-логи не дали ответа.
- Явления A и B документировать в тикете РАЗДЕЛЬНО.
---
## Поправки основного агента (GitHub Copilot)
1. **ICMP может быть закрыт на шлюзе** — тогда ping-зонд не даст порога.
Нужен fallback: mtr --tcp / tcptraceroute или TCP-сегменты переменного
размера (то, что Соннет отбросил как «сырые TCP» — но для определения
ПОРОГА размера они валидны).
2. **Второй клиент с другого ISP** (был у Pro) — отсутствует. Дешёво исключает
локальную сеть пользователя. Добавить в фазу 1.
3. **Параллельный контрольный канал YMQ** — стоит сохранить во всех тестах:
отделяет «плохой интернет вообще» от «плох путь до Nubes».
4. **port-forward идёт через API-сервер** — не тот сетевой путь; чистый
результат исключает приложение, но НЕ доказывает шлюз. Формулировка Соннета
«доказательство невиновности приложения» — корректна, использовать её.
5. **Точный интервал таймаутов** надо измерять новым тестом с timestamp на
каждый таймаут (в прошлом тесте тайминги не логировались поштучно).
6. Смена nginx log format (ConfigMap) на managed может требовать релода/помощи
платформы — держать как запасной шаг.
## Сравнение трёх планов
| | Flash (v1) | Pro (v2) | Соннет (v3) |
|---|---|---|---|
| Разделение явлений A/B | нет | частично | **явно, с причиной B=таймер** |
| Ошибки команд | cilium через operator | нет | нет |
| PMTU-зонд | нет | есть | есть, точный список размеров |
| port-forward | нет | есть | **первым kubectl-тестом** |
| Сырые TCP | недооценены | да | отброшены (спорно для порога) |
| Второй ISP | нет | есть | нет |
| Критерии доказательства | слабые | таблица | таблицы + фазы |
| Объяснение 51с | нет | «прикладной таймер» | **экспонента RTO 1-2-4-8-16** |
Вердикт: план Соннета — лучший из трёх. Принять за основу с поправками 1–6.
@@ -0,0 +1,132 @@
# План: доказать, что таймауты — в пути/шлюзе Nubes — v4 (ОПУС 4.8)
> ✅ **ПОМЕТКА: план от Claude Opus 4.8** (пользователь передал ответ Опуса,
> который изучил репозиторий и три предыдущих плана).
> Сохранено 2026-08-15. Ниже — план Опуса + ответы основного агента на его
> уточняющие вопросы.
## TL;DR
Доказательство — изоляцией слоёв. «Виновата платформа» = таймауты
воспроизводятся БЕЗ нашего кода (port-forward чист, echo/сырой TCP таймаутит)
+ tcpdump с клиента ловит потерю сегмента, когда nginx/под ответили за
миллисекунды.
**Два РАЗНЫХ явления — раздельно:**
- **A. Большие POST ~51с** — локализовано: MSS=1448 → пакет 1488 > underlay
1450 → дроп при DF, PMTUD мёртв. Осталось зафиксировать порог ping-зондом.
- **B. Малые тела 512б, ~1 таймаут/37с** — MTU не объясняет. Регулярность =
таймер (шлюз VIP / ARP-флап kube-vip / conntrack GC). Главная нераскрытая часть.
## Ответы Опуса на вопросы
1. **Echo vs сырой TCP.** Сырой TCP недостаточен (только SYN/ACK; потери в
фазе данных). Самое дешёвое доказательство невиновности приложения —
`kubectl port-forward` мимо шлюза, а не echo. Echo — позже, только если
port-forward+логи не закрыли вопрос (он внутри кластера, а подозрение на шлюзе).
2. **Поймать таймаут.** tcpdump на клиенте, корреляция по TCP seq/ack: ретрансмиты
SYN → потеря на установлении; тишина после ACK → потеря после nginx; ICMP
frag-needed → PMTUD; кто и через сколько шлёт RST → чей таймер; интервал
между таймаутами → подтвердить ~37с.
3. **Через kubectl.** nginx access-log request_time/upstream_response_time:
499+большой request_time = клиент ушёл; 200+малый upstream без ответа клиенту
= потеря nginx↔клиент; записи нет = потеря до nginx. cilium monitor --type
drop — на cilium-ПОДЕ нужной ноды (не operator). externalTrafficPolicy
Cluster/Local.
## Шаги (дешевле → дороже)
- **Фаза 0 (бесплатно):** nginx access-log за прошлый тест → логи kube-vip
(флапы) → тикет Nubes (A и B раздельно).
- **Фаза 1 (клиент, ~30 мин):** PMTUD-зонд ping -M do (fallback mtr --tcp) →
tcpdump + повтор теста через long_compare_local.py с логом timestamp каждого
таймаута → контрольный канал YMQ во всех прогонах.
- **Фаза 2 (kubectl, без деплоя):** port-forward ПЕРВЫМ (невиновность приложения)
→ cilium monitor drop параллельно → проверка externalTrafficPolicy/nodePort.
- **Фаза 3 (echo, если 0–2 не хватило):** Go echo без ingress на NodePort →
таймаутит → nginx и код исключены → потом echo через ingress (изоляция nginx).
- **Фаза 4 (тикет):** одновременный tcpdump + nginx-log + cilium monitor,
корреляция по seq → pcap+логи+ping в тикет.
**Что считается доказательством:** echo-NodePort таймаутит тем же паттерном →
nginx+код исключены; nginx 200/upstream<100мс без ответа клиенту → потеря
nginx↔клиент; pcap показывает недоставленный сегмент; ping-порог → MTU;
cilium drop → внутри кластера; port-forward чист + внешний IP таймаутит →
приложение невиновно; стабильный интервал ~37с → таймер шлюза.
---
## Уточнения Опуса и ответы основного агента
1. **Второй клиент с другого ISP?** НЕТ. В распоряжении только ВМ
(5.172.178.213), но она рядом с платформой — с неё таймаутов нет, как
«второй клиент из другого сегмента» не подходит. Частичная замена уже есть:
контрольный канал YMQ с той же локали — 1 ошибка на 959 при 49 таймаутах на
Nubes (виноват локальный интернет → YMQ страдал бы так же). Полагаемся на
YMQ-канал; второй ISP — если появится доступ к VPS.
2. **Debug-под с NET_RAW?** Не проверяли; платформа managed, ноды недоступны,
привилегии урезаны — с высокой вероятностью запрещено. Под — alpine без
NET_RAW, серверный tcpdump не получится. Ограничиваемся nginx access-log +
cilium monitor + echo А/Б. Возможность kubectl debug проверяется одной
попыткой.
3. **Echo сразу?** НЕТ — только если фазы 0–2 не дадут однозначного ответа.
Доп. риск: новый NodePort для echo на managed может быть недоступен.
## Поправка основного агента
port-forward идёт через API-сервер (не тот сетевой путь) — он доказывает
только невиновность приложения, а не вину шлюза. Использовать формулировку
Опуса из таблицы доказательств (она корректна).
---
## ФИНАЛ (обновление Опуса с зафиксированными решениями, 2026-08-15)
**TL;DR.** Изоляция слоёв. «Виновата платформа» = два несмешиваемых вывода:
(1) приложение невиновно — port-forward через API-сервер чист (иной путь,
шлюз этим НЕ обвиняется); (2) виноват путь/шлюз — echo/сырой TCP таймаутит тем
же паттерном И tcpdump с клиента ловит потерю сегмента, когда nginx/под
ответили за миллисекунды.
**Зафиксированные решения:**
1. Второго ISP нет; ВМ рядом с Nubes → не «другой сегмент». Опора — контрольный
канал YMQ (49 таймаутов Nubes vs 1/959 YMQ уже исключают локальный интернет).
2. Серверный tcpdump практически недоступен (managed, exec только в alpine-под
без NET_RAW). kubectl debug с NET_RAW — одна проверочная попытка. Замена:
nginx access-log + cilium monitor + echo А/Б.
3. Echo — не сразу, только если фазы 0–2 неоднозначны; доп. риск — новый
NodePort для echo может быть закрыт на managed.
4. port-forward доказывает ТОЛЬКО невиновность приложения, НЕ вину шлюза.
**Шаги (дешевле → дороже):**
- Фаза 0 (бесплатно): nginx access-log за прошлый тест → логи kube-vip →
тикет Nubes (A и B раздельно).
- Фаза 1 (клиент, ~30 мин): PMTUD-зонд ping -M do (fallback mtr --tcp) →
tcpdump + long_compare_local.py с timestamp каждого таймаута → YMQ-канал
во всех прогонах.
- Фаза 2 (kubectl): port-forward первым → cilium monitor drop параллельно →
externalTrafficPolicy/nodePort. Одна попытка kubectl debug NET_RAW.
- Фаза 3 (echo, если 0–2 не хватило): Go echo без ingress на NodePort →
затем через ingress (изоляция nginx).
- Фаза 4 (тикет): tcpdump клиента + nginx-log + cilium monitor, корреляция
по seq → pcap+логи+ping.
---
## Сравнение ответов ЧЕТЫРЁХ агентов на один промпт (основной агент)
Условия НЕ равны: Соннет читал планы Flash и Pro; Опус читал все три плана и
весь репозиторий. Это «накопленный контекст», а не чистый тест моделей.
| | Flash | Pro | Sonnet | Opus |
|---|---|---|---|---|
| Вклад | первым заметил: 512б-таймауты не объясняются MTU | инструменты изоляции: PMTU-зонд, второй ISP, port-forward, таблица доказательств | концептуальная структура: A/B, 51с=экспонента RTO, 1/37с=таймер, port-forward первым | интеграция: синтез всего + факты репозитория + фиксация решений |
| Ошибки | cilium через operator; echo переоценён | «51с = прикладной таймер» (хуже экспоненты RTO) | нет | нет |
| Итог | черновик | сильный методолог | лучший аналитик | лучший интегратор |
Вывод основного агента: ключевые идеи распределились по всем четырём —
даже слабый Flash увидел главное (второе явление), Pro дал инструменты,
Sonnet построил структуру, Opus всё собрал. Конвергенция четырёх моделей на
одной цепочке (nginx-логи → PMTU → tcpdump → port-forward → cilium → echo) —
признак устойчивости плана. Плюс поправки основного агента приняты Опуса
в финал (port-forward). Итоговый план — продукт всех участников, исполнять.
+106
View File
@@ -0,0 +1,106 @@
# План сетевых экспериментов: shared-sqs на Nubes
> ⚠️ **ПОМЕТКА: анализ от СЛАБОГО агента — DeepSeek V4 Flash (не Соннет).**
> Пользователь по ошибке задал вопрос DeepSeek V4 Flash вместо Соннета.
> План сохранён как рабочий черновик; критику основного агента см. в HISTORY
> (2026-08-15) и в конце беседы. Технические неточности Flash см. ниже в разделах
> «Поправки основного агента».
## Контекст
Платформа Nubes (managed Kubernetes). Сервис shared-sqs (SQS-очередь, Go,
HTTP 4100). С ВМ внутри сети p50 = 6–8 мс, таймаутов нет. С интернета —
проблемы.
**Два независимых явления:**
1. Большие POST (>~1400 байт) виснут ~51 с — объясняется MTU/MSS 1448
(пакеты 1488 > underlay 1450, дроп).
2. Малые тела (512 байт): 49 из 888 запросов ReadTimeout 30 с, равномерно
~1 в 37 с — MTU-моделью НЕ объясняется. Второй независимый источник потерь.
Топология: клиент → 185.247.187.151:32391 (kube-vip NodePort) →
shturval-ingress-controller (nginx, ns ingress) → Cilium (pod mtu=1400) → под.
---
## 1. Echo-сервис или сырые TCP?
Сырых TCP недостаточно (проверяют только установление). Echo нужен, но:
1. Тот же путь: тот же кластер, тот же ingress (отдельный host/path), тот же Cilium.
2. Echo должен возвращать **тело запроса** (не «pong»), чтобы гонять тела
1400/1500/2000 байт в обе стороны.
3. Тот же network policy/namespace.
## 2. Как поймать момент таймаута
tcpdump с локали (нет доступа к нодам):
- захват: `tcpdump -i any -nn -s 0 'host 185.247.187.151 and port 32391' -w /tmp/cap.pcap`
- смотреть: SYN/ретрасмиты/RST/DUP ACK/сегменты >1450 с DF.
- `ss -ti` на установленном соединении → реальный MSS.
Признаки:
| Признак | Значение |
|---|---|
| SYN→SYN-ACK, потом тишина на данных | потери после установления (nginx/Cilium/под) |
| ретрасмиты SYN | потери на установлении (kube-vip/NodePort/шлюз) |
| ретрасмиты с растущим RTO (1,2,4,8,16,30+) | потеря пакета |
| сегменты >1450 с DF без ответа | MTU-гипотеза |
| RST | смотреть кто шлёт (платформа vs приложение) |
Вместо tcpdump на нодах — kubectl:
- **cilium monitor --type drop** — дропы с причинами (MTU/policy/conntrack).
- Логи nginx-ingress.
## 3. kube-vip / NodePort / nginx — проверка через kubectl
Декомпозиция по access-log nginx:
- запись есть → дошло до nginx → проблема после nginx.
- записи нет → потеря до nginx (kube-vip/NodePort/шлюз).
Метрики: `request_time` vs `upstream_response_time`:
- request_time≈30с, upstream мал → задержка между nginx и клиентом (обратный путь/шлюз).
- upstream≈30с → задержка nginx→под (Cilium/наш под).
Проверки:
- `kubectl -n kube-system get pods -o wide | grep kube-vip` + логи.
- `kubectl -n ingress get svc -o yaml` — nodePort 32391, externalTrafficPolicy
(Local → обслуживает только нода с VIP; Cluster → SNAT через любую ноду).
- access-log nginx с таймингами.
Дешёвый тест без приложения: GET на несуществующий путь через тот же NodePort
(ответит nginx default backend) — если GET без тела стабилен, а POST теряется —
потери в передаче данных.
## 4. План «дешевле → дороже»
1. Анализ access-log nginx за 30-мин тест (бесплатно).
2. Connect-тест 32391: 1000+ соединений, мерить connect (бесплатно).
3. GET без тела, 1000+ запросов (бесплатно).
4. tcpdump с локали + стрельба POST 512 и 1500 байт, поймать 2–3 таймаута в pcap.
5. Echo-сервис на ту же платформу (средняя стоимость).
6. cilium monitor параллельно со стрельбой.
7. Матрица размеров тел 512/1000/1400/1450/1500/2000 × 15+ мин через echo
+ контрольный канал к YMQ.
8. Тикет платформе с пачкой доказательств.
Критический вопрос: малые тела теряются ~1 раз в 37 с. Кандидаты: потери на
шлюзе, ARP/leader kube-vip, conntrack GC Cilium. Шаги 4–6 должны их разделить.
---
## Поправки основного агента (GitHub Copilot)
1. **Ошибка Flash**: `kubectl exec -n kube-system deploy/cilium-operator -- cilium monitor`
— неверно. cilium monitor запускается на **cilium-поде (DaemonSet)**:
`kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`.
И только на ноде, где живёт интересующий под.
2. **Приоритеты**: echo-сервис — НИЖЕ по ценности, чем кажется. Уже известно,
что с ВМ таймаутов нет → проблема между интернетом и внутренней сетью
(kube-vip/NodePort/шлюз), а echo сидит внутри. Сначала: nginx-логи (ш.1),
tcpdump (ш.4), cilium monitor (ш.6) — они дешёвые и локализуют лучше.
3. **WSL-нюанс**: tcpdump на локальной WSL-машине может требовать sudo и
конкретного интерфейса (`-i any` работает не всегда).
4. **Шаг 7 дорогой**: 7 размеров × 15 мин ≈ 2 часа. Достаточно 512/1400/1450/
1500/2000 × 10 мин, порог искать бинарно.
5. **Шаг 3 неточен**: GET на несуществующий путь того же host может уйти в наш
под (его router отдаст 400/404) — «приложение не участвует» не гарантировано.
+66
View File
@@ -0,0 +1,66 @@
# Тикет в Nubes: периодические сетевые таймауты внешнего пути (shared-sqs)
**Дата:** 2026-08-15
**Сервис:** shared-sqs (SQS-очередь, Go), инстанс iot-naeel,
ns f1ffb134-7d16-45bd-8bef-69f6ec8ab33c, deployment containerk8s.
**Внешний адрес:** 185.247.187.151:443 (домен sqs.containerk8s.dev.nubes.ru,
NodePort 32391), kube-vip, ingress shturval-ingress-controller.
## Явление A: большие POST (~51с зависания)
- MSS, анонсируемый шлюзом = 1448 (проверено на живом соединении), MTU пода
1400, underlay MTU 1450. Сегменты 1488 байт превышают underlay → дроп при DF.
- PMTUD не работает: ICMP Type 3 Code 4 до клиента не доходит.
- Воспроизводится POST с телом >~1.4KB: зависание ~51с (экспонента ретрасмитов
1-2-4-8-16с).
- Просьба: исправить MSS анонса на шлюзе (или включить MSS clamping /
PMTUD на внешнем шлюзе).
## Явление B: периодические таймауты на МАЛЫХ телах (512 байт) — ГЛАВНОЕ
**Симптом:** с внешней машины SQS-операции с телом 512 байт получают
ReadTimeout 30с с периодом **31.2–32.9с** (внутри прогона стабилен до 0.1с;
18+9+8 интервалов измерены в трёх прогонах). Параллельный канал Yandex YMQ с
той же машины — 1 ошибка на 959 запросов.
**Доказательства (все — в HISTORY/2026-08-14-session-log.md):**
1. **nginx access-log** за 30-мин тест: 862×200, **max request_time 68мс,
499=0**. nginx все запросы обрабатывает за миллисекунды.
2. **Серверный tcpdump** (ephemeral-контейнер в поде shared-sqs, eth0:4100):
в момент каждого таймаута — «дыра» 29.5с БЕЗ единого пакета: ни SYN, ни
данных, ни ретрансмитов. Зависший запрос **не доходит до пода** (иначе был
бы виден). 8 дыр на 8 сбоев, период 31.2с.
3. **cilium monitor --type drop** на ноде пода во время теста — **0 дропов**.
4. **kubectl port-forward** (в обход шлюза) — тот же клиент, та же нагрузка:
**24977 раундов, 0 сбоев**. Приложение невиновно.
5. С ВМ (внутренняя сеть): таймаутов нет вообще (p50 6–8мс).
6. **kube-vip в ошибках**: все 4 vip-пода каждую ~1с не могут создать leader
election record (namespaces "0a42bef3-7ce1-42b7-aa80-4274c4d9568f" not found),
provider — «failed to ensure load balancer: no address pools could be found»
(ретраи с backoff до 5 мин). vipHost закреплён за control-plane нодой.
**Вывод:** запросы периодически теряются на участке внешний клиент → ingress
(шлюз/VIP/kube-vip/NodePort платформы). Внутри кластера потерь нет. Период
31–33с указывает на платформенный таймер/цикл (сессионный таймер шлюза,
kube-vip announce, conntrack GC).
**Просьба:**
1. Проверить конфигурацию kube-vip (leader election в несуществующем
namespace, address pools) и внешний шлюз 185.247.187.151.
2. Объяснить/устранить периодический сбой каждые ~31с.
3. Исправить MSS анонс (1448 → ≤1410) для явления A.
**Дополнение (полный read-only обзор кластера 2026-08-15):**
- Внутри кластера цикла с периодом 31–33с НЕТ нигде: kube-vip пишет ошибки
каждые 1с, provider/services-controller — каждые 5 мин. Таймер 31с — на
внешнем шлюзе (вне кластера), что совпадает с потерей на участке
клиент→ingress.
- kube-vip поды 34 дня без рестарта: leader election бьётся в namespace
удалённого инстанса (0a42bef3-...) — вероятно, кэш удалённого LB-сервиса;
перезапуск DaemonSet убрал бы этот шум.
- ConfigMap kubevip в ns kube-vip отсутствует полностью — пулы LB никогда не
были настроены; VIP держится только аннотацией vipHost.
**Как воспроизвести:** приложить tests/ (long_compare_local.py, tcp_mtu_probe.py)
или: цикл send/receive/delete 512б с retries=0 и read_timeout=30 с внешней
машины → сбой каждые ~31с.
@@ -0,0 +1,138 @@
# Применимость обхода из drhider к shared-sqs (разбор)
**Дата:** 2026-10-08
**Основание:** `nubes-large-body-workaround-findings-2026-10-08.md` (находки из `~/nubes/drhider`).
**Статус:** анализ. **Ничего ещё не внедрено** — сначала замеры (см. раздел 5).
---
## 1. Почему нельзя скопировать схему 1:1
В drhider обе стороны протокола — свои (браузер + своё приложение на Flask),
поэтому туда встроили двухшаговый протокол: `PUT` файла на ВМ-буфер → `POST`
ссылки в кластер → исходящий `GET` (pull).
В shared-sqs **обе стороны чужие по протоколу**: клиенты — сторонние приложения
с AWS SDK (подпись SigV4), протокол SQS синхронный (запрос → ответ). Вставить
«положите тело на буфер, пришлите ссылку» нельзя без изменения клиентов и потери
совместимости с AWS SDK.
→ Прямой аналог буфера в SQS — **прозрачный обратный прокси на ВМ**, а не буфер
и не хранилище. Данные идут потоком, протокол не меняется.
## 2. Что именно болит у shared-sqs (наши факты)
| Явление | Причина по нашим документам | Источник |
|---|---|---|
| A. Большие тела (>~1.4 КБ) виснут ~51 с | MSS, анонсируемый шлюзом = 1448; MTU пода 1400, underlay 1450 → сегменты 1488 дропаются при DF; PMTUD мёртв (ICMP не доходит) | `nubes-ticket.md:11-13` |
| B. Малые тела (512 Б) — `ReadTimeout` 30 с с периодом 31–33 с | таймер на ВНЕШНЕМ шлюзе; доказано tcpdump: запросы не доходят до пода, nginx чист, Cilium 0 дропов | `nubes-ticket.md` (явление B), HANDOFF |
Что уже проверено ранее:
- **внутри кластера** — проблем нет; **с ВМ (5.172.178.213) p50 = 6–8 мс, таймаутов нет**;
- **MTU пода уже 1400** — то есть тот фикс, который помог drhider (1500→1400), у нас
**уже применён**, а явление A осталось: причина в MSS-анонсе шлюза, а это вне
нашего контроля (`nubes-ticket.md:16-17`);
- лимит SQS — тело ≤ 262144 Б (256 КБ), т.е. большие тела легальны и попадают
ровно в проблемную зону.
## 3. Варианты, применимые к SQS
### Вариант 1 (основной) — публичная точка входа на ВМ-прокси
Схема: `клиент (AWS SDK) → HTTPS на ВМ → nginx (ВМ) → под shared-sqs в кластере`.
Убираем из пути внешний шлюз платформы. Основания:
- путь «интернет → ВМ» в drhider держал тела 63 КБ и 5 МБ **успешно**
(`PROBLEM-AND-SOLUTION.md`), то есть у ВМ нет той патологии, что у шлюза кластера;
- путь «ВМ → кластер» у нас измерен: 6–8 мс, таймаутов нет.
**Технические условия (важные, иначе не заработает):**
1. **SigV4 подписывает заголовок `Host`.** nginx обязан передавать внешний хост:
`proxy_set_header Host $host;`. Сервис берёт хост именно из запроса —
`auth_middleware.go:315-317` (`canonicalHeaderValue`: `name == "host"` → `r.Host`).
Подменять Host на внутренний (например, на служебное имя сервиса) **нельзя** —
подпись перестанет сходиться, клиент получит `SignatureDoesNotMatch`.
2. **URL очередей сейчас жёстко внутренние и без TLS.** `tenantQueueURL`
(`tenant_helpers.go:29-37`) формирует `http://{region}.{host}:{port}/{tenantID}/{queue}`.
Хост и порт читаются **только из `goaws.yaml`** (`conf/config.go:87-90`; в
`applyEnvironmentDefaults` env-переменных для Host нет). Следствия:
- клиенты, которые сами задают `endpoint_url` (все наши тесты), берут из
`QueueUrl` только путь — они не пострадают;
- клиенты **без** `endpoint_url` пойдут на хост из `QueueUrl` → обязаны получить
внешний адрес. Значит: либо (а) подставляем внешний хост/схему в конфиг, либо
(б) nginx переписывает `QueueUrl` в ответах (хрупко), либо (в) требуем от
клиентов явный `endpoint_url`.
3. **Транспорт.** Схема `http://` в URL — при переносе на ВМ либо оставляем
`http` (нежелательно для интернета), либо переводим на `https` и правим
формирование URL. Для TLS нужен сертификат на ВМ-домен.
4. **Доступ ВМ → кластер.** ВМ вне кластера; понадобится NodePort/VIP (или
опубликованный внутренний адрес). Путь ВМ→кластер нужно замерить (см. раздел 5).
5. **Проксирование тел** — без буферизации на диск: `proxy_request_buffering off`,
`client_max_body_size` ≥ 256 КБ (+ запас).
### Вариант 2 — настройки ingress через `ShturvalServiceConfig`
В drhider этим вылечили RST (`keep-alive: 10` → `75`), подняли
`proxy-body-size: 1024m`, выключили HTTP/2, подняли `client-body-timeout: 60`.
Правки делаются **только через `ShturvalServiceConfig`** — kubectl откатывает ArgoCD
(`PROBLEM-AND-SOLUTION.md`).
Оценка для нас: **скорее всего не поможет**. Наш таймер (явление B) доказанно
находится **вне кластера** (tcpdump: запросы не доходят до пода; nginx чист), а
MSS-анонс делает **внешний шлюз**. Но правка дешёвая и безопасная → имеет смысл
проверить замером «до/после», прежде чем строить прокси.
### Вариант 3 — MTU-фикс
**Уже сделан** (MTU пода 1400). Явление A не убрал — так как причина в шлюзе.
Для SQS этот путь закрыт.
### Вариант 4 — клиентские ретраи (+ идемпотентность)
Маскирует явление B, **не решает** явление A (51 с ожидания при большом теле).
Для standard-очередей ретрай без идемпотентности даёт дубли (FIFO-дедуп по
`MessageDeduplicationId` у нас есть, для standard — нет). Годится как временная
мера, не как решение.
## 4. Сводка: что какой вариант лечит
| Вариант | Явление A (большие тела) | Явление B (таймер) | Стоимость/риск |
|---|---|---|---|
| 1. Прокси на ВМ | да (по аналогии с drhider) | да (шлюз вне пути) | TLS, Host, внешние URL, доступ ВМ→кластер |
| 2. Настройки ingress | вероятно нет | вероятно нет | низкая; нужен доступ к платформенному конфигу |
| 3. MTU | нет (уже 1400) | нет | — |
| 4. Ретраи клиентов | нет | частично | дубли для standard |
## 5. Что обязательно замерить ДО решения
Правило проекта: рекомендация без замера — догадка. Замеры на том же хосте и тем
же инструментом:
1. **С ВМ → внешний endpoint кластера**: тела 1 КБ / 10 КБ / 100 КБ / 256 КБ —
есть ли ~51 с? (Малые тела с ВМ проверены: 6–8 мс, таймаутов нет; большие, судя
по всему, отдельно не мерили.)
2. **ВМ → кластер (NodePort/VIP)**: те же размеры — сколько занимает.
3. **Явление B с ВМ**: сохраняется ли период 31–33 с.
4. **После варианта 2** (если включаем): те же замеры, сравнение «до/после».
Инструменты уже есть в репозитории: `tests/tcp_mtu_probe.py`,
`tests/long_compare_local.py`, `tests/compare_ymq_vs_shared.py`,
`tests/platform_probe.py`, `tests/reboot_probe.py`.
## 6. Вывод
- Тикет в поддержку — **не решение** (болезнь платформы), оставляем только как
историю; в drhider её тоже не «починили», а обошли.
- Архитектурный аналог решения drhider для SQS — **прозрачный прокси на ВМ**
(вариант 1). Ключевые камни: сохранение `Host` для SigV4, внешние `QueueUrl`
(сейчас `http://{region}.{host}:{port}` из `goaws.yaml`), TLS, доступ ВМ→кластер.
- Перед стройкой — замеры из раздела 5; возможно, вариант 2 закроет часть проблемы
за 10 минут вместо новой инфраструктуры.
## 7. Открытые вопросы (нужны твои решения)
1. Внешний домен и сертификат для ВМ-прокси — какой брать?
2. Клиенты без явного `endpoint_url` — есть ли такие (иначе вопрос №3 в разделе 3
отпадает)?
3. Делать ли сначала замеры (раздел 5) — я готов.
+110
View File
@@ -0,0 +1,110 @@
# Отчёт Opus — КРАКО и план доработок shared-sqs (2026-10-05)
> Полный текст ответа Opus (без сокращений) на промпт
> `opus-review-prompt-2026-10-05.md`. Сохранён для истории и критического
> анализа.
---
# 1. КРАКО — критический разбор архитектуры
## Что сделано хорошо
- **Изоляция тенантов проста и надёжна в ядре.** Ключ очереди `{AccessKey}:{queueName}` (`tenant_helpers.go`) делает кросс-тенантный доступ через SQS физически невозможным: тенант берётся из контекста, ключ строится из его AccessKey. Простое и верное решение.
- **SigV4 реально проверяется** (вопреки формулировке «подпись не проверяется» — это про JWT, не про SQS). В `auth_middleware.go` полноценный canonical request, `subtle.ConstantTimeCompare`, проверка clock skew, поддержка presigned. Это сильная сторона.
- **Аккуратно закрыты классические гонки.** Атомарный receive под одним `Lock` (устранён TOCTOU «потери» сообщений, `receive_message.go`), double-check под Lock в `send_message.go`, пер-очередной Lock в `PeriodicTasks` вместо глобального на весь обход (`gosqs.go`).
- **OOM-защита по нескольким фронтам:** лимит тела 3 MB, `MaxHeaderBytes`, пер-очередной лимит сообщений, глобальный лимит тенантов.
- **Продуманный graceful shutdown и HTTP-таймауты** с обоснованием под long-poll (`goaws.go`).
- **IDOR-защита в UI** и урезанные права UI-тенанта (не может создавать/удалять тенантов, видит только себя) — `admin.go`.
- **Персистентность с write-sequence guard** (last-write-wins по ключу) и синхронным удалением, чтобы удалённые очереди/сообщения не «воскресали» из Redis.
## Главные слабости и риски (по коду)
### 🔴 Критичные (безопасность)
1. **SecretKey детерминированно выводится из email + захардкоженная соль в исходнике.**
`tenant_store.go`: `secretKey = SHA256(lower(email) + ":" + "shared-sqs:secret-key:v1")`, `accessKey = SHA256(email)[:12]`. Email — не секрет. Кто знает email уже залогинившегося пользователя (тенант существует) и соль (она в репозитории форка), тот вычисляет AccessKey+SecretKey и получает **полный доступ к чужим очередям напрямую через SQS-эндпоинт** (JWT там не нужен — только SigV4). Это нарушает базовый принцип: секрет обязан быть случайным, а не функцией публичного идентификатора.
2. **Идентичность тенанта берётся из НЕподписанного JWT (claim `email`), а из email выводятся креды.**
`jwt.go` подпись не проверяет; доверие целиком на `PingNubesAPI`, который принимает токен, если **хоть один** стенд вернул не 401/403 (`jwt.go`). Если шлюз при GET `/svc` не связывает жёстко токен ↔ email, появляется вектор подмены email → провиженинг/доступ под чужой личностью. Плюс кэш валидации даёт отозванному токену жить до 5–7 мин (`cache.go`).
### 🟠 Высокие (production-readiness)
3. **Один глобальный мьютекс `SyncQueues` на весь сервис.** Любой send/receive/tick берёт единственный `Lock` (`globals.go`). `receiveMessagesUnderLock` держит глобальный Lock на время O(N)-скана сообщений, а long-poll переобходит его каждые 100 мс. Это главный архитектурный боттлнек — ровно он давал всплески latency, упомянутые в комментариях. Не масштабируется по числу очередей/тенантов.
4. **Невозможна горизонтальная масштабируемость → один под = SPOF.** Память — источник правды, Redis — write-through-кэш одной реплики. Две реплики разъедутся (каждая со своей `map`, last-write-wins затирает). Значит сервис принципиально single-instance: нет HA, рестарт = простой + окно потери данных.
5. **Асинхронная запись в Redis = окно потери подтверждённых сообщений.** `SendMessage` отвечает клиенту 200, а `HSet` уходит в горутину позже (`redis.go`). Краш пода между append-в-память и async-записью → подтверждённое сообщение потеряно. Для очереди это нарушение durability (AWS SQS персистит до ack).
6. **`MessageRetentionPeriod` не реализован** (`// TODO - not used`, `models.go`). Сообщения не истекают по времени, растут до пер-очередного лимита, после чего новые `SendMessage` отклоняются `OverLimit`. Нет TTL-эвикции — скрытая деградация.
7. **Сообщения хранятся плоским слайсом `[]SqsMessage`.** Receive/Delete/visibility-tick — O(N) на операцию; при лимите 120k сообщений и глобальном локе это дорого и усугубляет п.3. Нет индексов по ReceiptHandle/состоянию.
### 🟡 Средние
8. **Пересчёт legacy-тенанта в `CreateFromJWT` сиротит очереди** (`tenant_store.go`): очереди остаются под старым AccessKey, новый тенант видит 0 очередей, данные зависают в памяти+Redis навсегда (утечка + «пропажа» данных для пользователя).
9. **Повсеместные O(total) скан-по-префиксу** под глобальным локом: `countTenantQueues`, `listTenantQueues`, `createTenantQueue` (лимит), `deleteTenant`, gauge-updater. Квадратичная тенденция при росте тенант×очередь.
10. **Admin-токен сравнивается небезопасно по времени** — `authHeader != expected` (`admin.go`), в отличие от UI-demo-токена (там `ConstantTimeCompare`). Timing-side-channel.
11. **Хардкод demo-кредов и публичного UI-bypass-токена в исходнике** (`seed.go`, `admin.go`). Сами же комментарии требуют удалить это перед продом. Включается env-флагами, но риск реальный.
12. **Два расходящихся пути записи/создания.** `admin.createTenantQueue`/`sendMessageToQueue` (`admin.go`) создают очередь и кладут сообщение в обход gosqs-логики: без FIFO-детекта, без проверки размера, без MD5/дедупликации, с другими дефолтами. Поведение UI ≠ поведение SQS API.
13. **Биллинг: горутина на каждую операцию + пул 5 коннектов** (`billing.go`). Под нагрузкой — навал горутин, ждущих коннект. Плюс `msg_count` всегда 1, даже для `SendMessageBatch` → неточный учёт.
14. **`/metrics` публичен** и отдаёт per-tenant данные (ID тенантов, число очередей/сообщений) — утечка бизнес-метрик наружу.
15. **Хрупкое определение протокола** — строго по `Content-Type == application/x-amz-json-1.0` (`router.go`); иные версии JSON-протокола не распознаются.
16. **Легаси-остатки GoAWS/SNS**: Topics/Subscriptions в `goaws.yaml`, SNS-поля в моделях, `config.go` кладёт yaml-очереди под голым именем `queue.Name` (без tenant-префикса → такие очереди недостижимы tenant-хендлерами; сейчас спасает `Queues: []`).
---
# 2. Приоритизированный план доработок
## P0 — блокеры продакшна (безопасность и durability)
- **P0.1. Убрать вывод SecretKey из email.** Файлы: `tenant_store.go` (`secretKeyFromEmail`/`accessKeyFromEmail`). Генерировать AccessKey/SecretKey через `crypto/rand` при первом провиженинге и хранить в Redis; идентичность по email держать отдельно от материала ключей. Детерминированным оставить максимум tenantID (не секрет).
- **P0.2. Усилить доверие к JWT.** Файлы: `jwt.go`. Либо проверять подпись по JWKS Nubes, либо брать email/ sub **из ответа валидирующего API** (а не из незаверенного payload), чтобы claim нельзя было подменить. Сузить «принять токен, если любой стенд не вернул 401/403» до явного 2xx от доверенного эндпоинта.
- **P0.3. Durability для `SendMessage`.** Файлы: `redis.go`, `send_message.go`. Сделать персист сообщения синхронным до ответа 200 (как уже сделано для delete), либо явно задокументировать «best-effort» семантику и снять претензию на SQS-совместимость по надёжности.
## P1 — архитектурная зрелость
- **P1.1. Разнести блокировку.** Файлы: `globals.go`, все gosqs-хендлеры. Перейти на per-queue mutex (map под коротким RLock + своя блокировка в `Queue`). Снимает главный боттлнек и O(N)-скан под глобальным локом.
- **P1.2. Индексировать сообщения.** Файлы: `models.go`. Хранить `map[receiptHandle]` / структуры готовности, чтобы receive/delete/visibility были не O(N).
- **P1.3. Реализовать `MessageRetentionPeriod`.** Файлы: `gosqs.go` (`processQueueTick`). Эвиктить просроченные сообщения по retention в периодической задаче + чистить Redis.
- **P1.4. Индекс тенанта → его очереди.** Файлы: `tenant_helpers.go`, `admin.go`. Держать `map[accessKey]set(queueName)` — убрать O(total) скан-по-префиксу в create/list/delete/gauge.
- **P1.5. Убрать demo-bypass из прод-сборки.** Файлы: `seed.go`, `admin.go`. Вынести за build-tag или удалить ветку `authenticateUIDemoToken` и фиксированные креды.
## P2 — надёжность, корректность, чистка
- **P2.1. BYOC Admin API** (уже TODO в `byoc-credentials.md`). Добавить `access_key/secret_key` в `createTenantRequest` + ветку на `CreateFixed` в `admin.go`. `generateTenantID` экспортировать из tenant-пакета.
- **P2.2. Чинить сиротство очередей при пересчёте legacy-тенанта** — `tenant_store.go`: либо мигрировать ключи очередей на новый AccessKey, либо запретить смену ключей.
- **P2.3. Constant-time сравнение admin-токена** — `admin.go`.
- **P2.4. Единый путь создания/отправки** — admin-хендлеры должны переиспользовать gosqs-логику (FIFO/размер/MD5/дедуп), а не дублировать с иными дефолтами.
- **P2.5. Биллинг:** батч-вставки через воркер/буфер вместо горутины-на-операцию; корректный `msg_count` для batch (`billing.go`, `router.go` actionHandler).
- **P2.6. Защитить/урезать `/metrics`** (auth или internal-only), чтобы не светить per-tenant данные.
- **P2.7. Чистка легаси:** убрать SNS Topics/Subscriptions из `goaws.yaml`, мёртвый путь загрузки yaml-очередей без tenant-префикса в `config.go`, SNS-поля в моделях, `deployments/**`.
- **P2.8. Redis reconnect/health** — сейчас после обрыва `Client` остаётся не-nil, записи молча падают в лог (`redis.go`).
---
# 3. Противоречия док ↔ код
1. **Идентичность JWT-тенанта.** `tenant-isolation.md` утверждает: тенант создаётся из claim **`sub`**, TenantID = `SHA256(sub) → "sless-{16hex}"`, индекс **`bySub`/`GetBySub`**. Код: идентичность по **`email`**, TenantID = `t-{16hex}` из email, индекс **`byEmail`/`GetByEmail`** (`tenant_store.go`). Доковое описание полностью устарело.
2. **Генерация SecretKey.** Док: «SecretKey = 32 random bytes». Код: для email-тенантов SecretKey **детерминированный** `SHA256(email+соль)` — не случайный.
3. **Версии (дрейф).** README deploy-примеры: `v0.1.25`; раздел «История» в README — до `v0.1.14`; `HANDOFF` — прод `v0.1.35`. Три разных значения.
4. **`NUBES_ENDPOINT`.** README: дефолт `https://deck-api.ngcloud.ru/api/v1`. Код: стенды `https://lk-api-gateway*.ngcloud.ru/api/v1/svc` (`jwt.go`). Разные хосты/пути.
5. **BYOC статус.** `byoc-credentials.md` честно помечает Admin API/UI как TODO — **совпадает** с кодом (в `createTenantRequest` только `Name`, `MaxQueues`). Это не противоречие, а подтверждение открытого вопроса №1.
6. **Метрики.** `billing-and-metrics.md`: пример `http://localhost:9090/metrics`, тогда как сервис слушает `4100` и отдаёт `/metrics` на основном порту (`router.go`). Также ссылка на `deployments/k8s/vmservicescrape.yaml`, помеченный в README как легаси «не использовать».
7. **Дерево в README** перечисляет `auth/ — AWS Signature V4 верификация`, но не упоминает JWT-путь, `billing/`, `metrics/`. Неполно относительно фактической структуры.
8. **`/metrics → PUBLIC`** в tenant-isolation.md соответствует коду, но сам факт публичности per-tenant метрик в доке не отмечен как риск.
---
> Заключительная реплика Opus: «Если нужно — могу превратить это в файл-отчёт в
> doc/thinking/ или углубиться в любой из пунктов (например, расписать
> PoC-эксплуатацию P0.1/P0.2 или конкретный дизайн per-queue-локов). Код я не
> трогал, как и требовалось.»
+231
View File
@@ -0,0 +1,231 @@
# Код-ревью 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.
@@ -0,0 +1,90 @@
# Критический разбор код-ревью Opus (2026-10-10)
> Дополняет `opus-report-2026-10-10.md` (дословный отчёт Opus).
> Здесь — проверка ключевых пунктов по коду + что подтверждено/отклонено + решения, которые нужны.
## 1. Вердикт
Отчёт сильный по конкуренции и durability. Главный дефект отчёта — смешивает три категории
без разделения: **(а)** реальные баги, **(б)** осознанный дизайн (JWT trusted-perimeter),
**(в)** отклонения от семантики AWS (в основном косметика). Часть 🔴 надо понизить.
## 2. Подтверждено по коду (критично, чинится без владельца)
### 2.1. 🔴1 — фатал `concurrent map iteration and map write` в CreateQueue
- `create_queue.go:73-84`: ветка «очередь уже существует» вызывает `SaveQueue(key, queue)`
под `SyncQueues.Lock`, **без** `queue.mu`.
- `SaveQueue` → `json.Marshal` (`redis.go:208,220`) итерирует `Duplicates`, `FIFOSequenceNumbers`,
`FIFOMessages`, `Tags`.
- Мутаторы этих map (send/tag/CMV) держат только `queue.mu` (`GetLocked` берёт глобальный RLock
на миг и отпускает — `globals.go:77-88`), поэтому глобальный Lock их не блокирует.
- Условие: идемпотентный `CreateQueue` существующей FIFO-очереди (обычный вызов SDK при старте)
+ параллельный `SendMessage`. Вероятность растёт с размером map. Необратимый краш процесса
(`recover` не ловит fatal).
- Шапка `redis.go:211-213` («вызывать под `SyncQueues.Lock`») описывает неверный контракт.
### 2.2. 🔴5 — неполный откат dedup при сбое Redis
- `send_message.go:151-160`: при ошибке `SaveMessageSync` откатывается только `RemoveIndex`,
а `InitDuplicatation` выполняется всё равно → ретрай с тем же dedupId даёт 200, но сообщение
не сохранено. Аналог в `send_message_batch.go`.
- `FIFOSequenceNumbers` (инкремент в `models.go:117`) тоже не откатывается.
### 2.3. 🔴8 — пустой `ReceiptHandle` в DeleteMessageBatch (protocol-gated)
- Срабатывает **только** при `Content-Type: application/x-amz-json-1.0`: тело декодируется
напрямую (`utils.go:35-46`), валидация `receiptHandle == ""` (`requests.go:568`, Query-форма)
на этом пути не вызывается.
- `delete_message_batch.go:51-57` проверяет только уникальность `Id`, handle не валидируется.
- `deleteMessageMap[""]` совпадает с невыданными сообщениями (у них `ReceiptHandle == ""`),
цикл `:79-98` удаляет все такие сообщения очереди.
- Одиночный `DeleteMessage` безопасен: пустой handle не кладётся в `receiptIdx`
(`models.go:233,251,373`).
### 2.4. Дополнительно подтверждено (не тестировалось, статический разбор)
- Инверсия порядка локов (`queue.mu` ↔ `SyncQueues.RLock`) и Redis-I/O под глобальным локом
(`delete_queue.go:45-56`).
- Fail-fast отсутствует: старт с недоступным Redis продолжает работу и следом `SaveQueue`
перезаписывает Redis пустотой (`goaws.go:103-106,125-134`).
## 3. Решение владельца: JWT
`jwt.go:7-11` прямо документирует trusted-perimeter: «Подпись JWT НЕ проверяется — аналогично
sless», валидация через `PingNubesAPI` (GET на 3 шлюза, приём по 2xx).
**Ключевой факт, который не проверен:** проверяет ли шлюз подпись и отдаёт 200 только для
валидных токенов? От этого зависит классификация:
- шлюз проверяет подпись → «доверяем шлюзу», не уязвимость;
- нет → 🔴, чинить первым делом.
Независимо от ответа чинятся три вещи:
1. `jwt.go:90-92` — три шлюза (prod/test/dev): токен тестового стенда принимается как prod.
Оставить prod-шлюз.
2. `cache.go:68-75` — grace при сетевой ошибке продлевается повторно → отозванный токен живёт
бесконечно. Нужен потолок суммарного grace (например, 2 мин от последней успешной проверки).
3. `tenant_store.go:213-291` — идентичность по email; привязать к стабильному `sub`/`iss`,
иначе смена email на IdP отдаёт чужой тенант.
## 4. Решение владельца: лимит по байтам (OOM, 🔴14)
Сейчас лимит только по числу сообщений (`validation.go:43-44`), по байтам/памяти — нет.
Нужны входные данные владельца: RAM ВМ и целевое число тенантов.
Структура (рекомендация):
- **Cap на тенанта** (суммарные байты тел+атрибутов по всем очередям) — изоляция.
- **Cap на очередь** — производный (доля тенантского лимита).
- **Глобальный предохранитель** — отказ send при RSS > порога (напр. 70% RAM), единственная
защита от суммы тенантов.
Реализация — счётчик байт в `Queue` (append/delete/purge/compact/restore) + тенантский
аккумулятор → требует `QueueStore`-интерфейса (план A).
Минимальный шаг без решения по квотам: снизить `MaxMessagesPerQueue` + глобальный
предохранитель по RSS.
## 5. Порядок исправления (после решений владельца)
1. 🔴1 — убрать `SaveQueue` без `queue.mu` в `create_queue.go:84` (и `admin.go`/`seed.go`).
2. 🔴8 — валидация `ReceiptHandle != ""` в `delete_message_batch.go`.
3. 🔴5 — полный откат dedup/FIFO-seq при сбое `SaveMessage(s)Sync`.
4. Lock order + I/O под глобальным локом.
5. Fail-fast на старте без Redis.
6. JWT (после решения п.3) + лимит по байтам (после п.4).
@@ -0,0 +1,116 @@
# Промпт для Opus — анализ архитектуры shared-sqs (2026-10-05)
> Назначение: дать модели Opus контекст и точный список файлов для чтения,
> чтобы она проанализировала архитектуру shared-sqs и выдала идеи
> архитектуры (в т.ч. «КРАКО» — критический разбор архитектуры) без чтения
> лишних файлов.
---
## ЗАДАЧА
Проанализируй архитектуру сервиса `shared-sqs` (SQS-совместимая очередь, Go,
мультитенантная, форк GoAWS) и выдай:
1. **Критический разбор архитектуры (КРАКО)** — слабые и сильные места,
риски, узкие места, что мешает назвать сервис готовым к продакшну.
2. **Идеи по архитектуре** — как довести сервис до production-ready:
приоритизированный список доработок (P0/P1/P2) с обоснованием.
Не меняй код. Только читай и анализируй.
## ЧТО ЧИТАТЬ (allowlist — ТОЛЬКО эти файлы, ничего больше)
**Точка входа и роутинг:**
- `app/cmd/goaws.go`
- `app/cmd/seed.go`
- `app/router/router.go`
**Конфигурация:**
- `app/conf/config.go`
- `app/conf/goaws.yaml`
**SQS-реализация (ядро):**
- `app/gosqs/gosqs.go`
- `app/gosqs/receive_message.go`
- `app/gosqs/send_message.go`
- `app/gosqs/create_queue.go`
- `app/gosqs/validation.go`
- `app/gosqs/tenant_helpers.go`
**Аутентификация / изоляция тенантов:**
- `app/auth/auth_middleware.go`
- `app/auth/jwt.go`
- `app/auth/cache.go`
- `app/tenant/tenant_store.go`
**Модели данных:**
- `app/models/models.go`
- `app/models/constants.go`
- `app/models/globals.go`
**Персистентность:**
- `app/persistence/redis.go`
**Биллинг и метрики:**
- `app/billing/billing.go`
- `app/metrics/metrics.go`
**Admin API:**
- `app/admin/admin.go`
**Интерфейсы:**
- `app/interfaces/interfaces.go`
**Документация (для контекста состояния):**
- `README.md`
- `doc/architecture/tenant-isolation.md`
- `doc/billing-and-metrics.md`
- `doc/byoc-credentials.md`
- `HANDOFF-NEW-CHAT.md`
## ⛔ ГДЕ НЕ ЛАЗИТЬ
- **Не читать** `doc/legacy/**` (исторический план, старые решения — устарели).
- **Не читать** `deployments/**` (легаси k8s/helm).
- **Не читать** `doc/thinking/nubes-*` (сетевые тикеты платформы — отдельная
задача, не относится к разбору архитектуры кода).
- **Не читать** тесты `*_test.go` — они нужны только как сигнал «что покрыто»;
для анализа архитектуры достаточно факта наличия из списка `app/gosqs/*`.
- Не искать файлы за пределами перечисленного списка.
## КЛЮЧЕВОЙ КОНТЕКСТ (факты, которые уже установлены — не перепроверять с нуля)
- **Стек**: Go 1.23, gorilla/mux v1.8.0, redis/go-redis/v9, lib/pq,
prometheus/client_golang. Сервис — один Go-бинарник.
- **Состояние**: прод v0.1.35, все тесты зелёные, нагрузка 168 591 операция
без сервисных ошибок. НО есть известные открытые вопросы (ниже).
- **Персистентность**: Redis write-through cache. Память — источник правды
для чтения; Redis — для восстановления после рестарта. Записи в Redis
асинхронные (кроме DeleteMessagePersist — синхронно).
- **Изоляция тенантов**: очередь хранится по ключу `{AccessKey}:{queueName}`;
`TenantStore` с тремя индексами (`byID`, `byAccessKey`, `byEmail`).
- **JWT-валидация**: подпись НЕ проверяется (trusted perimeter) — только
структура + `exp` + `PingNubesAPI()` (проверка token через API Gateway).
- **Биллинг**: асинхронная запись в PostgreSQL (таблица `sqs_usage_records`),
опционально (включается при `BILLING_PG_HOST`).
- **Внешняя проблема (НЕ код, на анализ кода не влияет)**: периодические
сетевые таймауты ~31–33с на внешнем шлюзе Nubes + MSS-баг для больших тел.
Разобрано в тикете — это задача платформы, не кода.
## ИЗВЕСТНЫЕ ОТКРЫТЫЕ ВОПРОСЫ (кандидаты в «доделать» — проверь их актуальность по коду)
1. **BYOC-credentials** — backend `CreateFixed` есть, но Admin API/UI
(`POST /admin/tenants` с произвольными ключами) помечен TODO.
2. **Подпись JWT не проверяется** — насколько это приемлемо для multi-tenant
прод-сервиса, или нужно усилить.
3. **Дрейф доков** — версии в README/Makefile/HANDOFF не всегда совпадают.
4. **Легаси** — невычищенные следы старого k8s/helm-деплоя и SNS.
## ФОРМАТ ОТВЕТА
1. **КРАКО** — короткий критический разбор: что хорошо, что плохо, главные
риски (по коду, а не по платформе).
2. **Приоритизированный план доработок** (P0/P1/P2), с указанием конкретных
файлов и сути правки.
3. Список **противоречий/несоответствий** между доками и кодом, если найдёшь.
@@ -0,0 +1,88 @@
# P1.1 (per-queue mutex) — анализ и состояние (2026-10-06)
## Зачем нужен
Сейчас все операции с очередями держат ОДИН глобальный `models.SyncQueues.RWMutex`.
Проблемы:
1. Любая операция блокирует весь сервис. При этом часть операций делает
**синхронный Redis-I/O прямо под глобальным локом** (таймаут до 3–5с):
`SaveMessageSync` (send), `DeleteMessagePersist` (delete), `DeleteMessagesPersist`
(retention-tick), `PurgeMessagesPersist` (purge), `DeleteQueue`. При деградации
Redis весь сервис встаёт на секунды.
2. `receiveMessagesUnderLock` держит глобальный лок на время O(N)-скана сообщений.
## Введённый groundwork (безопасно, поведение не меняется)
- `models.Queue` получил поле `mu sync.RWMutex` (не сериализуется) + методы
`LockMu/UnlockMu/RLockMu/RUnlockMu`.
- Исправлено копирование `Queue` by value (`numberOfHiddenMessagesInQueue`) —
иначе `go vet` ругался на copylocks.
- Глобальный лок пока используется везде (как раньше) — **поведение корректно**.
## Блокеры полного перехода (почему это не механическая замена)
1. **DLQ — общий указатель.** `Queue.DeadLetterQueue *Queue`. Перенос сообщения в
DLQ мутирует `DeadLetterQueue.Messages` из-под лока ИСХОДНОЙ очереди
(`gosqs.go:130`, `change_message_visibility.go:76`). При per-queue локах нужен
порядок взятия (сначала исходная, потом DLQ) + гарантия отсутствия цикла.
2. **`marshalQueueMeta` мутирует ЧУЖУЮ очередь.** `persistence/redis.go:146-160`
временно зануляет `queue.Messages` И `queue.DeadLetterQueue.Messages` ради
сериализации. Под глобальным локом это безопасно; под per-queue — гонка с
операциями над DLQ. Нужен НЕмутирующий снапшот (DTO без `Messages`),
но нельзя копировать `Queue` целиком (копируется мьютекс) → нужен отдельный
тип-снапшот.
3. **DLQ-указатель после загрузки из Redis не релинкуется** на объект из map
(загружается как вложенная копия) — предсуществующая проблема, которую
P1.1 обнажит (переносы в DLQ пойдут в detached-копию).
4. **Объём — атомарный.** Переводить на per-queue локи надо СРАЗУ все операции
с содержимым очереди (send/receive/delete/visibility/purge/attributes/tags/
admin/gauge-tick) — частичный перевод создаёт рассинхрон (операции перестанут
взаимоисключаться). Это ~20+ мест в 8 файлах.
5. **Нет покрытия** для FIFO/batch/DLQ/visibility — самых сложных путей.
## Вывод
P1.1 — это дизайнерская задача (порядок локов + неммутирующий снапшот + релинк
DLQ), а не построчная замена. Делать её «вслепую» без покрытия FIFO/batch/DLQ
рискованно: ошибка = дедлоки/потеря данных на проде. Рекомендуется отдельный
проход: сначала тесты на FIFO/DLQ/batch, затем атомарный перевод.
## СТАТУС: РЕАЛИЗОВАНО (2026-10-06)
Перевод выполнен атомарно. Что сделано:
- `marshalQueueMeta` больше НЕ мутирует очередь (DTO-снапшот queueMetaDTO).
- Все операции с содержимым очереди используют per-queue лок
(`QueueStore.GetLocked/GetRLocked` + `queue.LockMu/RUnlockMu`) в gosqs, admin,
metrics/gauge_updater, PeriodicTasks.
- Глобальный `SyncQueues`-лок остался ТОЛЬКО для доступа к map и неизменяемых
полей (create/delete/list/find/snapshot).
- DLQ-переносы берут лок DLQ отдельно (порядок main→DLQ).
- Устранён дедлок в create_queue (setQueueAttributesV1 вызывается ВНЕ глобального лока).
- Добавлены конкурентные тесты (два тенанта / одна очередь), прогон `go test -race` — зелёный.
## P1.2 (индексация сообщений) — та же природа
Индексация `Messages` (receipt→index) меняет структуру хранения, затронутую теми
же путями (receive/delete/visibility/tick/сериализация), и требует покрытия тех
же путей. Рекомендуется вместе с P1.1.
## P1.2 — СТАТУС: ОТЛОЖЕНО (обоснование, 2026-10-06)
После P1.1 главный боттлнек (глобальный лок на синхронный Redis-I/O и O(N)-скан
по ВСЕМ очередям) устранён: теперь O(N)-сканы ограничены ОДНОЙ очередью и не
блокируют остальные. Индекс `receipt→index` поверх срезa `[]SqsMessage` НЕ даёт
O(1): удаление из середины среза сдвигает все последующие индексы (O(N) на
переиндексацию). Реальный выигрыш требует смены контейнера (map+order-list), что
затрагивает сериализацию и все пути — крупная рискованная правка с маргинальной
выгодой на текущих объёмах. Отложено до появления реальной нагрузки/лимитов.
## P1.2 — СДЕЛАНО (2026-10-07)
Возврат к пункту по прямому указанию. Решение — tombstone + receipt/uuid-индексы
+ амортизированная компакция (удаление НЕ сдвигает срез → индексы стабильны,
O(1)-удаление без смены контейнера и без изменения Redis-схемы).
Дизайн и результаты: `p1.2-message-index-design-2026-10-07.md`.
По ходу найден и исправлен дефект утечки per-queue блокировки в receive
(возврат до `defer UnlockMu`) — см. там же, раздел 6.
@@ -0,0 +1,154 @@
# 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-схема не изменилась; рестарт не «воскрешает» удалённые сообщения.
## 6. РЕАЛИЗОВАНО (2026-10-07) — и найденный по ходу дефект
Коммиты: `22e9bc8` (код), `e05af04` (тесты примитивов), `5b3fb51` (фикс ниже).
### 6.1. Найденный дефект (МОЯ РЕГРЕССИЯ) — утечка per-queue блокировки
При переводе `receiveMessagesUnderLock` на `LiveCount()` я сохранил исходный
порядок строк:
```go
queue := models.SyncQueues.GetLocked(key) // взяли Lock
if queue == nil || queue.LiveCount() == 0 {
return nil, false // ← возврат БЕЗ Unlock
}
defer queue.UnlockMu()
```
Возврат на «пустой» очереди оставлял `queue.mu` захваченным НАВСЕГДА: любой
последующий `Lock`/`RLock` этой очереди ждал вечно (в Go мьютекс не
освобождается при выходе из функции).
Почему стало заметно именно сейчас: раньше условие было `len(queue.Messages) == 0`
и срабатывало только на полностью пустой очереди; после перехода на tombstone
добавился новый случай — «все сообщения удалены, но срез ещё не компактирован»
(`LiveCount()==0` при `len(Messages)>0`), который в стресс-сценарии возникает
на каждом цикле.
Диагностика (без гаданий):
- симптом: `ReadTimeout` при нагрузке 2000 сообщений, единичные всплески ~1.9 с на `delete_batch`;
- снят стек горутин по `SIGQUIT` → десятки горутин в `sync.RWMutex.RLock` на `Queue.RLockMu`
(`models.go:101`), включая `metrics.updateGauges`; HTTP-хендлеры `GetQueueAttributesV1` — там же.
- аудит всех `GetLocked`/`GetRLocked` показал: дефект ровно один — в `receive_message.go`.
Фикс (`5b3fb51`): `defer queue.UnlockMu()` — ДО любой проверки/возврата.
Регрессионный тест `TestReceiveEmptyQueue_DoesNotLeakLock` (на дефекте падает
за 2 с, на фиксе — зелёный).
### 6.2. Результаты проверок
| Проверка | Результат |
|---|---|
| `go build ./...`, `go vet ./...` | OK |
| `go test -race ./app/...` | зелёные (admin, models, gosqs) |
| `tests/api_test.sh` (aws CLI, локальный сервис) | **PASS=22 FAIL=0** |
| `tests/sdk_test.py` (boto3) | **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 с |
Стресс подтверждает и отсутствие «протечки» tombstone: после каждого цикла
очередь возвращается к 0 живых (`CompactIfNeeded` в тике работает).
### 6.3. Замечание
В первом прогоне api/sdk/fifo уже проходили (22/22, 15/15, PASS×3) — то есть
дефект проявлялся не на каждом сценарии, а на последовательных циклах полного
опустошения очереди. Поэтому стресс-проверка была обязательной.
+264
View File
@@ -0,0 +1,264 @@
# План доработок shared-sqs — для DeepSeek Flash (2026-10-05)
> Исполнитель: кодер (DeepSeek Flash). Источник правил: память + отчёт Opus
> (`opus-report-2026-10-05.md`) + мой анализ (`my-analysis-of-opus-report-2026-10-05.md`).
>
> ⛔ ВАЖНО: прод НЕ используется, никто сервис не юзает. **Обратная
> совместимость и миграции НЕ нужны.** Всё делается корректно «на будущее».
> Кодер должен работать в репозитории `/home/naeel/nubes/SQS_und_IoT/SQS-service`.
---
## Правила работы (обязательные для кодера)
1. **Коммитить почаще** — после каждой логически завершённой правки, отдельным
коммитом с понятным сообщением вида `sqs: <P0.1> ...`. Не копить правки.
2. **Один трек-пункт = один/несколько коммитов.** Не смешивать P0 и P2 в одном коммите.
3. После каждой правки — `go build ./...` и `go test ./...` (зелёные до/после).
4. Не трогать файлы вне перечисленных ниже. Не менять публичные HTTP-контракты
SQS API без явного указания (совместимость с AWS SDK должна сохраняться).
5. Правила платформы (сетевые таймауты/MSS) в этот план НЕ входят — это не код.
---
# ЧАСТЬ 1 — P0: блокеры продакшна (безопасность + durability)
## P0.1 — Убрать детерминированный SecretKey (случайные креды)
**Проблема:** `tenant_store.go` выводит AccessKey/SecretKey из email через SHA256
с захардкоженной солью. Любой, кто знает email и соль (соль в исходнике), вычисляет
чужие креды и получает доступ к очередям через SQS-эндпоинт (JWT не нужен — только SigV4).
**Файлы:** `app/tenant/tenant_store.go`.
**Что сделать:**
- Удалить `tenantIDFromEmail`, `accessKeyFromEmail`, `secretKeyFromEmail`, константу
`secretKeySalt`, и полностью переписать `CreateFromJWT`:
- `tenantID` — `generateTenantID()` (случайный, уже есть);
- `accessKey` — `generateAccessKey()` (случайный, уже есть);
- `secretKey` — `generateSecretKey()` (случайный, уже есть).
- Сохранять связь `email → tenant` в индексе `byEmail` (как сейчас), НО ключи больше
не зависят от email.
- При повторном логине с тем же email — возвращать существующего тенанта
(идемпотентность по `byEmail` сохранить), НЕ пересоздавать и НЕ пересчитывать ключи.
- Убрать логику «legacy-пересчёта» (ветку удаления старой записи): с нуля её не нужно —
если тенант с email найден, просто вернуть его как есть.
- Случайные ключи и так генерируются через `crypto/rand` (`generateAccessKey`,
`generateSecretKey`) — их уже существует, использовать их.
**Критерий готовности:** после логина новый тенант получает AccessKey/SecretKey,
не совпадающие с `SHA256(email)`; повторный логин с тем же email возвращает те же
креды (из Redis/индекса), а не пересоздаёт.
## P0.2 — Усилить доверие к JWT (не доверять неподписанному payload)
**Проблема:** `jwt.go` не проверяет подпись; `PingNubesAPI` принимает токен, если
хоть один стенд вернул не-401/403. Идентичность берётся из незаверенного claim `email`.
**Файлы:** `app/auth/jwt.go`, `app/auth/cache.go`.
**Что сделать (минимально безопасно, без внешнего JWKS):**
- `PingNubesAPI`: принимать токен ТОЛЬКО при явном HTTP 2xx от доверенного эндпоинта
(сейчас логика «любой ответ, кроме 401/403 → OK»). 401/403 → `ErrTokenRejected`;
прочие коды → тоже считаем ошибкой (не принимать).
- Порядок стендов оставить как есть (prod/test/dev), но успех — только по 2xx.
- Дополнительно: если в ответе валидирующего API доступна расшифровка токена
(email/sub), использовать её вместо доверия к payload. Если API возвращает только
статус — оставить claim `email` как есть, но после P0.1 это уже не даёт чужих кредов.
- `cache.go` — оставить TTL 5м/grace 2м (риск отозванного токена приемлем и задокументирован).
**Критерий готовности:** токен, на который все стенды отвечают 500/503/иным не-401-кодом,
больше НЕ принимается; принимается только явный 2xx.
## P0.3 — Durability SendMessage (синхронный персист до 200)
**Проблема:** `SendMessage` отвечает 200, а Redis-запись уходит асинхронно в горутину
(`redis.go` `asyncWrite`). Краш пода в этом окне = потеря подтверждённого сообщения.
**Файлы:** `app/persistence/redis.go`, `app/gosqs/send_message.go`.
**Что сделать:**
- Ввести синхронный вариант записи сообщения (по аналогии с уже существующим
синхронным `DeleteMessagePersist`): `SaveMessageSync(key, msg)` — выполняет `HSet`
с коротким таймаутом и ждёт результат.
- В `SendMessageV1`: после добавления в `queue.Messages` вызывать `SaveMessageSync`
ДО возврата 200. При ошибке Redis — вернуть клиенту ошибку (InternalError) и
откатить append сообщения (не оставлять сообщение в памяти без персиста), либо
залогировать и вернуть ошибку — выбрать стратегию и зафиксировать её в комментарии.
- Оставить `SaveMessage` (async) для некритичных случаев, но SendMessage должен
персистить синхронно.
**Критерий готовности:** краш пода сразу после 200 на SendMessage НЕ приводит к
потере сообщения — оно уже в Redis.
---
# ЧАСТЬ 2 — P1: архитектурная зрелость
## P1.1 — Per-queue мьютекс вместо глобального
**Проблема:** один `SyncQueues.RWMutex` на весь сервис — любой send/receive/tick
берёт единственный Lock; long-poll переобходит его каждые 100 мс; O(N)-скан под локом.
**Файлы:** `app/models/globals.go`, `app/models/models.go`, все gosqs-хендлеры,
`app/gosqs/gosqs.go` (`PeriodicTasks`/`processQueueTick`), `app/persistence/redis.go`.
**Что сделать:**
- Добавить в `Queue` поле `mu sync.RWMutex` (per-queue блокировка).
- `SyncQueues` оставить как `map[string]*Queue` под отдельным коротким
`sync.RWMutex` ТОЛЬКО для доступа к самой map (вставка/удаление/чтение указателя),
а состояние ОЧЕРЕДИ защищать её собственным `mu`.
- Перевести все операции на схему: короткий `SyncQueues.RLock` → взять указатель
`queue` → `SyncQueues.RUnlock` → `queue.mu.Lock()` на время работы с содержимым.
- В `processQueueTick` снять глобальный Lock с обхода: пройти по снапшоту очередей,
каждую блокировать её `mu` отдельно.
- Обновить `persistence`-вызовы: они должны вызываться под `queue.mu`, как сейчас
под глобальным (см. комментарии «ВЫЗЫВАТЬ под Lock»).
**Критерий готовности:** нет ни одной операции, блокирующей все очереди сервиса
на время O(N)-работы; тесты на конкурентный доступ проходят.
## P1.2 — Индексация сообщений (убрать O(N) по слайсу)
**Проблема:** `Messages []SqsMessage` — receive/delete/visibility — O(N); при лимите
~120k сообщений и текущих локах это основной источник latency.
**Файлы:** `app/models/models.go`, `app/gosqs/receive_message.go`,
`app/gosqs/delete_message.go`, `app/gosqs/gosqs.go`.
**Что сделать (инкрементально, без ломки схемы):**
- Добавить в `Queue` индекс `receiptIndex map[string]int` (ReceiptHandle → индекс в
`Messages`) и/или `readyIndex []int`; поддерживать его при каждом receive/delete/таймауте.
- DeleteMessage: по ReceiptHandle — O(1) вместо скана слайса.
- Visibility-tick: по `receiptIndex` определять истёкшие, а не сканировать весь слайс.
- Если полный рефакторинг со слайса на map+heap сложен — сначала версию с индексом,
сохранив слайс, чтобы не ломать Redis-сериализацию (`marshalMessage`).
**Критерий готовности:** DeleteMessage и visibility-перерасчёт не зависят от полного
размера очереди.
## P1.3 — Реализовать MessageRetentionPeriod
**Проблема:** `MessageRetentionPeriod` помечен `// TODO - not used`; сообщения не
истекают, растут до лимита, потом `OverLimit`.
**Файлы:** `app/models/models.go`, `app/gosqs/gosqs.go` (`processQueueTick`),
`app/persistence/redis.go`.
**Что сделать:**
- В `processQueueTick` добавить эвикцию сообщений старше retention: если
`SentTime + MessageRetentionPeriod < now` — удалить сообщение и его Redis-запись (`HDel`).
- Учитывать retention при `IsReadyForReceipt`/выдаче: не выдавать просроченные.
- Дефолт retention уже задан (345600 = 4 дня в `config.go` `applyEnvironmentDefaults`).
**Критерий готовности:** сообщение автоматически исчезает из очереди и Redis по
истечении retention, очереди не забиваются до `OverLimit`.
## P1.4 — Индекс тенант → его очереди
**Проблема:** `countTenantQueues`, `listTenantQueues`, `createTenantQueue` (лимит),
`deleteTenant`, gauge-updater — O(total) скан-по-префиксу по всем очередям.
**Файлы:** `app/gosqs/tenant_helpers.go`, `app/admin/admin.go`.
**Что сделать:**
- Вести индекс `map[accessKey]map[queueName]struct{}` (или в `TenantStore`) для
O(1) подсчёта/списка очередей тенанта.
- `countTenantQueues` → `len(index[accessKey])`.
- Обновлять индекс в `CreateQueue`/`DeleteQueue`/`PurgeQueue`/`DeleteTenant` и при
восстановлении из Redis на старте.
**Критерий готовности:** лимиты/списки очередей не сканируют все очереди сервиса.
## P1.5 — Убрать demo-bypass из прод-сборки
**Проблема:** хардкод demo-кредов (`seed.go`) и публичного UI-bypass-токена
(`authenticateUIDemoToken` в `admin.go`).
**Файлы:** `app/cmd/seed.go`, `app/admin/admin.go`.
**Что сделать:**
- Убрать `authenticateUIDemoToken` и фиксированный UI-токен; UI-доступ — только
через реальную JWT-валидацию.
- Демо-тенант (`SHARED_SQS_SEED_DEMO`) оставить за env-флагом, но креды для демо
генерировать через `CreateFixed` только при `SHARED_SQS_SEED_DEMO=true` (как сейчас),
НЕ хранить захардкоженный `demo-secret-key-shared-sqs-ngcloud-2026` в коде — генерировать
случайным образом и выводить в лог при старте.
**Критерий готовности:** без env-флага `SHARED_SQS_SEED_DEMO` и без явно заданного
admin UI-токена в прод-конфиге бэкдор-путей нет.
---
# ЧАСТЬ 3 — P2: надёжность, корректность, чистка
## P2.1 — BYOC Admin API
**Файлы:** `app/admin/admin.go`, `app/tenant/tenant_store.go`.
- В `createTenantRequest` добавить опц. поля `access_key`, `secret_key`.
- Если оба заданы → `CreateFixed` (валидация мин. длины: access_key ≥8, secret_key ≥16).
- Если одно без другого → 400. Экспортировать/использовать `generateTenantID`.
- Детали в `doc/byoc-credentials.md` (раздел с TODO).
## P2.2 — Чинить сиротство очередей (после отказа от legacy-пересчёта в P0.1 — уже неактуально)
После P0.1 ветка «legacy-пересчёта» удалена — сиротства больше не возникает.
**Действие:** убедиться, что после P0.1 в `CreateFromJWT` не осталось пути, где
тенант меняет AccessKey без переноса очередей. Если креды теперь неизменны на весь
жизненный цикл тенанта — пункт закрыт.
## P2.3 — Constant-time сравнение admin-токена
**Файлы:** `app/admin/admin.go`.
- Заменить `authHeader != expected` на `subtle.ConstantTimeCompare`.
## P2.4 — Единый путь создания/отправки (admin ≠ gosqs)
**Файлы:** `app/admin/admin.go`.
- `createTenantQueue`/`sendMessageToQueue` должны переиспользовать логику gosqs
(создание через общий код, FIFO-детект, проверка размера, MD5, дедуп), а не
дублировать с иными дефолтами.
## P2.5 — Биллинг: воркер/батч вместо горутины на операцию; корректный msg_count
**Файлы:** `app/billing/billing.go`, `app/router/router.go` (`actionHandler`).
- Заменить `go func(){ Insert }` на буфер+воркер (батч-инсерты).
- `RecordUsage` принимать корректный `msg_count` (для SendMessageBatch — число
сообщений в батче), а не всегда 1.
## P2.6 — Защитить/урезать `/metrics`
**Файлы:** `app/router/router.go`.
- Убрать per-tenant данные из публичных метрик ИЛИ закрыть `/metrics` аутентификацией.
- Минимально: убрать лейбл `tenant` с идентификаторами из публичного `/metrics`,
оставить агрегаты без идентификации тенантов.
## P2.7 — Чистка легаси
**Файлы:** `app/conf/goaws.yaml`, `app/conf/config.go`, `app/models/*`.
- Убрать SNS Topics/Subscriptions из `goaws.yaml`.
- Убрать мёртвый путь загрузки yaml-очередей без tenant-префикса в `config.go`.
- Убрать SNS-поля из моделей.
- Не трогать `deployments/**` (вне scope кода; при желании — отдельно).
## P2.8 — Redis reconnect/health
**Файлы:** `app/persistence/redis.go`.
- После обрыва соединения — попытка переподключения / пометка нездоровья, а не
молчаливые ошибки в лог. Мин.: пересоздание `Client` при повторном ping-фейле.
---
# Порядок выполнения (коммитить почаще, по пунктам)
1. P0.1 → коммит
2. P0.3 → коммит (durability, пока не зависит от P1.1)
3. P0.2 → коммит
4. P1.1 → коммит (самый крупный, тестировать пристально)
5. P1.2 → коммит
6. P1.3 → коммит
7. P1.4 → коммит
8. P1.5 → коммит
9. P2.1 … P2.8 → по одному коммиту каждый
После каждого коммита — `go build ./... && go test ./...`.
@@ -0,0 +1,290 @@
# Шаг 3 — HA (active/passive) и путь к выносу состояния
**Дата:** 2026-10-10
**Статус:** реализовано (код в `main`, задеплоено на ВМ 213, **флаг выключен** — поведение прежнее)
**Связанные документы:**
- `doc/thinking/step3-state-extraction-design-2026-10-10.md` — разбор вариантов A/B/C
- `doc/thinking/vm-public-entrypoint-2026-10-10.md` — публичная точка входа, TLS, Шаг 2
- `HISTORY/2026-10-10-session-log.md` — хронология сессии
---
## 1. Какую проблему решаем
Сейчас прод — **одна ВМ** (213, `5.172.178.213`) и **один процесс** `shared-sqs`. Внутри процесса
живёт **источник правды** — in-memory `models.SyncQueues` (все очереди и сообщения). Redis —
write-through кэш и восстановление при рестарте.
Отсюда три боли:
| # | Боль | Следствие |
|---|---|---|
| 1 | ВМ/процесс упал → сервиса нет | простой до ручного/автоматического рестарта (сейчас `Restart=always`, но это только рестарт того же инстанса) |
| 2 | Redis на той же ВМ | падение ВМ уносит и состояние, и кэш — восстановление «с нуля по последнему AOF» |
| 3 | Обновление версии = простой | нужно перезапустить единственный инстанс |
Полноценное решение (вариант A из дизайна) — сделать **Redis/внешнее хранилище источником правды**
и превратить инстансы в stateless. Это большой объём: атомарные операции над очередями (длинные
скрипты/Lua, видимость, DLQ, batch, дедупликация), переписывание `models.SyncQueues` и всего, что
на него опирается (`gosqs.PeriodicTasks`, метрики, админ-peek).
Задача была: **снять боль №1 и №3 дёшево, не переписывая ядро**, и подробно задокументировать, как
потом перейти к варианту A.
---
## 2. Почему выбран вариант C (active/passive), а не A
| Вариант | Что даёт | Цена | Вердикт |
|---|---|---|---|
| **A.** Redis — источник правды | настоящий horizontal scale-out | переписать ядро очередей (Lua/транзакции), недели работы, риск регрессий в самой опасной части (доставка/видимость/DLQ) | **потом**, когда упрёмся в нагрузку |
| **B.** Шардирование по тенантам | масштаб без переписывания ядра | сложная маршрутизация, каждый шард всё равно SPOF, эксплуатация N наборов | не берём |
| **C.** active/passive + leader lock | failover, рестарт/деплой без простоя (после ручного чекпоинта) | не даёт scale-out; пассивный инстанс не принимает трафик | **берём сейчас** |
Ключевой довод: **в 100% случаев причина простоя — недоступность одной ВМ**, а не нехватка CPU.
Вариант C закрывает именно это, стоит ~200 строк кода и не трогает ядро очередей.
Дополнительный довод: вариант C **не мешает** потом сделать A. Наоборот — он вводит понятие
«активный инстанс», которое в варианте A просто исчезнет (все инстансы станут равными).
---
## 3. Как это работает
### 3.1. Схема
```mermaid
flowchart LR
LB["nginx / LB<br/>(проверяет /ready)"]
A["ВМ A: shared-sqs<br/>лидер → /ready 200"]
P["ВМ B: shared-sqs<br/>пассив → /ready 503"]
R[("Redis<br/>ssq:leader, ssq:queues, ssq:tenants")]
LB -->|трафик только на ready| A
LB -.->|503 — трафик не идёт| P
A <-->|SETNX / PEXPIRE heartbeat| R
P <-->|пытается взять лок| R
```
### 3.2. Механика лока
- Ключ: **`ssq:leader`**. Значение — **`<hostname>-<pid>`** (уникальный id инстанса,
`app/cmd/goaws.go:180`).
- **Взять лок:** `SET ssq:leader <id> NX PX <ttl>` — атомарно; `app/persistence/redis.go:666`.
- **Продлить:** Lua-скрипт — `PEXPIRE` **только если** значение всё ещё наше
(`GET == ARGV[1]`), `app/persistence/redis.go:676-682`, вызов `:692`.
- **Отпустить:** Lua-скрипт — `DEL` **только если** значение наше, `app/persistence/redis.go:684-690`,
вызов `:706`. Вызывается при graceful shutdown (`app/cmd/goaws.go:254`).
- **Цикл:** `app/leader/leader.go:92-124`. Каждые `interval` (по умолчанию **3 с**):
- если я лидер → `RefreshLock`; не подтвердился → теряю лидерство (падаю в passive);
- если я пассивен → `TryAcquireLock`; получил → становлюсь лидером.
- **TTL лока:** по умолчанию **10 с**. Правило: `ttl > 3 × interval`, чтобы сетевые задержки и
короткие всплески не приводили к ложной потере лидерства.
### 3.3. Готовность (`/ready`)
- `GET /ready` (`app/router/router.go:220-229`) отдаёт **200** если `ReadyFunc` не задана **или**
вернула `true`, иначе **503**.
- `ReadyFunc` — пакетная переменная (`app/router/router.go:216`), задаётся в `main` только при
включённом leader-election: `router.ReadyFunc = elector.IsLeader` (`app/cmd/goaws.go:182`).
- `/ready` зарегистрирован **и** в data-plane (`router.go:48`), и в control-plane (`router.go:90`).
- `/health` (liveness) — **всегда 200**, независимо от лидерства. Это сознательно: процесс жив,
его не надо перезапускать, просто он не должен получать трафик.
### 3.4. Включение/выключение
| Способ | Значение | Поведение |
|---|---|---|
| по умолчанию | — | `--leader-election=false` → `ReadyFunc == nil` → **всегда ready** (одиночный режим, как раньше) |
| флаг | `--leader-election` | включается |
| env | `SHARED_SQS_LEADER_ELECTION=true` | включается (удобно для systemd `EnvironmentFile`) |
| тюнинг | `--leader-ttl` (10s), `--leader-interval` (3s) | значения лока |
Код: `app/cmd/goaws.go:47-49` (флаги), `:159-187` (инициализация).
### 3.5. Поведение без Redis
Если `REDIS_ADDR` не задан (`persistence.Client == nil`), `TryAcquireLock`/`RefreshLock` возвращают
`true` — инстанс **всегда лидер**. Логика: без Redis мы единственные, делить лок не с кем.
⚠️ Если Redis **был, но отвалился после старта** — `TryAcquireLock` вернёт ошибку → инстанс
станет passive → `/ready` 503. При одном инстансе это означает «нет healthy-бэкенда». Поэтому
leader-election **нельзя включать на единственном инстансе, если Redis может исчезнуть** —
см. §6.
---
## 4. Что именно проверено (замеры, не гипотезы)
Проверка выполнена на ВМ 213 двумя временными инстансами нового бинарника (порты 4200 и 4210),
Redis — локальный:
```bash
# 1) две копии с --leader-election
./shared-sqs --port=4200 --control-port=4201 --leader-election &
./shared-sqs --port=4210 --control-port=4211 --leader-election &
curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4200/ready # 200 (лидер)
curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4210/ready # 503 (пассив)
redis-cli get ssq:leader # naeel-vm-21935
# 2) имитируем падение лидера БЕЗ graceful shutdown
kill -9 21935
sleep 12
curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4210/ready # 200
redis-cli get ssq:leader # naeel-vm-21957
```
| Что мерили | Результат |
|---|---|
| Две копии одновременно | `200` у первой, `503` у второй — split-brain нет |
| Значение ключа | `<hostname>-<pid>` того инстанса, который отдал 200 |
| `kill -9` лидера → перехват | пассивный стал лидером, `/ready` → `200`; перехват занял **~12 с** (теоретический максимум `ttl + interval` = 13 с) |
| Логи | `leader: инстанс стал ЛИДЕРОМ (активный)` (16:29:12), `leader: потерян лидерский лок (пассивный)` |
| Регрессия прод-API | `aws sqs list-queues / send-message / receive-message` через `https://sqs.sinefo.ru` — OK |
| Прод после деплоя | `systemctl is-active shared-sqs` = active, `/health` 200, `/ready` 200 |
Временные инстансы остановлены, ключ `ssq:leader` удалён, прод работает с **выключенным**
leader-election (`/ready` 200 безусловно).
---
## 5. Как включить на проде (когда появится вторая ВМ)
### 5.1. Предусловия
1. Вторая ВМ, тот же бинарник, тот же `sqs.sinefo.ru`.
2. **Общий Redis**, доступный обеим ВМ (сейчас на 213 — только локальный!).
Без общего Redis лок бессмысленен: каждый увидит свой.
3. LB (nginx `upstream` + `proxy_next_upstream`, или внешний LB) с проверкой `GET /ready`.
### 5.2. Шаги
```bash
# /etc/shared-sqs.env на ОБЕИХ ВМ
SHARED_SQS_LEADER_ELECTION=true
SHARED_SQS_ADMIN_TOKEN=<общий>
REDIS_ADDR=<общий-redis>:6379
REDIS_PASSWORD=<...>
# systemd: EnvironmentFile=/etc/shared-sqs.env, ExecStart=... (флаги не нужны — env достаточно)
sudo systemctl restart shared-sqs
```
В nginx upstream — health-check по `/ready`:
```nginx
upstream sqs_backend {
server 10.10.102.21:4100 max_fails=1 fail_timeout=5s; # ВМ A
server <ВМ-B-IP>:4100 max_fails=1 fail_timeout=5s; # ВМ B
}
# плюс активная проверка /ready (nginx OSS: passive-режим через max_fails,
# nginx plus / внешний LB: active health check на GET /ready)
```
Проверка после включения — **обязательно** (иначе будет failover «в никуда»):
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://sqs.sinefo.ru/ready # 200 ровно у одного
# затем жёстко убить лидера и убедиться, что трафик поехал на второй за ≤ ttl+interval
```
### 5.3. Порядок действий при деплое новой версии
1. Обновить бинарник на **пассивной** ВМ, перезапустить — `/ready` остаётся 503, трафика нет.
2. Дождаться, пока пассивная станет лидером нельзя «по кнопке» — переключение идёт только через
TTL. Поэтому: остановить лидера (`systemctl stop`) → лок освобождается сразу
(`ReleaseLock` в `elector.Stop()`) → пассивный берёт лок на следующем тике (≤ 3 с).
3. Обновить бинарник на бывшей лидерской ВМ → она встанет пассивной.
Итого простой при деплое — **единицы секунд** вместо «пока перезапускается».
---
## 6. Ограничения — важно понимать до включения
1. 🔴 **Состояние НЕ шарится между инстансами.** `models.SyncQueues` — in-memory. Пассивный
инстанс загрузил состояние из Redis **на своём старте** и с тех пор его не обновляет.
→ Пока активно ограничение: **пассивный не должен получать SQS-трафик.** Именно для этого
`/ready` = 503: LB обязан его уважать. Если LB игнорирует `/ready`, клиенты начнут получать
«пустые» очереди с пассивного. Это главный риск варианта C.
2. 🟠 **Свежесть состояния после failover.** Новый лидер видит состояние на момент своего старта,
а не на момент падения предыдущего. Write-through (Redis) спасает: очереди и сообщения,
записанные лидером, лежат в Redis. А **изменения, не персистентные**, теряются: в первую
очередь `SaveQueue` (метаданные очереди) — асинхронная запись; см. открытый пункт в
`HISTORY/2026-10-10-session-log.md` §6. Плюс любой инстанс можно перезапустить, чтобы
подтянуть актуальное состояние из Redis.
3. 🟠 **Простой = время перехвата.** `kill -9` → до `ttl + interval` (≈13 с по умолчанию).
Уменьшать TTL рискованно (ложные потери лидерства при сетевых задержках); лучше иметь
failover на уровне LB + второй инстанс, уже прогретый.
4. 🟠 **Redis — общий SPOF.** Если Redis недоступен, лидер теряет лок → оба инстанса отдают 503.
То есть вариант C переносит зависимость, но не устраняет её. Для продакшена Redis должен быть
отдельным от SQS-ВМ и с репликацией.
5. 🟡 **Split-brain теоретически возможен** при сетевом разделении, когда старый лидер не может
продлить лок (считает себя пассивным), а Redis тем же временем отдаёт лок новому — это
нормально (старый сам сдаётся). Обратный случай — только при «залипшем» Redis (например,
пауза процесса Redis > TTL). Практический риск низкий, но именно поэтому LB **должен**
проверять `/ready`, а не полагаться на то, что трафик не пойдёт куда не надо.
6. 🟡 **Control-plane (`4101`) не участвует.** Он остаётся локальным на каждой ВМ; админка на
пассивной покажет своё (устаревшее) состояние.
---
## 7. Чего сознательно НЕ сделано
- Нет fencing/lease-токенов для операций записи в Redis (нужно только начиная с варианта A).
- Нет авто-переключения DNS/nginx (ручное: LB + health-check).
- Нет эндпоинта `/leader` с деталями (кто лидер, когда взят лок) — при необходимости добавить
3 строки в control-plane.
- Нет репликации Redis в terraform/скриптах — платформенная задача (NUBES/девопсы).
---
## 8. Путь к варианту A (когда менять)
**Триггеры, после которых вариант C перестаёт устраивать:**
- пассивный инстанс простаивает, а нагрузка уже выше возможностей одной ВМ (CPU/память/сеть);
- нужны **несколько активных** инстансов (scale-out, а не только failover);
- требуется zero-downtime деплой без ручного переключения.
**Куда двигаться (по шагам, каждый самостоятелен):**
1. **Убрать асинхронность записи метаданных** — `SaveQueue` синхронно (как `SaveMessagesSync`).
Мелкая правка, снимает риск №2 из §6. *Делать первым — дешево и полезно независимо от A.*
2. **Вынести `models.SyncQueues` за интерфейс** (например `QueueStore`), оставив текущую
in-memory реализацию. Поведение не меняется, но появляется точка подмены.
3. **Атомарные операции очереди в Redis** — приёмка/видимость/удаление/дедуп через
Lua-скрипты (или Redis Streams). Это основная часть работы. Тесты — на существующих
`app/gosqs/*_test.go` + `tests/load_test.py`.
4. **Убрать `PeriodicTasks` из процесса** — вынести в отдельный «sweeper» (тоже через лок,
чтобы не дублировался на всех инстансах).
5. **Снять лидер-лок** — все инстансы становятся равными, `/ready` всегда 200, LB просто
round-robin. Код `app/leader` при этом удаляется (он был нужен только для C).
**Что замерить перед переходом:**
- число операций/с и p99 по SQS-API на проде (`/metrics` уже есть);
- сколько сейчас тормозит Redis при write-through (latency `HGETALL`/`HSET`);
- объём `ssq:msg:*` (память Redis) при текущем темпе — там сейчас весь бэклог очередей.
---
## 9. Откат (выключить HA)
```bash
# на каждой ВМ
sudo sed -i '/SHARED_SQS_LEADER_ELECTION/d' /etc/shared-sqs.env
sudo systemctl restart shared-sqs
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4100/ready # 200 (безусловно)
redis-cli del ssq:leader # удалить осиротевший лок
```
Код при этом остаётся — просто `ReadyFunc == nil`. Удалять пакет `app/leader` не обязательно.
---
## 10. Ссылки на код
| Файл | Что |
|---|---|
| `app/leader/leader.go` | `Elector`: `New` (39), `Start` (50), `IsLeader` (56), `Stop` (63), `loop` (92), `step` (106) |
| `app/persistence/redis.go` | `TryAcquireLock` (666), `refreshLockScript` (676), `releaseLockScript` (684), `RefreshLock` (692), `ReleaseLock` (706) |
| `app/router/router.go` | `/ready` в data-plane (48), `/ready` в control-plane (90), `ReadyFunc` (216), `readyHandler` (220) |
| `app/cmd/goaws.go` | флаги (47-49), инициализация и `ReadyFunc` (159-187), `elector.Stop()` (254) |
@@ -0,0 +1,85 @@
# Шаг 3: вынос состояния — дизайн (2026-10-10)
> Стратегический пункт. Определяет потолок сервиса: пока очередь живёт в памяти одного
> процесса — HA/масштабирования нет. Файл — анализ вариантов и план; перед реализацией
> нужны решения/замеры (см. §5).
## 1. Проблема (факты)
- **Источник истины — память**: `models.SyncQueues` (map очередей + индексы tombstone) и
`tenant.TenantStore`. Redis (`app/persistence/redis.go`) — **write-through**: записи
асинхронны (`asyncWrite`), чтение — **только при старте** (`LoadAllQueues`, `LoadAllTenantsRaw`).
- Следствия:
- две реплики разъедутся (каждая со своей map; last-write-wins затирает);
- падение процесса ИЛИ ВМ = полный простой;
- сервис принципиально single-instance → нет HA, нет масштабирования.
- Замеренный ранее риск (P1.1): глобальный лок `SyncQueues` + сетевой Redis-I/O **под локом**
давал таймауты до 3–5с. При выносе в Redis локально-атомарные операции станут сетевыми —
это ключевой вопрос латентности.
## 2. Варианты
### A. Redis как источник истины (полный вынос)
- Все операции читают/пишут Redis; память — только кэш (или ничего).
- Атомарность — **Lua-скрипты** (dequeue, visibility, dedup, DLQ) поверх LIST/ZSET/HASH,
либо Redis Streams.
- **Плюс:** несколько инстансов data-plane, HA, потолок снят, «настоящий облачный» SQS.
- **Минус:** переписывание ядра очередей (по сути новый движок); сетевой round-trip на КАЖДЫЙ
SQS-вызов (латентность!); сложность гонок/транзакций/FIFO/dedup/DLQ на Lua.
- **Объём:** крупный.
### B. Шардирование по тенантам
- Тенанты распределяются по инстансам (consistent hashing); каждый держит СВОИ очереди в памяти.
- **Плюс:** ядро очередей не переписываем; масштабирование по числу тенантов.
- **Минус:** HA нет (шард живёт на одном инстансе — его падение = часть тенантов недоступна);
нужен роутинг/ребаланс (LB или сам сервис); состояние всё так же в памяти.
### C. Активный/пассивный (failover)
- Один активный инстанс; состояние непрерывно реплицируется в Redis; пассивный подхватывает
при падении (переключение по health/leader-election).
- **Плюс:** HA **без переписывания ядра** (as-is); быстрее всего по срокам.
- **Минус:** горизонтального масштабирования нет (один активный); окно даунтайма при
переключении; надо решить рассинхрон (last-write-wins, потеря «подтверждённых» сообщений
в async-окне).
## 3. Сравнение
| | A (Redis = истина) | B (шарды) | C (active/passive) |
|---|---|---|---|
| HA (падение инстанса) | да | частично | да (с окном) |
| Масштабирование (нагрузка) | да | да (по тенантам) | нет |
| Переписывание ядра | **да (крупно)** | нет | нет |
| Латентность на вызов | растёт (сеть) | как сейчас | как сейчас |
| Сроки | большие | средние | малые |
## 4. Предварительная рекомендация
Идти **поэтапно**, не прыгая в A сразу:
1. **Прежде всего — C** (active/passive): даёт HA за малые сроки, без переписывания ядра.
Убирает главную боль «одна ВМ = SPOF».
2. **Потом A** — когда реально упрёмся в потолок нагрузки/числа тенантов (и когда есть
средства на новый движок + замеры латентности).
3. B — рассматривать только если появится требование «много независимых тенантов на разных
инстансах» без общей нагрузки.
> Обоснование: A — самая дорогая и рискованная; делать её «на всякий случай» рано (сервис
> ещё не под нагрузкой). C снимает SPOF дешевле.
## 5. Что замерить/решить ПЕРЕД реализацией
1. **Нагрузка**: сколько SQS-вызовов/с ожидается? Какая латентность допустима? (определяет,
переживёт ли вариант A сетевые round-trip'ы).
2. **Объём состояния**: число очередей × сообщений сейчас и на горизонте — влезает ли в Redis
(память/лимиты), сколько стоит снапшот.
3. **Требование к HA**: окно даунтайма при переключении (для C) — допустимо ли?
4. **Порог переключения** (для C): health-check + leader-election (Redis SETNX/lock).
## 6. Ближайшие безопасные шаги (независимо от варианта)
- **Тенанты read-through Redis**: сейчас тенант, созданный control-plane, не виден data-plane
до рестарта. Добавить Redis-индекс `accessKey → tenant` и fallback в auth-middleware —
нужно для ЛЮБОГО multi-instance варианта и снимает текущее ограничение.
- **Метрики/health** для переключения (готово: `/health`, `/metrics`).
- **Разнести процессы data/control** (листенеры уже разделены, коммит `c1520c0`) — после
read-through тенантов возможен полноценный процесс-split.
@@ -0,0 +1,56 @@
# Находка: унаследованные Go-тесты gosqs сломаны форком (2026-10-06)
> Статус: подтверждено чтением кода. Меняет ранее данную рекомендацию.
## Факт 1 — тесты не компилируются
Все 13 файлов `app/gosqs/*_test.go` импортируют два пакета, которых НЕТ в репо
(и никогда не было в git — проверено `git ls-files`, `git log --all`):
- `shared-sqs/app/fixtures` (тестовые данные GoAws: `BASE_URL`, `QueueName`,
`LOCAL_ENVIRONMENT`, `CreateQueueRequest`, `FullyPopulatedQueue` и др.)
- `shared-sqs/app/test` (`GenerateRequestInfo`)
Оба — из upstream `github.com/Admiral-Piett/goaws`.
## Факт 2 — даже с восстановленными пакетами тесты ПАДАЮТ
Тесты унаследованы от дофоркового GoAws и не адаптированы к мультитенантному форку:
1. **Нет тенанта в контексте.** Все хендлеры начинаются с
`t := getTenantFromContext(req); if t == nil { return InvalidClientTokenId }`
(напр. `app/gosqs/create_queue.go:44-47`). Тесты вызывают хендлер без
`auth.TenantContextKey` в контексте → тенант `nil` → ответ 403
`InvalidClientTokenId`, тогда как тесты ожидают `http.StatusOK` (200).
→ Любой такой assert падает.
2. **Ключ очереди теперь tenant-scoped.** Код хранит очереди под ключом
`{accessKey}:{queueName}` (`tenantQueueKey`, `tenant_helpers.go:20`), а тесты
читают `models.SyncQueues.Queues[fixtures.QueueName]` — по «голому» имени,
без префикса → ключ не найден (`actualQueue == nil`), assert падает.
3. **URL/ARN содержат tenantID**, а не accountID
(`tenantQueueURL`/`tenantQueueARN`) — ожидания тестов про URL/ARN тоже не совпадут.
## Вывод
Это **мёртвые тесты**: они не просто не собираются — они тестируют поведение
ДО форка и принципиально несовместимы с текущим мультитенантным кодом.
Наличие `fixtures`+`test` проблему НЕ решает (будет компилироваться, но краснеть).
## Что это значит для плана
- Прежняя рекомендация «сначала восстановить fixtures → зелёные тесты → P1.1»
**ошибочна**. Пакеты восстановить можно (исходники upstream получены), но
тесты всё равно не пройдут.
- Правильный путь: **не воскрешать мёртвые тесты, а написать НОВЫЕ** — для
текущего мультитенантного поведения (тенант в контексте, tenant-scoped ключи).
Либо удалить унаследованные, либо переписать их в мультитенантном виде.
- Пункт P1.1 (per-queue mutex) требует тестов на конкурентный доступ — их надо
писать с нуля под текущую архитектуру.
## Источники
- `app/gosqs/create_queue.go:44-47` — требование тенанта из контекста.
- `app/gosqs/create_queue.go:52` — `key := tenantQueueKey(t.AccessKey, queueName)`.
- `app/gosqs/tenant_helpers.go:20` — формат ключа `{accessKey}:{queueName}`.
- `app/gosqs/create_queue_test.go:38` — `Queues[fixtures.QueueName]` без префикса.
- `git ls-files 'app/fixtures*' 'app/test*'` — пакеты отсутствуют в истории.
@@ -0,0 +1,175 @@
# Публичная точка SQS на ВМ 213 — как устроено (справочник, 2026-10-10)
> Текущее состояние и конфигурация. Хронология и ошибки — в `HISTORY/2026-10-10-session-log.md`.
> Проверено снаружи: `https://sqs.sinefo.ru/health` → 200.
## 1. Схема
```
клиент (AWS SDK, SigV4)
│ https://sqs.sinefo.ru (443, TLS Let's Encrypt)
▼
[ ВМ 213: главный nginx ] conf.d/sqs-sinefo.conf
│ proxy_pass http://127.0.0.1:4100 ; Host $http_host
▼
[ shared-sqs ] ~/sqs-test/shared-sqs (Go, memory-only)
```
ВМ: публичный `5.172.178.213` (NAT), внутренний `10.10.102.21`, hostname `naeel-vm`.
## 2. Файлы
| Что | Где |
|---|---|
| Vhost nginx (80 ACME+редирект, 443 TLS) | `/etc/nginx/conf.d/sqs-sinefo.conf` (ВМ) |
| Сертификат | `/etc/letsencrypt/live/sqs.sinefo.ru/{fullchain,privkey}.pem` |
| ACME-webroot | `/var/www/acme/.well-known/acme-challenge/` |
| Бинарник/лог/креды | `~/sqs-test/shared-sqs`, `~/sqs-test/sqs.log` |
| Бэкапы nginx | `/etc/nginx/conf.d.bak-*` |
## 3. Конфиг vhost (эталон)
```nginx
server {
listen 80;
server_name sqs.sinefo.ru;
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
server_name sqs.sinefo.ru;
ssl_certificate /etc/letsencrypt/live/sqs.sinefo.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sqs.sinefo.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
client_max_body_size 10m;
proxy_request_buffering off;
location / {
proxy_pass http://127.0.0.1:4100;
proxy_http_version 1.1;
proxy_set_header Host $http_host; # ВАЖНО: не $host — иначе SigV4 не сойдётся
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
}
```
## 4. Критичные условия (иначе не работает)
1. **`Host $http_host`** — SigV4 подписывает заголовок `Host` целиком (с портом).
`$host` порт отбрасывает → `SignatureDoesNotMatch`. Сервис берёт хост из запроса:
`app/auth/auth_middleware.go:315-317`.
2. **DNS:** `sqs.sinefo.ru A 5.172.178.213`; делегирование `sinefo.ru` → `ns3/ns4.ngcloud.ru`.
Сертификат выдаётся только когда домен резолвится (HTTP-01 через главный nginx, порт 80).
3. **Порт 8443 на 213 занят** DNAT-ом транзита (`→ 95.179.252.111:443`) — не использовать.
4. Проксирование тел — `proxy_request_buffering off`; лимит SQS 256 КБ.
## 5. Замеры (2026-10-10) — болезнь managed-шлюза НЕ воспроизводится
- HTTP 30× (1КБ–256КБ): 30/30 200, max 0.99 с.
- HTTPS 10× (26КБ–260КБ): 10/10 200, 0.7–3.2 с.
- Long-poll `WaitTimeSeconds=20` → 20.3 с, 200.
Гипотеза мануала (Шаг 1) подтверждена: 0 зависаний ~51 с, 0 таймаутов 31–33 с.
## 6. Управление
```bash
# запуск SQS на ВМ
cd ~/sqs-test && setsid nohup env SHARED_SQS_SEED_DEMO=true \
./shared-sqs --admin-token=dev-token-123 --port=4100 --loglevel=info > sqs.log 2>&1 </dev/null &
# креды демо-тенанта
grep "seed: создан тенант" ~/sqs-test/sqs.log | tail -1
# проверка
curl -sS https://sqs.sinefo.ru/health
```
## 7. QueueUrl — ИСПРАВЛЕНО (2026-10-10)
**Было:** `QueueUrl` = `http://region.host:port/<tenantID>/<queue>` (плейсхолдеры — на ВМ нет `goaws.yaml`).
**Стало:** база URL задаётся через `ExternalURLBase` (config `goaws.yaml` **или** env
`SHARED_SQS_EXTERNAL_URL_BASE`). На ВМ запускается с `SHARED_SQS_EXTERNAL_URL_BASE=https://sqs.sinefo.ru` →
`QueueUrl` = `https://sqs.sinefo.ru/<tenantID>/<queue>` (проверено снаружи).
Изменения (коммиты `469132b`, `ca0372b`):
- `app/models/configuration.go` — поле `Environment.ExternalURLBase`;
- `app/gosqs/tenant_helpers.go` — `tenantQueueURL` учитывает `ExternalURLBase`; добавлена
экспортированная `TenantQueueURL`;
- `app/conf/config.go` — env-override `SHARED_SQS_EXTERNAL_URL_BASE` в `applyEnvironmentDefaults`
(срабатывает и при отсутствии `goaws.yaml` — важная тонкость: `LoadYamlConfig` делает ранний
`return` без конфига, поэтому override там не срабатывал);
- `app/cmd/seed.go` — демо-очереди формируют URL через `gosqs.TenantQueueURL` (убрано
дублирование логики — оно и было причиной, что фикс «не применялся»);
- тест `app/gosqs/tenant_helpers_test.go`.
## 8. Устойчивость: systemd + локальный Redis (2026-10-10)
- **Redis** установлен на 213 (`apt install redis-server` 7.0.15), слушает `127.0.0.1:6379`,
включён **AOF** (`/etc/redis/redis.conf`: `appendonly yes`) — состояние переживает рестарт.
- **systemd-юнит** `/etc/systemd/system/shared-sqs.service`: `Restart=always`, `RestartSec=2`,
`WantedBy=multi-user.target` (автостарт при ребуте), `Requires=redis-server.service`,
`User=naeel`, `WorkingDirectory=/home/naeel/sqs-test`, `StandardOutput=append:.../sqs.log`.
- **env** `/etc/shared-sqs.env` (600, root): `SHARED_SQS_ADMIN_TOKEN`, `SHARED_SQS_SEED_DEMO`,
`SHARED_SQS_EXTERNAL_URL_BASE=https://sqs.sinefo.ru`, `REDIS_ADDR=127.0.0.1:6379`.
- Управление: `sudo systemctl {status,restart,stop,start} shared-sqs`.
**Закрыто:** крах процесса (авторестарт) и ребут ВМ (автостарт + Redis с диска из AOF; проверено —
сообщение пережило рестарт).
**НЕ закрыто:** падение самой ВМ — SQS и Redis лежат на одной машине (single point of failure).
Настоящая отказоустойчивость — после Шага 3 (Redis на отдельном хосте + несколько инстансов).
### Дефекты seed, обнаруженные при включении Redis (исправлены, коммит `c0aa618`)
1. **Демо-очереди не персистились** — после рестарта у демо-тенанта не было очередей.
Фикс: `persistence.SaveQueue` + `SaveMessages` в `app/cmd/seed.go`.
2. **Дубли тенантов** — каждый старт создавал нового демо-тенанта (новые креды). При
`Restart=always` это утечка. Фикс: идемпотентность в `seedDemoData` (проверка по имени
через `store.List()` → «сид пропущен»).
### Для прода
`SHARED_SQS_SEED_DEMO=false` (демо-данные не нужны), тенанты/очереди создаются через API
(они персистятся штатно).
## 9. Прод-хардненинг (2026-10-10, выполнено)
- `SHARED_SQS_SEED_DEMO=false` — демо-данные отключены (существующий тенант подхватывается из Redis).
- **admin-token сменён** на случайный 64-hex (хранится в `/etc/shared-sqs.env`, 600). Прежний
`dev-token-123` был слабым, а сервис теперь публичный. Прочитать: `sudo sed -n '1p' /etc/shared-sqs.env`.
- **logrotate** `/etc/logrotate.d/shared-sqs`: daily, rotate 7, compress, delaycompress,
copytruncate, `su naeel naeel` (copytruncate — процесс держит файл открытым).
- Billing (PostgreSQL) и JWT-валидация — **не настроены** (сервис работает без них).
## 10. Разбиение data-plane / control-plane (Шаг 2, 2026-10-10, коммит `c1520c0`)
- `router.NewDataPlane(store)` — публичный SQS API: `/health`, `/static`, `/examples`, SQS actions.
- `router.NewControlPlane(store, adminToken)` — админка: `/admin` (UI+API), `/metrics`, `/ui`, `/static`.
- `main`: два HTTP-сервера — data-plane на `0.0.0.0:<--port>` (4100, публично), control-plane
на `127.0.0.1:<--control-port>` (4101, только localhost).
- Проверено: публично `/health` → 200; SQS API работает; `/admin` и `/metrics` публично → **403**
(падают в SQS-auth — админка наружу **НЕ доступна**). Локально: `127.0.0.1:4101/admin` → 200,
`4101/metrics` без токена → 401.
**Почему пока два листенера в одном процессе, а не два процесса:** состояние (очереди/тенанты) —
in-memory; два процесса разъедутся (админка создаст тенанта в своей памяти — data-plane его не увидит).
Полноценный процесс-split — после Шага 3 (Redis как источник истины).
## 11. Надёжность записи (durability) и billing (2026-10-10)
### 11.1. Синхронная запись критичных операций
- `SendMessage` — sync (`SaveMessageSync`, фикс P1.1) ✅
- `DeleteMessage` / `DeleteMessageBatch` — sync (`DeleteMessagePersist`/`DeleteMessagesPersist`) ✅
- **`SendMessageBatch` — ИСПРАВЛЕНО (коммит `3559414`):** было `SaveMessages` (async) → стало
`SaveMessagesSync` (ответ 200 только после записи всех сообщений; при ошибке — откат append).
Заодно: не персистим дубли (раньше в Redis попадали дубликаты, которых нет в очереди).
- Проверено: batch из 2 сообщений в пустую очередь **пережил рестарт** сервиса.
- Остаётся async: `SaveQueue` (метаданные очереди: FIFO-состояние, duplicates) и метаданные
в Create/Delete/SetAttributes/Purge/Tags — потеря возможна только в узком окне при крахе;
сами сообщения защищены.
### 11.2. Billing — предусмотрен, но НЕ включён
- Модуль есть (`app/billing`, PG-воркер + буфер), включается через `BILLING_PG_HOST` (сейчас не задан).
- Решение: **оставить выключенным**; включать, когда появятся реальные клиенты/тарификация.
- Учёт операций уже рассчитан под биллинг — при включении заработает без изменений ядра.
+6 -2
View File
@@ -68,10 +68,14 @@ done
N=$(echo "$RM" | json 'len(d.get("Messages", []))') N=$(echo "$RM" | json 'len(d.get("Messages", []))')
[[ "$N" == "3" ]] && ok "ReceiveMessage (3 сообщения)" || bad "ReceiveMessage: получили $N" [[ "$N" == "3" ]] && ok "ReceiveMessage (3 сообщения)" || bad "ReceiveMessage: получили $N"
# ChangeMessageVisibility по первому handle # ChangeMessageVisibility по первому handle.
# visibility-timeout=5с (НЕ 1с): после истечения таймаута сообщение снова
# видимо, а handle сбрасывается — delete по старому handle даёт
# ReceiptHandleIsInvalid (корректная AWS-семантика). При 1с сетевые задержки
# приводили к случайным FAIL теста.
RH1=$(echo "$RM" | json 'd["Messages"][0]["ReceiptHandle"]') RH1=$(echo "$RM" | json 'd["Messages"][0]["ReceiptHandle"]')
if [[ -n "$RH1" ]]; then if [[ -n "$RH1" ]]; then
"${AWS[@]}" sqs change-message-visibility --queue-url "$QUEUE_URL" --receipt-handle "$RH1" --visibility-timeout 1 >/dev/null 2>&1 \ "${AWS[@]}" sqs change-message-visibility --queue-url "$QUEUE_URL" --receipt-handle "$RH1" --visibility-timeout 5 >/dev/null 2>&1 \
&& ok "ChangeMessageVisibility" || bad "ChangeMessageVisibility" && ok "ChangeMessageVisibility" || bad "ChangeMessageVisibility"
fi fi
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""CLI v2 (aws cli) в нагрузке: 50 циклов send/receive/delete с замером латентности.
Запуск на ВМ: python3 tests/cli_load_test.py"""
import boto3
import subprocess
import time
import os
import re
ENV = dict(os.environ)
EP = 'https://sqs.containerk8s.dev.nubes.ru'
sqs = boto3.client('sqs', endpoint_url=EP, region_name='us-east-1')
def cli(*a, tmo=20):
t = time.time()
p = subprocess.run(['aws', '--endpoint-url', EP, '--region', 'us-east-1', 'sqs', *a],
env=ENV, capture_output=True, text=True, timeout=tmo)
return p, time.time() - t
def main():
q = 'cliload-%d' % int(time.time())
sqs.create_queue(QueueName=q)
u = EP + '/' + q
lat = {'send': [], 'receive': [], 'delete': []}
failed = 0
for i in range(50):
p, t = cli('send-message', '--queue-url', u, '--message-body', 'msg-%d' % i)
lat['send'].append(t)
if p.returncode != 0:
failed += 1
print('send FAIL', i, p.stderr[:120])
continue
p, t = cli('receive-message', '--queue-url', u, '--max-number-of-messages', '1', '--visibility-timeout', '3')
lat['receive'].append(t)
m = re.search(r'"ReceiptHandle":\s*"([^"]+)"', p.stdout)
if not m:
failed += 1
print('receive FAIL', i, p.stdout[:100], p.stderr[:100])
continue
p, t = cli('delete-message', '--queue-url', u, '--receipt-handle', m.group(1))
lat['delete'].append(t)
if p.returncode != 0:
failed += 1
print('delete FAIL', i, p.stderr[:120])
for k in lat:
v = sorted(lat[k])
n = len(v)
if n:
print('%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms' % (k, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
else:
print('%s: n=0' % k)
sqs.delete_queue(QueueUrl=u)
print('CLI load done, failed=%d' % failed)
if __name__ == '__main__':
main()
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Сравнение shared-SQS vs Yandex YMQ (очередь newsqs).
Короткий замер: последовательные операции, N send → N receive+delete.
Метрики: p50/p95/max латентности send/receive/delete, число ошибок.
Требуемые переменные окружения:
SHARED_AK, SHARED_SK — креды shared-SQS
YMQ_AK, YMQ_SK — креды Yandex YMQ (SA newsqs)
YMQ_QUEUE_URL — URL очереди Яндекса (newsqs)
Запуск: python3 tests/compare_ymq_vs_shared.py [N]
"""
import os
import sys
import time
import uuid
import boto3
from botocore.config import Config
N = int(sys.argv[1]) if len(sys.argv) > 1 else 50
BODY = "x" * 512
SHARED_EP = "https://sqs.containerk8s.dev.nubes.ru"
YMQ_EP = "https://message-queue.api.cloud.yandex.net"
SHARED_AK = os.environ["SHARED_AK"]
SHARED_SK = os.environ["SHARED_SK"]
YMQ_AK = os.environ["YMQ_AK"]
YMQ_SK = os.environ["YMQ_SK"]
YMQ_QUEUE_URL = os.environ["YMQ_QUEUE_URL"]
CFG = Config(connect_timeout=15, read_timeout=30, retries={"max_attempts": 0})
def mk_client(endpoint, region, ak, sk):
return boto3.client(
"sqs",
endpoint_url=endpoint,
region_name=region,
aws_access_key_id=ak,
aws_secret_access_key=sk,
config=CFG,
)
def stats(name, values):
v = sorted(values)
if not v:
print("%s: n=0" % name)
return
n = len(v)
print("%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms" %
(name, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
def bench(client, queue_url, label):
errs = 0
lat_send, lat_recv, lat_del = [], [], []
t_start = time.time()
for i in range(N):
t0 = time.time()
try:
client.send_message(QueueUrl=queue_url, MessageBody=BODY)
lat_send.append(time.time() - t0)
except Exception as e:
errs += 1
if errs <= 3:
print(" send err: %s %s" % (type(e).__name__, str(e)[:80]))
got = 0
tries = 0
while got < N and tries < N * 6:
tries += 1
t0 = time.time()
try:
r = client.receive_message(
QueueUrl=queue_url, MaxNumberOfMessages=1, VisibilityTimeout=30)
lat_recv.append(time.time() - t0)
msgs = r.get("Messages", [])
except Exception as e:
errs += 1
if errs <= 3:
print(" recv err: %s %s" % (type(e).__name__, str(e)[:80]))
continue
for m in msgs:
got += 1
t0 = time.time()
try:
client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"])
lat_del.append(time.time() - t0)
except Exception as e:
errs += 1
if errs <= 3:
print(" del err: %s %s" % (type(e).__name__, str(e)[:80]))
total = time.time() - t_start
print("== %s (N=%d) ==" % (label, N))
stats(" send ", lat_send)
stats(" receive", lat_recv)
stats(" delete ", lat_del)
print(" received=%d/%d errors=%d total=%.1fs" % (got, N, errs, total))
print(" throughput: send=%.1f op/s, recv+del=%.1f op/s" %
(N / total if total else 0, got / total if total else 0))
return errs
def drain(client, queue_url):
while True:
r = client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=10)
msgs = r.get("Messages", [])
if not msgs:
break
for m in msgs:
try:
client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"])
except Exception:
pass
def main():
shared = mk_client(SHARED_EP, "us-east-1", SHARED_AK, SHARED_SK)
ymq = mk_client(YMQ_EP, "ru-central1", YMQ_AK, YMQ_SK)
# Временная очередь shared-SQS
qname = "cmp-%s" % uuid.uuid4().hex[:8]
shared_url = shared.create_queue(QueueName=qname)["QueueUrl"]
print("shared queue: %s" % shared_url)
# YMQ newsqs: перед тестом чистим
drain(ymq, YMQ_QUEUE_URL)
print("ymq queue: %s (drained)" % YMQ_QUEUE_URL)
print()
e1 = bench(shared, shared_url, "shared-SQS (наш сервис)")
print()
e2 = bench(ymq, YMQ_QUEUE_URL, "Yandex YMQ (newsqs)")
# Очистка
drain(ymq, YMQ_QUEUE_URL)
try:
shared.delete_queue(QueueUrl=shared_url)
print("\ncleanup: shared temp queue deleted, ymq drained")
except Exception as ex:
print("\ncleanup err: %s" % str(ex)[:80])
print("\nRESULT: errors shared=%d ymq=%d" % (e1, e2))
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Детерминированная проверка DLQ-переноса (без гонок тайминга):
после каждой доставки ждём истечения visibility + тик PeriodicTasks (2.5с).
Если 5/5 PASS — сервис корректен, флап fifo_dlq_probe — гонка теста."""
import json
import time
import uuid
import boto3
from botocore.config import Config
ENDPOINT = "https://sqs.containerk8s.dev.nubes.ru"
REGION = "us-east-1"
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 0}))
def drain(url, n=10):
msgs = []
while True:
b = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=n).get("Messages", [])
if not b:
break
msgs.extend(b)
return msgs
def run(i):
uid = str(uuid.uuid4())[:8]
dlq_name = "dlqdet-%s-%d" % (uid, i)
main_name = "maindet-%s-%d" % (uid, i)
dlq_url = sqs.create_queue(QueueName=dlq_name)["QueueUrl"]
tenant = dlq_url.split("/")[-2]
arn = "arn:aws:sqs:%s:%s:%s" % (REGION, tenant, dlq_name)
policy = json.dumps({"deadLetterTargetArn": arn, "maxReceiveCount": "2"})
main_url = sqs.create_queue(
QueueName=main_name,
Attributes={"VisibilityTimeout": "1", "RedrivePolicy": policy},
)["QueueUrl"]
sqs.send_message(QueueUrl=main_url, MessageBody="to-dlq")
r1 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
time.sleep(2.5) # visibility истёк + тик вернул (Retry=1)
r2 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
time.sleep(2.5) # visibility истёк + тик: Retry=2 >= maxReceiveCount=2 -> DLQ
r3 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
time.sleep(2.0)
dlq_msgs = drain(dlq_url)
main_left = drain(main_url)
ok = (len(r1) == 1 and len(r2) == 1 and len(r3) == 0
and len(dlq_msgs) == 1 and dlq_msgs[0]["Body"] == "to-dlq"
and len(main_left) == 0)
print("run %d: r1=%d r2=%d r3=%d dlq=%d main_left=%d -> %s"
% (i, len(r1), len(r2), len(r3), len(dlq_msgs), len(main_left),
"PASS" if ok else "FAIL"), flush=True)
try:
sqs.delete_queue(QueueUrl=main_url)
sqs.delete_queue(QueueUrl=dlq_url)
except Exception:
pass
return ok
ok = 0
for i in range(1, 6):
try:
if run(i):
ok += 1
except Exception as e:
print("run %d: EXC %s %s" % (i, type(e).__name__, str(e)[:120]), flush=True)
print("TOTAL: %d/5 PASS" % ok, flush=True)
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""tests/fifo_dlq_probe.py — e2e: FIFO порядок + dedup внутри окна + DLQ (план Соннета P2).
Проверки:
1. FIFO: 10 сообщений в одну группу выдаются строго по порядку.
2. FIFO dedup: два send с одинаковым MessageDeduplicationId внутри окна
(5 мин) → в очереди оказывается ОДНО сообщение.
3. DLQ: сообщение после MaxReceiveCount=2 попыток (VisibilityTimeout=1с)
переносится в dead-letter очередь.
"""
import json
import os
import sys
import time
import uuid
import boto3
from botocore.config import Config
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
REGION = os.environ.get("REGION", "us-east-1")
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 3}))
uid = str(uuid.uuid4())[:8]
def drain(url, n=10):
msgs = []
while True:
b = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=n).get("Messages", [])
if not b:
break
msgs.extend(b)
return msgs
results = {}
def check_fifo_order():
q = "fifo-order-%s.fifo" % uid
u = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
for i in range(10):
sqs.send_message(QueueUrl=u, MessageBody="m%d" % i, MessageGroupId="g", MessageDeduplicationId="d%d" % (i,))
got = []
while True:
b = sqs.receive_message(QueueUrl=u, MaxNumberOfMessages=10).get("Messages", [])
if not b:
break
got.extend(b)
for m in b:
sqs.delete_message(QueueUrl=u, ReceiptHandle=m["ReceiptHandle"])
bodies = [m["Body"] for m in got]
want = ["m%d" % i for i in range(10)]
results["fifo_order"] = bodies == want
print("fifo_order: got=%s want=%s %s" % (bodies, want, "PASS" if bodies == want else "FAIL"), flush=True)
sqs.delete_queue(QueueUrl=u)
def check_fifo_dedup():
q = "fifo-dedup-%s.fifo" % uid
u = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
sqs.send_message(QueueUrl=u, MessageBody="first", MessageGroupId="g", MessageDeduplicationId="SAME")
sqs.send_message(QueueUrl=u, MessageBody="dup", MessageGroupId="g", MessageDeduplicationId="SAME")
msgs = drain(u)
ok = len(msgs) == 1 and msgs[0]["Body"] == "first"
results["fifo_dedup"] = ok
print("fifo_dedup: count=%d body=%s %s" % (len(msgs), msgs[0]["Body"] if msgs else None,
"PASS" if ok else "FAIL"), flush=True)
sqs.delete_queue(QueueUrl=u)
def check_dlq():
dlq_name = "fifo-dlq-%s" % uid
main_name = "fifo-main-%s" % uid
dlq_url = sqs.create_queue(QueueName=dlq_name)["QueueUrl"]
# tenantID из URL DLQ: http://us-east-1.goaws.com:4100/t-<id>/<name>
tenant = dlq_url.split("/")[-2]
arn = "arn:aws:sqs:%s:%s:%s" % (REGION, tenant, dlq_name)
policy = json.dumps({"deadLetterTargetArn": arn, "maxReceiveCount": "2"})
main_url = sqs.create_queue(
QueueName=main_name,
Attributes={"VisibilityTimeout": "1", "RedrivePolicy": policy},
)["QueueUrl"]
sqs.send_message(QueueUrl=main_url, MessageBody="to-dlq")
for attempt in range(3):
b = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
print("attempt %d: %s" % (attempt + 1, "received" if b else "empty"), flush=True)
time.sleep(1.5)
# Перенос в DLQ выполняет фоновый тик PeriodicTasks (период 1с) при
# истечении VisibilityTimeout. Мгновенная проверка гоняет с фазой тика —
# опрашиваем DLQ до 10с.
dlq_msgs = []
for _ in range(10):
dlq_msgs = drain(dlq_url)
if dlq_msgs:
break
time.sleep(1)
ok = len(dlq_msgs) == 1 and dlq_msgs[0]["Body"] == "to-dlq"
results["dlq"] = ok
print("dlq: count=%d body=%s %s" % (len(dlq_msgs), dlq_msgs[0]["Body"] if dlq_msgs else None,
"PASS" if ok else "FAIL"), flush=True)
sqs.delete_queue(QueueUrl=main_url)
sqs.delete_queue(QueueUrl=dlq_url)
def main():
try:
check_fifo_order()
except Exception as e:
results["fifo_order"] = False
print("fifo_order: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
try:
check_fifo_dedup()
except Exception as e:
results["fifo_dedup"] = False
print("fifo_dedup: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
try:
check_dlq()
except Exception as e:
results["dlq"] = False
print("dlq: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
print("SUMMARY: %s" % json.dumps(results), flush=True)
return 0 if all(results.values()) else 1
if __name__ == "__main__":
sys.exit(main())
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""ДЛИННОЕ сравнение shared-SQS vs Yandex YMQ — с ЛОКАЛИ, по таймеру.
Схема (честная — обе очереди в одних сетевых условиях, раунды попеременно):
1. Старт: seed K сообщений в каждую очередь.
2. Раунд для каждой очереди: receive(1) -> delete -> send(1) (восполнение пула).
3. Раунды чередуются (наш, яндекс, наш, яндекс...) до истечения DURATION.
Метрики: p50/p95/max для send/receive/delete, ошибки по типам (с таймаутами),
op/s. Прогресс каждые 60с пишется в LOG и stdout.
Env: SHARED_AK, SHARED_SK, YMQ_AK, YMQ_SK, YMQ_QUEUE_URL, DURATION (сек, default 1800)
"""
import os
import sys
import time
import uuid
import boto3
from botocore.config import Config
DURATION = int(os.environ.get("DURATION", "1800"))
K = 10
BODY = "x" * 512
LOG = "tests/long_compare.log"
SHARED_EP = "https://sqs.containerk8s.dev.nubes.ru"
YMQ_EP = "https://message-queue.api.cloud.yandex.net"
CFG = Config(connect_timeout=15, read_timeout=30, retries={"max_attempts": 0})
def mk(endpoint, region, ak, sk):
return boto3.client("sqs", endpoint_url=endpoint, region_name=region,
aws_access_key_id=ak, aws_secret_access_key=sk, config=CFG)
class Stat:
def __init__(self):
self.vals = {"send": [], "receive": [], "delete": []}
self.errs = {} # type -> count
self.ops = {"send": 0, "receive": 0, "delete": 0}
def op(self, name, fn):
self.ops[name] += 1
t0 = time.time()
try:
fn()
self.vals[name].append(time.time() - t0)
except Exception as e:
k = type(e).__name__
self.errs[k] = self.errs.get(k, 0) + 1
def stat_line(st, label):
out = [label]
for name in ("send", "receive", "delete"):
v = sorted(st.vals[name])
n = len(v)
if n:
out.append("%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms" %
(name, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
else:
out.append("%s: n=0" % name)
out.append("errors=%s" % (st.errs if st.errs else "0"))
return " | ".join(out)
def logp(msg):
line = "%s %s" % (time.strftime("%H:%M:%S"), msg)
print(line, flush=True)
try:
with open(LOG, "a") as f:
f.write(line + "\n")
except Exception:
pass
def main():
shared = mk(SHARED_EP, "us-east-1", os.environ["SHARED_AK"], os.environ["SHARED_SK"])
ymq = mk(YMQ_EP, "ru-central1", os.environ["YMQ_AK"], os.environ["YMQ_SK"])
ymq_url = os.environ["YMQ_QUEUE_URL"]
# временная очередь shared
qname = "longcmp-%s" % uuid.uuid4().hex[:8]
shared_url = shared.create_queue(QueueName=qname)["QueueUrl"]
def drain(client, url):
while True:
try:
r = client.receive_message(QueueUrl=url, MaxNumberOfMessages=10)
msgs = r.get("Messages", [])
except Exception:
break
if not msgs:
break
for m in msgs:
try:
client.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"])
except Exception:
pass
drain(shared, shared_url)
drain(ymq, ymq_url)
ss = Stat()
ys = Stat()
sts = {"shared": (shared, shared_url, ss), "ymq": (ymq, ymq_url, ys)}
# seed K в обе очереди
for key in ("shared", "ymq"):
c, u, st = sts[key]
for _ in range(K):
st.op("send", lambda c=c, u=u: c.send_message(QueueUrl=u, MessageBody=BODY))
logp("LONG COMPARE START: duration=%ds K=%d shared=%s" % (DURATION, K, shared_url))
logp("ymq=%s" % ymq_url)
deadline = time.time() + DURATION
last_prog = time.time()
rounds = 0
def round_for(key):
nonlocal rounds
c, u, st = sts[key]
# receive (замер) + получить сообщения для delete
got = []
t0 = time.time()
st.ops["receive"] += 1
try:
r = c.receive_message(QueueUrl=u, MaxNumberOfMessages=1, VisibilityTimeout=30)
st.vals["receive"].append(time.time() - t0)
got = r.get("Messages", [])
except Exception as e:
st.errs[type(e).__name__] = st.errs.get(type(e).__name__, 0) + 1
for m in got:
st.op("delete", lambda c=c, u=u, m=m: c.delete_message(QueueUrl=u, ReceiptHandle=m["ReceiptHandle"]))
st.op("send", lambda c=c, u=u: c.send_message(QueueUrl=u, MessageBody=BODY))
while time.time() < deadline:
round_for("shared")
round_for("ymq")
rounds += 1
if time.time() - last_prog >= 60:
last_prog = time.time()
logp("progress %d/%ds" % (int(time.time() - (deadline - DURATION)), DURATION))
logp(" " + stat_line(ss, "SHARED"))
logp(" " + stat_line(ys, "YMQ"))
logp("LONG COMPARE DONE: rounds=%d" % rounds)
logp(stat_line(ss, "FINAL SHARED"))
logp(stat_line(ys, "FINAL YMQ"))
# cleanup
drain(shared, shared_url)
drain(ymq, ymq_url)
try:
shared.delete_queue(QueueUrl=shared_url)
logp("cleanup: shared temp queue deleted, both drained")
except Exception as e:
logp("cleanup err: %s" % str(e)[:100])
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""tests/reboot_probe.py — тест рестарта пода с in-flight сообщениями (план Соннета P0).
Фаза 1 (phase1): 10 очередей x 20 сообщений (проверка восстановления из Redis)
+ основная очередь со 100 сообщениями, из которых 50 выдаются (in-flight).
Фаза 2 (phase2, ПОСЛЕ kubectl delete pod --grace-period=0):
- /health поднимается;
- во всех 10 очередях должны быть ровно по 20 сообщений;
- из основной должны выдаться все 100 уникальных тел (50 in-flight + 50 видимых).
"""
import json
import os
import sys
import time
import uuid
import boto3
import urllib.request
from botocore.config import Config
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
REGION = os.environ.get("REGION", "us-east-1")
STATE = "/tmp/reboot_state.json"
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 3}))
def wait_health(timeout=180):
t0 = time.time()
while time.time() - t0 < timeout:
try:
with urllib.request.urlopen(ENDPOINT + "/health", timeout=10) as r:
if r.status == 200:
return True
except Exception:
pass
time.sleep(3)
return False
def drain(url):
got = []
while True:
msgs = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10).get("Messages", [])
if not msgs:
break
got.extend(msgs)
return got
def phase1():
uid = str(uuid.uuid4())[:8]
state = {"uid": uid, "restore_queues": [], "main_queue": None, "bodies": []}
# 10 очередей по 20 сообщений — проверка восстановления из Redis
for i in range(10):
u = sqs.create_queue(QueueName="reboot-restore-%d-%s" % (i, uid))["QueueUrl"]
for j in range(20):
sqs.send_message(QueueUrl=u, MessageBody="r-%d-%d-%s" % (i, j, uid))
state["restore_queues"].append(u)
print("restore queue %d ready" % i, flush=True)
# основная: 100 сообщений, 50 выдаём (in-flight)
u = sqs.create_queue(QueueName="reboot-main-%s" % uid)["QueueUrl"]
bodies = ["m-%03d-%s" % (i, uid) for i in range(100)]
for b in bodies:
sqs.send_message(QueueUrl=u, MessageBody=b)
first = sqs.receive_message(QueueUrl=u, MaxNumberOfMessages=10, WaitTimeSeconds=1).get("Messages", [])
# добираем до 50 in-flight (Max=10 за вызов)
while len(first) < 50:
batch = sqs.receive_message(QueueUrl=u, MaxNumberOfMessages=10, WaitTimeSeconds=1).get("Messages", [])
if not batch:
break
first.extend(batch)
print("in-flight на момент рестарта: %d" % len(first), flush=True)
state["main_queue"] = u
state["bodies"] = bodies
json.dump(state, open(STATE, "w"))
print("PHASE1_DONE", flush=True)
def phase2():
st = json.load(open(STATE))
print("ждём /health после рестарта...", flush=True)
if not wait_health():
print("HEALTH_FAIL: /health не поднялся", flush=True)
sys.exit(1)
print("health ok", flush=True)
# восстановление 10 очередей
restore_ok = True
for u in st["restore_queues"]:
msgs = drain(u)
if len(msgs) != 20:
restore_ok = False
print("RESTORE FAIL: %s -> %d сообщений" % (u.split("/")[-1], len(msgs)), flush=True)
print("restore: %s" % ("OK (10x20)" if restore_ok else "FAIL"), flush=True)
# основная: все 100 уникальных
got = drain(st["main_queue"])
got_bodies = {m["Body"] for m in got}
want = set(st["bodies"])
missing = want - got_bodies
dups = len(got) - len(got_bodies)
print("main: получено %d, уникальных %d, пропущено %d, дублей %d"
% (len(got), len(got_bodies), len(missing), dups), flush=True)
print("MAIN_RESULT: %s" % ("PASS" if not missing else "FAIL (потеря!)"), flush=True)
# cleanup
for u in st["restore_queues"] + [st["main_queue"]]:
try:
sqs.delete_queue(QueueUrl=u)
except Exception:
pass
print("PHASE2_DONE", flush=True)
if __name__ == "__main__":
if len(sys.argv) != 2 or sys.argv[1] not in ("phase1", "phase2"):
print("usage: reboot_probe.py phase1|phase2")
sys.exit(2)
if sys.argv[1] == "phase1":
phase1()
else:
phase2()
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""tcp_mtu_probe.py — бинарный поиск MTU-порога по TCP к шлюзу Nubes.
Критерий: время до ответа nginx. Тело дошло → мгновенный ответ (<2с).
Сегмент дропнут (MTU/PMTUD) → ответ не приходит (nginx ждёт тело) → считаем
"не прошло" по таймауту FAST_TIMEOUT. Порог ищем делением пополам между
LO и HI, потом уточняем повторными пробами вокруг порога.
"""
import socket
import ssl
import time
HOST = "185.247.187.151"
PORT = 443 # реальный путь клиентов; 32391 (NodePort) снаружи закрыт
LO = 512 # заведомо проходит
HI = 20000 # заведомо не проходит (14+ сегментов по MSS)
FAST_TIMEOUT = 20 # быстрее полного зависания ~51с
FAST_OK = 2.0 # ответ быстрее этого = прошло
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def probe(n):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(FAST_TIMEOUT)
t0 = time.time()
try:
s.connect((HOST, PORT))
mss = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_MAXSEG)
ss = ctx.wrap_socket(s, server_hostname="sqs.containerk8s.dev.nubes.ru")
body = b"x" * n
req = (b"POST / HTTP/1.1\r\n"
b"Host: sqs.containerk8s.dev.nubes.ru\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
b"Content-Length: " + str(n).encode() + b"\r\n"
b"Connection: close\r\n\r\n" + body)
ss.sendall(req)
data = ss.recv(4096)
dt = time.time() - t0
first = data.split(b"\r\n")[0][:40].decode("latin1", "replace")
return dt, mss, first
except Exception as e:
return time.time() - t0, None, "%s" % type(e).__name__
finally:
try:
s.close()
except Exception:
pass
def passed(n):
dt, mss, resp = probe(n)
ok = dt < FAST_OK
print(" size=%6d: %.2fs mss=%s %s -> %s" %
(n, dt, mss if mss else "-", resp, "OK" if ok else "DROP"), flush=True)
return ok
def main():
# санпроверка границ
print("check LO=%d:" % LO)
if not passed(LO):
print("LO не проходит — границы неверны, выход")
return
print("check HI=%d:" % HI)
if passed(HI):
print("HI тоже проходит мгновенно — порога нет в диапазоне, выход")
return
lo, hi = LO, HI
while hi - lo > 50:
mid = (lo + hi) // 2
if passed(mid):
lo = mid
else:
hi = mid
print("\nпорог найден: lo=%d hi=%d (проходит <=lo, дроп >=hi)" % (lo, hi))
# уточнение вокруг порога: 3 пробы на каждую из 4 точек рядом
print("\nуточнение (по 3 пробы):")
for n in (lo - 200, lo, (lo + hi) // 2, hi + 200):
res = sum(1 for _ in range(3) if passed(max(n, 1)))
print(" size=%d: %d/3 прошли" % (n, res))
if __name__ == "__main__":
main()