145 lines
8.4 KiB
Markdown
145 lines
8.4 KiB
Markdown
# Архитектура координатора
|
||
|
||
Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит
|
||
запрос», в конце — «куда добавлять новое».
|
||
|
||
---
|
||
|
||
## 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`, осталось их подключить.
|