Adds the SciMesh coordinator: a durable task-queue server on PostgreSQL that owns all database access, with workers reaching it over HTTP only. Structured as a modular monolith following Clean Architecture: domain entities and their invariants, no I/O usecase business operations + repository/clock ports transport HTTP handlers, DTOs, auth, error mapping storage PostgreSQL repositories, transactions carried in context infra config, pool, clock, server, lease reaper Dependencies point strictly inward; domain imports nothing from the module. Working: layer wiring, routing, shared-token auth, access logging, request IDs, domain-error to status-code mapping, transactional boundaries, graceful shutdown (HTTP drain -> reaper stop -> pool close), migrations, and a Compose stack starting Postgres -> migrations -> coordinator. The domain is complete and covered by unit tests that need no database: lease ownership, stale attempts, idempotent result replay, retry budgets, and lease expiry. Repository methods are stubs returning ErrNotImplemented (HTTP 501). The SQL for atomic claiming (FOR UPDATE SKIP LOCKED) and for lease expiry is written and ready to wire up. See coordinator/ARCHITECTURE.md for the layer map and a request traced through every layer.
8.4 KiB
Архитектура координатора
Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит запрос», в конце — «куда добавлять новое».
1. Четыре слоя
infra конфиг, пул БД, часы, HTTP-сервер, reaper ← драйверы
transport HTTP-хендлеры ← входящее: кто зовёт нас
storage репозитории на SQL ← исходящее: кого зовём мы
usecase операции + ПОРТЫ (интерфейсы) ← прикладные правила
domain Task, Job и их инварианты ← бизнес-правила
┌── transport ──┐
domain ◄── usecase ◄┤ ├◄── infra
└── storage ────┘
transport и storage — один и тот же слой (в книгах он зовётся «адаптеры»),
просто разделённый по направлению: транспорт принимает запросы снаружи, storage
обращается наружу сам. Так путь к файлу говорит о его роли, а не о категории.
Единственное правило: зависимости идут только внутрь. domain не импортирует
ничего из проекта. usecase видит только domain. transport и storage не
знают друг о друге.
Проверить в любой момент:
go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal
# пусто = правило соблюдено
2. Где что лежит
| Файл | Что внутри | Строк |
|---|---|---|
domain/task.go |
Task и все переходы состояний: аренда, завершение, провал, истечение |
~245 |
domain/job.go |
Job, разбиение на чанки, вывод статуса из счётчиков задач |
~107 |
domain/errors.go |
Нарушения бизнес-правил (ErrLeaseConflict, ErrStaleAttempt, …) |
~18 |
usecase/ports.go |
Порты: TaskRepository, JobRepository, TxManager, Clock |
~79 |
usecase/task.go |
Операции над задачей: claim, renew, complete, fail, expire | ~200 |
usecase/job.go |
Операции над job: create, status, results, stitch | ~180 |
usecase/dto.go |
Входные структуры юзкейсов | ~51 |
transport/http/server.go |
Роутер и сборка middleware | ~60 |
transport/http/handlers.go |
По хендлеру на эндпоинт | ~180 |
transport/http/dto.go |
JSON-форматы запросов и ответов | ~118 |
transport/http/middleware.go |
request-ID, access-лог, bearer-авторизация | ~103 |
transport/http/errors.go |
Маппинг доменных ошибок в HTTP-коды | ~55 |
storage/postgres/task_repo.go |
SQL по задачам, включая атомарный claim | ~109 |
storage/postgres/job_repo.go |
SQL по job'ам | ~39 |
storage/postgres/tx.go |
TxManager: транзакция через контекст |
~65 |
infra/*.go |
Конфиг, пул, часы, сервер, reaper | ~240 |
cmd/coordinator/main.go |
Composition root — единственное место со всеми конкретными типами | ~73 |
3. Трасса запроса: POST /tasks/claim
Как воркер получает задачу. Четыре остановки, по одной на слой:
① transport/http/handlers.go → handleClaim
разбирает JSON, отдаёт usecase.ClaimTaskInput
│
▼
② usecase/task.go → ClaimTask.Execute
сначала подчищает протухшие аренды, потом просит одну задачу
через ПОРТ TaskRepository (реализацию не знает)
│
▼
③ usecase/ports.go → TaskRepository.ClaimNext
контракт: «атомарно выдай одну задачу»
│
▼
④ storage/postgres/task_repo.go → claimNextSQL
SELECT ... FOR UPDATE SKIP LOCKED + UPDATE одним запросом
Обратно поднимается *domain.Task, юзкейс сужает его до domain.ClaimedTask
(воркеру не отдаём version, max_attempts и чужие ошибки), хендлер
превращает в JSON. Пустая очередь — это nil, nil на шаге ② и 204 на ①.
Трасса POST /tasks/{id}/result такая же, но с одним отличием: решение
принимает сущность, а не юзкейс.
handlers.go → CompleteTask.Execute → tx.WithinTx(
GetForUpdate → task.CompleteWith(...) ←── ЗДЕСЬ правила
│ (чужая аренда? устаревший
Update ←─────────────┘ attempt? повтор того же
syncJobStatus манифеста?)
)
4. Куда добавлять новое
| Хочу… | Правлю |
|---|---|
| новое бизнес-правило (когда задачу можно повторить) | domain/task.go + тест рядом |
| новую операцию (отменить job) | usecase/job.go + порт в ports.go, если нужен новый запрос к БД |
| новый HTTP-эндпоинт | transport/http/handlers.go + маршрут в server.go + DTO в dto.go |
| новый SQL-запрос | storage/postgres/*_repo.go |
| новую настройку | infra/config.go + .env.example |
| поменять код ответа на ошибку | transport/http/errors.go |
Правило при сомнении: если код можно описать фразой «когда X, то Y» без
упоминания HTTP, SQL и конфигов — это domain. Если он оркеструет несколько
шагов и транзакцию — usecase. Если знает про JSON — transport, про SQL — storage.
5. Три вещи, которые надо понять один раз
Порты объявляет потребитель. TaskRepository описан в usecase/ports.go, а
реализован в storage/postgres. Поэтому usecase не импортирует storage —
стрелка зависимости смотрит внутрь, хотя вызов на рантайме идёт наружу.
Транзакция едет в контексте. TxManager.WithinTx кладёт pgx.Tx в контекст
по неэкспортируемому ключу; репозитории достают её через conn(ctx, pool).
Благодаря этому юзкейс говорит «сделай это атомарно», ни разу не упомянув pgx.
Атомарный claim нельзя разложить на шаги. ClaimNext — один SQL-запрос,
потому что SELECT + отдельный UPDATE вернул бы гонку, при которой одну
задачу выдают двум воркерам. Поэтому ClaimTask.Execute выглядит тонким: там
нечего оркестровать, вся гарантия — внутри запроса.
6. Что уже работает, а что заглушка
Работает: слои и проводка, роутинг, авторизация, access-лог, маппинг ошибок, транзакции, graceful shutdown, миграции, весь domain с 12 юнит-тестами без БД.
Заглушки (ErrNotImplemented → HTTP 501): методы репозиториев. SQL для двух
главных операций уже написан в task_repo.go — claimNextSQL и
expireLeasesSQL, осталось их подключить.