# 2026-04-09 — Thinking Log **Агент:** GitHub Copilot (Claude Opus 4) --- ## Анализ H2 file lock — корневая причина ### Симптом При каждом `kubectl rollout restart` ElasticMQ стартует, но `SendMessage` зависает навсегда. В логах: `The file is locked: /data/elasticmq.mv.db [2.2.224/7]`, затем `dead letters` и `AskTimeoutException` на SendMessage. ### Ошибочная гипотеза (v0.1.10) Предположил что JVM не освобождает JDBC-level lock при crash → добавил `FILE_LOCK=NO` в JDBC URI. **Это было НЕПРАВИЛЬНО.** `FILE_LOCK=NO` отключает только JDBC soft-lock. H2 MVStore использует `java.nio.FileChannel.lock()` — это OS-level file lock, не зависящий от JDBC параметров. ### Почему "работало" после каждой чистки После `rm /data/elasticmq.mv.db` + restart — файл создаётся заново, lock отсутствует. Но при следующем rollout restart проблема возвращается. ### Корневая причина (найдена 2026-04-09) **Deployment strategy: `RollingUpdate` + PVC: `ReadWriteOnce`** Цепочка событий при `kubectl rollout restart`: 1. Kubernetes добавляет аннотацию `restartedAt` → меняется template → начинается rollout 2. Стратегия `RollingUpdate` (maxSurge=25%, maxUnavailable=25%) → для replicas=1: - maxSurge=1 (ceil 0.25) → Kubernetes поднимает НОВЫЙ pod - maxUnavailable=0 (floor 0.25) → старый pod ЕЩЁ ЖИВА 3. PVC `ReadWriteOnce` — допускает mount с нескольких pod на ОДНОЙ НОДЕ (это не ReadWriteOncePod) 4. Оба pod монтируют один PVC → оба пытаются открыть `/data/elasticmq.mv.db` 5. Старый ElasticMQ держит `FileChannel.lock()` → новый ElasticMQ получает `MVStoreException: The file is locked` 6. Persistence actor (SqlQueuePersistenceActor) в новом pod падает → dead letters 7. Старый pod убивается (readinessProbe eventual fail) → lock освобождается — но поздно 8. SQS REST server работает (port 9324 слушает), но WRITE-операции (SendMessage) зависают — actor мёртв ### Решение — 3 изменения в ensureDeployment 1. **Strategy: Recreate** (вместо RollingUpdate) - Kubernetes СНАЧАЛА убивает старый pod, ПОТОМ поднимает новый - Два pod НИКОГДА не работают одновременно → lock невозможен - Downtime ~25-30 секунд (JVM startup) — допустимо для мультитенант SQS 2. **preStop hook: sleep 3** - При SIGTERM JVM начинает shutdown - `sleep 3` даёт H2 время на `fsync` + `FileChannel.close()` - Без preStop: Kubernetes может убить pod раньше чем H2 закончит flush 3. **livenessProbe timeoutSeconds: 1 → 3** - JVM стартует за 20-23 секунды - initialDelaySeconds=5 + failureThreshold=5 × period=10 = 55 сек запас — хватает для старта - НО: `timeoutSeconds=1` — если GC pause > 1 сек → liveness fail → unnecessary restart → CrashLoopBackOff - Поднимаем до 3 секунд. GC pause > 3 сек — это уже реальная проблема которую стоит рестартить ## Дополнительные обнаруженные проблемы ### /_next/ и /queues/ ingress — глобальные (архитектурная) Пути `/_next/` и `/queues/` на хосте `sqs.kube5s.ru` общие. При двух тенантах с `enableUI=true` — конфликт ingress. **Решение отложено** — пока один тенант с UI. При мультитенант UI → нужен отдельный хост per tenant. ### imagePullPolicy: Always на UI `softwaremill/elasticmq-ui:latest` + `Always` → upstream может сломать при обновлении. **Пока оставляем** — будем пинить версию когда стабилизируем. ### memory limit 512Mi vs Xmx 384m `-Xmx384m` + JVM overhead ~150 МБ = ~534 МБ > limit 512 Mi. OOMKill возможен при нагрузке. **Пока оставляем** — в idle не стреляет. Учтём при нагрузочном тестировании. ## Самоанализ ошибки Почему неправильно решил в v0.1.10: - Увидел `The file is locked` → сразу искал H2-настройки → нашёл `FILE_LOCK=NO` - НЕ проверил deployment strategy (RollingUpdate — default в Kubernetes) - НЕ проверил ReadWriteOnce behavior (допускает multi-pod на одной ноде) - НЕ проверил что происходит при rollout (два pod одновременно) - Лечил симптом (lock message) вместо причины (concurrent access) **Вывод:** при любой ошибке связанной с persistence/lock/state — ПЕРВЫМ делом проверять: кто ещё имеет доступ к файлу? Сколько pod одновременно работают? Какая стратегия деплоя? --- ## Анализ shared-sqs — форк GoAWS для multi-tenant SQS **Агент:** GitHub Copilot (Claude Opus 4) **Время:** 2026-04-09, вечер ### Контекст Пользователь решил делать shared multi-tenant SQS сервис (вариант А — форк GoAWS). Нужен детальный план для другого агента (Sonnet). ### Исследование GoAWS Скачал и проанализировал исходники: - **router.go** — gorilla/mux, единый `actionHandler` диспатчит по Action name из routingTableV1 - **globals.go** — `SyncQueues` = один map[string]*Queue с RWMutex. Это ВЕСЬ state. - **models.go** — Queue struct: Name, URL, ARN, Messages []SqsMessage, VisibilityTimeout и т.д. - **create_queue.go** — создаёт очередь, ключ в map = queueName, URL = `http://host:port/accountID/queueName` - **send_message.go** — извлекает queueName из последнего сегмента URL, ищет в SyncQueues - **gosqs.go** — PeriodicTasks каждую секунду: visibility timeout reset, DLQ routing, dedup cleanup - **configuration.go** — Environment struct с Host, Port, Region, AccountID (глобальная, ОДНА на всех) ### Ключевое наблюдение GoAWS УЖЕ имеет `/{account}/{queueName}` маршрут в роутере. AccountID используется в URL/ARN. Это значит: tenantID = accountID — естественное отображение. Queue URL для тенанта: `http://host:port/{tenantID}/{queueName}` — совпадает с route pattern. ### Архитектурное решение: tenant isolation Ключ в SyncQueues: `{accessKey}:{queueName}` (вместо просто `{queueName}`) Почему AccessKey: уже есть в auth context, уникален, не надо лишний lookup по TenantID→AccessKey. ### Идентифицированные ловушки (17 штук) Задокументировал в PLAN.md все точки где могут быть баги: 1. crypto/rand vs math/rand для генерации ключей 2. Удаление из двух индексов при delete tenant 3. Парсинг AWS Signature V4 Authorization header 4. Long polling таймаут (до 20 сек) 5. Игнорирование X-Amz-Security-Token 6. URL parsing — последний сегмент = queueName (НЕ tenantID) 7. getQueueFromPath() — совместимость с новым URL форматом 8. FIFO queue naming (.fifo суффикс) 9. DLQ ARN parsing для tenant-scoped lookup 10. QueueUrl в ответах ОБЯЗАН содержать tenantID 11. DELETE tenant → удалить все очереди (memory leak) 12. Admin API — отдельная auth (bearer token, не tenant credentials) 13. Graceful shutdown (GoAWS не обрабатывает signals) 14. Deployment strategy: Recreate (урок из ERR-SQS-06) 15. .gitignore для бинарника 16. go mod tidy после удаления SNS 17. Import paths: goaws → shared-sqs ### Результат Создан `shared-sqs/PLAN.md` — ~400 строк, 9 этапов, 17 ловушек, примеры кода. Достаточно подробный для Sonnet чтобы реализовать без дополнительных вопросов. --- ## Этап 1 — Клон GoAWS + чистка (GitHub Copilot / Claude Sonnet 4.6) **Время:** 2026-04-09 ### Анализ перед началом Директория `shared-sqs/` содержит только `PLAN.md` и `doc/`. Нужно: 1. Клонировать GoAWS upstream в `_upstream/`, скопировать `app/`, `go.mod`, `go.sum`, `Dockerfile` 2. Сменить module на `shared-sqs`, Go 1.22 3. Заменить все import paths `github.com/Admiral-Piett/goaws/app/` → `shared-sqs/app/` 4. Удалить `gosns/` и все SNS-связанные места 5. Удалить тестовые директории ### Риски - GoAWS может иметь скрытые зависимости на SNS внутри SQS-handlers (например, DeadLetter → SNS upsert) - `router.go` содержит SNS-actions в routingTableV1 — нужно аккуратно вычистить - После удаления SNS могут остаться неиспользуемые imports — нужен `go mod tidy` ### Стратегия Все команды — по SSH. Файлы go модифицировать через `sed` (замена import paths). После удаления SNS — `go build`, смотреть на ошибки, фиксить одну за одной. ### Результат Этапа 1 - GoAWS склонирован, module = `shared-sqs`, Go 1.22 - Все import paths заменены (sed по всем .go) - gosns/, mocks/, fixtures/, servertest/, test/ — удалены - SNS-код убран из: models.go, configuration.go, globals.go, helpers.go, requests.go, responses.go, router.go, conf/config.go - Нюанс: responses.go был обрезан слишком агрессивно — DeleteMessageBatch типы потерялись. Добавил их вручную. - go build OK, /health → "OK" - Коммит: f4352a1 --- ## Этап 2 — TenantStore (GitHub Copilot / Claude Sonnet 4.6) ### Анализ Нужно создать `app/tenant/tenant_store.go`: - Tenant struct: ID, Name, AccessKey, SecretKey, MaxQueues, CreatedAt, Active - TenantStore: два индекса (byID + byAccessKey), RWMutex - Ловушка #1: crypto/rand для ключей - Ловушка #2: Delete из ОБОИХ индексов - AccessKey формат: `SSAK-{12 random bytes hex}` = SSAK- + 24 hex chars - SecretKey: 32 bytes → 64 hex chars --- ## Сессия 2 (продолжение) — Claude Sonnet 4.6 ### Продолжение с момента прерывания Предыдущая сессия была прервана на Этапе 4 (все 13 handlers переписаны, but go build not yet run). **Немедленное действие:** go build → чисто. Этап 4 пройден. ### Этап 4 — результат - go build: OK - Бинарник: 10MB - Коммит: 08053ca "shared-sqs: Этап 4 — изоляция очередей по тенанту" ### Этап 5 — Admin API **Решение:** Создан `app/admin/admin.go` с handlers: - `POST /admin/tenants` — create, возвращает secret_key ОДИН РАЗ - `GET /admin/tenants` — список без secret_key - `GET /admin/tenants/{id}` — деталь без secret_key - `DELETE /admin/tenants/{id}` — удаляет тенанта + все его очереди из SyncQueues (Trap #11) - `GET /admin/health` — детальный health с counts **Trap #12 реализован:** Bearer token auth в bearerAuthMiddleware, полностью изолирован от tenant auth. **Trap #11 реализован:** DELETE итерирует SyncQueues, удаляет все ключи с prefix `{accessKey}:`. Без этого — memory leak. ### Этап 6 — Entry Point **Обновлён goaws.go:** - Флаги: --config, --admin-token, --port, --debug, --loglevel - Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > `log.Fatal` (Trap #13) - TenantStore инициализируется при старте - `router.New(tenantStore, adminToken)` — передача зависимостей - HTTP сервер с таймаутами (WriteTimeout = 35s > max WaitTimeSeconds 20s для long polling) - Graceful shutdown: SIGTERM/SIGINT → close(quit) → srv.Shutdown(10s) **Trap #13 реализован:** SIGTERM → quit channel → PeriodicTasks останавливается корректно. ### Этап 7 — Dockerfile + K8s **Dockerfile:** multi-stage (golang:1.22-alpine → alpine:3.19), CGO_ENABLED=0 **K8s manifests:** - namespace.yaml, deployment.yaml, service.yaml, secret.yaml - `strategy: Recreate` — НЕ RollingUpdate (Trap #14: in-memory state, split brain risk) ### Этап 8 — Makefile Таргеты: build, docker-build, docker-push, test, run, clean. **Фикс:** Makefile через heredoc потерял табы → пересоздан через Python с \t. ### Итоговое состояние go build → OK (все этапы 1-8) Коммиты: - 08053ca — Этап 4 - 0736832 — Этапы 5+6 - 2c9a2b2 — Этапы 7+8 Остался Этап 9 — bash тесты. Ждём указания пользователя.