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

145 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура координатора
Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит
запрос», в конце — «куда добавлять новое».
---
## 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` не
знают друг о друге.
Проверить в любой момент:
```sh
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`, осталось их подключить.