Files
SciMesh/coordinator/ARCHITECTURE.md
T
Efremenko Arhip bda22666d7 feat(coordinator): scaffold task-queue service in Go
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.
2026-07-22 13:49:01 +03:00

8.4 KiB
Raw Blame History

Архитектура координатора

Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит запрос», в конце — «куда добавлять новое».


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.goclaimNextSQL и expireLeasesSQL, осталось их подключить.