Files
SQS-service/doc/thinking/plan-for-flash-2026-10-05.md
T

18 KiB
Raw Blame History

План доработок 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 ./....