Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0b9b208a | ||
|
|
a339ac853b | ||
|
|
0494b8bc2a | ||
|
|
0f9cbcec48 | ||
|
|
a4abdb970a | ||
|
|
b4fe7aaf72 | ||
|
|
e308085fe2 | ||
|
|
988101afab | ||
|
|
215325a9e0 | ||
|
|
c06d8673ce | ||
|
|
1473bbe2a8 | ||
|
|
9883def0c2 | ||
|
|
079eca071e | ||
|
|
8c5350abdf | ||
|
|
9fe81bc531 | ||
|
|
b281d5811a | ||
|
|
771952e22e | ||
|
|
565d4466e4 | ||
|
|
59e7fb0155 | ||
|
|
320f52615e | ||
|
|
f2977a990e | ||
|
|
5665e7df98 | ||
|
|
361eb2e344 | ||
|
|
749396da05 | ||
|
|
700a96a259 | ||
|
|
5d738e0a14 | ||
|
|
a18b8b8ae4 | ||
|
|
706bc85e17 | ||
|
|
9a8221163a | ||
|
|
644c287002 | ||
|
|
f20cc7fe00 | ||
|
|
284aef5d6f | ||
|
|
f059ac626c | ||
|
|
5c5a2af0a1 | ||
|
|
bc76f386e5 | ||
|
|
19fbb8e926 | ||
|
|
96169086f0 | ||
|
|
c43af32495 | ||
|
|
11e9333033 | ||
|
|
0a759a3f01 | ||
|
|
b9a975b0ea | ||
|
|
a201dd5ef9 | ||
|
|
dc75411907 | ||
|
|
6cdc115d60 | ||
|
|
fa76133efc | ||
|
|
7d8998408c | ||
|
|
e2a57175a0 | ||
|
|
2991ed202b | ||
|
|
9b235282fc | ||
|
|
3a1461315f | ||
|
|
172ff76fb8 | ||
|
|
6f14eeb32e | ||
|
|
87a483c2fb | ||
|
|
18d58cce84 | ||
|
|
dcabfcd0c3 | ||
|
|
e9cf6f0842 | ||
|
|
779ff8c10e | ||
|
|
e584cfc481 | ||
|
|
4ac19999a9 | ||
|
|
df4bdc9de9 | ||
|
|
9a458ec4ef | ||
|
|
49eb662798 | ||
|
|
5a9a10c681 | ||
|
|
f5ead0a450 | ||
|
|
c8c6455caf | ||
|
|
33f629f387 | ||
|
|
a7e949a0a7 | ||
|
|
163cbe14bf | ||
|
|
80ff72a0fe | ||
|
|
c6a66747eb | ||
|
|
0c1f5f06d4 | ||
|
|
67407220c3 | ||
|
|
1b1b971378 | ||
|
|
73196579e8 | ||
|
|
a3db1a1e67 | ||
|
|
16db1e41f7 | ||
|
|
ad9cc8f95c | ||
|
|
746958884e | ||
|
|
7ad28b939d | ||
|
|
1012f5d95a | ||
|
|
f5baec507c | ||
|
|
5a1414bee9 | ||
|
|
8b738efd5d | ||
|
|
d0aeb7fc95 | ||
|
|
a055473706 | ||
|
|
6e67daa9eb | ||
|
|
0f3a2d92d8 | ||
|
|
0bef7604fd | ||
|
|
6ef92908a1 | ||
|
|
f953112cfd | ||
|
|
19cbf7f113 | ||
|
|
9ec8f50313 | ||
|
|
08f5478a66 | ||
|
|
bde6cdb4ba | ||
|
|
43ceec1f77 | ||
|
|
f5b16b057f | ||
|
|
f8de0b2b9d | ||
|
|
7547a30bde | ||
|
|
6bac7dad3c | ||
|
|
c7956c4683 | ||
|
|
d648beede2 | ||
|
|
ac9b921401 | ||
|
|
5be87ad762 | ||
|
|
e83e0b5e1f | ||
|
|
ec861edce5 | ||
|
|
2ce9687e52 | ||
|
|
66836b962d | ||
|
|
484ecd0dfa | ||
|
|
983c5843ec | ||
|
|
b4a89dd7c2 | ||
|
|
8af8ddcf48 | ||
|
|
d271170dd2 | ||
|
|
e0ee95cbab | ||
|
|
6829632651 | ||
|
|
3b41455b20 | ||
|
|
4fc3c69fdf | ||
|
|
e5ba27951a | ||
|
|
c3243a6b7e | ||
|
|
4a092d2e4e | ||
|
|
58da6ef139 | ||
|
|
6d45406ee0 | ||
|
|
dbf578c500 | ||
|
|
a5945f2d38 | ||
|
|
dc92121acc | ||
|
|
5d6390fd98 | ||
|
|
6517145622 | ||
|
|
f1c3163be4 | ||
|
|
bda22666d7 |
@@ -0,0 +1,66 @@
|
|||||||
|
name: coordinator
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "coordinator/**"
|
||||||
|
- ".github/workflows/coordinator.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "coordinator/**"
|
||||||
|
- ".github/workflows/coordinator.yml"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: coordinator
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: scimesh
|
||||||
|
POSTGRES_PASSWORD: scimesh
|
||||||
|
POSTGRES_DB: scimesh
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U scimesh"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: coordinator/go.mod
|
||||||
|
cache-dependency-path: coordinator/go.sum
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||||
|
|
||||||
|
- name: unit tests (race)
|
||||||
|
run: go test -race ./...
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --build-tags=integration ./...
|
||||||
|
|
||||||
|
- name: install migrate CLI
|
||||||
|
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1
|
||||||
|
|
||||||
|
- name: apply migrations
|
||||||
|
run: migrate -path internal/storage/postgres/migrations -database "$TEST_DATABASE_URL" up
|
||||||
|
|
||||||
|
- name: integration tests
|
||||||
|
run: go test -tags=integration ./internal/storage/postgres/ -v
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: python
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "scimesh/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- ".github/workflows/python.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "scimesh/**"
|
||||||
|
- "tests/**"
|
||||||
|
- "pyproject.toml"
|
||||||
|
- ".github/workflows/python.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
- run: python -m pip install --upgrade pip
|
||||||
|
- run: python -m pip install -e '.[dev]'
|
||||||
|
- run: pytest -q
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# Builds static coordinator and worker-agent binaries for every major
|
||||||
|
# platform on a v* tag push, attaches them (plus SHA-256 checksums) to the
|
||||||
|
# GitHub Release, and pushes the coordinator image to GHCR.
|
||||||
|
#
|
||||||
|
# git tag v1.0.0 && git push origin v1.0.0
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
binaries:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [linux, darwin, windows]
|
||||||
|
arch: [amd64, arm64]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: coordinator
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: coordinator/go.mod
|
||||||
|
cache-dependency-path: coordinator/go.sum
|
||||||
|
|
||||||
|
- name: vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: build coordinator and worker-agent
|
||||||
|
env:
|
||||||
|
VERSION: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
for cmd in coordinator worker-agent; do
|
||||||
|
CGO_ENABLED=0 GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} \
|
||||||
|
go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.version=${VERSION#v}" \
|
||||||
|
-o "dist/${cmd}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }}" \
|
||||||
|
"./cmd/${cmd}"
|
||||||
|
done
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: binaries-${{ matrix.os }}-${{ matrix.arch }}
|
||||||
|
path: coordinator/dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
# Only the binary artifacts: the image job also uploads a buildkit
|
||||||
|
# cache artifact (*.dockerbuild) that download-artifact cannot fetch.
|
||||||
|
pattern: binaries-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: checksums
|
||||||
|
working-directory: artifacts
|
||||||
|
run: sha256sum * > SHA256SUMS.txt
|
||||||
|
|
||||||
|
- uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
artifacts/*
|
||||||
|
install.sh
|
||||||
|
install.ps1
|
||||||
|
# Pre-release tags (e.g. v1.1.0-alpha.1) publish as pre-releases.
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-alpha') }}
|
||||||
|
generate_release_notes: true
|
||||||
|
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}/coordinator
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: coordinator
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ github.ref_name }}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: users
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "users/**"
|
||||||
|
- ".github/workflows/users.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "users/**"
|
||||||
|
- ".github/workflows/users.yml"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: users
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: scimesh
|
||||||
|
POSTGRES_PASSWORD: scimesh
|
||||||
|
POSTGRES_DB: scimesh_users
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U scimesh"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgres://scimesh:scimesh@localhost:5432/scimesh_users?sslmode=disable
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: users/go.mod
|
||||||
|
cache-dependency-path: users/go.sum
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||||
|
|
||||||
|
- name: unit tests (race)
|
||||||
|
run: go test -race ./...
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --build-tags=integration ./...
|
||||||
|
|
||||||
|
- name: install migrate CLI
|
||||||
|
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1
|
||||||
|
|
||||||
|
- name: apply migrations
|
||||||
|
run: migrate -path migrations -database "$TEST_DATABASE_URL" up
|
||||||
|
|
||||||
|
- name: integration tests
|
||||||
|
run: go test -tags=integration ./internal/storage/postgres/ -v
|
||||||
@@ -11,3 +11,10 @@ results/
|
|||||||
*_similarities.csv
|
*_similarities.csv
|
||||||
test_results.csv
|
test_results.csv
|
||||||
test_structures/
|
test_structures/
|
||||||
|
|
||||||
|
# Local coordinator-worker execution state
|
||||||
|
worker-data*/
|
||||||
|
scimesh-worker-data/
|
||||||
|
coordinator/.demo/
|
||||||
|
site/
|
||||||
|
coordinator/bin/
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Session Goal
|
||||||
|
|
||||||
|
COMPLETED
|
||||||
|
|
||||||
|
выполни полностью намеченный сейчас план. автономно
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
1. Фаза 1 — SQLite-хранилище: `coordinator/internal/storage/sqlite` (все порты, TxManager, миграции, `SCIMESH_DB=sqlite|postgres`, тесты).
|
||||||
|
2. Фаза 2 — Встроенный userservice: перенос `users/internal/*` в `coordinator/internal/userservice/` (sqlite-хранилище), запуск на 127.0.0.1, BootstrapAdmin.
|
||||||
|
3. Фаза 3 — `coordinator serve` (data-dir, всё-в-одном, --workers N, --open) + subcommand `coordinator agent`.
|
||||||
|
4. Фаза 4 — Управляемый venv + install.sh/install.ps1 + ассеты релиза.
|
||||||
|
5. Документация: mkdocs, README, PLAN.md (CTX-17 done, CTX-18), STATUS.md.
|
||||||
|
6. Проверка: полный E2E без внешних сервисов + все тесты/lint/vet.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- ВСЕ ФАЗЫ ВЫПОЛНЕНЫ И ЗАПУШЕНЫ:
|
||||||
|
- 9883def — SQLite-бэкенд (SCIMESH_DB=sqlite|postgres, миграции, тесты).
|
||||||
|
- 1473bbe — встроенный userservice + serve/agent subcommands.
|
||||||
|
- c06d867 — install.sh/install.ps1 + make serve.
|
||||||
|
- 215325a — документация (README, mkdocs, PLAN CTX-17/CTX-18, STATUS).
|
||||||
|
- E2E «чистая машина»: `coordinator serve` → health/login (embedded userservice) → molwt-filter джоб через локального агента → результат byte-точный.
|
||||||
|
- Верификация: 208 pytest, pyright 0, 18 Go-пакетов ok, gofmt чист, vet чист, golangci 0 issues, postgres integration ok, все CI-раны success.
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
SciMesh is a Python package for molecular-similarity workloads. Source lives in
|
SciMesh is a Python package for molecular-similarity workloads. Source lives in
|
||||||
`scimesh/`: `chemistry/` reads data and makes fingerprints, `workloads/`
|
`scimesh/`: `chemistry/` reads data and makes fingerprints, `workloads/`
|
||||||
contains commands, and `core/` provides the workload protocol and registry.
|
contains commands, and `core/` provides the workload protocol and registry.
|
||||||
The worker daemon in `scimesh/worker/` is a coordinator client, not a database
|
The Go worker agent (`coordinator/internal/agent/`) is a coordinator client,
|
||||||
client. Tests are in `tests/`; specifications in `docs/`; roadmap: `PLAN.md`.
|
not a database client; the Python side of a claimed task lives in
|
||||||
|
`scimesh/worker/` (the per-task SDK execution entry). Tests are in `tests/`;
|
||||||
|
specifications in `docs/`; roadmap: `PLAN.md`.
|
||||||
|
|
||||||
For distributed work, read `.agents/`, `docs/api-contract.md`,
|
For distributed work, read `.agents/`, `docs/api-contract.md`,
|
||||||
and `STATUS.md`. Use one CTX task per pull request; local workloads are the
|
and `STATUS.md`. Use one CTX task per pull request; local workloads are the
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help agent coordinator demo-ui demo-down demo-logs smoke-two-worker docs docs-serve
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' \
|
||||||
|
'SciMesh developer commands:' \
|
||||||
|
' make agent Build the Go worker agent (coordinator/bin/worker-agent).' \
|
||||||
|
' make coordinator Build the coordinator server as a static binary' \
|
||||||
|
' (coordinator/bin/coordinator).' \
|
||||||
|
' make demo-ui Start the local UI pipeline demo with 2 Go worker agents.' \
|
||||||
|
' make demo-ui WORKERS=3 Start the demo with 3 workers.' \
|
||||||
|
' make demo-logs Follow coordinator logs for the demo.' \
|
||||||
|
' make demo-down Stop demo containers and workers.' \
|
||||||
|
' make smoke-two-worker E2E: two Go agents process 4 shards and the' \
|
||||||
|
' result must match the local CLI reference.' \
|
||||||
|
' make docs Build the MkDocs site into site/.' \
|
||||||
|
' make docs-serve Serve the MkDocs site at http://localhost:8000.' \
|
||||||
|
'' \
|
||||||
|
'After make demo-ui: open http://localhost:18080/ui (operator / demo-ui-secret).'
|
||||||
|
|
||||||
|
# Convenient entry points from the repository root. Extra settings are passed
|
||||||
|
# through, for example: make demo-ui WORKERS=3
|
||||||
|
agent:
|
||||||
|
$(MAKE) -C coordinator agent
|
||||||
|
|
||||||
|
coordinator:
|
||||||
|
$(MAKE) -C coordinator coordinator
|
||||||
|
|
||||||
|
demo-ui:
|
||||||
|
$(MAKE) -C coordinator demo-ui
|
||||||
|
|
||||||
|
demo-down:
|
||||||
|
$(MAKE) -C coordinator demo-down
|
||||||
|
|
||||||
|
demo-logs:
|
||||||
|
$(MAKE) -C coordinator demo-logs
|
||||||
|
|
||||||
|
smoke-two-worker:
|
||||||
|
./scripts/two-worker-smoke.sh
|
||||||
|
|
||||||
|
docs:
|
||||||
|
.venv/bin/mkdocs build
|
||||||
|
|
||||||
|
docs-serve:
|
||||||
|
.venv/bin/mkdocs serve
|
||||||
@@ -5,11 +5,13 @@
|
|||||||
> platform. It is intentionally detailed enough to split into independent task
|
> platform. It is intentionally detailed enough to split into independent task
|
||||||
> briefs for developers or coding agents.
|
> briefs for developers or coding agents.
|
||||||
>
|
>
|
||||||
> **Planning baseline.** This branch starts from `Workers`: the Python package
|
> **Current planning baseline (2026-08-01).** The Go/PostgreSQL coordinator,
|
||||||
> has local `similarity-search` and `similarity-graph` workloads plus a Worker
|
> Python Worker Agent, versioned distributed-workload protocol, artifact-backed
|
||||||
> Daemon client. The coordinator and PostgreSQL implementation do not yet
|
> task lifecycle, reducer orchestration, operator UI, User Service, and
|
||||||
> exist. The Worker contract and the Go/PostgreSQL design briefs in `docs/` are
|
> distributed `similarity-search` are implemented on `main`. The evidence-based
|
||||||
> part of this plan.
|
> completion tracker is [`STATUS.md`](STATUS.md); this document defines the
|
||||||
|
> remaining direction and dependencies. Earlier descriptions of a missing
|
||||||
|
> coordinator are historical context, not current work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ Coordinator reducer -> final artifact -> download/status API
|
|||||||
|
|
||||||
### 2.1 In scope
|
### 2.1 In scope
|
||||||
|
|
||||||
- Go 1.22+ coordinator service with PostgreSQL 15+;
|
- Go 1.25+ coordinator service with PostgreSQL 15+;
|
||||||
- Python Worker Daemon running existing SciMesh workloads locally;
|
- Python Worker Daemon running existing SciMesh workloads locally;
|
||||||
- durable job, task, worker, and artifact metadata;
|
- durable job, task, worker, and artifact metadata;
|
||||||
- local coordinator-managed artifact storage for the first deployment;
|
- local coordinator-managed artifact storage for the first deployment;
|
||||||
@@ -66,7 +68,8 @@ Coordinator reducer -> final artifact -> download/status API
|
|||||||
|
|
||||||
- cloud object storage, Kubernetes, autoscaling, and multi-region operation;
|
- cloud object storage, Kubernetes, autoscaling, and multi-region operation;
|
||||||
- arbitrary shell commands sent by coordinator to workers;
|
- arbitrary shell commands sent by coordinator to workers;
|
||||||
- user accounts, multi-tenancy, billing, or sophisticated authorization;
|
- billing and sophisticated multi-tenant administration beyond the implemented
|
||||||
|
User Service and owner scoping;
|
||||||
- GPU scheduling and multiprocessing inside a worker;
|
- GPU scheduling and multiprocessing inside a worker;
|
||||||
- Docker as a required runtime dependency;
|
- Docker as a required runtime dependency;
|
||||||
- video/CV processing implementation;
|
- video/CV processing implementation;
|
||||||
@@ -490,14 +493,84 @@ brute-force graph for both `greater` and `less` threshold directions.
|
|||||||
|
|
||||||
### 7.3 Future workload policy
|
### 7.3 Future workload policy
|
||||||
|
|
||||||
A new workload is accepted only when it supplies:
|
A workload is more than a runner. It must define validation, planner and
|
||||||
|
reducer behavior, worker allowlist/capabilities, input and output artifacts,
|
||||||
|
UI/API parameters, reproducible execution environment, result verification,
|
||||||
|
golden cross-worker fixtures, and applicable resource limits. The future public
|
||||||
|
contract is described in [`docs/scimesh-sdk-roadmap.md`](docs/scimesh-sdk-roadmap.md).
|
||||||
|
Its normative future interfaces and execution semantics are defined in the
|
||||||
|
design-draft [`docs/scimesh-sdk-contract.md`](docs/scimesh-sdk-contract.md).
|
||||||
|
|
||||||
- an input/parameter validator;
|
Every workload declaration must classify its task decomposition and input/output
|
||||||
- an explicit sharding strategy;
|
artifact shapes, determinism, reduction semantics, verifier mode, supported
|
||||||
- bounded-memory task execution;
|
trust modes, CPU/memory/accelerator needs, and maximum output growth. The
|
||||||
- deterministic reduction semantics;
|
initial profiles are:
|
||||||
- fixture-based local and distributed correctness tests;
|
|
||||||
- a `describe()` payload for UI/API discovery.
|
| Profile | Current acceptance policy |
|
||||||
|
| --- | --- |
|
||||||
|
| Byte-exact deterministic | Supported for untrusted quorum when whole artifacts have identical SHA-256. |
|
||||||
|
| Canonical-exact deterministic | Deferred until the parser, schema, ordering, encoding, and serializer are versioned. |
|
||||||
|
| Numeric deterministic with tolerance | Deferred until structured numeric comparison exists. |
|
||||||
|
| Stochastic/search-based | Requires domain-specific evidence, repeated runs, or trusted execution. |
|
||||||
|
| Trusted-only or domain-verified | May be planned only with an explicit trust policy and verifier. |
|
||||||
|
|
||||||
|
The current untrusted quorum records one vote per owner and accepts a task only
|
||||||
|
when distinct owners upload artifacts with the same complete-file SHA-256. It
|
||||||
|
therefore supports only the byte-exact profile (or a workload that first makes
|
||||||
|
its output byte-identical through a specified canonicalization step). Reducers
|
||||||
|
must fail safely rather than silently merge inconsistent partial results.
|
||||||
|
|
||||||
|
Before a workload is admitted to untrusted execution it needs a reproducibility
|
||||||
|
gate: pinned environment/container digest and dependency versions; fixed locale,
|
||||||
|
timezone, UTF-8/newline/CSV settings; explicit invalid-row and algorithm
|
||||||
|
options; canonical representation and ordering; deterministic filenames/archive
|
||||||
|
metadata; golden fixtures from two independently provisioned workers; local vs
|
||||||
|
distributed parity; and retry/out-of-order completion tests. A loose dependency
|
||||||
|
constraint is insufficient for byte-exact quorum.
|
||||||
|
|
||||||
|
Near-term critical path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
distributed similarity-graph
|
||||||
|
-> reliability, security, and cross-language CI
|
||||||
|
-> stable first release
|
||||||
|
-> SDK foundation and descriptor-batch
|
||||||
|
-> additional deterministic workloads
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial deterministic-workload backlog: `descriptor-batch` (the first SDK
|
||||||
|
reference workload), molecule standardization, SMARTS screening, fingerprint
|
||||||
|
export, fixed-template SMIRKS enumeration with strict caps, and reaction
|
||||||
|
validation/descriptors. `similarity-graph` remains ahead of this backlog.
|
||||||
|
Bounded combinatorial libraries and seeded conformers need specialized controls.
|
||||||
|
ML, retrosynthesis, docking, QM, molecular dynamics, and GPU workloads are
|
||||||
|
deferred until verifier/trust and reproducibility requirements are met.
|
||||||
|
|
||||||
|
### 7.4 Future verification, concurrency, and accelerators
|
||||||
|
|
||||||
|
Verification is a future versioned workload capability, not permanent
|
||||||
|
whole-file-SHA logic. Planned modes are `ExactArtifactVerifier`,
|
||||||
|
`CanonicalRecordVerifier`, `NumericToleranceVerifier`, `DomainSpecificVerifier`,
|
||||||
|
and `TrustedWorkerPolicy`. Exact SHA-256 remains the first and safest mode;
|
||||||
|
canonical and numeric modes must compare bounded structured data and publish
|
||||||
|
sanitized evidence and failure reasons.
|
||||||
|
|
||||||
|
Worker concurrency remains **1** until implemented and tested. Its target model
|
||||||
|
is one physical machine running one Worker Agent with `N` execution slots and
|
||||||
|
one isolated subprocess per active Task, rather than one registered worker per
|
||||||
|
CPU core. `max_concurrency` must be separate from `cpu_count`; each task keeps
|
||||||
|
its own heartbeat, attempt directory, lease lifecycle, resource request, and
|
||||||
|
graceful-drain behavior. CPU-bound scientific code should use processes and
|
||||||
|
avoid nested oversubscription.
|
||||||
|
|
||||||
|
Accelerator support is also deferred. The coordinator matches generic resource
|
||||||
|
requirements; the Worker Agent discovers and isolates devices (including
|
||||||
|
`CUDA_VISIBLE_DEVICES`) and owns process/accounting lifecycle; the Python
|
||||||
|
workload owns batching, memory strategy, deterministic output, and scientific
|
||||||
|
validation; reducers/verifiers define CPU/GPU-independent semantics. CUDA and
|
||||||
|
scientific kernels do not belong in the Go coordinator. GPU work follows stable
|
||||||
|
CPU slices, generic resource requirements, pinned worker images, and tested
|
||||||
|
CPU/GPU or domain-valid equivalence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -780,6 +853,10 @@ for the exact sparse similarity graph.
|
|||||||
**Goal:** Add a small server-rendered or static HTML UI to inspect jobs, tasks,
|
**Goal:** Add a small server-rendered or static HTML UI to inspect jobs, tasks,
|
||||||
workers, and download final artifacts.
|
workers, and download final artifacts.
|
||||||
|
|
||||||
|
**Detailed delivery plan:** [`docs/web-interface-plan.md`](docs/web-interface-plan.md).
|
||||||
|
The plan deliberately starts with a clearly labelled diagnostic UI before
|
||||||
|
CTX-09 enables final result downloads.
|
||||||
|
|
||||||
**Depends on:** CTX-04, CTX-09.
|
**Depends on:** CTX-04, CTX-09.
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
@@ -816,6 +893,167 @@ workers, and download final artifacts.
|
|||||||
- failure/retry scenarios have automated coverage;
|
- failure/retry scenarios have automated coverage;
|
||||||
- README contains architecture diagram, security caveat, and troubleshooting.
|
- README contains architecture diagram, security caveat, and troubleshooting.
|
||||||
|
|
||||||
|
### CTX-13 — In-worker CPU parallelism
|
||||||
|
|
||||||
|
**Goal:** Allow a worker to use a bounded, configured number of CPU threads or
|
||||||
|
processes while preserving the existing one-task-per-lease coordinator model.
|
||||||
|
|
||||||
|
**Depends on:** CTX-12.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- worker concurrency is an explicit configuration value with a safe default of
|
||||||
|
one;
|
||||||
|
- a task's internal parallel execution has bounded memory and does not build a
|
||||||
|
dense N×N similarity matrix;
|
||||||
|
- CPU-parallel `similarity-search` and `similarity-graph` outputs match the
|
||||||
|
single-threaded local reference byte-for-byte where ordering is observable;
|
||||||
|
- result ordering is deterministic across worker counts and block sizes;
|
||||||
|
- cancellation, lease loss, and worker failure stop child work safely and do
|
||||||
|
not report a successful result;
|
||||||
|
- benchmarks and tests cover one-worker and multi-worker configurations.
|
||||||
|
|
||||||
|
### CTX-14 — GPU-accelerated workload execution
|
||||||
|
|
||||||
|
**Goal:** Add an optional GPU execution backend for supported molecular
|
||||||
|
workloads, while retaining the validated CPU implementation as the reference
|
||||||
|
and fallback.
|
||||||
|
|
||||||
|
**Depends on:** CTX-13.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- GPU capability and backend version are advertised explicitly by a worker;
|
||||||
|
- the coordinator schedules GPU work only to compatible workers and CPU-only
|
||||||
|
workers continue to claim CPU tasks;
|
||||||
|
- unsupported hardware, unavailable drivers, and GPU execution errors produce
|
||||||
|
sanitized failures or a documented CPU fallback;
|
||||||
|
- GPU results match the CPU reference within a documented, tested numerical
|
||||||
|
tolerance and preserve deterministic output ordering;
|
||||||
|
- GPU memory use is bounded and no dense N×N similarity matrix is created;
|
||||||
|
- CPU-only CI verifies backend selection and contract behavior, with GPU
|
||||||
|
integration tests documented for compatible runners.
|
||||||
|
|
||||||
|
### CTX-15 — User Service and access control
|
||||||
|
|
||||||
|
**Goal:** Introduce a dedicated User Service for user identity and access
|
||||||
|
control, without coupling workers to user credentials or moving scientific
|
||||||
|
workload logic into the service.
|
||||||
|
|
||||||
|
**Depends on:** CTX-12.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- the service has a versioned, documented API in
|
||||||
|
[`docs/user-service-api-contract.md`](docs/user-service-api-contract.md) and
|
||||||
|
owns user identity data;
|
||||||
|
- credentials and authentication tokens are stored and handled securely; they
|
||||||
|
are never logged or exposed to workers;
|
||||||
|
- authenticated identity is propagated to coordinator requests through an
|
||||||
|
explicit, validated boundary;
|
||||||
|
- authorization restricts access to jobs and artifacts to the intended user or
|
||||||
|
project;
|
||||||
|
- unauthenticated, expired-token, and cross-user access attempts have
|
||||||
|
automated failure tests;
|
||||||
|
- the existing single-operator demo remains usable through a documented local
|
||||||
|
development configuration.
|
||||||
|
|
||||||
|
### CTX-16 — Workload SDK foundation
|
||||||
|
|
||||||
|
**Goal:** Provide a strict Python authoring SDK for installed, allowlisted
|
||||||
|
scientific workloads while retaining the CTX-07 distributed protocol as a
|
||||||
|
compatible wire profile.
|
||||||
|
|
||||||
|
**Depends on:** CTX-07 and CTX-08. Coordinator-backed generalized scheduling
|
||||||
|
also depends on CTX-10 through CTX-14, but the Python contract and local
|
||||||
|
conformance runtime can land independently and must fail closed for unavailable
|
||||||
|
features.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- public manifest, workflow, task, artifact, resource, execution, provenance,
|
||||||
|
and verifier value objects are immutable, typed, JSON-safe, versioned, and
|
||||||
|
strict about unknown fields;
|
||||||
|
- installed workload discovery requires an administrator allowlist plus exact
|
||||||
|
workload version and package digest; job parameters cannot select code;
|
||||||
|
- compatibility negotiation covers SDK/protocol/profile/feature/environment
|
||||||
|
versions and occurs before planner invocation;
|
||||||
|
- plans/tasks pin package and manifest digests plus selected trust mode, and
|
||||||
|
quorum candidates carry coordinator-owned candidate/owner and scientific
|
||||||
|
binding identities;
|
||||||
|
- `core-batch-v1` has a trusted local conformance executor with atomic resource
|
||||||
|
reservation, sealed-output/provenance validation, declared verifier
|
||||||
|
invocation, and golden scientific parity;
|
||||||
|
- exact, canonical-record, and structured numeric-tolerance verifier
|
||||||
|
primitives return bounded sanitized decisions;
|
||||||
|
- the existing distributed `similarity-search` is available through an adapter
|
||||||
|
without changing its wire schema, worker alias boundary, or scientific
|
||||||
|
result, and parity is tested;
|
||||||
|
- advanced dynamic, stream, accelerator, gang, and side-effect profiles are
|
||||||
|
rejected unless an enforcing runtime advertises their required features;
|
||||||
|
- an author guide documents package entry points, security boundaries,
|
||||||
|
conformance tests, and current coordinator/Worker limitations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CTX-17 — Self-provisioning coordinator and setup wizard
|
||||||
|
|
||||||
|
**Goal:** A downloaded coordinator binary should bring up a working platform
|
||||||
|
with as little manual configuration as possible: it provisions its own
|
||||||
|
schema, and a `setup` command walks the operator through the remaining
|
||||||
|
environment (database creation, secrets, admin account).
|
||||||
|
|
||||||
|
**Depends on:** the Go coordinator and the release build pipeline. All steps
|
||||||
|
are implemented: embedded migrations with `AUTO_MIGRATE`, the `setup` wizard,
|
||||||
|
the embedded SQLite storage backend (`SCIMESH_DB=sqlite`), the embedded
|
||||||
|
userservice, and the `coordinator serve` single-binary mode with local worker
|
||||||
|
agents. The standalone `users/` service and the PostgreSQL engine remain for
|
||||||
|
cluster deployments.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- the binary embeds the migrations and applies pending ones on startup by
|
||||||
|
default (`AUTO_MIGRATE=false` opts out for managed databases); applying is
|
||||||
|
idempotent and safe under concurrent starts;
|
||||||
|
- `coordinator setup` (interactive, then non-interactive with `--yes`) checks
|
||||||
|
database reachability, offers to create the role/database when credentials
|
||||||
|
allow it, applies migrations, writes a `.env` with a generated `JWT_SECRET`
|
||||||
|
and storage path, and prints exact next steps;
|
||||||
|
- `coordinator --version` and the setup output agree on the release build;
|
||||||
|
- the wizard explains what it cannot do itself: running PostgreSQL and the
|
||||||
|
userservice, with concrete commands (docker compose, systemd) to finish;
|
||||||
|
- the Docker image keeps working without the separate migrate step, and the
|
||||||
|
release workflow publishes the binaries that support `setup`;
|
||||||
|
- setup fails closed on non-interactive input and never logs secrets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CTX-18 — Single-binary platform (`coordinator serve`)
|
||||||
|
|
||||||
|
**Goal:** A scientist installs one file, runs one command, and gets the whole
|
||||||
|
platform: coordinator, both databases, the userservice, and local workers —
|
||||||
|
no PostgreSQL, no Docker, no Python setup.
|
||||||
|
|
||||||
|
**Depends on:** CTX-17 (embedded migrations, SQLite, embedded userservice).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
|
||||||
|
- `coordinator serve` provisions `~/.scimesh` (databases, secrets chmod 0600,
|
||||||
|
generated admin password printed once, artifacts dir) and serves the UI on
|
||||||
|
127.0.0.1:8080 by default; `--open` opens the browser;
|
||||||
|
- `--workers N` spawns N `coordinator agent` subprocesses that claim and
|
||||||
|
execute tasks locally; agents are stopped on shutdown;
|
||||||
|
- the embedded userservice listens on the loopback interface and shares the
|
||||||
|
JWT secret with the coordinator, so UI login/registration work unchanged;
|
||||||
|
- a managed scientific runtime venv (`~/.scimesh/venv`) is bootstrapped on
|
||||||
|
first start; `SCIMESH_PIP_PACKAGE` controls what gets installed (the PyPI
|
||||||
|
name is not ours), and `TASK_RUNNER` points at the venv python;
|
||||||
|
- `install.sh` / `install.ps1` detect the platform, download the release
|
||||||
|
binary, and print the start command; both are release assets;
|
||||||
|
- the PostgreSQL engine and the standalone userservice stay fully supported;
|
||||||
|
- `coordinator serve` passes the full local E2E without any external service:
|
||||||
|
health, login, upload, claim, compute, reduction, byte-exact result.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Suggested assignment bundles
|
## 10. Suggested assignment bundles
|
||||||
@@ -831,6 +1069,7 @@ parallel unless one engineer owns integration.
|
|||||||
| Distributed computation | CTX-07, CTX-08, CTX-10 | Scientific Python engineer |
|
| Distributed computation | CTX-07, CTX-08, CTX-10 | Scientific Python engineer |
|
||||||
| Product surface | CTX-09, CTX-11 | Full-stack/backend engineer |
|
| Product surface | CTX-09, CTX-11 | Full-stack/backend engineer |
|
||||||
| Quality gate | CTX-12 | DevOps/QA engineer |
|
| Quality gate | CTX-12 | DevOps/QA engineer |
|
||||||
|
| Workload SDK | CTX-16 | Scientific Python/platform engineer |
|
||||||
|
|
||||||
Suggested order for a small team:
|
Suggested order for a small team:
|
||||||
|
|
||||||
@@ -944,15 +1183,23 @@ Before merging a task, reviewer checks:
|
|||||||
Do not start these before CTX-12 is accepted.
|
Do not start these before CTX-12 is accepted.
|
||||||
|
|
||||||
- Replace local artifact storage with S3/MinIO behind an `ArtifactStore` API.
|
- Replace local artifact storage with S3/MinIO behind an `ArtifactStore` API.
|
||||||
- Add worker labels/capacity-aware scheduling and concurrency > 1.
|
- Add worker labels and capacity-aware scheduling.
|
||||||
|
- Implement CTX-13 for bounded in-worker CPU parallelism.
|
||||||
|
- Implement CTX-14 for optional GPU-accelerated workload execution.
|
||||||
|
- Implement CTX-15 for the User Service and authenticated user/project access.
|
||||||
- Add cancellation propagation to workers.
|
- Add cancellation propagation to workers.
|
||||||
- Add image outputs and final PDF reporting to job artifacts.
|
- Add image outputs and final PDF reporting to job artifacts.
|
||||||
- Add CV/video workloads using the same planner/runner/reducer contract.
|
- Add CV/video workloads using the same planner/runner/reducer contract.
|
||||||
- Add observability export (Prometheus/OpenTelemetry).
|
- Add observability export (Prometheus/OpenTelemetry).
|
||||||
- Add per-user/project authorization and signed artifact URLs.
|
- Add signed artifact URLs.
|
||||||
- Add shard caching and content-addressed input deduplication.
|
- Add shard caching and content-addressed input deduplication.
|
||||||
- Add job priority and fair scheduling.
|
- Add job priority and fair scheduling.
|
||||||
- Add a CLI for submitting and monitoring remote jobs.
|
- Add a CLI for submitting and monitoring remote jobs.
|
||||||
|
- Publish the scimesh Python package to PyPI so the managed venv bootstrap
|
||||||
|
(`SCIMESH_PIP_PACKAGE`) works out of the box on a scientist's machine.
|
||||||
|
- Bundle a Python runtime (python-build-standalone) into the release so local
|
||||||
|
workers need no system Python at all.
|
||||||
|
- Native installers (.msi/.dmg/.deb) built by the release workflow.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
# SciMesh
|
# SciMesh
|
||||||
|
|
||||||
SciMesh is a small local framework for scientific workloads on molecular datasets. It currently provides exact molecular similarity search and exact sparse similarity-graph construction. It runs in one local Python process: there is no network service, multiprocessing, coordinator, database, or dense similarity matrix.
|
SciMesh is a local-first platform for scientific computation on molecular
|
||||||
|
datasets. It turns a scientific run into independent tasks, dispatches them
|
||||||
|
to worker agents, and deterministically combines the partial results into a
|
||||||
|
checksum-protected final artifact.
|
||||||
|
|
||||||
The ChEMBL TSV database is intentionally not included in this repository. Download it separately and pass its path to the commands below. The expected columns are `chembl_id` and `canonical_smiles`.
|
- **The Workload SDK (`scimesh.sdk`)** — a strict Python framework for
|
||||||
|
authoring scientific workloads: `similarity-search` (exact top-k Tanimoto),
|
||||||
|
`similarity-graph` (exact sparse graph), `descriptor-batch`, and
|
||||||
|
`molwt-filter`. Workloads are ordinary user scripts built on the SDK; they
|
||||||
|
run locally, in the conformance harness, and on claimed coordinator tasks
|
||||||
|
without touching any other part of the program.
|
||||||
|
- **The coordinator and worker agents** — a Go/PostgreSQL coordinator with an
|
||||||
|
operator UI and Go worker agents that execute SDK workloads in a Python
|
||||||
|
subprocess. The UI is workload-agnostic: the "New computation" form offers
|
||||||
|
every workload from the embedded SDK library, and each workload declares its
|
||||||
|
own form controls (`UIElement`) through the SDK.
|
||||||
|
|
||||||
|
The ChEMBL TSV database is intentionally not included in this repository.
|
||||||
|
Download it separately and pass its path to the commands below. The expected
|
||||||
|
columns are `chembl_id` and `canonical_smiles`. See
|
||||||
|
[`STATUS.md`](STATUS.md) and [`PLAN.md`](PLAN.md).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -21,6 +39,50 @@ conda install -c conda-forge rdkit
|
|||||||
pip install -e .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Every `v*` tag pushes a GitHub Release with static binaries for `coordinator`
|
||||||
|
and `worker-agent` on linux/darwin/windows × amd64/arm64 (plus SHA-256
|
||||||
|
checksums), the installer scripts, and the `coordinator` image on GHCR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/emil28092005/SciMesh/coordinator:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
For scientists: one command downloads the right binary and prints the start
|
||||||
|
instructions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.sh | bash
|
||||||
|
coordinator serve --open
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.ps1 | iex"
|
||||||
|
coordinator serve --open
|
||||||
|
```
|
||||||
|
|
||||||
|
`coordinator serve` is the single-binary mode: it embeds SQLite (coordinator +
|
||||||
|
userservice databases), the userservice itself, and local worker agents
|
||||||
|
(`--workers N`, default 1). On first start it generates secrets and the admin
|
||||||
|
password under `~/.scimesh`, prints the login, and opens the UI. No
|
||||||
|
PostgreSQL, no Docker, no environment variables. The scientific runtime is a
|
||||||
|
managed venv (`~/.scimesh/venv`); point `SCIMESH_PIP_PACKAGE` at your scimesh
|
||||||
|
wheel to install it automatically.
|
||||||
|
|
||||||
|
Manual download and run of a release binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -L -o coordinator https://github.com/emil28092005/SciMesh/releases/latest/download/coordinator-linux-amd64
|
||||||
|
chmod +x coordinator
|
||||||
|
./coordinator --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Cluster deployments keep the PostgreSQL engine (`SCIMESH_DB=postgres` with
|
||||||
|
`DATABASE_URL`, or `coordinator setup` to provision it) and the standalone
|
||||||
|
userservice (`users/`). `coordinator agent` runs a worker agent from the same
|
||||||
|
binary.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Run the built-in help command for copy-paste examples of both workloads:
|
Run the built-in help command for copy-paste examples of both workloads:
|
||||||
@@ -38,6 +100,41 @@ scimesh similarity-search --help
|
|||||||
scimesh similarity-graph --help
|
scimesh similarity-graph --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Manual pipeline demo
|
||||||
|
|
||||||
|
To inspect the coordinator, Web UI, and distributed pipeline by hand, install
|
||||||
|
development dependencies once and start the isolated demo from the repository
|
||||||
|
root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -e '.[dev]'
|
||||||
|
make demo-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
The MkDocs documentation site is served inside the UI at `/ui/docs/`
|
||||||
|
(`make docs` builds it from `mkdocs/`; the demo mounts `site/`
|
||||||
|
automatically, or set `SCIMESH_DOCS_DIR` for a manual coordinator). The site
|
||||||
|
covers the complete Workload SDK: guides (`mkdocs/sdk/`), the full
|
||||||
|
auto-generated API reference for `scimesh.sdk` (`mkdocs/api/`), and the
|
||||||
|
documentation rules the site is written by (`mkdocs/approach.md`).
|
||||||
|
|
||||||
|
Open `http://localhost:18080/ui` and sign in with username
|
||||||
|
`root@scimesh.local` and password `rootpassword`. The command starts
|
||||||
|
PostgreSQL, the coordinator, and two Go worker agents (built by `make agent`;
|
||||||
|
each executes the SDK workload in a Python subprocess). The **New computation**
|
||||||
|
form offers every upload-ready workload from the installed library — the
|
||||||
|
controls come from each workload's own SDK declarations. Upload a small ChEMBL
|
||||||
|
TSV, then use the job page to follow shard progress, inspect bounded
|
||||||
|
**Preview CSV** results, and see a live processing-speed chart in shards per
|
||||||
|
minute. The **Workloads** page shows the installed SDK workload library
|
||||||
|
(descriptions, parameters, and artifact schemas) from the embedded catalog;
|
||||||
|
regenerate it with `make workloads-export` (or `scimesh workload export`)
|
||||||
|
whenever workloads change. To change the worker count, run
|
||||||
|
`make demo-ui WORKERS=3`; stop everything with `make demo-down`.
|
||||||
|
|
||||||
|
Run `make help` to display these commands in the terminal.
|
||||||
|
|
||||||
## Similarity search
|
## Similarity search
|
||||||
|
|
||||||
`similarity-search` finds the top-k molecules most similar to a query. The query is supplied either by ChEMBL ID or by SMILES. It uses Morgan fingerprints with `radius=2` and `fpSize=2048`, Tanimoto similarity, streaming TSV reads, and a bounded heap. Invalid SMILES and the query molecule are skipped.
|
`similarity-search` finds the top-k molecules most similar to a query. The query is supplied either by ChEMBL ID or by SMILES. It uses Morgan fingerprints with `radius=2` and `fpSize=2048`, Tanimoto similarity, streaming TSV reads, and a bounded heap. Invalid SMILES and the query molecule are skipped.
|
||||||
@@ -105,3 +202,60 @@ pytest
|
|||||||
```
|
```
|
||||||
|
|
||||||
The package separates common dataset parsing and fingerprints from independent workloads. Add future workloads through the workload registry without changing the main CLI.
|
The package separates common dataset parsing and fingerprints from independent workloads. Add future workloads through the workload registry without changing the main CLI.
|
||||||
|
|
||||||
|
The coordinator and worker agent are Go modules under `coordinator/` and `users/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd coordinator && make coordinator agent && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
`make check` runs the full gate: vet, lint, race tests, the PostgreSQL
|
||||||
|
integration suite, and the two-worker end-to-end smoke script.
|
||||||
|
|
||||||
|
## Workload SDK
|
||||||
|
|
||||||
|
`scimesh.sdk` is the framework only: strict and immutable workload manifests,
|
||||||
|
typed artifact ports, static map/reduce plans, resource eligibility and local
|
||||||
|
reservations, exact/canonical/numeric verifier primitives, installed-package
|
||||||
|
allowlisting, and a local conformance executor. It contains no scientific
|
||||||
|
workload code. Workloads are user scripts built on the SDK: the built-in
|
||||||
|
`similarity-search`, `similarity-graph`, `descriptor-batch`, and
|
||||||
|
`molwt-filter` live in `scimesh/workloads/` (each a small package with
|
||||||
|
`core.py` + `definition.py`), composed by `scimesh/workloads/library.py` and
|
||||||
|
registered through `scimesh.workloads` entry points. The Worker Agent executes
|
||||||
|
those SDK-built workloads directly (see `scimesh/worker/runners.py`), so the
|
||||||
|
same scientific handlers run locally, in conformance, and on claimed
|
||||||
|
coordinator tasks. `scimesh workload list` and `scimesh workload run` run any
|
||||||
|
SDK workload from the command line; `scimesh workload export` writes the
|
||||||
|
coordinator's embedded workload catalog, and `scimesh workload allowlist`
|
||||||
|
prints the JSON for `SCIMESH_WORKLOAD_ALLOWLIST`.
|
||||||
|
|
||||||
|
Workloads can also declare how they should appear in the coordinator UI:
|
||||||
|
a tuple of `UIElement`s (`scimesh.sdk.UIElement`) shapes the "New computation"
|
||||||
|
form — widget, label, help, defaults, and ordering — plus the coordinator-side
|
||||||
|
reduction mode (`reduction`: `top-k` or `ordered-concat`) and whether a single
|
||||||
|
uploaded dataset can drive the workload (`upload_ready`). The strict parameter
|
||||||
|
schema stays the authoritative validation contract.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[SDK author guide](docs/workload-sdk.md), [contract](docs/scimesh-sdk-contract.md),
|
||||||
|
and [delivery roadmap](docs/scimesh-sdk-roadmap.md).
|
||||||
|
|
||||||
|
Dynamic workflows, real Worker concurrency, coordinator-backed GPU allocation,
|
||||||
|
streaming, and gang execution remain fail-closed until their versioned runtime
|
||||||
|
features are implemented; declaring those profiles does not silently enable
|
||||||
|
them.
|
||||||
|
|
||||||
|
The included `LocalCoreBatchExecutor` is a trusted, single-threaded in-process
|
||||||
|
conformance harness. It validates scientific parity, sealed outputs, provenance,
|
||||||
|
and limits, but intentionally refuses profiles that claim network/process
|
||||||
|
isolation, secrets, accelerators, gangs, checkpoints, or retries; those require
|
||||||
|
the future enforcing Agent runtime.
|
||||||
|
|
||||||
|
## Team
|
||||||
|
|
||||||
|
- [Emil](https://github.com/emil28092005) — Project Lead
|
||||||
|
- [Kristina](https://github.com/kristtma) — Tech Lead
|
||||||
|
- [Veniamin](https://t.me/Veniamin_Kt) — Scientific Lead
|
||||||
|
- [Arkhip](https://github.com/hIpa-ussr) — Programmer
|
||||||
|
- [Reranchik](https://github.com/RERAN4K) — Programmer
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# SciMesh Status
|
# SciMesh Status
|
||||||
|
|
||||||
**Updated:** 2026-07-23
|
**Updated:** 2026-08-02
|
||||||
**Branch baseline:** `planning` at `13f9a0b`
|
**Branch baseline:** `main`; this revision adds the single-binary platform.
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
@@ -15,39 +15,87 @@ the reference behaviour for future distributed execution:
|
|||||||
- Python Worker skeleton: claim, heartbeat, input checksum validation,
|
- Python Worker skeleton: claim, heartbeat, input checksum validation,
|
||||||
artifact upload, completion and failure reporting.
|
artifact upload, completion and failure reporting.
|
||||||
|
|
||||||
The Go coordinator, PostgreSQL schema, coordinator artifact storage, planner,
|
The Go coordinator and its PostgreSQL-backed task lifecycle are implemented:
|
||||||
reducer, and end-to-end distributed execution are **not implemented yet**.
|
registration, atomic claiming, lease renewal, artifact storage, dataset
|
||||||
|
chunking, result/failure reporting, and job progress. The Go worker agent now
|
||||||
|
uses the live coordinator contract. Completed shard results are reduced once
|
||||||
|
into a checksum-protected final CSV, downloadable through the coordinator.
|
||||||
|
|
||||||
|
**Single-binary platform (`coordinator serve`)**: the coordinator now ships
|
||||||
|
an embedded SQLite storage backend (`SCIMESH_DB=sqlite`), an embedded
|
||||||
|
userservice, and `serve`/`agent` subcommands, so one downloaded binary runs
|
||||||
|
the whole platform — coordinator, both databases, UI logins, and local
|
||||||
|
workers — with no PostgreSQL, no Docker, and no environment variables. The
|
||||||
|
first start provisions `~/.scimesh` (secrets, admin password printed once,
|
||||||
|
managed scientific-runtime venv) and opens the UI. `install.sh` / `install.ps1`
|
||||||
|
download the right release binary in one command and are release assets. The
|
||||||
|
PostgreSQL engine, the `setup` wizard, and the standalone `users/` service
|
||||||
|
remain fully supported for cluster deployments. The full E2E passes with zero
|
||||||
|
external services: health, UI login, job upload, local agent compute,
|
||||||
|
reduction, and a byte-exact final CSV.
|
||||||
|
|
||||||
|
The User Service is merged into `main`. It owns user accounts, authentication,
|
||||||
|
roles, and verified-contributor status; the coordinator scopes user jobs and
|
||||||
|
worker operations to the authenticated owner. Its documented v1 contract is in
|
||||||
|
[`docs/user-service-api-contract.md`](docs/user-service-api-contract.md).
|
||||||
|
Users can create and revoke worker keys for self-service Worker Agent
|
||||||
|
enrollment. Untrusted workers require quorum agreement from distinct owners on
|
||||||
|
the complete result-artifact SHA-256 before a task is accepted.
|
||||||
|
|
||||||
## Milestone tracker
|
## Milestone tracker
|
||||||
|
|
||||||
| CTX | Status | Notes |
|
| CTX | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| CTX-00 API and error contract | Ready to implement | `docs/api-contract.md` created; needs owner review/freeze. |
|
| CTX-00 API and error contract | Implemented | Contract, OpenAPI, and request examples are in `docs/`. |
|
||||||
| CTX-01 Go coordinator bootstrap | Not started | Depends on CTX-00. |
|
| CTX-01 Go coordinator bootstrap | Implemented | Go service and Docker runtime in `coordinator/`. |
|
||||||
| CTX-02 PostgreSQL migrations | Not started | Depends on CTX-00 and CTX-01. |
|
| CTX-02 PostgreSQL migrations | Implemented | Embedded into the binary (`AUTO_MIGRATE`); the CLI path is still available for managed databases. |
|
||||||
| CTX-03 Transactional queue | Not started | Depends on CTX-02. |
|
| CTX-03 Transactional queue | Implemented | Real-PostgreSQL integration tests cover atomic claims and concurrency; the SQLite backend mirrors the semantics. |
|
||||||
| CTX-04 Worker registry and HTTP API | Not started | Depends on CTX-03. |
|
| CTX-04 Worker registry and HTTP API | Implemented | Registration, claim, heartbeat, result, failure, and status endpoints. |
|
||||||
| CTX-05 Artifact storage | Not started | Depends on CTX-02 and CTX-04. |
|
| CTX-05 Artifact storage | Implemented | Coordinator-owned inputs/results, checksum verification, and upload flow. |
|
||||||
| CTX-06 Python Worker live-contract alignment | Partially prepared | Worker skeleton exists; needs real Go contract tests. |
|
| CTX-06 Python Worker live-contract alignment | Superseded | The Python worker daemon was removed; the Go worker agent (`coordinator/internal/agent/` + `cmd/worker-agent`, or `coordinator agent`) implements the lifecycle and executes SDK workloads via `scimesh/worker/task.py`. |
|
||||||
| CTX-07 Distributed workload protocol | Not started | Depends on artifact and Worker contracts. |
|
| CTX-07 Distributed workload protocol | Implemented | Versioned Python contract models, registry, strict plan validation, and deterministic reduction ordering. |
|
||||||
| CTX-08 Distributed similarity-search | Not started | Local reference exists. |
|
| CTX-08 Distributed similarity-search | Implemented | Planner/worker/reducer match the local reference byte-exactly. |
|
||||||
| CTX-09 Reducer and final-result API | Not started | Depends on CTX-07 and CTX-08. |
|
| CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side reducers (`top-k` and `ordered-concat`), sanitized failure, final artifact, `result_uri`. |
|
||||||
| CTX-10 Distributed similarity-graph | Not started | Local reference exists. |
|
| CTX-10 Distributed similarity-graph | Not started | Local reference exists; the SDK-built local graph workload enforces the pair-coverage invariant. |
|
||||||
| CTX-11 Dashboard/operator view | Not started | Deferred until API and reducer work. |
|
| CTX-11 Dashboard/operator view | Implemented | Protected live control room, workload library, workload-agnostic "New computation" form (SDK-declared `UIElement`s), MkDocs at `/ui/docs/`, final-result download. |
|
||||||
| CTX-12 Reliability, security, CI | Not started | Final milestone. |
|
| CTX-12 Reliability, security, CI | In progress | vet, gofmt, race tests, golangci-lint (0 issues), PostgreSQL integration, and smoke checks exist. |
|
||||||
|
| CTX-15 User Service and access control | Implemented | User/owner scoping, verified contributors, worker keys, quorum; also embeddable (`coordinator serve`). |
|
||||||
|
| CTX-16 Workload SDK foundation | Implemented | Strict immutable manifests/plans/artifacts, digest/trust-pinned tasks, negotiation, verifier primitives, conformance harness. |
|
||||||
|
| CTX-17 Self-provisioning + setup wizard | Implemented | Embedded migrations, `coordinator setup`, SQLite backend, embedded userservice, `serve` mode. |
|
||||||
|
| CTX-18 Single-binary platform | Implemented | `coordinator serve` (data dir, secrets, admin bootstrap, local agents, managed venv) + `install.sh`/`install.ps1`; full no-external-service E2E green. |
|
||||||
|
| SDK roadmap step 3: `descriptor-batch` | Implemented | Byte-identical local/distributed output, quorum verifier test. |
|
||||||
|
| SDK-built `similarity-search` and `similarity-graph` | Implemented | SDK-built packages, byte-identical to single-process references. |
|
||||||
|
| SDK-built `molwt-filter` | Implemented | Minimal authoring example; also the single-binary E2E workload. |
|
||||||
|
| SDK authoring scaffold | Implemented | `MapReduceWorkload` with `UIElement` declarations, `reduction`, `upload_ready`; generic `scimesh workload list|run|export|allowlist` CLI. |
|
||||||
|
|
||||||
## Next recommended assignment
|
## Next recommended assignment
|
||||||
|
|
||||||
Assign **CTX-00** to the coordinator role in `.agents/coordinator.md`: review
|
Assign **CTX-10** to the distributed-science role: implement deterministic
|
||||||
and freeze `docs/api-contract.md` against `PLAN.md`. Do not begin coordinator
|
block-pair planning and reduction for `similarity-graph`.
|
||||||
or Worker API implementation until the contract owner accepts it.
|
|
||||||
|
|
||||||
## Known constraints
|
## Known constraints
|
||||||
|
|
||||||
- Distributed execution is not available; use the local `scimesh` CLI.
|
- The worker/coordinator flow accepts both underscore API workload names and
|
||||||
- No Go module, PostgreSQL migrations, runtime configuration, or integration
|
hyphenated names at the runner boundary; the runner normalizes them.
|
||||||
environment exists yet.
|
- The worker executes SDK-built workloads through `scimesh/worker/runners.py`
|
||||||
- Local worker unit tests do not prove interoperability with a live coordinator.
|
(a workload-generic v1-wire bridge over `TaskSpec`/`LocalTaskContext`);
|
||||||
|
`query_id` resolution and parameter validation live in the workload itself.
|
||||||
|
`max_rows` is a plan-time option and is rejected per task by the stage
|
||||||
|
projection.
|
||||||
|
- A real-stack worker test uses a small `query_smiles` shard. The Python
|
||||||
|
planner resolves `query_id` once and shares `query_smiles`; the upload UI
|
||||||
|
currently accepts `query_smiles` only.
|
||||||
|
- The coordinator accepts uploaded distributed jobs only for
|
||||||
|
`similarity-search` with `query_smiles`. It rejects `similarity-graph` until
|
||||||
|
CTX-10 supplies cross-shard pair planning.
|
||||||
|
- The SDK can execute `core-batch-v1` locally, but the protocol-v1 coordinator
|
||||||
|
still has flat single-input/single-result tasks and no package/resource
|
||||||
|
leases. General DAG, concurrent-Agent, GPU, stream, and gang execution needs
|
||||||
|
a versioned coordinator/Worker rollout; unsupported features fail before
|
||||||
|
planner invocation.
|
||||||
|
- The local SDK executor is intentionally trusted and in-process. It does not
|
||||||
|
enforce process/network/timeout/credential isolation and rejects declarations
|
||||||
|
that would require those guarantees.
|
||||||
|
|
||||||
## Update rule
|
## Update rule
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Keep the build context small and never bake secrets or local state into an image.
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
Makefile
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Local build artifacts
|
||||||
|
/coordinator
|
||||||
|
/bin/
|
||||||
|
*.out
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Copy to .env and adjust. All settings are read from the environment.
|
||||||
|
|
||||||
|
COORDINATOR_ADDR=:8080
|
||||||
|
DATABASE_URL=postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable
|
||||||
|
|
||||||
|
# Shared bearer token every worker must present. Leave empty to disable auth (dev only).
|
||||||
|
WORKER_AUTH_TOKEN=change-me
|
||||||
|
|
||||||
|
# Optional local operator UI. Use a separate value; never reuse the worker token.
|
||||||
|
# When empty, /ui is disabled.
|
||||||
|
UI_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only;
|
||||||
|
# set a path to also write a size-rotated file (kept across restarts).
|
||||||
|
LOG_LEVEL=info
|
||||||
|
# LOG_FILE=./logs/coordinator.log
|
||||||
|
|
||||||
|
# Directory where artifact bytes are stored.
|
||||||
|
COORDINATOR_STORAGE_DIR=./data
|
||||||
|
# Upper bound on an uploaded dataset or artifact body (bytes). Default 1 GiB.
|
||||||
|
MAX_UPLOAD_BYTES=1073741824
|
||||||
|
|
||||||
|
# Optional tuning (defaults shown).
|
||||||
|
DB_MAX_CONNS=10
|
||||||
|
# How long to keep retrying the initial DB connection while Postgres boots.
|
||||||
|
DB_CONNECT_TIMEOUT=30s
|
||||||
|
REQUEST_TIMEOUT=15s
|
||||||
|
LEASE_DURATION=2m
|
||||||
|
DEFAULT_MAX_ATTEMPTS=3
|
||||||
|
REAPER_INTERVAL=30s
|
||||||
|
# A worker silent longer than this is marked offline by the reaper.
|
||||||
|
WORKER_OFFLINE_AFTER=1m
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/coordinator
|
||||||
|
/bin/
|
||||||
|
.env
|
||||||
|
*.out
|
||||||
|
/logs/
|
||||||
|
/data/
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 3m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
# "standard" = errcheck, govet, ineffassign, staticcheck, unused.
|
||||||
|
default: standard
|
||||||
|
enable:
|
||||||
|
# Catches `err == ErrFoo` where errors.Is is required. Directly relevant
|
||||||
|
# here: domain exposes sentinel errors that use cases may wrap with %w.
|
||||||
|
- errorlint
|
||||||
|
# Returning nil after checking a non-nil error — a silent bug factory.
|
||||||
|
- nilerr
|
||||||
|
# http.Get/Do without a context: every outbound call must be cancellable.
|
||||||
|
- noctx
|
||||||
|
# Unclosed response bodies leak connections.
|
||||||
|
- bodyclose
|
||||||
|
# Common security mistakes (weak crypto, unhandled file perms).
|
||||||
|
- gosec
|
||||||
|
# Style and naming consistency.
|
||||||
|
- revive
|
||||||
|
- misspell
|
||||||
|
- unconvert
|
||||||
|
|
||||||
|
settings:
|
||||||
|
errcheck:
|
||||||
|
# Deferred Close/Rollback are intentionally ignored in a few places
|
||||||
|
# (rollback after commit is a documented no-op).
|
||||||
|
check-type-assertions: true
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: exported
|
||||||
|
disabled: true # internal packages need no exported-symbol comments
|
||||||
|
gosec:
|
||||||
|
excludes:
|
||||||
|
- G404 # math/rand is fine for jitter; nothing here is security-sensitive
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
rules:
|
||||||
|
# Tests may skip error checks and use long literals freely.
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
- gosec
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
settings:
|
||||||
|
goimports:
|
||||||
|
local-prefixes:
|
||||||
|
- github.com/emil28092005/SciMesh/coordinator
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Архитектура координатора
|
||||||
|
|
||||||
|
Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит
|
||||||
|
запрос», в конце — «куда добавлять новое».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`, осталось их подключить.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Requires BuildKit (the RUN --mount cache lines below). Docker 23+ enables it
|
||||||
|
# by default when the buildx plugin is present; install `docker-buildx` if a
|
||||||
|
# build fails with "the --mount option requires BuildKit".
|
||||||
|
|
||||||
|
# --- build stage ----------------------------------------------------------
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy manifests first: this layer stays cached until dependencies actually
|
||||||
|
# change, so editing Go sources does not re-download the module graph.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Release builds inject the tag via build-arg; local builds stay "dev".
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
# The cache mounts persist the module cache and the compiler's build cache
|
||||||
|
# *across* builds, so a rebuild after a code edit recompiles only what changed
|
||||||
|
# instead of the whole dependency tree.
|
||||||
|
#
|
||||||
|
# CGO_ENABLED=0 produces a fully static binary, so the runtime image needs no
|
||||||
|
# libc. -trimpath strips local paths; -s -w drop the symbol table and DWARF.
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build \
|
||||||
|
-trimpath -ldflags="-s -w -X main.version=${VERSION#v}" \
|
||||||
|
-o /out/coordinator ./cmd/coordinator
|
||||||
|
|
||||||
|
# --- runtime stage --------------------------------------------------------
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
# ca-certificates for outbound TLS; wget backs the container healthcheck.
|
||||||
|
RUN apk add --no-cache ca-certificates wget \
|
||||||
|
&& adduser -D -H -u 10001 coordinator \
|
||||||
|
# Pre-create the storage and log dirs owned by the non-root user. A named
|
||||||
|
# volume mounted here inherits this ownership from the image, so the process
|
||||||
|
# can write to it — a host bind mount, owned by root, cannot.
|
||||||
|
&& mkdir -p /var/lib/scimesh/artifacts /var/log/scimesh \
|
||||||
|
&& chown -R coordinator:coordinator /var/lib/scimesh /var/log/scimesh
|
||||||
|
|
||||||
|
COPY --from=build /out/coordinator /usr/local/bin/coordinator
|
||||||
|
|
||||||
|
# Never run as root: a compromised process should not own the container.
|
||||||
|
USER coordinator
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Exec form, not shell: the binary becomes PID 1 and receives SIGTERM directly,
|
||||||
|
# which is what its graceful shutdown depends on.
|
||||||
|
ENTRYPOINT ["/usr/local/bin/coordinator"]
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke agent coordinator setup serve workloads-export demo-ui demo-down demo-reset demo-logs
|
||||||
|
|
||||||
|
# `check` deliberately uses its own Compose project and host ports. This keeps
|
||||||
|
# it from connecting to or replacing a developer's local PostgreSQL instance.
|
||||||
|
CHECK_PROJECT ?= scimesh-check
|
||||||
|
CHECK_POSTGRES_PORT ?= 55432
|
||||||
|
CHECK_COORDINATOR_PORT ?= 18080
|
||||||
|
CHECK_HOST ?= http://localhost:$(CHECK_COORDINATOR_PORT)
|
||||||
|
CHECK_TOKEN ?= dev-token
|
||||||
|
CHECK_DATABASE_URL ?= postgres://scimesh:scimesh@localhost:$(CHECK_POSTGRES_PORT)/scimesh?sslmode=disable
|
||||||
|
CHECK_COMPOSE = POSTGRES_PORT=$(CHECK_POSTGRES_PORT) COORDINATOR_PORT=$(CHECK_COORDINATOR_PORT) docker compose -p $(CHECK_PROJECT)
|
||||||
|
|
||||||
|
# --- local manual demo ---------------------------------------------------
|
||||||
|
# A separate project and ports mean this demo cannot collide with the normal
|
||||||
|
# `make up` stack or a developer's local PostgreSQL on 5432.
|
||||||
|
DEMO_PROJECT ?= scimesh-demo
|
||||||
|
DEMO_POSTGRES_PORT ?= 55432
|
||||||
|
DEMO_COORDINATOR_PORT ?= 18080
|
||||||
|
DEMO_UI_TOKEN ?= demo-ui-secret
|
||||||
|
DEMO_WORKER_TOKEN ?= demo-worker-token
|
||||||
|
DEMO_WORKERS ?= 2
|
||||||
|
# Short public knob for `make demo-ui WORKERS=3`; DEMO_WORKERS remains useful
|
||||||
|
# for scripts and backwards-compatible documentation.
|
||||||
|
WORKERS ?= $(DEMO_WORKERS)
|
||||||
|
DEMO_DIR ?= .demo
|
||||||
|
|
||||||
|
# workloads.json is the UI workload catalog, generated from the Python SDK
|
||||||
|
# workload library. It is checked in so the binary embeds it; regenerate it
|
||||||
|
# whenever workloads or their manifests change (requires the Python venv).
|
||||||
|
WORKLOADS_JSON := internal/workloads/workloads.json
|
||||||
|
|
||||||
|
# Version injected into the binaries via -ldflags; falls back to "dev".
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
|
||||||
|
# The Go worker agent: a static coordinator client that executes SDK
|
||||||
|
# workloads in a Python subprocess per claimed task.
|
||||||
|
agent:
|
||||||
|
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/worker-agent ./cmd/worker-agent
|
||||||
|
@printf '%s\n' 'Built bin/worker-agent. Configure via environment:' ' COORDINATOR_URL, WORKER_AUTH_TOKEN, WORK_DIR, CPU_COUNT, MEMORY_MB,' ' POLL_INTERVAL, REQUEST_TIMEOUT, HEARTBEAT_INTERVAL, CAPABILITIES,' ' TASK_RUNNER, MAX_TASKS, EXIT_WHEN_IDLE, WORKER_NAME, WORKER_ID'
|
||||||
|
|
||||||
|
# The coordinator server as a static binary, the same way the Docker image
|
||||||
|
# builds it (CGO_ENABLED=0, trimmed). Requires PostgreSQL at runtime.
|
||||||
|
coordinator:
|
||||||
|
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/coordinator ./cmd/coordinator
|
||||||
|
@printf '%s\n' \
|
||||||
|
'Built bin/coordinator. Configure via environment:' \
|
||||||
|
' DATABASE_URL, COORDINATOR_ADDR, COORDINATOR_TOKEN, UI_AUTH_TOKEN,' \
|
||||||
|
' COORDINATOR_STORAGE_DIR, SCIMESH_DOCS_DIR, JWT_SECRET, USERSERVICE_URL' \
|
||||||
|
' (embedded schema migrations run on startup; AUTO_MIGRATE=false disables)'
|
||||||
|
|
||||||
|
# Interactive wizard: checks the database, creates it when missing (via
|
||||||
|
# POSTGRES_ADMIN_URL or --admin-db), applies the embedded schema, generates a
|
||||||
|
# JWT_SECRET, and writes a .env file. Non-interactive: SETUP_ARGS=--yes.
|
||||||
|
setup: coordinator
|
||||||
|
./bin/coordinator setup $(SETUP_ARGS)
|
||||||
|
|
||||||
|
# The single-binary mode: everything embedded (sqlite + userservice + local
|
||||||
|
# workers), no PostgreSQL or Docker. SETUP_ARGS=--workers 2 --open.
|
||||||
|
serve: coordinator
|
||||||
|
./bin/coordinator serve $(SETUP_ARGS)
|
||||||
|
|
||||||
|
workloads-export:
|
||||||
|
cd .. && .venv/bin/scimesh workload export -o coordinator/$(WORKLOADS_JSON)
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' \
|
||||||
|
'SciMesh coordinator commands:' \
|
||||||
|
' make up / make down Start or stop the normal coordinator stack.' \
|
||||||
|
' make demo-ui [WORKERS=3] Start isolated UI demo services and local workers.' \
|
||||||
|
' make demo-logs Follow coordinator logs for the UI demo.' \
|
||||||
|
' make demo-down Stop the demo services and workers.' \
|
||||||
|
' make demo-reset Stop the demo and wipe its data volumes.' \
|
||||||
|
' make workloads-export Regenerate the embedded UI workload catalog.' \
|
||||||
|
' make setup Interactive one-shot provisioning wizard.' \
|
||||||
|
' make test / make vet Run Go verification.' \
|
||||||
|
'' \
|
||||||
|
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
|
||||||
|
|
||||||
|
demo-ui:
|
||||||
|
@DEMO_PROJECT="$(DEMO_PROJECT)" \
|
||||||
|
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
|
||||||
|
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
|
||||||
|
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
|
||||||
|
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
|
||||||
|
DEMO_WORKERS="$(WORKERS)" \
|
||||||
|
DEMO_DIR="$(DEMO_DIR)" \
|
||||||
|
./scripts/demo-ui.sh start
|
||||||
|
|
||||||
|
demo-down:
|
||||||
|
@DEMO_PROJECT="$(DEMO_PROJECT)" \
|
||||||
|
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
|
||||||
|
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
|
||||||
|
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
|
||||||
|
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
|
||||||
|
DEMO_DIR="$(DEMO_DIR)" \
|
||||||
|
./scripts/demo-ui.sh stop
|
||||||
|
|
||||||
|
demo-reset:
|
||||||
|
@DEMO_PROJECT="$(DEMO_PROJECT)" \
|
||||||
|
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
|
||||||
|
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
|
||||||
|
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
|
||||||
|
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
|
||||||
|
DEMO_DIR="$(DEMO_DIR)" \
|
||||||
|
./scripts/demo-ui.sh reset
|
||||||
|
|
||||||
|
demo-logs:
|
||||||
|
@DEMO_PROJECT="$(DEMO_PROJECT)" \
|
||||||
|
DEMO_POSTGRES_PORT="$(DEMO_POSTGRES_PORT)" \
|
||||||
|
DEMO_COORDINATOR_PORT="$(DEMO_COORDINATOR_PORT)" \
|
||||||
|
DEMO_UI_TOKEN="$(DEMO_UI_TOKEN)" \
|
||||||
|
DEMO_WORKER_TOKEN="$(DEMO_WORKER_TOKEN)" \
|
||||||
|
DEMO_DIR="$(DEMO_DIR)" \
|
||||||
|
./scripts/demo-ui.sh logs
|
||||||
|
|
||||||
|
# --- build / run ---------------------------------------------------------
|
||||||
|
build:
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run ./cmd/coordinator
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Needs a running PostgreSQL; the spec forbids mocks for these guarantees.
|
||||||
|
# make test-integration TEST_DATABASE_URL='postgres://...'
|
||||||
|
test-integration:
|
||||||
|
TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test -tags=integration ./... -v
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# One command that runs everything: unit tests + vet + lint, then brings up the
|
||||||
|
# stack and runs the integration suite and the end-to-end smoke test.
|
||||||
|
# Needs Docker. Hand this to a reviewer.
|
||||||
|
check: vet lint
|
||||||
|
go test -race ./...
|
||||||
|
$(CHECK_COMPOSE) up -d --build
|
||||||
|
@echo "waiting for the coordinator to be ready..."
|
||||||
|
@attempt=0; until curl -fsS "$(CHECK_HOST)/health" >/dev/null; do \
|
||||||
|
attempt=$$((attempt + 1)); \
|
||||||
|
if [ $$attempt -ge 30 ]; then $(CHECK_COMPOSE) logs coordinator; exit 1; fi; \
|
||||||
|
sleep 1; \
|
||||||
|
done
|
||||||
|
TEST_DATABASE_URL="$(CHECK_DATABASE_URL)" \
|
||||||
|
go test -tags=integration ./internal/storage/postgres/ -v
|
||||||
|
HOST="$(CHECK_HOST)" TOKEN="$(CHECK_TOKEN)" ./scripts/smoke.sh
|
||||||
|
@echo "\nall checks passed ✓"
|
||||||
|
|
||||||
|
# Runs golangci-lint without installing it system-wide. Install it for speed:
|
||||||
|
# pacman -S golangci-lint (Arch)
|
||||||
|
LINT_VERSION := v2.12.2
|
||||||
|
lint:
|
||||||
|
@command -v golangci-lint >/dev/null 2>&1 \
|
||||||
|
&& golangci-lint run --build-tags=integration ./... \
|
||||||
|
|| go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(LINT_VERSION) run --build-tags=integration ./...
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# --- migrations ----------------------------------------------------------
|
||||||
|
# Requires the golang-migrate CLI:
|
||||||
|
# go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||||
|
# DATABASE_URL must be set, e.g.:
|
||||||
|
# export DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable'
|
||||||
|
migrate-up:
|
||||||
|
migrate -path internal/storage/postgres/migrations -database "$(DATABASE_URL)" up
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
migrate -path internal/storage/postgres/migrations -database "$(DATABASE_URL)" down 1
|
||||||
|
|
||||||
|
# --- docker --------------------------------------------------------------
|
||||||
|
# `up` starts Postgres, applies migrations, then launches the coordinator.
|
||||||
|
up:
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Also drops the database volume — use when the schema is beyond repair.
|
||||||
|
down-clean:
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f coordinator
|
||||||
|
|
||||||
|
ps:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
docker compose up -d --build --force-recreate coordinator
|
||||||
|
|
||||||
|
psql:
|
||||||
|
docker compose exec postgres psql -U scimesh -d scimesh
|
||||||
|
|
||||||
|
# --- api ------------------------------------------------------------------
|
||||||
|
# Exercises every endpoint against a running coordinator; exits non-zero on the
|
||||||
|
# first unexpected status. See also api/requests.http for clicking through them
|
||||||
|
# one at a time in an editor.
|
||||||
|
smoke:
|
||||||
|
./scripts/smoke.sh
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# SciMesh Coordinator
|
||||||
|
|
||||||
|
Durable task-queue server for SciMesh, in Go on PostgreSQL. It owns all database
|
||||||
|
access; workers talk to it only over HTTP and never receive DB credentials.
|
||||||
|
|
||||||
|
Built as a **modular monolith following Clean Architecture** — one binary, four
|
||||||
|
layers, dependencies pointing strictly inward. See
|
||||||
|
`docs/database-integration-task.md` and `docs/worker-daemon-task.md` in the repo
|
||||||
|
root for the full contract.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
infra config, pgxpool, http.Server, clock ← frameworks & drivers
|
||||||
|
transport http handlers ← inbound: who calls us
|
||||||
|
storage sql repositories ← outbound: who we call
|
||||||
|
usecase business operations + PORTS ← application rules
|
||||||
|
domain Task, Job + their invariants ← enterprise rules
|
||||||
|
|
||||||
|
┌── transport ──┐
|
||||||
|
domain ◄── usecase ◄┤ ├◄── infra
|
||||||
|
└── storage ────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`transport` and `storage` are one layer — the "interface adapters" ring — split
|
||||||
|
by direction rather than by category, so a file's path tells you its role.
|
||||||
|
|
||||||
|
The rule that matters: **source dependencies point only inward**. `domain`
|
||||||
|
imports nothing from this module; `usecase` sees only `domain`; `transport` and
|
||||||
|
`storage` know nothing of each other. Verify it at any time with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal # must be empty
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
coordinator/
|
||||||
|
cmd/coordinator/main.go # composition root: the only place with concrete types
|
||||||
|
internal/
|
||||||
|
domain/ # entities + rules, no I/O
|
||||||
|
task.go Task, lease/complete/fail/expire transitions
|
||||||
|
job.go Job, chunk fan-out, status derivation
|
||||||
|
errors.go business-rule violations
|
||||||
|
usecase/ # one type per operation, dependencies injected
|
||||||
|
ports.go TaskRepository, JobRepository, TxManager, Clock
|
||||||
|
dto.go use-case boundary inputs
|
||||||
|
task.go claim, renew, complete, fail, expire
|
||||||
|
job.go create, status, results, stitch
|
||||||
|
transport/http/ # routing, DTOs, middleware, error mapping
|
||||||
|
storage/postgres/ # SQL behind the ports; TxManager via context
|
||||||
|
infra/ # config.go db.go clock.go server.go
|
||||||
|
migrations/ # golang-migrate SQL, run as an explicit command
|
||||||
|
```
|
||||||
|
|
||||||
|
A full map — file-by-file table, a request traced through every layer, and a
|
||||||
|
"where do I add X" guide — lives in [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
### With Docker (nothing to install but Docker)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make up # Postgres → migrations → coordinator
|
||||||
|
curl localhost:8080/health
|
||||||
|
make logs # follow the coordinator
|
||||||
|
make down # stop (add down-clean to drop the DB volume)
|
||||||
|
```
|
||||||
|
|
||||||
|
To enable the local operator UI, set a separate credential before starting:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
UI_AUTH_TOKEN='local-ui-secret' make up
|
||||||
|
# Open http://localhost:8080/ui and use any username with this value as password.
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI is disabled by default and never accepts the worker bearer token.
|
||||||
|
The **control room** shows live workers, recent runs, shard state/attempts,
|
||||||
|
safe failures, coordinator artifacts, and the final CSV for completed
|
||||||
|
similarity-search jobs. The job page follows the real stages: TSV accepted →
|
||||||
|
shards execute → workers return CSVs → `reducing` → final deterministic global
|
||||||
|
top-k result. It polls only its own coordinator read-model and never controls
|
||||||
|
or exposes worker processes.
|
||||||
|
|
||||||
|
For a hands-on run, open `/ui`, choose **New similarity search**, select a
|
||||||
|
small ChEMBL-style TSV, then leave one or more `scimesh-worker` processes
|
||||||
|
running in separate terminals. The detail page updates every two seconds and
|
||||||
|
stops polling after a completed, failed, or cancelled job. Use **Preview CSV**
|
||||||
|
to inspect a bounded first page of a partial or completed final result before
|
||||||
|
downloading it. The UI never exposes source datasets or shard inputs; partial
|
||||||
|
CSVs remain available only as diagnostics.
|
||||||
|
|
||||||
|
### One-command manual demo
|
||||||
|
|
||||||
|
From the repository root, create the Python environment once, then start a
|
||||||
|
self-contained UI demo with two local reference workers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -e '.[dev]'
|
||||||
|
make demo-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses a separate Docker project and ports `18080` (coordinator) and
|
||||||
|
`55432` (PostgreSQL), so it does not conflict with the normal stack. Open
|
||||||
|
`http://localhost:18080/ui`, use username `operator` and password
|
||||||
|
`demo-ui-secret`, upload a small ChEMBL TSV, and observe the workers process
|
||||||
|
it. Change the worker count with `make demo-ui WORKERS=3`; stop all demo
|
||||||
|
services and workers with `make demo-down`.
|
||||||
|
|
||||||
|
The job page shows a live **Processing speed** graph in completed shards per
|
||||||
|
minute. It uses the coordinator snapshots observed by the open browser tab, so
|
||||||
|
it is a transparent local-session measurement rather than a persisted metric.
|
||||||
|
Use **Preview CSV** before downloading a partial diagnostic or completed final
|
||||||
|
result. Run `make help` from either the repository root or this directory for
|
||||||
|
the full list of demo commands.
|
||||||
|
|
||||||
|
`up` starts three services in order: Postgres waits until `pg_isready` passes, a
|
||||||
|
one-shot `migrate` container applies the schema and exits, and only then does the
|
||||||
|
coordinator start — so it never queries a database that has no tables.
|
||||||
|
|
||||||
|
> **Needs BuildKit.** The Dockerfile uses `RUN --mount=type=cache` to reuse the
|
||||||
|
> Go module and compiler caches between builds. If the build fails with
|
||||||
|
> *"the --mount option requires BuildKit"*, install the buildx plugin —
|
||||||
|
> `pacman -S docker-buildx` on Arch, `apt install docker-buildx-plugin` on Debian.
|
||||||
|
|
||||||
|
### Locally, against your own Postgres
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # then edit DATABASE_URL / WORKER_AUTH_TOKEN
|
||||||
|
# it is loaded automatically — no export needed
|
||||||
|
|
||||||
|
make tidy # fetch deps (needs network once)
|
||||||
|
make migrate-up # apply schema (needs the migrate CLI)
|
||||||
|
make run # start the server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings come from the environment. A `.env` file is loaded at startup via
|
||||||
|
`godotenv` as a local-dev convenience (override its path with `ENV_FILE`):
|
||||||
|
|
||||||
|
- a missing `.env` is not an error — production injects real env vars;
|
||||||
|
- **real environment variables always win** over the file, so an orchestrator's
|
||||||
|
values are never shadowed by a stale `.env` baked into an image.
|
||||||
|
|
||||||
|
See `.env.example`; only `DATABASE_URL` is required.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| ------ | ---------------------------------- | --------------------------------------------- |
|
||||||
|
| POST | `/workers/register` | Register a worker, get its id |
|
||||||
|
| POST | `/jobs` | Create job + tasks from chunk URIs |
|
||||||
|
| POST | `/jobs/upload` | Upload a dataset; coordinator chunks it |
|
||||||
|
| GET | `/jobs/{job_id}` | Aggregate job progress |
|
||||||
|
| POST | `/tasks/claim` | Atomically lease one task (`204` if none) |
|
||||||
|
| GET | `/tasks/{task_id}/input` | Download the task's input shard |
|
||||||
|
| POST | `/tasks/{task_id}/heartbeat` | Renew the caller's lease (→ `running`) |
|
||||||
|
| PUT | `/tasks/{task_id}/artifacts/{name}`| Upload a partial-result artifact |
|
||||||
|
| POST | `/tasks/{task_id}/result` | Complete with an artifact id (idempotent) |
|
||||||
|
| POST | `/tasks/{task_id}/failure` | Record failure / retryable state |
|
||||||
|
| GET | `/artifacts/{artifact_id}/download`| Download an artifact by id |
|
||||||
|
| GET | `/health` | Readiness incl. database (unauthenticated) |
|
||||||
|
|
||||||
|
The full contract is in [`docs/api-contract.md`](../docs/api-contract.md) and
|
||||||
|
[`docs/openapi.yaml`](../docs/openapi.yaml); a worker-author guide is in
|
||||||
|
[`docs/building-workers.md`](../docs/building-workers.md).
|
||||||
|
|
||||||
|
## Poking the API
|
||||||
|
|
||||||
|
Two ways, both checked in:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make smoke # every endpoint, asserted; non-zero exit on failure
|
||||||
|
```
|
||||||
|
|
||||||
|
`api/requests.http` runs the same calls one at a time from an editor with a REST
|
||||||
|
client (VSCodium/VS Code "REST Client", JetBrains HTTP Client). Later requests
|
||||||
|
reuse ids captured from earlier responses, so it doubles as API documentation.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Works end to end: a worker registers, a dataset is uploaded and chunked into
|
||||||
|
shard tasks (or a job is created from chunk URIs), tasks are leased one at a
|
||||||
|
time, downloaded, heartbeated (`leased → running`), completed via uploaded
|
||||||
|
result artifacts, and reflected in job progress. A reaper reclaims expired
|
||||||
|
leases and marks silent workers offline.
|
||||||
|
|
||||||
|
Done: schema + migrations, atomic claim (`FOR UPDATE SKIP LOCKED`), optimistic
|
||||||
|
concurrency, result/failure paths, lease expiry, worker registry + liveness,
|
||||||
|
artifact storage, dataset upload + chunking, request-size limits.
|
||||||
|
|
||||||
|
Still stubbed: `StitchJob.Execute` — merging per-chunk top-k into the final CSV
|
||||||
|
is workload semantics that belongs to the Python side (reducer).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Unit tests need **no database** — domain rules, use-case orchestration (over
|
||||||
|
in-memory `internal/memstore`), and HTTP handlers (via `httptest`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test # go test ./...
|
||||||
|
make vet
|
||||||
|
make lint
|
||||||
|
go test -race ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration tests run against a **real PostgreSQL** (the spec forbids mocks
|
||||||
|
here — they verify `FOR UPDATE SKIP LOCKED`, optimistic concurrency, rollback):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
make test-integration TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable'
|
||||||
|
```
|
||||||
|
|
||||||
|
CI (`.github/workflows/coordinator.yml`) runs vet, gofmt, race tests, lint, and
|
||||||
|
the integration suite against a Postgres service on every push and PR.
|
||||||
|
|
||||||
|
For the complete local verification, including an isolated Docker PostgreSQL
|
||||||
|
and the HTTP smoke flow, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
|
It uses Compose project `scimesh-check` and ports `55432`/`18080` by default,
|
||||||
|
so it does not connect to a PostgreSQL already running on `5432`. Override
|
||||||
|
`CHECK_POSTGRES_PORT`, `CHECK_COORDINATOR_PORT`, or `CHECK_PROJECT` if needed.
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# SciMesh Coordinator — API requests
|
||||||
|
#
|
||||||
|
# Runnable from any editor with a REST client (VSCodium/VS Code "REST Client",
|
||||||
|
# JetBrains HTTP Client). Click "Send Request" above each block, top to bottom:
|
||||||
|
# later requests reuse ids captured from earlier responses.
|
||||||
|
#
|
||||||
|
# Start the stack first: docker compose up -d
|
||||||
|
|
||||||
|
@host = http://localhost:8080
|
||||||
|
@token = change-me
|
||||||
|
@worker = worker-1
|
||||||
|
|
||||||
|
### Readiness — the only unauthenticated endpoint (probes the database)
|
||||||
|
GET {{host}}/health
|
||||||
|
|
||||||
|
### Auth check — no token must be rejected with 401
|
||||||
|
POST {{host}}/tasks/claim
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "worker_id": "{{worker}}" }
|
||||||
|
|
||||||
|
### 0. Register a worker (201)
|
||||||
|
# @name register
|
||||||
|
POST {{host}}/workers/register
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "lab-worker-01",
|
||||||
|
"capabilities": ["similarity_search"],
|
||||||
|
"cpu_count": 8,
|
||||||
|
"memory_mb": 16384
|
||||||
|
}
|
||||||
|
|
||||||
|
@workerId = {{register.response.body.worker_id}}
|
||||||
|
|
||||||
|
### 0b. Upload a dataset — the coordinator splits it into shard tasks (201)
|
||||||
|
# Text fields first, the file part last (it is streamed, not buffered).
|
||||||
|
# @name uploadJob
|
||||||
|
POST {{host}}/jobs/upload
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: multipart/form-data; boundary=----scimesh
|
||||||
|
|
||||||
|
------scimesh
|
||||||
|
Content-Disposition: form-data; name="workload"
|
||||||
|
|
||||||
|
similarity_search
|
||||||
|
------scimesh
|
||||||
|
Content-Disposition: form-data; name="parameters"
|
||||||
|
|
||||||
|
{"top_k":10}
|
||||||
|
------scimesh
|
||||||
|
Content-Disposition: form-data; name="chunk_rows"
|
||||||
|
|
||||||
|
2
|
||||||
|
------scimesh
|
||||||
|
Content-Disposition: form-data; name="file"; filename="chembl.tsv"
|
||||||
|
Content-Type: text/tab-separated-values
|
||||||
|
|
||||||
|
id smiles
|
||||||
|
A CC
|
||||||
|
B CCC
|
||||||
|
C CCCC
|
||||||
|
D CCCCC
|
||||||
|
------scimesh--
|
||||||
|
|
||||||
|
### Download a task's input shard (200) — taskId must be a shard task from an
|
||||||
|
### uploaded job (claim one first; its input.uri is /tasks/{id}/input).
|
||||||
|
GET {{host}}/tasks/{{taskId}}/input
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### 1. Create a job and its chunks (201)
|
||||||
|
# The coordinator splits the submission into one task per chunk, transactionally.
|
||||||
|
# @name createJob
|
||||||
|
POST {{host}}/jobs
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"workload": "similarity_search",
|
||||||
|
"input_uri": "s3://chembl/full.sdf",
|
||||||
|
"parameters": { "top_k": 10 },
|
||||||
|
"chunks": [
|
||||||
|
{ "chunk_index": 0, "input_uri": "s3://chembl/shard-0.sdf", "input_sha256": "aaa", "max_attempts": 3 },
|
||||||
|
{ "chunk_index": 1, "input_uri": "s3://chembl/shard-1.sdf", "input_sha256": "bbb", "max_attempts": 3 },
|
||||||
|
{ "chunk_index": 2, "input_uri": "s3://chembl/shard-2.sdf", "input_sha256": "ccc", "max_attempts": 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@jobId = {{createJob.response.body.id}}
|
||||||
|
|
||||||
|
### 2. Claim a task (200, or 204 when the queue is empty)
|
||||||
|
# Each call leases a different task; run it repeatedly to see chunk_index advance.
|
||||||
|
# @name claim
|
||||||
|
POST {{host}}/tasks/claim
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"worker_id": "{{worker}}",
|
||||||
|
"capabilities": ["similarity_search"],
|
||||||
|
"max_concurrency": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@taskId = {{claim.response.body.task_id}}
|
||||||
|
@attempt = {{claim.response.body.attempt}}
|
||||||
|
|
||||||
|
### 3. Heartbeat — renew the lease while the task is still running (200)
|
||||||
|
POST {{host}}/tasks/{{taskId}}/heartbeat
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"worker_id": "{{worker}}",
|
||||||
|
"attempt": {{attempt}}
|
||||||
|
}
|
||||||
|
|
||||||
|
### 3a. Upload a partial-result artifact (200) — while the task is leased
|
||||||
|
# Identity travels in headers per the contract; the body is streamed as-is.
|
||||||
|
# @name uploadArtifact
|
||||||
|
PUT {{host}}/tasks/{{taskId}}/artifacts/result.csv
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: text/csv
|
||||||
|
X-Worker-ID: {{worker}}
|
||||||
|
X-Task-Attempt: {{attempt}}
|
||||||
|
|
||||||
|
query,match,score
|
||||||
|
CHEMBL25,CHEMBL139,0.87
|
||||||
|
|
||||||
|
@artifactId = {{uploadArtifact.response.body.artifact_id}}
|
||||||
|
|
||||||
|
### 3b. Download the artifact by id (200)
|
||||||
|
GET {{host}}/artifacts/{{artifactId}}/download
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### 3c. Upload a second artifact — used by the conflict check below (200)
|
||||||
|
# @name uploadArtifact2
|
||||||
|
PUT {{host}}/tasks/{{taskId}}/artifacts/secondary.csv
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: text/csv
|
||||||
|
X-Worker-ID: {{worker}}
|
||||||
|
X-Task-Attempt: {{attempt}}
|
||||||
|
|
||||||
|
query,match,score
|
||||||
|
CHEMBL25,CHEMBL521,0.42
|
||||||
|
|
||||||
|
@artifactId2 = {{uploadArtifact2.response.body.artifact_id}}
|
||||||
|
|
||||||
|
### 4. Submit the result, referencing the uploaded artifact (200)
|
||||||
|
POST {{host}}/tasks/{{taskId}}/result
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"worker_id": "{{worker}}",
|
||||||
|
"attempt": {{attempt}},
|
||||||
|
"result": { "artifact_id": "{{artifactId}}", "content_type": "text/csv" },
|
||||||
|
"metrics": { "elapsed_ms": 1234, "candidates": 50000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
### 4a. Replay the same result — must be idempotent (200, not 409)
|
||||||
|
POST {{host}}/tasks/{{taskId}}/result
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"worker_id": "{{worker}}",
|
||||||
|
"attempt": {{attempt}},
|
||||||
|
"result": { "artifact_id": "{{artifactId}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
### 4b. A different artifact for the same task — conflict (409)
|
||||||
|
POST {{host}}/tasks/{{taskId}}/result
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"worker_id": "{{worker}}",
|
||||||
|
"attempt": {{attempt}},
|
||||||
|
"result": { "artifact_id": "{{artifactId2}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
### 4c. Another worker submitting for this task — conflict (409)
|
||||||
|
POST {{host}}/tasks/{{taskId}}/result
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"worker_id": "impostor",
|
||||||
|
"attempt": {{attempt}},
|
||||||
|
"result": { "artifact_id": "{{artifactId}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
### 5. Report a failure instead (200)
|
||||||
|
# retryable=true returns the task to the queue while attempts remain;
|
||||||
|
# retryable=false fails it terminally.
|
||||||
|
POST {{host}}/tasks/{{taskId}}/failure
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"worker_id": "{{worker}}",
|
||||||
|
"attempt": {{attempt}},
|
||||||
|
"error_code": "download_failed",
|
||||||
|
"error_message": "checksum mismatch on shard",
|
||||||
|
"retryable": true
|
||||||
|
}
|
||||||
|
|
||||||
|
### 6. Job progress (200)
|
||||||
|
GET {{host}}/jobs/{{jobId}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### --- error cases -------------------------------------------------------
|
||||||
|
|
||||||
|
### Malformed UUID in the path (400)
|
||||||
|
POST {{host}}/tasks/not-a-uuid/result
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "worker_id": "{{worker}}", "attempt": 1, "result_uri": "s3://x", "result_sha256": "x" }
|
||||||
|
|
||||||
|
### Unknown field in the body (400) — a misspelled key must not pass silently
|
||||||
|
POST {{host}}/tasks/claim
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "worker_ID": "{{worker}}" }
|
||||||
|
|
||||||
|
### Unknown job (404)
|
||||||
|
GET {{host}}/jobs/00000000-0000-0000-0000-000000000000
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### Stitching is not implemented yet (501)
|
||||||
|
# Any endpoint whose use case is still a stub answers 501.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runAgent implements `coordinator agent`: the worker agent as a subcommand of
|
||||||
|
// the same binary, so one file can serve the whole platform. `serve` spawns
|
||||||
|
// these for its local workers.
|
||||||
|
func runAgent(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("agent", flag.ContinueOnError)
|
||||||
|
flags.Usage = func() {
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator agent [options]\n")
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "Runs as a worker agent: claims tasks, executes SDK workloads in a\n")
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "Python subprocess, uploads results.\n\n")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
coordinatorURL = flags.String("coordinator-url", os.Getenv("COORDINATOR_URL"), "coordinator base URL")
|
||||||
|
token = flags.String("token", os.Getenv("WORKER_AUTH_TOKEN"), "worker bearer token")
|
||||||
|
workDir = flags.String("work-dir", os.Getenv("WORK_DIR"), "worker work directory")
|
||||||
|
name = flags.String("name", os.Getenv("WORKER_NAME"), "worker name (default: hostname)")
|
||||||
|
workerID = flags.String("worker-id", os.Getenv("WORKER_ID"), "persistent worker id (optional)")
|
||||||
|
cpuCount = flags.Int("cpu", envInt("CPU_COUNT", 1), "advertised CPU cores")
|
||||||
|
memoryMB = flags.Int("memory-mb", envInt("MEMORY_MB", 1024), "advertised memory in MiB")
|
||||||
|
poll = flags.Duration("poll-interval", 2*time.Second, "claim poll interval")
|
||||||
|
taskRunner = flags.String("task-runner", os.Getenv("TASK_RUNNER"), "python command + args that run scimesh.worker.task")
|
||||||
|
maxTasks = flags.Int("max-tasks", envInt("MAX_TASKS", 0), "stop after N completed tasks (0 = unlimited)")
|
||||||
|
exitWhenIdle = flags.Bool("exit-when-idle", false, "exit when the queue is empty")
|
||||||
|
)
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() > 0 {
|
||||||
|
return fmt.Errorf("agent takes no positional arguments")
|
||||||
|
}
|
||||||
|
if *coordinatorURL == "" || *token == "" || *workDir == "" {
|
||||||
|
return fmt.Errorf("--coordinator-url, --token, and --work-dir are required")
|
||||||
|
}
|
||||||
|
if *taskRunner == "" {
|
||||||
|
*taskRunner = "python -m scimesh.worker.task"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
config := agent.Config{
|
||||||
|
CoordinatorURL: strings.TrimRight(*coordinatorURL, "/"),
|
||||||
|
Capabilities: agent.DefaultCapabilities(),
|
||||||
|
Token: *token,
|
||||||
|
WorkerName: *name,
|
||||||
|
WorkerID: *workerID,
|
||||||
|
WorkDir: *workDir,
|
||||||
|
CPUCount: *cpuCount,
|
||||||
|
MemoryMB: *memoryMB,
|
||||||
|
PollInterval: *poll,
|
||||||
|
RequestTimeout: 30 * time.Second,
|
||||||
|
Heartbeat: 15 * time.Second,
|
||||||
|
TaskRunner: strings.Fields(*taskRunner),
|
||||||
|
MaxTasks: *maxTasks,
|
||||||
|
ExitWhenIdle: *exitWhenIdle,
|
||||||
|
}
|
||||||
|
tokens := agent.NewTokenProvider("", "", config.Token, config.RequestTimeout)
|
||||||
|
client := agent.NewClient(config.CoordinatorURL, tokens, config.RequestTimeout)
|
||||||
|
runner := agent.NewTaskRunner(config.TaskRunner)
|
||||||
|
daemon := agent.NewDaemon(&config, client, runner, logger)
|
||||||
|
return daemon.RunForever()
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(name string, fallback int) int {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/infra"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/metrics"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/storage/blob"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/storage/sqlite"
|
||||||
|
httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version is injected at build time (-ldflags "-X main.version=...") and
|
||||||
|
// reported by --version. "dev" marks a local build.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
args := os.Args[1:]
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch args[0] {
|
||||||
|
case "setup":
|
||||||
|
if err := runSetup(args[1:]); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "serve":
|
||||||
|
if err := runServe(args[1:]); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "agent":
|
||||||
|
if err := runAgent(args[1:]); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showVersion := flag.Bool("version", false, "print the build version and exit")
|
||||||
|
flag.Parse()
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println("coordinator " + version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// All work happens in run() so its defers (pool.Close, log flush, signal
|
||||||
|
// stop) still execute: os.Exit skips deferred calls entirely.
|
||||||
|
if err := run(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageDeps carries the engine-specific database handles and the repository
|
||||||
|
// implementations. The usecases below only ever see the ports.
|
||||||
|
type storageDeps struct {
|
||||||
|
tx usecase.TxManager
|
||||||
|
taskRepo usecase.TaskRepository
|
||||||
|
jobRepo usecase.JobRepository
|
||||||
|
workerRepo usecase.WorkerRepository
|
||||||
|
artifactRepo usecase.ArtifactRepository
|
||||||
|
uiReadRepo usecase.UIReadRepository
|
||||||
|
taskResultRepo usecase.TaskResultRepository
|
||||||
|
statsRepo interface {
|
||||||
|
Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error)
|
||||||
|
}
|
||||||
|
ready func(ctx context.Context) error
|
||||||
|
migrate func(ctx context.Context, log *slog.Logger) error
|
||||||
|
close func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
boot := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||||
|
|
||||||
|
cfg, err := infra.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
boot.Error("load config", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runWithConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWithConfig boots the coordinator server with an explicit config. The
|
||||||
|
// `serve` subcommand builds such a config for the single-binary mode; the
|
||||||
|
// plain `coordinator` binary loads it from the environment.
|
||||||
|
func runWithConfig(cfg infra.Config) error {
|
||||||
|
// Bootstrap logger, used only until config says where logs should go. It
|
||||||
|
// writes to stderr so it never contaminates the configured stdout stream.
|
||||||
|
boot := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||||
|
|
||||||
|
// The real logger: stdout plus an optional rotated file (LOG_FILE).
|
||||||
|
log, logCloser, err := infra.NewLogger(cfg)
|
||||||
|
if err != nil {
|
||||||
|
boot.Error("init logger", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = logCloser.Close() }()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
var deps *storageDeps
|
||||||
|
switch cfg.DatabaseEngine {
|
||||||
|
case "sqlite":
|
||||||
|
deps, err = openSQLite(ctx, cfg, log)
|
||||||
|
case "postgres":
|
||||||
|
deps, err = openPostgres(ctx, cfg, log)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("SCIMESH_DB must be sqlite or postgres")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("init storage", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer deps.close()
|
||||||
|
|
||||||
|
// A downloaded binary provisions its own schema; AUTO_MIGRATE=false keeps
|
||||||
|
// out-of-band migration workflows (the migrate CLI, CI, managed databases).
|
||||||
|
if cfg.AutoMigrate {
|
||||||
|
if err := deps.migrate(ctx, log); err != nil {
|
||||||
|
log.Error("apply migrations", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blobStore, err := blob.NewFSStore(cfg.StorageDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("init blob storage", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
clk := infra.NewClock()
|
||||||
|
tx, taskRepo, jobRepo, workerRepo, artifactRepo, uiReadRepo, taskResultRepo :=
|
||||||
|
deps.tx, deps.taskRepo, deps.jobRepo, deps.workerRepo, deps.artifactRepo, deps.uiReadRepo, deps.taskResultRepo
|
||||||
|
|
||||||
|
catalog, err := workloads.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("load workload catalog", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
useCases := httptransport.UseCases{
|
||||||
|
RegisterWorker: usecase.NewRegisterWorker(workerRepo, clk),
|
||||||
|
CreateJob: usecase.NewCreateJob(jobRepo, taskRepo, tx, clk),
|
||||||
|
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, catalog),
|
||||||
|
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration, catalog),
|
||||||
|
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
||||||
|
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize, catalog),
|
||||||
|
ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk, catalog),
|
||||||
|
FailTask: usecase.NewFailTask(taskRepo, jobRepo, workerRepo, tx, clk, catalog),
|
||||||
|
GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo),
|
||||||
|
CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk),
|
||||||
|
UploadArtifact: usecase.NewUploadArtifact(taskRepo, workerRepo, artifactRepo, blobStore, tx, clk),
|
||||||
|
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
|
||||||
|
GetJobResult: usecase.NewGetJobResult(jobRepo, usecase.NewDownloadArtifact(artifactRepo, blobStore)),
|
||||||
|
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
|
||||||
|
Dashboard: usecase.NewDashboard(uiReadRepo, catalog),
|
||||||
|
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background reapers are tracked so shutdown can wait for them. Without this
|
||||||
|
// the process would exit mid-UPDATE, and the deferred close() would pull
|
||||||
|
// connections out from under them.
|
||||||
|
expireLeases := usecase.NewExpireLeases(taskRepo, jobRepo, tx, clk, catalog)
|
||||||
|
markOffline := usecase.NewMarkWorkersOffline(workerRepo, clk, cfg.WorkerOfflineAfter)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, r := range []struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context) (int64, error)
|
||||||
|
}{
|
||||||
|
{"reaper requeued expired leases", expireLeases.Execute},
|
||||||
|
{"reaper marked workers offline", markOffline.Execute},
|
||||||
|
} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name string, fn func(context.Context) (int64, error)) {
|
||||||
|
defer wg.Done()
|
||||||
|
infra.RunPeriodic(ctx, log, name, cfg.ReaperInterval, fn)
|
||||||
|
}(r.name, r.fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business metrics: gauges of tasks/jobs/workers by status, sampled from the
|
||||||
|
// database on every Prometheus scrape.
|
||||||
|
m := metrics.New()
|
||||||
|
m.RegisterBusiness(func(ctx context.Context) (metrics.Stats, error) {
|
||||||
|
tasks, jobs, workers, err := deps.statsRepo.Counts(ctx)
|
||||||
|
return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err
|
||||||
|
})
|
||||||
|
|
||||||
|
// deps.ready backs /health: readiness means the database answers, not just
|
||||||
|
// that the process is alive.
|
||||||
|
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, deps.ready, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
|
||||||
|
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||||
|
|
||||||
|
// Shutdown order matters, and defers alone cannot express it (they run
|
||||||
|
// LIFO, so the deferred stop() would fire *after* the wait below).
|
||||||
|
//
|
||||||
|
// 1. stop() cancel the context, telling the reaper to finish
|
||||||
|
// 2. wg.Wait() let it return from its current tick
|
||||||
|
// 3. deferred close() closes an idle pool, not a busy one
|
||||||
|
//
|
||||||
|
// Calling stop() here also covers the path where RunServer failed on its
|
||||||
|
// own: the context would never be cancelled otherwise and wg.Wait()
|
||||||
|
// would block forever.
|
||||||
|
stop()
|
||||||
|
wg.Wait()
|
||||||
|
log.Info("shutdown complete")
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSQLite opens the embedded database and builds the sqlite repositories.
|
||||||
|
func openSQLite(ctx context.Context, cfg infra.Config, log *slog.Logger) (*storageDeps, error) {
|
||||||
|
if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil {
|
||||||
|
return nil, fmt.Errorf("create storage dir: %w", err)
|
||||||
|
}
|
||||||
|
db, err := sqlite.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
closeOnce := &sync.Once{}
|
||||||
|
return &storageDeps{
|
||||||
|
tx: sqlite.NewTxManager(db),
|
||||||
|
taskRepo: sqlite.NewTaskRepo(db),
|
||||||
|
jobRepo: sqlite.NewJobRepo(db),
|
||||||
|
workerRepo: sqlite.NewWorkerRepo(db),
|
||||||
|
artifactRepo: sqlite.NewArtifactRepo(db),
|
||||||
|
uiReadRepo: sqlite.NewUIReadRepo(db),
|
||||||
|
taskResultRepo: sqlite.NewTaskResultRepo(db),
|
||||||
|
statsRepo: sqlite.NewStatsRepo(db),
|
||||||
|
ready: func(ctx context.Context) error { return db.PingContext(ctx) },
|
||||||
|
migrate: func(ctx context.Context, log *slog.Logger) error { return sqlite.Migrate(ctx, db, log) },
|
||||||
|
close: func() { closeOnce.Do(func() { _ = db.Close() }) },
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPostgres connects to PostgreSQL and builds the postgres repositories.
|
||||||
|
func openPostgres(ctx context.Context, cfg infra.Config, log *slog.Logger) (*storageDeps, error) {
|
||||||
|
pool, err := infra.NewPool(ctx, cfg, log)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
closeOnce := &sync.Once{}
|
||||||
|
return &storageDeps{
|
||||||
|
tx: postgres.NewTxManager(pool),
|
||||||
|
taskRepo: postgres.NewTaskRepo(pool),
|
||||||
|
jobRepo: postgres.NewJobRepo(pool),
|
||||||
|
workerRepo: postgres.NewWorkerRepo(pool),
|
||||||
|
artifactRepo: postgres.NewArtifactRepo(pool),
|
||||||
|
uiReadRepo: postgres.NewUIReadRepo(pool),
|
||||||
|
taskResultRepo: postgres.NewTaskResultRepo(pool),
|
||||||
|
statsRepo: postgres.NewStatsRepo(pool),
|
||||||
|
ready: func(ctx context.Context) error { return pool.Ping(ctx) },
|
||||||
|
migrate: func(ctx context.Context, log *slog.Logger) error { return postgres.Migrate(ctx, cfg.DatabaseURL, log) },
|
||||||
|
close: func() { closeOnce.Do(pool.Close) },
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/infra"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/userservice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runServe implements `coordinator serve`: the single-binary mode for a
|
||||||
|
// scientist. It provisions a data directory (default ~/.scimesh) with the
|
||||||
|
// coordinator and userservice sqlite databases, secrets, the admin account,
|
||||||
|
// and optionally local worker agents — then runs the same server run() does.
|
||||||
|
func runServe(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
|
flags.Usage = func() {
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator serve [options]\n")
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "Runs the whole SciMesh platform from one binary: embedded databases, the\n")
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "userservice, and optional local workers. No PostgreSQL or Docker needed.\n\n")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
dataDir = flags.String("data-dir", defaultDataDir(), "data directory (default: ~/.scimesh)")
|
||||||
|
addr = flags.String("addr", "127.0.0.1:8080", "listen address")
|
||||||
|
workers = flags.Int("workers", 1, "number of local worker agents to spawn")
|
||||||
|
open = flags.Bool("open", false, "open the UI in the browser")
|
||||||
|
docsDir = flags.String("docs-dir", "", "built MkDocs site directory to serve at /ui/docs/")
|
||||||
|
email = flags.String("admin-email", "admin@scimesh.local", "admin account email")
|
||||||
|
password = flags.String("admin-password", "", "admin password (generated on first run when empty)")
|
||||||
|
)
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() > 0 {
|
||||||
|
return fmt.Errorf("serve takes no positional arguments")
|
||||||
|
}
|
||||||
|
if *workers < 0 {
|
||||||
|
return fmt.Errorf("--workers must be >= 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
if err := os.MkdirAll(*dataDir, 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create data dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Secrets, persisted in the data dir so restarts keep working.
|
||||||
|
jwtSecret, err := loadOrGenerate(filepath.Join(*dataDir, "jwt.secret"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
workerToken, err := loadOrGenerate(filepath.Join(*dataDir, "worker.token"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Admin account: generated once and printed, remembered for later boots.
|
||||||
|
if *password == "" {
|
||||||
|
*password, err = loadOrGenerate(filepath.Join(*dataDir, "admin.password"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Scientific runtime: ensure the managed venv (best effort).
|
||||||
|
venvPython := filepath.Join(*dataDir, "venv", binName("bin/python"))
|
||||||
|
ensureRuntime(log, *dataDir, venvPython)
|
||||||
|
|
||||||
|
// 4. Embedded userservice on the loopback interface.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
usersAddr, closeUsers, err := userservice.Serve(ctx, userservice.Config{
|
||||||
|
DBPath: filepath.Join(*dataDir, "users.db"),
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
AdminEmail: *email,
|
||||||
|
AdminPassword: *password,
|
||||||
|
Log: log,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("embedded userservice: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = closeUsers() }()
|
||||||
|
|
||||||
|
// 5. Local worker agents before the server, so they can claim immediately.
|
||||||
|
coordinatorURL := "http://" + *addr
|
||||||
|
agents, err := spawnAgents(ctx, log, *dataDir, *workers, coordinatorURL, workerToken, venvPython)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stopAgents(agents)
|
||||||
|
|
||||||
|
// 6. The coordinator server itself.
|
||||||
|
cfg := infra.Config{
|
||||||
|
Addr: *addr,
|
||||||
|
DatabaseEngine: "sqlite",
|
||||||
|
DBPath: filepath.Join(*dataDir, "scimesh.db"),
|
||||||
|
Token: workerToken,
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
UserserviceURL: "http://" + usersAddr,
|
||||||
|
PublicUserserviceURL: "http://" + usersAddr,
|
||||||
|
LogLevel: "info",
|
||||||
|
StorageDir: filepath.Join(*dataDir, "artifacts"),
|
||||||
|
DocsDir: *docsDir,
|
||||||
|
MaxUploadBytes: 1 << 30,
|
||||||
|
DBMaxConns: 4,
|
||||||
|
DBConnectTimeout: 10 * time.Second,
|
||||||
|
RequestTimeout: 15 * time.Second,
|
||||||
|
HeartbeatInterval: 15 * time.Second,
|
||||||
|
LeaseDuration: 2 * time.Minute,
|
||||||
|
DefaultMaxAttempts: 3,
|
||||||
|
QuorumSize: 2,
|
||||||
|
ReaperInterval: 30 * time.Second,
|
||||||
|
WorkerOfflineAfter: 1 * time.Minute,
|
||||||
|
AutoMigrate: true,
|
||||||
|
}
|
||||||
|
if *open {
|
||||||
|
openBrowser("http://" + *addr + "/ui")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the login once the server is about to start.
|
||||||
|
fmt.Printf("\nSciMesh is starting at http://%s/ui\n", *addr)
|
||||||
|
fmt.Printf(" admin login: %s / %s\n", *email, *password)
|
||||||
|
if runtimeStatus(venvPython) {
|
||||||
|
fmt.Printf(" scientific runtime: ready (%s)\n", venvPython)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" scientific runtime: NOT ready — install Python 3, then restart serve\n")
|
||||||
|
}
|
||||||
|
fmt.Printf(" data directory: %s\n\n", *dataDir)
|
||||||
|
|
||||||
|
err = runWithConfig(cfg)
|
||||||
|
cancel()
|
||||||
|
stopAgents(agents)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDataDir returns the platform-appropriate data directory.
|
||||||
|
func defaultDataDir() string {
|
||||||
|
if dir := os.Getenv("SCIMESH_DATA_DIR"); dir != "" {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
return ".scimesh"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".scimesh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOrGenerate reads a secret file, creating it with fresh random content
|
||||||
|
// (chmod 0600) when missing.
|
||||||
|
// #nosec G304 -- the path is an operator-supplied secret file inside the data dir.
|
||||||
|
func loadOrGenerate(path string) (string, error) {
|
||||||
|
if raw, err := os.ReadFile(path); err == nil {
|
||||||
|
return strings.TrimSpace(string(raw)), nil
|
||||||
|
}
|
||||||
|
buffer := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
secret := hex.EncodeToString(buffer)
|
||||||
|
if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil {
|
||||||
|
return "", fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnAgents starts `coordinator agent` subprocesses that claim tasks from
|
||||||
|
// the coordinator. Each gets its own work directory under the data dir.
|
||||||
|
func spawnAgents(ctx context.Context, log *slog.Logger, dataDir string, count int,
|
||||||
|
coordinatorURL, token, venvPython string) ([]*exec.Cmd, error) {
|
||||||
|
|
||||||
|
var agents []*exec.Cmd
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
workDir := filepath.Join(dataDir, "workers", fmt.Sprintf("%d", i))
|
||||||
|
if err := os.MkdirAll(workDir, 0o750); err != nil {
|
||||||
|
return agents, err
|
||||||
|
}
|
||||||
|
taskRunner := defaultTaskRunner(venvPython)
|
||||||
|
// #nosec G204,G702 -- the command is this binary itself with operator flags.
|
||||||
|
cmd := exec.CommandContext(ctx, os.Args[0], "agent",
|
||||||
|
"--coordinator-url", coordinatorURL,
|
||||||
|
"--token", token,
|
||||||
|
"--work-dir", workDir,
|
||||||
|
"--task-runner", taskRunner,
|
||||||
|
)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return agents, fmt.Errorf("start local agent %d: %w", i, err)
|
||||||
|
}
|
||||||
|
agents = append(agents, cmd)
|
||||||
|
log.Info("local worker agent started", "index", i)
|
||||||
|
}
|
||||||
|
return agents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopAgents terminates the spawned agents and waits briefly for them.
|
||||||
|
func stopAgents(agents []*exec.Cmd) {
|
||||||
|
for _, agent := range agents {
|
||||||
|
if agent.Process != nil {
|
||||||
|
_ = agent.Process.Kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for _, agent := range agents {
|
||||||
|
_, _ = agent.Process.Wait()
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTaskRunner picks the managed venv python when present, else the
|
||||||
|
// system `python`.
|
||||||
|
func defaultTaskRunner(venvPython string) string {
|
||||||
|
if runtimeStatus(venvPython) {
|
||||||
|
return venvPython + " -m scimesh.worker.task"
|
||||||
|
}
|
||||||
|
return "python -m scimesh.worker.task"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureRuntime creates the managed venv and installs scimesh into it, unless
|
||||||
|
// it already exists. Best effort: a missing Python only logs a hint.
|
||||||
|
func ensureRuntime(log *slog.Logger, dataDir, venvPython string) {
|
||||||
|
if runtimeStatus(venvPython) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
python := findPython()
|
||||||
|
if python == "" {
|
||||||
|
log.Warn("python3 not found; local workers need it to run scientific workloads")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("creating the scientific runtime venv", "python", python)
|
||||||
|
venvDir := filepath.Dir(filepath.Dir(venvPython))
|
||||||
|
// #nosec G204 -- python comes from PATH and venvDir from the data dir.
|
||||||
|
create := exec.CommandContext(context.Background(), python, "-m", "venv", venvDir)
|
||||||
|
if out, err := create.CombinedOutput(); err != nil {
|
||||||
|
log.Warn("venv creation failed; local workers need a manual Python install", "err", err, "output", string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pip := filepath.Join(venvDir, binName("bin/pip"))
|
||||||
|
// The scimesh package is installed from an explicit source only: the PyPI
|
||||||
|
// name is not ours yet, so `pip install scimesh` would fetch a stranger's
|
||||||
|
// package. Operators publish a wheel or index via SCIMESH_PIP_PACKAGE.
|
||||||
|
source := os.Getenv("SCIMESH_PIP_PACKAGE")
|
||||||
|
if source == "" {
|
||||||
|
log.Warn("scientific runtime venv created, but scimesh is not installed",
|
||||||
|
"hint", pip+" install <your scimesh wheel or index> (or set SCIMESH_PIP_PACKAGE)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #nosec G204,G702 -- pip and source are operator-configured paths.
|
||||||
|
install := exec.CommandContext(context.Background(), pip, "install", source)
|
||||||
|
if out, err := install.CombinedOutput(); err != nil {
|
||||||
|
log.Warn("pip install failed", "err", err, "output", string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("scientific runtime installed", "venv", venvDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPython locates a usable python3.
|
||||||
|
func findPython() string {
|
||||||
|
for _, candidate := range []string{"python3", "python"} {
|
||||||
|
path, err := exec.LookPath(candidate)
|
||||||
|
if err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeStatus reports whether the managed venv python exists.
|
||||||
|
func runtimeStatus(venvPython string) bool {
|
||||||
|
info, err := os.Stat(venvPython)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// binName adapts a relative path to the platform layout.
|
||||||
|
func binName(relative string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
parts := strings.Split(relative, "/")
|
||||||
|
parts[len(parts)-1] += ".exe"
|
||||||
|
return strings.Join(parts, string(filepath.Separator))
|
||||||
|
}
|
||||||
|
return relative
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser opens the UI in the platform's default browser.
|
||||||
|
func openBrowser(target string) {
|
||||||
|
command := ""
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
command = "open"
|
||||||
|
case "windows":
|
||||||
|
command = "rundll32"
|
||||||
|
default:
|
||||||
|
command = "xdg-open"
|
||||||
|
}
|
||||||
|
if command == "rundll32" {
|
||||||
|
// #nosec G204 -- target is the local UI URL the operator asked to open.
|
||||||
|
_ = exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", target).Start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #nosec G204 -- target is the local UI URL the operator asked to open.
|
||||||
|
_ = exec.CommandContext(context.Background(), command, target).Start()
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/setup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runSetup implements `coordinator setup` with non-interactive flags and an
|
||||||
|
// interactive fallback for anything still missing.
|
||||||
|
func runSetup(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("setup", flag.ContinueOnError)
|
||||||
|
flags.Usage = func() {
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator setup [options]\n")
|
||||||
|
_, _ = fmt.Fprintf(flags.Output(), "Provisions the coordinator database, schema, and local .env settings.\n\n")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
databaseURL = flags.String("db", "", "coordinator database URL (default: DATABASE_URL)")
|
||||||
|
adminURL = flags.String("admin-db", "", "maintenance URL to create a missing database (default: same host, 'postgres' db)")
|
||||||
|
envFile = flags.String("env-file", "", "settings file to write (default: .env)")
|
||||||
|
force = flags.Bool("force", false, "overwrite an existing settings file")
|
||||||
|
yes = flags.Bool("yes", false, "non-interactive: use defaults, fail on anything missing")
|
||||||
|
)
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() > 0 {
|
||||||
|
return fmt.Errorf("setup takes no positional arguments")
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseURLValue := *databaseURL
|
||||||
|
if databaseURLValue == "" {
|
||||||
|
databaseURLValue = os.Getenv("DATABASE_URL")
|
||||||
|
}
|
||||||
|
envFileValue := *envFile
|
||||||
|
if envFileValue == "" {
|
||||||
|
envFileValue = os.Getenv("ENV_FILE")
|
||||||
|
}
|
||||||
|
adminURLValue := *adminURL
|
||||||
|
if adminURLValue == "" {
|
||||||
|
adminURLValue = os.Getenv("POSTGRES_ADMIN_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
options := setup.Options{
|
||||||
|
DatabaseURL: databaseURLValue,
|
||||||
|
AdminDatabaseURL: adminURLValue,
|
||||||
|
EnvFile: envFileValue,
|
||||||
|
Force: *force,
|
||||||
|
Yes: *yes,
|
||||||
|
ConnectTimeout: 10 * time.Second,
|
||||||
|
Out: os.Stdout,
|
||||||
|
In: os.Stdin,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
log.Info("setup started", "db", setup.SanitizeDatabaseURL(databaseURLValue), "env_file", envFileValue)
|
||||||
|
|
||||||
|
summary, err := setup.Run(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("setup failed", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(os.Stdout, summary)
|
||||||
|
log.Info("setup complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Command worker-agent is the Go worker agent: a coordinator client that
|
||||||
|
// executes SDK workloads in a Python subprocess per claimed task.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version is injected at build time (-ldflags "-X main.version=...") and
|
||||||
|
// reported by --version. "dev" marks a local build.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
showVersion := flag.Bool("version", false, "print the build version and exit")
|
||||||
|
flag.Parse()
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println("worker-agent " + version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config, err := agent.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("invalid configuration", "error", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
tokens := agent.NewTokenProvider(
|
||||||
|
config.WorkerKey,
|
||||||
|
config.UserserviceURL,
|
||||||
|
config.Token,
|
||||||
|
config.RequestTimeout,
|
||||||
|
)
|
||||||
|
client := agent.NewClient(config.CoordinatorURL, tokens, config.RequestTimeout)
|
||||||
|
runner := agent.NewTaskRunner(config.TaskRunner)
|
||||||
|
daemon := agent.NewDaemon(config, client, runner, logger)
|
||||||
|
if err := daemon.RunForever(); err != nil {
|
||||||
|
logger.Error("agent stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Demo overlay: Prometheus scrapes the coordinator's /metrics, Grafana shows the
|
||||||
|
# provisioned SciMesh dashboard. Merged by scripts/demo-ui.sh with a third -f.
|
||||||
|
# Both share the coordinator's compose network, so Prometheus reaches it by name.
|
||||||
|
|
||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.54.1
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
ports:
|
||||||
|
- "${PROMETHEUS_PORT:-19090}:9090"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.2.0
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: admin
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
|
||||||
|
# Anonymous viewing so the demo dashboard opens without a login.
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||||
|
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
|
||||||
|
GF_USERS_DEFAULT_THEME: dark
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
ports:
|
||||||
|
- "${GRAFANA_PORT:-13000}:3000"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Demo overlay: adds the userservice (its own Postgres + migrations) alongside
|
||||||
|
# the coordinator and wires the two together with a shared JWT secret, so the
|
||||||
|
# operator UI authenticates through userservice login/registration.
|
||||||
|
#
|
||||||
|
# Used only by scripts/demo-ui.sh, merged onto docker-compose.yml with a second
|
||||||
|
# -f. Not part of the plain `make up` stack.
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres-users:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-scimesh}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
|
||||||
|
POSTGRES_DB: scimesh_users
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d scimesh_users"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
migrate-users:
|
||||||
|
image: migrate/migrate:v4.17.1
|
||||||
|
depends_on:
|
||||||
|
postgres-users:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ../users/migrations:/migrations:ro
|
||||||
|
command:
|
||||||
|
- -path=/migrations
|
||||||
|
- -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
|
||||||
|
- up
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
userservice:
|
||||||
|
build:
|
||||||
|
context: ../users
|
||||||
|
depends_on:
|
||||||
|
postgres-users:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate-users:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
USERSERVICE_ADDR: ":8081"
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
# Seeds the first admin the very first time it boots (idempotent after).
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-root@scimesh.local}
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
ports:
|
||||||
|
- "${USERSERVICE_PORT:-18081}:8081"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Turn the coordinator UI into session mode: the same shared secret verifies
|
||||||
|
# userservice tokens locally, and USERSERVICE_URL is where login/register proxy.
|
||||||
|
coordinator:
|
||||||
|
environment:
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
USERSERVICE_URL: http://userservice:8081
|
||||||
|
# Browser/host-facing URLs for the "add your machine" command. A user's
|
||||||
|
# worker runs on the host, so it reaches the published ports on localhost,
|
||||||
|
# not the in-cluster service names.
|
||||||
|
PUBLIC_COORDINATOR_URL: http://localhost:${COORDINATOR_PORT:-8080}
|
||||||
|
PUBLIC_USERSERVICE_URL: http://localhost:${USERSERVICE_PORT:-8081}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: scimesh
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-scimesh}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-scimesh}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
# Everything else waits on this, so the check must prove the server
|
||||||
|
# accepts queries — not merely that the port is open.
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d ${POSTGRES_DB:-scimesh}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
# The coordinator applies its embedded schema migrations on startup
|
||||||
|
# (AUTO_MIGRATE, on by default), so no separate migration step is needed.
|
||||||
|
|
||||||
|
coordinator:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
COORDINATOR_ADDR: ":8080"
|
||||||
|
# Host is the service name: compose resolves it on the project network.
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh}?sslmode=disable
|
||||||
|
WORKER_AUTH_TOKEN: ${WORKER_AUTH_TOKEN:-dev-token}
|
||||||
|
# Empty disables /ui. Set this separately from the worker token.
|
||||||
|
UI_AUTH_TOKEN: ${UI_AUTH_TOKEN:-}
|
||||||
|
# Directory of the built MkDocs site served at /ui/docs/ (empty disables
|
||||||
|
# the docs route; the demo mounts ./site automatically).
|
||||||
|
SCIMESH_DOCS_DIR: ${SCIMESH_DOCS_DIR:-}
|
||||||
|
DB_MAX_CONNS: "10"
|
||||||
|
REQUEST_TIMEOUT: "15s"
|
||||||
|
LEASE_DURATION: "2m"
|
||||||
|
REAPER_INTERVAL: "30s"
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
# Logs are teed to stdout (docker logs) and this rotated file on a named
|
||||||
|
# volume, so they survive a rebuild.
|
||||||
|
LOG_FILE: /var/log/scimesh/coordinator.log
|
||||||
|
# Artifact bytes live on a named volume, durable across rebuilds.
|
||||||
|
COORDINATOR_STORAGE_DIR: /var/lib/scimesh/artifacts
|
||||||
|
ports:
|
||||||
|
- "${COORDINATOR_PORT:-8080}:8080"
|
||||||
|
# Named volumes (not host bind mounts): they inherit the image's directory
|
||||||
|
# ownership, so the non-root process can write to them. A bind mount would
|
||||||
|
# be root-owned and unwritable by uid 10001.
|
||||||
|
volumes:
|
||||||
|
- coordinator_logs:/var/log/scimesh
|
||||||
|
- coordinator_data:/var/lib/scimesh/artifacts
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
coordinator_logs:
|
||||||
|
coordinator_data:
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
module github.com/emil28092005/SciMesh/coordinator
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/prometheus/client_golang v1.19.1
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.55.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/crypto v0.17.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.40.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
modernc.org/libc v1.74.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.55.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||||
|
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||||
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||||
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||||
|
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
|
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||||
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
|
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||||
|
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
||||||
|
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenProvider supplies the current bearer token. A static token is served
|
||||||
|
// forever; a worker key is exchanged at the userservice for short-lived JWTs
|
||||||
|
// and refreshed before they expire (mirroring the former Python worker).
|
||||||
|
type TokenProvider interface {
|
||||||
|
Token() (string, error)
|
||||||
|
Refresh() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticToken serves a fixed token forever; empty means no Authorization.
|
||||||
|
type StaticToken struct{ token string }
|
||||||
|
|
||||||
|
func (s *StaticToken) Token() (string, error) { return s.token, nil }
|
||||||
|
func (s *StaticToken) Refresh() error { return nil }
|
||||||
|
|
||||||
|
// WorkerKeyToken exchanges a long-lived worker key for short-lived JWTs.
|
||||||
|
type WorkerKeyToken struct {
|
||||||
|
userserviceURL string
|
||||||
|
workerKey string
|
||||||
|
timeout time.Duration
|
||||||
|
leeway float64
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
refreshAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkerKeyToken(userserviceURL, workerKey string, timeout time.Duration) *WorkerKeyToken {
|
||||||
|
return &WorkerKeyToken{
|
||||||
|
userserviceURL: strings.TrimRight(userserviceURL, "/"),
|
||||||
|
workerKey: workerKey,
|
||||||
|
timeout: timeout,
|
||||||
|
leeway: 0.2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token returns the current token, exchanging first when missing or stale.
|
||||||
|
func (p *WorkerKeyToken) Token() (string, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.token == "" || time.Now().After(p.refreshAt) {
|
||||||
|
if err := p.exchangeLocked(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh forces an immediate exchange.
|
||||||
|
func (p *WorkerKeyToken) Refresh() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.exchangeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WorkerKeyToken) exchangeLocked() error {
|
||||||
|
payload, err := json.Marshal(map[string]string{"key": p.workerKey})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, p.userserviceURL+"/worker-tokens/exchange", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
client := &http.Client{Timeout: p.timeout}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("worker key exchange request failed")
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("worker key exchange rejected with status %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("worker key exchange request failed")
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &data); err != nil {
|
||||||
|
return fmt.Errorf("worker key exchange response is invalid")
|
||||||
|
}
|
||||||
|
token, _ := data["token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("worker key exchange response is missing a token")
|
||||||
|
}
|
||||||
|
var ttl time.Duration
|
||||||
|
switch value := data["expires_in"].(type) {
|
||||||
|
case float64:
|
||||||
|
ttl = time.Duration(value * float64(time.Second))
|
||||||
|
case int:
|
||||||
|
ttl = time.Duration(value) * time.Second
|
||||||
|
}
|
||||||
|
p.token = token
|
||||||
|
p.refreshAt = time.Time{}
|
||||||
|
if ttl > 0 {
|
||||||
|
p.refreshAt = time.Now().Add(time.Duration(float64(ttl) * (1.0 - p.leeway)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenProvider picks the strategy: a worker key (with userservice) wins
|
||||||
|
// over a static bearer token.
|
||||||
|
func NewTokenProvider(workerKey, userserviceURL, bearerToken string, timeout time.Duration) TokenProvider {
|
||||||
|
if workerKey != "" && userserviceURL != "" {
|
||||||
|
return NewWorkerKeyToken(userserviceURL, workerKey, timeout)
|
||||||
|
}
|
||||||
|
return &StaticToken{token: bearerToken}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkerKeyTokenExchangesAndCaches(t *testing.T) {
|
||||||
|
var exchanges atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/worker-tokens/exchange" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload["key"] != "scimesh_wk_live_x" {
|
||||||
|
http.Error(w, "bad key", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exchanges.Add(1)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"token": "jwt-1",
|
||||||
|
"expires_in": 100,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewWorkerKeyToken(server.URL, "scimesh_wk_live_x", 5*time.Second)
|
||||||
|
token, err := provider.Token()
|
||||||
|
if err != nil || token != "jwt-1" {
|
||||||
|
t.Fatalf("token = %q, err = %v", token, err)
|
||||||
|
}
|
||||||
|
// The second call within the TTL reuses the cache.
|
||||||
|
again, err := provider.Token()
|
||||||
|
if err != nil || again != "jwt-1" {
|
||||||
|
t.Fatalf("cached token = %q, err = %v", again, err)
|
||||||
|
}
|
||||||
|
if exchanges.Load() != 1 {
|
||||||
|
t.Errorf("exchanges = %d, want 1", exchanges.Load())
|
||||||
|
}
|
||||||
|
// An explicit refresh re-exchanges.
|
||||||
|
if err := provider.Refresh(); err != nil {
|
||||||
|
t.Fatalf("Refresh: %v", err)
|
||||||
|
}
|
||||||
|
if exchanges.Load() != 2 {
|
||||||
|
t.Errorf("exchanges after refresh = %d, want 2", exchanges.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerKeyTokenRejectsBadKey(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewWorkerKeyToken(server.URL, "bad", 5*time.Second)
|
||||||
|
if _, err := provider.Token(); err == nil {
|
||||||
|
t.Error("expected exchange failure for a rejected key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTokenProviderSelectsStrategy(t *testing.T) {
|
||||||
|
if _, ok := NewTokenProvider("", "", "static", time.Second).(*StaticToken); !ok {
|
||||||
|
t.Error("expected a static token provider")
|
||||||
|
}
|
||||||
|
if _, ok := NewTokenProvider("key", "http://users", "", time.Second).(*WorkerKeyToken); !ok {
|
||||||
|
t.Error("expected a worker-key provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRefreshesTokenOnceOn401(t *testing.T) {
|
||||||
|
var attempts atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts.Add(1)
|
||||||
|
if attempts.Load() == 1 {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := &StaticToken{token: "t"}
|
||||||
|
client := NewClient(server.URL, provider, 5*time.Second)
|
||||||
|
status, _, err := client.requestJSON(http.MethodGet, "/ok", map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Errorf("status = %d", status)
|
||||||
|
}
|
||||||
|
if attempts.Load() != 2 {
|
||||||
|
t.Errorf("attempts = %d, want 2 (401 then retry)", attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoordinatorError is a non-retriable coordinator response.
|
||||||
|
type CoordinatorError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *CoordinatorError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
// TransientError is a timeout, connection error, or 5xx response.
|
||||||
|
type TransientError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *TransientError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
// ConflictError means the worker no longer owns the task lease.
|
||||||
|
type ConflictError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *ConflictError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
// Client speaks the v1 worker contract over HTTP with a token provider.
|
||||||
|
//
|
||||||
|
// API calls never follow redirects (a redirect is a contract violation); the
|
||||||
|
// artifact download follows redirects but strips the Authorization header on
|
||||||
|
// cross-origin hops, matching the Python worker's SameOriginAuthRedirectHandler.
|
||||||
|
// A 401 response refreshes the token exactly once and retries, so a lapsed JWT
|
||||||
|
// does not fail an in-flight task.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
tokens TokenProvider
|
||||||
|
timeout time.Duration
|
||||||
|
apiClient *http.Client
|
||||||
|
dlClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
tokens: tokens,
|
||||||
|
timeout: timeout,
|
||||||
|
apiClient: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
|
},
|
||||||
|
dlClient: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return fmt.Errorf("too many redirects")
|
||||||
|
}
|
||||||
|
// Go strips Authorization on cross-host redirects by default;
|
||||||
|
// strip it explicitly on any origin change to be safe.
|
||||||
|
if len(via) > 0 && origin(req.URL) != origin(via[0].URL) {
|
||||||
|
req.Header.Del("Authorization")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func origin(u *url.URL) string {
|
||||||
|
return u.Scheme + "://" + u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) authHeaders() (map[string]string, error) {
|
||||||
|
token, err := c.tokens.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &CoordinatorError{msg: "token refresh failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
return map[string]string{"Authorization": "Bearer " + token}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) refreshAndRetry() bool {
|
||||||
|
return c.tokens.Refresh() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register advertises the worker and returns its identity and heartbeat policy.
|
||||||
|
func (c *Client) Register(name string, capabilities []string, cpuCount int, memoryMB int) (*RegisteredWorker, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"cpu_count": cpuCount,
|
||||||
|
}
|
||||||
|
if memoryMB > 0 {
|
||||||
|
payload["memory_mb"] = memoryMB
|
||||||
|
}
|
||||||
|
status, body, err := c.requestJSON("POST", "/workers/register", payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status != http.StatusCreated {
|
||||||
|
return nil, &CoordinatorError{msg: fmt.Sprintf("worker registration rejected with status %d", status)}
|
||||||
|
}
|
||||||
|
return ParseRegistered(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim leases one compatible task, or returns nil when the queue is empty.
|
||||||
|
func (c *Client) Claim(workerID string, capabilities []string) (*Task, error) {
|
||||||
|
status, body, err := c.requestJSON("POST", "/tasks/claim", map[string]any{
|
||||||
|
"worker_id": workerID,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"max_concurrency": 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if status == http.StatusNoContent {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return nil, &CoordinatorError{msg: fmt.Sprintf("unexpected claim status %d", status)}
|
||||||
|
}
|
||||||
|
return ParseTask(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat renews the lease and returns the new deadline.
|
||||||
|
func (c *Client) Heartbeat(task *Task, workerID string) (time.Time, error) {
|
||||||
|
status, body, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/heartbeat", map[string]any{
|
||||||
|
"worker_id": workerID,
|
||||||
|
"attempt": task.Attempt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
if status == http.StatusConflict {
|
||||||
|
return time.Time{}, &ConflictError{msg: "heartbeat rejected because the task lease was lost"}
|
||||||
|
}
|
||||||
|
return time.Time{}, &CoordinatorError{msg: fmt.Sprintf("heartbeat rejected with status %d", status)}
|
||||||
|
}
|
||||||
|
raw, ok := body["lease_expires_at"].(string)
|
||||||
|
if !ok {
|
||||||
|
return time.Time{}, &CoordinatorError{msg: "heartbeat response is missing lease_expires_at"}
|
||||||
|
}
|
||||||
|
lease, err := time.Parse(time.RFC3339, raw)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, &CoordinatorError{msg: "heartbeat returned an invalid lease_expires_at"}
|
||||||
|
}
|
||||||
|
task.LeaseExpiresAt = lease
|
||||||
|
task.leaseExpiresRaw = raw
|
||||||
|
return lease, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit completes a task with the uploaded coordinator-owned artifact.
|
||||||
|
func (c *Client) Submit(task *Task, workerID string, uploaded *Uploaded, metrics map[string]any) error {
|
||||||
|
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/result", map[string]any{
|
||||||
|
"worker_id": workerID,
|
||||||
|
"attempt": task.Attempt,
|
||||||
|
"result": map[string]any{"artifact_id": uploaded.ArtifactID},
|
||||||
|
"metrics": metrics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
|
||||||
|
if status == http.StatusConflict {
|
||||||
|
return &ConflictError{msg: "result rejected because the task lease was lost"}
|
||||||
|
}
|
||||||
|
return &CoordinatorError{msg: fmt.Sprintf("result rejected with status %d", status)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail reports a sanitized failure.
|
||||||
|
func (c *Client) Fail(task *Task, workerID string, code, message string, retryable bool) error {
|
||||||
|
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/failure", map[string]any{
|
||||||
|
"worker_id": workerID,
|
||||||
|
"attempt": task.Attempt,
|
||||||
|
"error_code": code,
|
||||||
|
"error_message": message,
|
||||||
|
"retryable": retryable,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
|
||||||
|
if status == http.StatusConflict {
|
||||||
|
return &ConflictError{msg: "failure rejected because the task lease was lost"}
|
||||||
|
}
|
||||||
|
return &CoordinatorError{msg: fmt.Sprintf("failure report rejected with status %d", status)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the task input to destination and returns its SHA-256.
|
||||||
|
func (c *Client) Download(uri, destination string) (string, error) {
|
||||||
|
resolved, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid input URI: %w", err)
|
||||||
|
}
|
||||||
|
if !resolved.IsAbs() {
|
||||||
|
base, parseErr := url.Parse(c.baseURL)
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", fmt.Errorf("invalid coordinator URL")
|
||||||
|
}
|
||||||
|
resolved = base.ResolveReference(resolved)
|
||||||
|
}
|
||||||
|
if resolved.Scheme != "http" && resolved.Scheme != "https" {
|
||||||
|
return "", fmt.Errorf("input URI must be an HTTP(S) URL")
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, resolved.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
headers, err := c.authHeaders()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for name, value := range headers {
|
||||||
|
request.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
response, err := c.dlClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", &TransientError{msg: "input download failed"}
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
|
||||||
|
return c.Download(uri, destination)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return "", &CoordinatorError{msg: fmt.Sprintf("input download rejected with status %d", response.StatusCode)}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// #nosec G304 -- destination is the worker's own attempt directory file.
|
||||||
|
target, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
digest := sha256.New()
|
||||||
|
_, copyErr := io.Copy(io.MultiWriter(target, digest), response.Body)
|
||||||
|
closeErr := target.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(destination)
|
||||||
|
return "", &TransientError{msg: "input download interrupted"}
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return "", closeErr
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload streams a partial artifact and verifies the returned metadata.
|
||||||
|
func (c *Client) Upload(task *Task, workerID string, path, contentType string) (*Uploaded, error) {
|
||||||
|
// #nosec G304 -- the upload path is this worker's own artifact file.
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
digest := sha256.New()
|
||||||
|
if _, err := io.Copy(digest, file); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
localSHA := hex.EncodeToString(digest.Sum(nil))
|
||||||
|
uploadURL := c.baseURL + "/tasks/" + url.PathEscape(task.TaskID) + "/artifacts/" + url.PathEscape(filepath.Base(path))
|
||||||
|
request, err := http.NewRequestWithContext(context.Background(), http.MethodPut, uploadURL, file)
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.ContentLength = info.Size()
|
||||||
|
request.Header.Set("Content-Type", contentType)
|
||||||
|
request.Header.Set("X-Worker-ID", workerID)
|
||||||
|
request.Header.Set("X-Task-Attempt", strconv.Itoa(task.Attempt))
|
||||||
|
headers, err := c.authHeaders()
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for name, value := range headers {
|
||||||
|
request.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
response, err := c.apiClient.Do(request)
|
||||||
|
_ = file.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &TransientError{msg: "artifact upload failed"}
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, &TransientError{msg: "artifact upload interrupted"}
|
||||||
|
}
|
||||||
|
if response.StatusCode == http.StatusConflict {
|
||||||
|
return nil, &ConflictError{msg: "artifact upload rejected because the task lease was lost"}
|
||||||
|
}
|
||||||
|
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
|
||||||
|
return c.Upload(task, workerID, path, contentType)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return nil, &CoordinatorError{msg: fmt.Sprintf("artifact upload rejected with status %d", response.StatusCode)}
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||||
|
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
|
||||||
|
}
|
||||||
|
uploaded, err := ParseUploaded(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
|
||||||
|
}
|
||||||
|
if uploaded.SHA256 != localSHA || uploaded.SizeBytes != info.Size() {
|
||||||
|
return nil, &CoordinatorError{msg: "artifact upload metadata does not match local artifact"}
|
||||||
|
}
|
||||||
|
return uploaded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) requestJSON(method, path string, payload any) (int, map[string]any, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(context.Background(), method, c.baseURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
headers, err := c.authHeaders()
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
for name, value := range headers {
|
||||||
|
request.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
response, err := c.apiClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, &TransientError{msg: "coordinator request failed"}
|
||||||
|
}
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, &TransientError{msg: "coordinator request interrupted"}
|
||||||
|
}
|
||||||
|
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
|
||||||
|
return c.requestJSON(method, path, payload)
|
||||||
|
}
|
||||||
|
if response.StatusCode >= 500 {
|
||||||
|
return response.StatusCode, nil, &TransientError{msg: fmt.Sprintf("coordinator returned %d", response.StatusCode)}
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
return response.StatusCode, nil, &CoordinatorError{msg: "coordinator returned invalid JSON"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response.StatusCode, decoded, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestClient(t *testing.T, server *httptest.Server) *Client {
|
||||||
|
t.Helper()
|
||||||
|
return NewClient(server.URL, &StaticToken{token: "test-token"}, 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRegisterClaimHeartbeat(t *testing.T) {
|
||||||
|
var registered, claimed, heartbeated bool
|
||||||
|
var server *httptest.Server //nolint:staticcheck // the handler closure references server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||||
|
http.Error(w, "missing token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/workers/register":
|
||||||
|
registered = true
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"worker_id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"heartbeat_interval_seconds": 15,
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/tasks/claim":
|
||||||
|
claimed = true
|
||||||
|
writeJSON(w, http.StatusOK, validTaskPayload())
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/tasks/11111111-1111-4111-8111-111111111111/heartbeat":
|
||||||
|
heartbeated = true
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"lease_expires_at": time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server)
|
||||||
|
|
||||||
|
registeredWorker, err := client.Register("test-worker", []string{"similarity-search"}, 2, 1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
if registeredWorker.WorkerID != "22222222-2222-4222-8222-222222222222" {
|
||||||
|
t.Errorf("worker id = %q", registeredWorker.WorkerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := client.Claim("22222222-2222-4222-8222-222222222222", []string{"similarity-search"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Claim: %v", err)
|
||||||
|
}
|
||||||
|
if task == nil || task.Workload != "similarity-search" {
|
||||||
|
t.Fatalf("claim = %+v", task)
|
||||||
|
}
|
||||||
|
|
||||||
|
renewed, err := client.Heartbeat(task, "22222222-2222-4222-8222-222222222222")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Heartbeat: %v", err)
|
||||||
|
}
|
||||||
|
if renewed.Before(time.Now()) {
|
||||||
|
t.Error("renewed lease is in the past")
|
||||||
|
}
|
||||||
|
if !registered || !claimed || !heartbeated {
|
||||||
|
t.Error("some endpoints were not hit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientClaimEmptyAndConflict(t *testing.T) {
|
||||||
|
var server *httptest.Server //nolint:staticcheck // the handler closure references server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/tasks/claim":
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case "/tasks/11111111-1111-4111-8111-111111111111/heartbeat":
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server)
|
||||||
|
|
||||||
|
task, err := client.Claim("worker", []string{"similarity-search"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Claim: %v", err)
|
||||||
|
}
|
||||||
|
if task != nil {
|
||||||
|
t.Error("expected no task for 204")
|
||||||
|
}
|
||||||
|
|
||||||
|
claimed, err := ParseTask(validTaskPayload())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTask: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := client.Heartbeat(claimed, "worker"); err == nil {
|
||||||
|
t.Error("expected conflict error")
|
||||||
|
} else if !errors.As(err, &conflictError) {
|
||||||
|
t.Errorf("error type = %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUploadSubmitFail(t *testing.T) {
|
||||||
|
var uploadedPath string
|
||||||
|
var server *httptest.Server //nolint:staticcheck // the handler closure references server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/tasks/11111111-1111-4111-8111-111111111111/artifacts/"):
|
||||||
|
if r.Header.Get("X-Worker-ID") != "worker" || r.Header.Get("X-Task-Attempt") != "1" {
|
||||||
|
t.Errorf("missing identity headers: %+v", r.Header)
|
||||||
|
}
|
||||||
|
uploadedPath = r.URL.Path
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"artifact_id": "33333333-3333-4333-8333-333333333333",
|
||||||
|
"uri": server.URL + "/artifacts/333/download",
|
||||||
|
"sha256": sha256Of(t, "partial body"),
|
||||||
|
"size_bytes": int64(len("partial body")),
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/result"):
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{})
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/failure"):
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server)
|
||||||
|
task, _ := ParseTask(validTaskPayload())
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
partial := filepath.Join(dir, "result.csv")
|
||||||
|
if err := os.WriteFile(partial, []byte("partial body"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
uploaded, err := client.Upload(task, "worker", partial, "text/csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload: %v", err)
|
||||||
|
}
|
||||||
|
if uploaded.SizeBytes != int64(len("partial body")) {
|
||||||
|
t.Errorf("size = %d", uploaded.SizeBytes)
|
||||||
|
}
|
||||||
|
if !strings.Contains(uploadedPath, "result.csv") {
|
||||||
|
t.Errorf("upload path = %q", uploadedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Submit(task, "worker", uploaded, map[string]any{"rows": 1}); err != nil {
|
||||||
|
t.Fatalf("Submit: %v", err)
|
||||||
|
}
|
||||||
|
if err := client.Fail(task, "worker", "ValueError", "bad input", false); err != nil {
|
||||||
|
t.Fatalf("Fail: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientDownloadVerifiesChecksumAndStripsAuthOnRedirect(t *testing.T) {
|
||||||
|
var redirectedAuth string
|
||||||
|
bucket := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
redirectedAuth = r.Header.Get("Authorization")
|
||||||
|
_, _ = w.Write([]byte("input bytes"))
|
||||||
|
}))
|
||||||
|
defer bucket.Close()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/tasks/11111111-1111-4111-8111-111111111111/input" {
|
||||||
|
http.Redirect(w, r, bucket.URL+"/presigned", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := newTestClient(t, server)
|
||||||
|
|
||||||
|
destination := filepath.Join(t.TempDir(), "input")
|
||||||
|
digest, err := client.Download(server.URL+"/tasks/11111111-1111-4111-8111-111111111111/input", destination)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download: %v", err)
|
||||||
|
}
|
||||||
|
if digest != sha256Of(t, "input bytes") {
|
||||||
|
t.Errorf("digest = %q", digest)
|
||||||
|
}
|
||||||
|
if redirectedAuth != "" {
|
||||||
|
t.Error("Authorization must be stripped on the redirected download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeErrorMessageRedactsPaths(t *testing.T) {
|
||||||
|
message := SanitizeErrorMessage(
|
||||||
|
"failed at /home/alice/work/attempts/1/input and /private/secret.txt",
|
||||||
|
"/home/alice/work",
|
||||||
|
)
|
||||||
|
for _, forbidden := range []string{"/home/alice", "/private/secret.txt"} {
|
||||||
|
if strings.Contains(message, forbidden) {
|
||||||
|
t.Errorf("message leaks %q: %q", forbidden, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(message, "<worker-dir>") {
|
||||||
|
t.Errorf("work dir not redacted: %q", message)
|
||||||
|
}
|
||||||
|
long := SanitizeErrorMessage(strings.Repeat("x", 500), "/tmp")
|
||||||
|
if len(long) != 300 {
|
||||||
|
t.Errorf("truncated length = %d", len(long))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Of(t *testing.T, value string) string {
|
||||||
|
t.Helper()
|
||||||
|
digest := sha256.Sum256([]byte(value))
|
||||||
|
return fmt.Sprintf("%x", digest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is read only from the environment, mirroring the former Python
|
||||||
|
// worker's configuration surface.
|
||||||
|
type Config struct {
|
||||||
|
CoordinatorURL string
|
||||||
|
Token string
|
||||||
|
WorkerKey string
|
||||||
|
UserserviceURL string
|
||||||
|
WorkerName string
|
||||||
|
WorkerID string // set after registration; overridable for tests
|
||||||
|
WorkDir string
|
||||||
|
CPUCount int
|
||||||
|
MemoryMB int // 0 = not advertised
|
||||||
|
PollInterval time.Duration
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
Heartbeat time.Duration
|
||||||
|
CleanupAfter time.Duration // 0 = keep attempt directories
|
||||||
|
Capabilities []string
|
||||||
|
TaskRunner []string // command + args; defaults to python -m scimesh.worker.task
|
||||||
|
MaxTasks int // 0 = unlimited
|
||||||
|
ExitWhenIdle bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func envList(name string) ([]string, error) {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var items []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s must be a JSON array", name)
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.TrimSpace(item) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must not contain empty entries", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig validates the environment and fails fast on invalid values.
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
url := os.Getenv("COORDINATOR_URL")
|
||||||
|
if url == "" {
|
||||||
|
return nil, fmt.Errorf("COORDINATOR_URL is required")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
return nil, fmt.Errorf("COORDINATOR_URL must be an absolute HTTP(S) URL")
|
||||||
|
}
|
||||||
|
workDir := os.Getenv("WORK_DIR")
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = "./scimesh-agent-data"
|
||||||
|
}
|
||||||
|
cpu := 1
|
||||||
|
if raw := os.Getenv("CPU_COUNT"); raw != "" {
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed < 1 {
|
||||||
|
return nil, fmt.Errorf("CPU_COUNT must be a positive integer")
|
||||||
|
}
|
||||||
|
cpu = parsed
|
||||||
|
}
|
||||||
|
memoryMB := 0
|
||||||
|
if raw := os.Getenv("MEMORY_MB"); raw != "" {
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed < 1 {
|
||||||
|
return nil, fmt.Errorf("MEMORY_MB must be a positive integer")
|
||||||
|
}
|
||||||
|
memoryMB = parsed
|
||||||
|
}
|
||||||
|
poll, err := durationEnv("POLL_INTERVAL", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timeout, err := durationEnv("REQUEST_TIMEOUT", 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
heartbeat, err := durationEnv("HEARTBEAT_INTERVAL", 15*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cleanup, err := durationEnv("CLEANUP_AFTER_SECONDS", 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
capabilities, err := envList("CAPABILITIES")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(capabilities) == 0 {
|
||||||
|
capabilities = DefaultCapabilities()
|
||||||
|
}
|
||||||
|
runner, err := envList("TASK_RUNNER")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(runner) == 0 {
|
||||||
|
runner = []string{"python", "-m", "scimesh.worker.task"}
|
||||||
|
}
|
||||||
|
maxTasks := 0
|
||||||
|
if raw := os.Getenv("MAX_TASKS"); raw != "" {
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed < 1 {
|
||||||
|
return nil, fmt.Errorf("MAX_TASKS must be a positive integer")
|
||||||
|
}
|
||||||
|
maxTasks = parsed
|
||||||
|
}
|
||||||
|
name := os.Getenv("WORKER_NAME")
|
||||||
|
if name == "" {
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
name = host
|
||||||
|
}
|
||||||
|
absWorkDir, err := filepath.Abs(workDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("WORK_DIR must be an absolute path")
|
||||||
|
}
|
||||||
|
return &Config{
|
||||||
|
CoordinatorURL: strings.TrimRight(url, "/"),
|
||||||
|
Token: os.Getenv("WORKER_AUTH_TOKEN"),
|
||||||
|
WorkerKey: os.Getenv("WORKER_KEY"),
|
||||||
|
UserserviceURL: strings.TrimRight(os.Getenv("USERSERVICE_URL"), "/"),
|
||||||
|
WorkerName: name,
|
||||||
|
WorkerID: os.Getenv("WORKER_ID"),
|
||||||
|
WorkDir: absWorkDir,
|
||||||
|
CPUCount: cpu,
|
||||||
|
MemoryMB: memoryMB,
|
||||||
|
PollInterval: poll,
|
||||||
|
RequestTimeout: timeout,
|
||||||
|
Heartbeat: heartbeat,
|
||||||
|
CleanupAfter: cleanup,
|
||||||
|
Capabilities: capabilities,
|
||||||
|
TaskRunner: runner,
|
||||||
|
MaxTasks: maxTasks,
|
||||||
|
ExitWhenIdle: os.Getenv("EXIT_WHEN_IDLE") == "1",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
return 0, fmt.Errorf("%s must be a non-negative duration", name)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCapabilities derives the worker's advertised capabilities from the
|
||||||
|
// embedded workload catalog, so an agent is workload-agnostic out of the box:
|
||||||
|
// it claims whatever enabled workloads the coordinator library declares.
|
||||||
|
// Explicit CAPABILITIES still overrides this for operators who want a subset.
|
||||||
|
func DefaultCapabilities() []string {
|
||||||
|
catalog, err := workloads.Load()
|
||||||
|
if err != nil {
|
||||||
|
return []string{"similarity-search"}
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(catalog.Enabled()))
|
||||||
|
for _, workload := range catalog.Enabled() {
|
||||||
|
names = append(names, workload.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome reports whether a claim was made and whether it completed.
|
||||||
|
type Outcome struct {
|
||||||
|
Claimed bool
|
||||||
|
Completed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daemon is the agent state machine: register, claim, execute via the Python
|
||||||
|
// task runner, upload, and submit — mirroring the Python worker's lifecycle.
|
||||||
|
// conflictError is the errors.As target for lease conflicts.
|
||||||
|
var conflictError *ConflictError
|
||||||
|
|
||||||
|
type Daemon struct {
|
||||||
|
config *Config
|
||||||
|
client *Client
|
||||||
|
runner *TaskRunner
|
||||||
|
log *slog.Logger
|
||||||
|
workerID string
|
||||||
|
registered bool
|
||||||
|
completed int
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDaemon(config *Config, client *Client, runner *TaskRunner, log *slog.Logger) *Daemon {
|
||||||
|
return &Daemon{config: config, client: client, runner: runner, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunForever loops until interrupted, idle-exit, or max tasks.
|
||||||
|
func (d *Daemon) RunForever() error {
|
||||||
|
failures := 0
|
||||||
|
for {
|
||||||
|
if !d.registered {
|
||||||
|
if err := d.register(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.cleanupExpiredDirectories()
|
||||||
|
outcome, err := d.runOnce()
|
||||||
|
if err != nil {
|
||||||
|
failures++
|
||||||
|
d.log.Warn("agent cycle failed", "error", err)
|
||||||
|
backoff := d.config.PollInterval
|
||||||
|
for i := 0; i < failures && i < 6; i++ {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
if backoff > 60*time.Second {
|
||||||
|
backoff = 60 * time.Second
|
||||||
|
}
|
||||||
|
time.Sleep(backoff)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = 0
|
||||||
|
if outcome.Claimed && outcome.Completed {
|
||||||
|
d.completed++
|
||||||
|
if d.config.MaxTasks > 0 && d.completed >= d.config.MaxTasks {
|
||||||
|
d.log.Info("max tasks reached")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !outcome.Claimed && d.config.ExitWhenIdle {
|
||||||
|
d.log.Info("queue empty, exiting")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if outcome.Claimed && d.config.ExitWhenIdle {
|
||||||
|
d.log.Info("one claim processed, exiting")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !outcome.Claimed {
|
||||||
|
time.Sleep(d.config.PollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) register() error {
|
||||||
|
registered, err := d.client.Register(
|
||||||
|
d.config.WorkerName,
|
||||||
|
d.config.Capabilities,
|
||||||
|
d.config.CPUCount,
|
||||||
|
d.config.MemoryMB,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
if d.config.WorkerID != "" {
|
||||||
|
d.workerID = d.config.WorkerID
|
||||||
|
} else {
|
||||||
|
d.workerID = registered.WorkerID
|
||||||
|
}
|
||||||
|
d.registered = true
|
||||||
|
d.mu.Unlock()
|
||||||
|
d.log.Info("registered", "worker_id", d.workerID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) workerIDOrEmpty() string {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
return d.workerID
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupExpiredDirectories removes task attempt directories older than the
|
||||||
|
// configured retention, mirroring the former Python worker's cleanup.
|
||||||
|
func (d *Daemon) cleanupExpiredDirectories() {
|
||||||
|
if d.config.CleanupAfter <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-d.config.CleanupAfter)
|
||||||
|
tasks, err := os.ReadDir(d.config.WorkDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, taskEntry := range tasks {
|
||||||
|
if !taskEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
taskDir := filepath.Join(d.config.WorkDir, taskEntry.Name())
|
||||||
|
attempts, err := os.ReadDir(taskDir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, attemptEntry := range attempts {
|
||||||
|
if !attemptEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := attemptEntry.Info()
|
||||||
|
if err == nil && info.ModTime().Before(cutoff) {
|
||||||
|
_ = os.RemoveAll(filepath.Join(taskDir, attemptEntry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries, err := os.ReadDir(taskDir); err == nil && len(entries) == 0 {
|
||||||
|
_ = os.Remove(taskDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) runOnce() (Outcome, error) {
|
||||||
|
workerID := d.workerIDOrEmpty()
|
||||||
|
if workerID == "" {
|
||||||
|
return Outcome{}, fmt.Errorf("agent is not registered")
|
||||||
|
}
|
||||||
|
task, err := d.client.Claim(workerID, d.config.Capabilities)
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{}, err
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
return Outcome{Claimed: false}, nil
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
taskDir := filepath.Join(d.config.WorkDir, task.TaskID, fmt.Sprint(task.Attempt))
|
||||||
|
if err := os.MkdirAll(taskDir, 0o750); err != nil {
|
||||||
|
return Outcome{Claimed: true}, err
|
||||||
|
}
|
||||||
|
heartbeat := newLeaseHeartbeat(task, workerID, d.client, d.config.Heartbeat)
|
||||||
|
completed := false
|
||||||
|
err = heartbeat.Start()
|
||||||
|
if err != nil {
|
||||||
|
if errors.As(err, &conflictError) {
|
||||||
|
d.log.Warn("lease lost", "task_id", task.TaskID)
|
||||||
|
return Outcome{Claimed: true}, nil
|
||||||
|
}
|
||||||
|
return Outcome{Claimed: true}, err
|
||||||
|
}
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
// Attempt directory cleanup is deliberately minimal in the prototype:
|
||||||
|
// attempt directories are retained under the work directory.
|
||||||
|
failure := d.executeTask(task, workerID, taskDir, started, heartbeat)
|
||||||
|
if failure != nil {
|
||||||
|
if errors.As(failure, &conflictError) {
|
||||||
|
d.log.Warn("lease lost", "task_id", task.TaskID)
|
||||||
|
return Outcome{Claimed: true}, nil
|
||||||
|
}
|
||||||
|
if err := heartbeat.RaiseIfFailed(); err != nil {
|
||||||
|
return Outcome{Claimed: true}, nil
|
||||||
|
}
|
||||||
|
d.reportFailure(task, workerID, failure)
|
||||||
|
return Outcome{Claimed: true}, nil
|
||||||
|
}
|
||||||
|
if err := heartbeat.RaiseIfFailed(); err != nil {
|
||||||
|
return Outcome{Claimed: true}, nil
|
||||||
|
}
|
||||||
|
completed = true
|
||||||
|
d.log.Info("task completed", "task_id", task.TaskID, "elapsed_seconds", time.Since(started).Seconds())
|
||||||
|
return Outcome{Claimed: true, Completed: completed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeTask returns nil on success or a classified failure.
|
||||||
|
func (d *Daemon) executeTask(task *Task, workerID, taskDir string, started time.Time, heartbeat *leaseHeartbeat) error {
|
||||||
|
inputPath := filepath.Join(taskDir, "input")
|
||||||
|
// Downloads use the coordinator-provided URI verbatim; a relative path is
|
||||||
|
// resolved against the coordinator by the client.
|
||||||
|
actualSHA, err := d.client.Download(task.Input.URI, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(actualSHA, task.Input.SHA256) {
|
||||||
|
return &CoordinatorError{msg: "input checksum mismatch"}
|
||||||
|
}
|
||||||
|
if err := heartbeat.RaiseIfFailed(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
manifestPath := filepath.Join(taskDir, "manifest.json")
|
||||||
|
manifest, err := d.runner.Run(task, taskDir, manifestPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := heartbeat.RaiseIfFailed(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
uploaded, err := d.client.Upload(task, workerID, manifest.ArtifactPath, manifest.ContentType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := heartbeat.RaiseIfFailed(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
metrics := map[string]any{"elapsed_seconds": roundSeconds(time.Since(started).Seconds())}
|
||||||
|
for name, value := range manifest.Metrics {
|
||||||
|
metrics[name] = value
|
||||||
|
}
|
||||||
|
return d.client.Submit(task, workerID, uploaded, metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daemon) reportFailure(task *Task, workerID string, failure error) {
|
||||||
|
var code string
|
||||||
|
var coordinatorErr *CoordinatorError
|
||||||
|
if errors.As(failure, &coordinatorErr) {
|
||||||
|
code = "ValueError"
|
||||||
|
} else {
|
||||||
|
code = "TaskRunnerFailed"
|
||||||
|
}
|
||||||
|
retryable := IsRetryableError(failure)
|
||||||
|
message := SanitizeErrorMessage(failure.Error(), d.config.WorkDir)
|
||||||
|
d.log.Warn("task failed", "task_id", task.TaskID, "error_code", code, "retryable", retryable)
|
||||||
|
if err := d.client.Fail(task, workerID, code, message, retryable); err != nil {
|
||||||
|
if errors.As(err, &conflictError) {
|
||||||
|
d.log.Warn("lease lost while reporting failure", "task_id", task.TaskID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.log.Warn("failure report rejected", "task_id", task.TaskID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaseHeartbeat renews the lease from the returned deadline at less than
|
||||||
|
// half of the remaining TTL, mirroring the Python worker.
|
||||||
|
type leaseHeartbeat struct {
|
||||||
|
task *Task
|
||||||
|
workerID string
|
||||||
|
client *Client
|
||||||
|
interval time.Duration
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
mu sync.Mutex
|
||||||
|
lease time.Time
|
||||||
|
failed error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLeaseHeartbeat(task *Task, workerID string, client *Client, interval time.Duration) *leaseHeartbeat {
|
||||||
|
return &leaseHeartbeat{
|
||||||
|
task: task,
|
||||||
|
workerID: workerID,
|
||||||
|
client: client,
|
||||||
|
interval: interval,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
lease: task.LeaseExpiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *leaseHeartbeat) Start() error {
|
||||||
|
if _, err := h.client.Heartbeat(h.task, h.workerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go h.loop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *leaseHeartbeat) Stop() {
|
||||||
|
h.once.Do(func() { close(h.stop) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *leaseHeartbeat) RaiseIfFailed() error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.failed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *leaseHeartbeat) loop() {
|
||||||
|
for {
|
||||||
|
delay := h.nextDelay()
|
||||||
|
select {
|
||||||
|
case <-h.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
renewed, err := h.client.Heartbeat(h.task, h.workerID)
|
||||||
|
h.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
h.failed = err
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.lease = renewed
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *leaseHeartbeat) nextDelay() time.Duration {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
remaining := time.Until(h.lease)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
half := remaining / 2
|
||||||
|
if h.interval < half {
|
||||||
|
return h.interval
|
||||||
|
}
|
||||||
|
return half
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundSeconds(seconds float64) float64 {
|
||||||
|
return float64(int64(seconds*1000)) / 1000
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sha256HexOf(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeRunnerScript writes a result manifest for --output and exits with the
|
||||||
|
// given code.
|
||||||
|
func fakeRunnerScript(t *testing.T, dir string, exitCode int) string {
|
||||||
|
t.Helper()
|
||||||
|
script := filepath.Join(dir, "fake-runner.sh")
|
||||||
|
content := `#!/bin/sh
|
||||||
|
out=""
|
||||||
|
task_dir=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--output) out="$2"; shift 2;;
|
||||||
|
--task-dir) task_dir="$2"; shift 2;;
|
||||||
|
*) shift;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'id,score\n' > "$task_dir/result.csv"
|
||||||
|
printf '{"artifact_path":"%s/result.csv","content_type":"text/csv","metrics":{"rows":1}}' "$task_dir" > "$out"
|
||||||
|
exit ` + fmt.Sprint(exitCode) + "\n"
|
||||||
|
if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return script
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeCoordinator implements the v1 contract over HTTP and records calls.
|
||||||
|
type fakeCoordinator struct {
|
||||||
|
server *httptest.Server
|
||||||
|
task map[string]any
|
||||||
|
submits []map[string]any
|
||||||
|
failures []map[string]any
|
||||||
|
heartbeats int
|
||||||
|
uploadSHA string
|
||||||
|
uploadSize int64
|
||||||
|
inputBytes []byte
|
||||||
|
conflict bool // 409 on heartbeat/upload/result
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeCoordinator(t *testing.T, task map[string]any) *fakeCoordinator {
|
||||||
|
t.Helper()
|
||||||
|
fake := &fakeCoordinator{task: task, inputBytes: []byte("input fixture")}
|
||||||
|
fake.uploadSHA = sha256HexOf(string(fake.inputBytes))
|
||||||
|
fake.uploadSize = int64(len(fake.inputBytes))
|
||||||
|
var server *httptest.Server
|
||||||
|
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/workers/register":
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"worker_id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"heartbeat_interval_seconds": 15.0,
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/tasks/claim":
|
||||||
|
if fake.task == nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, fake.task)
|
||||||
|
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/input"):
|
||||||
|
_, _ = w.Write(fake.inputBytes)
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/heartbeat"):
|
||||||
|
fake.heartbeats++
|
||||||
|
if fake.conflict {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"lease_expires_at": time.Now().Add(2 * time.Minute).UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/artifacts/"):
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
fake.uploadSize = int64(len(raw))
|
||||||
|
fake.uploadSHA = fmt.Sprintf("%x", sha256.Sum256(raw))
|
||||||
|
if fake.conflict {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"artifact_id": "33333333-3333-4333-8333-333333333333",
|
||||||
|
"uri": server.URL + "/artifacts/333/download",
|
||||||
|
"sha256": fake.uploadSHA,
|
||||||
|
"size_bytes": fake.uploadSize,
|
||||||
|
})
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/result"):
|
||||||
|
var payload map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
fake.submits = append(fake.submits, payload)
|
||||||
|
if fake.conflict {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/failure"):
|
||||||
|
var payload map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
fake.failures = append(fake.failures, payload)
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
fake.server = server
|
||||||
|
return fake
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCoordinator) close() { f.server.Close() }
|
||||||
|
|
||||||
|
func testDaemon(t *testing.T, fake *fakeCoordinator, script string) *Daemon {
|
||||||
|
t.Helper()
|
||||||
|
config := &Config{
|
||||||
|
CoordinatorURL: fake.server.URL,
|
||||||
|
WorkerName: "test-worker",
|
||||||
|
WorkerID: "22222222-2222-4222-8222-222222222222",
|
||||||
|
WorkDir: t.TempDir(),
|
||||||
|
CPUCount: 1,
|
||||||
|
PollInterval: time.Millisecond,
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
Heartbeat: 15 * time.Second,
|
||||||
|
Capabilities: []string{"similarity-search"},
|
||||||
|
TaskRunner: []string{script},
|
||||||
|
}
|
||||||
|
client := NewClient(fake.server.URL, &StaticToken{token: "test-token"}, 5*time.Second)
|
||||||
|
runner := NewTaskRunner(config.TaskRunner)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
daemon := NewDaemon(config, client, runner, logger)
|
||||||
|
if err := daemon.register(); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
return daemon
|
||||||
|
}
|
||||||
|
|
||||||
|
func validClaimedTaskPayload() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"task_id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"attempt": 1.0,
|
||||||
|
"lease_expires_at": time.Now().Add(time.Minute).UTC().Format(time.RFC3339),
|
||||||
|
"workload": "similarity-search",
|
||||||
|
"input": map[string]any{
|
||||||
|
"uri": "/tasks/11111111-1111-4111-8111-111111111111/input",
|
||||||
|
"sha256": sha256HexOf("input fixture"),
|
||||||
|
},
|
||||||
|
"parameters": map[string]any{"query_smiles": "CCO"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonCompletesAClaimedTask(t *testing.T) {
|
||||||
|
fake := newFakeCoordinator(t, validClaimedTaskPayload())
|
||||||
|
defer fake.close()
|
||||||
|
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
|
||||||
|
|
||||||
|
outcome, err := daemon.runOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runOnce: %v", err)
|
||||||
|
}
|
||||||
|
if !outcome.Claimed || !outcome.Completed {
|
||||||
|
t.Fatalf("outcome = %+v", outcome)
|
||||||
|
}
|
||||||
|
if len(fake.submits) != 1 {
|
||||||
|
t.Fatalf("submits = %d", len(fake.submits))
|
||||||
|
}
|
||||||
|
result := fake.submits[0]["result"].(map[string]any)
|
||||||
|
if result["artifact_id"] != "33333333-3333-4333-8333-333333333333" {
|
||||||
|
t.Errorf("result artifact = %v", result)
|
||||||
|
}
|
||||||
|
metrics := fake.submits[0]["metrics"].(map[string]any)
|
||||||
|
if metrics["rows"] != float64(1) {
|
||||||
|
t.Errorf("metrics = %v", metrics)
|
||||||
|
}
|
||||||
|
if _, ok := metrics["elapsed_seconds"].(float64); !ok {
|
||||||
|
t.Errorf("missing elapsed_seconds: %v", metrics)
|
||||||
|
}
|
||||||
|
if fake.heartbeats < 1 {
|
||||||
|
t.Error("expected at least one heartbeat")
|
||||||
|
}
|
||||||
|
if len(fake.failures) != 0 {
|
||||||
|
t.Errorf("unexpected failures: %v", fake.failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonReportsChecksumMismatchAsPermanentFailure(t *testing.T) {
|
||||||
|
payload := validClaimedTaskPayload()
|
||||||
|
payload["input"].(map[string]any)["sha256"] = strings.Repeat("b", 64)
|
||||||
|
fake := newFakeCoordinator(t, payload)
|
||||||
|
defer fake.close()
|
||||||
|
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
|
||||||
|
|
||||||
|
outcome, err := daemon.runOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runOnce: %v", err)
|
||||||
|
}
|
||||||
|
if outcome.Completed {
|
||||||
|
t.Fatal("task must not complete on checksum mismatch")
|
||||||
|
}
|
||||||
|
if len(fake.failures) != 1 {
|
||||||
|
t.Fatalf("failures = %d", len(fake.failures))
|
||||||
|
}
|
||||||
|
failure := fake.failures[0]
|
||||||
|
if failure["error_code"] != "ValueError" || failure["retryable"] != false {
|
||||||
|
t.Errorf("failure = %v", failure)
|
||||||
|
}
|
||||||
|
if !strings.Contains(failure["error_message"].(string), "checksum") {
|
||||||
|
t.Errorf("message = %v", failure["error_message"])
|
||||||
|
}
|
||||||
|
if len(fake.submits) != 0 {
|
||||||
|
t.Error("no submission expected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonReportsPermanentRunnerFailure(t *testing.T) {
|
||||||
|
fake := newFakeCoordinator(t, validClaimedTaskPayload())
|
||||||
|
defer fake.close()
|
||||||
|
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), ExitPermanent))
|
||||||
|
|
||||||
|
outcome, err := daemon.runOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runOnce: %v", err)
|
||||||
|
}
|
||||||
|
if outcome.Completed {
|
||||||
|
t.Fatal("task must not complete")
|
||||||
|
}
|
||||||
|
if len(fake.failures) != 1 || fake.failures[0]["retryable"] != false {
|
||||||
|
t.Fatalf("failures = %v", fake.failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonReportsRetryableRunnerFailure(t *testing.T) {
|
||||||
|
fake := newFakeCoordinator(t, validClaimedTaskPayload())
|
||||||
|
defer fake.close()
|
||||||
|
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 1))
|
||||||
|
|
||||||
|
outcome, err := daemon.runOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runOnce: %v", err)
|
||||||
|
}
|
||||||
|
if outcome.Completed {
|
||||||
|
t.Fatal("task must not complete")
|
||||||
|
}
|
||||||
|
if len(fake.failures) != 1 || fake.failures[0]["retryable"] != true {
|
||||||
|
t.Fatalf("failures = %v", fake.failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonLeaseConflictStopsWithoutFailureReport(t *testing.T) {
|
||||||
|
fake := newFakeCoordinator(t, validClaimedTaskPayload())
|
||||||
|
fake.conflict = true
|
||||||
|
defer fake.close()
|
||||||
|
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
|
||||||
|
|
||||||
|
outcome, err := daemon.runOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runOnce: %v", err)
|
||||||
|
}
|
||||||
|
if !outcome.Claimed {
|
||||||
|
t.Fatal("task was claimed")
|
||||||
|
}
|
||||||
|
if len(fake.failures) != 0 {
|
||||||
|
t.Errorf("no failure report expected after lease loss: %v", fake.failures)
|
||||||
|
}
|
||||||
|
if len(fake.submits) != 0 {
|
||||||
|
t.Errorf("no submission expected after lease loss: %v", fake.submits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonIdleClaimIsNotCompleted(t *testing.T) {
|
||||||
|
fake := newFakeCoordinator(t, nil)
|
||||||
|
defer fake.close()
|
||||||
|
daemon := testDaemon(t, fake, fakeRunnerScript(t, t.TempDir(), 0))
|
||||||
|
|
||||||
|
outcome, err := daemon.runOnce()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runOnce: %v", err)
|
||||||
|
}
|
||||||
|
if outcome.Claimed || outcome.Completed {
|
||||||
|
t.Fatalf("outcome = %+v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Package agent implements a Go worker agent: a coordinator client and
|
||||||
|
// task-lifecycle supervisor that executes SDK workloads in a Python
|
||||||
|
// subprocess. It mirrors the Python worker's v1 wire contract exactly; the
|
||||||
|
// Python worker remains the reference implementation.
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||||
|
sha256Pattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||||
|
workloadPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisteredWorker is the coordinator's answer to /workers/register.
|
||||||
|
type RegisteredWorker struct {
|
||||||
|
WorkerID string
|
||||||
|
HeartbeatIntervalSeconds float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input is the claimed task's input artifact.
|
||||||
|
type Input struct {
|
||||||
|
URI string
|
||||||
|
SHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task is one claimed, leased task.
|
||||||
|
type Task struct {
|
||||||
|
TaskID string
|
||||||
|
Attempt int
|
||||||
|
LeaseExpiresAt time.Time
|
||||||
|
Workload string
|
||||||
|
Input Input
|
||||||
|
Parameters map[string]any
|
||||||
|
leaseExpiresRaw string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploaded is the coordinator-owned metadata returned after artifact upload.
|
||||||
|
type Uploaded struct {
|
||||||
|
ArtifactID string
|
||||||
|
URI string
|
||||||
|
SHA256 string
|
||||||
|
SizeBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireString(value any, field string) (string, error) {
|
||||||
|
text, ok := value.(string)
|
||||||
|
if !ok || strings.TrimSpace(text) == "" {
|
||||||
|
return "", fmt.Errorf("%s must be a non-empty string", field)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeCoordinatorURI(value any, field string) (string, error) {
|
||||||
|
uri, err := requireString(value, field)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(uri, "/") {
|
||||||
|
// A network-path reference (//host/path) or dot segments would
|
||||||
|
// resolve to another origin; reject both.
|
||||||
|
if strings.HasPrefix(uri, "//") {
|
||||||
|
return "", fmt.Errorf("%s must be a safe coordinator path", field)
|
||||||
|
}
|
||||||
|
for _, segment := range strings.Split(uri, "/") {
|
||||||
|
if segment == ".." {
|
||||||
|
return "", fmt.Errorf("%s must be a safe coordinator path", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uri, nil
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(uri)
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||||
|
return "", fmt.Errorf("%s must be an absolute HTTP(S) URL or coordinator path", field)
|
||||||
|
}
|
||||||
|
return uri, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Hex(value any, field string) (string, error) {
|
||||||
|
digest, err := requireString(value, field)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
digest = strings.ToLower(digest)
|
||||||
|
if !sha256Pattern.MatchString(digest) {
|
||||||
|
return "", fmt.Errorf("%s must be a SHA-256 hex digest", field)
|
||||||
|
}
|
||||||
|
return digest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uuid(value any, field string) (string, error) {
|
||||||
|
text, err := requireString(value, field)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !uuidPattern.MatchString(text) {
|
||||||
|
return "", fmt.Errorf("%s must be a UUID", field)
|
||||||
|
}
|
||||||
|
return strings.ToLower(text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTask validates a claimed-task response with the same strictness as the
|
||||||
|
// Python worker's ClaimedTask.from_json.
|
||||||
|
func ParseTask(payload map[string]any) (*Task, error) {
|
||||||
|
rawInput, ok := payload["input"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("input must be an object")
|
||||||
|
}
|
||||||
|
rawAttempt, ok := payload["attempt"].(float64)
|
||||||
|
if !ok || rawAttempt < 1 || rawAttempt != float64(int(rawAttempt)) {
|
||||||
|
return nil, fmt.Errorf("attempt must be a positive integer")
|
||||||
|
}
|
||||||
|
taskID, err := uuid(payload["task_id"], "task_id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
||||||
|
}
|
||||||
|
rawLease, err := requireString(payload["lease_expires_at"], "lease_expires_at")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
||||||
|
}
|
||||||
|
lease, err := time.Parse(time.RFC3339, rawLease)
|
||||||
|
if err != nil || lease.Location() == nil {
|
||||||
|
return nil, fmt.Errorf("lease_expires_at must include a timezone")
|
||||||
|
}
|
||||||
|
workload, err := requireString(payload["workload"], "workload")
|
||||||
|
if err != nil || !workloadPattern.MatchString(workload) {
|
||||||
|
return nil, fmt.Errorf("workload must be a canonical name")
|
||||||
|
}
|
||||||
|
uri, err := safeCoordinatorURI(rawInput["uri"], "input.uri")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
||||||
|
}
|
||||||
|
digest, err := sha256Hex(rawInput["sha256"], "input.sha256")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
||||||
|
}
|
||||||
|
parameters, ok := payload["parameters"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
parameters = map[string]any{}
|
||||||
|
}
|
||||||
|
return &Task{
|
||||||
|
TaskID: taskID,
|
||||||
|
Attempt: int(rawAttempt),
|
||||||
|
LeaseExpiresAt: lease,
|
||||||
|
leaseExpiresRaw: rawLease,
|
||||||
|
Workload: workload,
|
||||||
|
Input: Input{URI: uri, SHA256: digest},
|
||||||
|
Parameters: parameters,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaseExpiresRaw returns the original lease timestamp string for
|
||||||
|
// round-tripping in heartbeat deadlines.
|
||||||
|
func (t *Task) LeaseExpiresRaw() string { return t.leaseExpiresRaw }
|
||||||
|
|
||||||
|
// ParseRegistered validates a registration response.
|
||||||
|
func ParseRegistered(payload map[string]any) (*RegisteredWorker, error) {
|
||||||
|
workerID, err := uuid(payload["worker_id"], "worker_id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid worker registration response: %w", err)
|
||||||
|
}
|
||||||
|
interval, ok := payload["heartbeat_interval_seconds"].(float64)
|
||||||
|
if !ok || interval <= 0 {
|
||||||
|
return nil, fmt.Errorf("heartbeat_interval_seconds must be positive")
|
||||||
|
}
|
||||||
|
return &RegisteredWorker{WorkerID: workerID, HeartbeatIntervalSeconds: interval}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseUploaded validates an artifact upload response.
|
||||||
|
func ParseUploaded(payload map[string]any) (*Uploaded, error) {
|
||||||
|
artifactID, err := uuid(payload["artifact_id"], "artifact_id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
|
||||||
|
}
|
||||||
|
uri, err := safeCoordinatorURI(payload["uri"], "uri")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
|
||||||
|
}
|
||||||
|
digest, err := sha256Hex(payload["sha256"], "sha256")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
|
||||||
|
}
|
||||||
|
rawSize, ok := payload["size_bytes"].(float64)
|
||||||
|
if !ok || rawSize < 0 || rawSize != float64(int64(rawSize)) {
|
||||||
|
return nil, fmt.Errorf("artifact size_bytes must be a non-negative integer")
|
||||||
|
}
|
||||||
|
return &Uploaded{ArtifactID: artifactID, URI: uri, SHA256: digest, SizeBytes: int64(rawSize)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskRunnerManifest is what the Python task entry writes on success.
|
||||||
|
type TaskRunnerManifest struct {
|
||||||
|
ArtifactPath string `json:"artifact_path"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Metrics map[string]any `json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode serializes a claim payload for /tasks/claim.
|
||||||
|
func Encode(v any) ([]byte, error) { return json.Marshal(v) }
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validTaskPayload() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"task_id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"attempt": 1.0,
|
||||||
|
"lease_expires_at": "2026-08-02T00:00:00Z",
|
||||||
|
"workload": "similarity-search",
|
||||||
|
"input": map[string]any{
|
||||||
|
"uri": "/tasks/11111111-1111-4111-8111-111111111111/input",
|
||||||
|
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
},
|
||||||
|
"parameters": map[string]any{"query_smiles": "CCO"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTaskAcceptsValidPayload(t *testing.T) {
|
||||||
|
task, err := ParseTask(validTaskPayload())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTask: %v", err)
|
||||||
|
}
|
||||||
|
if task.TaskID != "11111111-1111-4111-8111-111111111111" {
|
||||||
|
t.Errorf("task id = %q", task.TaskID)
|
||||||
|
}
|
||||||
|
if task.Attempt != 1 || task.Workload != "similarity-search" {
|
||||||
|
t.Errorf("attempt/workload = %d/%q", task.Attempt, task.Workload)
|
||||||
|
}
|
||||||
|
if task.Input.SHA256 != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
|
||||||
|
t.Errorf("sha256 = %q", task.Input.SHA256)
|
||||||
|
}
|
||||||
|
if task.LeaseExpiresAt.IsZero() {
|
||||||
|
t.Error("lease must parse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTaskRejectsInvalidPayloads(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(map[string]any)
|
||||||
|
}{
|
||||||
|
{"non-uuid task id", func(p map[string]any) { p["task_id"] = "../outside" }},
|
||||||
|
{"zero attempt", func(p map[string]any) { p["attempt"] = 0 }},
|
||||||
|
{"naive lease", func(p map[string]any) { p["lease_expires_at"] = "2026-08-02T00:00:00" }},
|
||||||
|
{"network-path uri", func(p map[string]any) {
|
||||||
|
p["input"].(map[string]any)["uri"] = "//outside.example/input"
|
||||||
|
}},
|
||||||
|
{"dot-segment uri", func(p map[string]any) {
|
||||||
|
p["input"].(map[string]any)["uri"] = "/tasks/../outside/input"
|
||||||
|
}},
|
||||||
|
{"short sha256", func(p map[string]any) {
|
||||||
|
p["input"].(map[string]any)["sha256"] = "abc"
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
payload := validTaskPayload()
|
||||||
|
test.mutate(payload)
|
||||||
|
if _, err := ParseTask(payload); err == nil {
|
||||||
|
t.Error("expected ParseTask to reject the payload")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRegisteredAndUploaded(t *testing.T) {
|
||||||
|
registered, err := ParseRegistered(map[string]any{
|
||||||
|
"worker_id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"heartbeat_interval_seconds": 15.0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseRegistered: %v", err)
|
||||||
|
}
|
||||||
|
if registered.HeartbeatIntervalSeconds != 15 {
|
||||||
|
t.Errorf("interval = %v", registered.HeartbeatIntervalSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded, err := ParseUploaded(map[string]any{
|
||||||
|
"artifact_id": "33333333-3333-4333-8333-333333333333",
|
||||||
|
"uri": "https://coordinator.example/artifacts/333/download",
|
||||||
|
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
"size_bytes": 12.0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseUploaded: %v", err)
|
||||||
|
}
|
||||||
|
if uploaded.SizeBytes != 12 {
|
||||||
|
t.Errorf("size = %d", uploaded.SizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ParseUploaded(map[string]any{"artifact_id": "missing"}); err == nil {
|
||||||
|
t.Error("expected invalid upload metadata to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeaseHeartbeatDelayIsBelowHalfTTL(t *testing.T) {
|
||||||
|
task, err := ParseTask(validTaskPayload())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task.LeaseExpiresAt = time.Now().Add(60 * time.Second)
|
||||||
|
heartbeat := newLeaseHeartbeat(task, "worker", nil, 15*time.Second)
|
||||||
|
delay := heartbeat.nextDelay()
|
||||||
|
if delay > 30*time.Second || delay <= 0 {
|
||||||
|
t.Errorf("delay = %v, want < 30s", delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Go's regexp (RE2) has no lookbehind, so these patterns conservatively
|
||||||
|
// anchor on the characters that typically precede a local path:
|
||||||
|
// whitespace, quotes, parens, brackets, or the start of the message.
|
||||||
|
windowsPathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\s'"\],)]+`)
|
||||||
|
posixPathPattern = regexp.MustCompile(`(^|[\s'"(\[=])/(?:[^\s'"\],)]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// SanitizeErrorMessage keeps coordinator-visible failures useful without
|
||||||
|
// exposing local paths. It mirrors the Python worker's sanitizer: local work
|
||||||
|
// directories and absolute paths are redacted, and the message is truncated
|
||||||
|
// to 300 characters.
|
||||||
|
func SanitizeErrorMessage(message string, workDir string) string {
|
||||||
|
message = strings.ReplaceAll(message, workDir, "<worker-dir>")
|
||||||
|
message = windowsPathPattern.ReplaceAllString(message, "<path>")
|
||||||
|
message = posixPathPattern.ReplaceAllString(message, "${1}<path>")
|
||||||
|
if len(message) > 300 {
|
||||||
|
message = message[:300]
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRetryableError classifies failures for the coordinator. Invalid scientific
|
||||||
|
// input and missing local tools are permanent; everything else (transient
|
||||||
|
// transport errors, unexpected runner failures) may be retried.
|
||||||
|
func IsRetryableError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var coordinatorErr *CoordinatorError
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if errors.As(err, &coordinatorErr) || errors.As(err, &pathErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskRunnerExit classifies subprocess exits.
|
||||||
|
const (
|
||||||
|
ExitPermanent = 3 // runner classified the failure as invalid input
|
||||||
|
)
|
||||||
|
|
||||||
|
func runnerExitError(exit int, stderr string) error {
|
||||||
|
if exit == ExitPermanent {
|
||||||
|
return &CoordinatorError{msg: stderr}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("task runner failed with exit code %d: %s", exit, stderr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskRunner spawns the Python task entry and returns the sealed partial
|
||||||
|
// artifact manifest it produced.
|
||||||
|
type TaskRunner struct {
|
||||||
|
command []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskRunner(command []string) *TaskRunner {
|
||||||
|
return &TaskRunner{command: command}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes one task: the task payload is written as JSON into the attempt
|
||||||
|
// directory, the Python entry computes and seals the partial, and the written
|
||||||
|
// manifest is parsed back. stderr is captured for failure reporting.
|
||||||
|
func (r *TaskRunner) Run(task *Task, taskDir string, manifestPath string, extraEnv []string) (*TaskRunnerManifest, error) {
|
||||||
|
if err := os.MkdirAll(taskDir, 0o750); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"task_id": task.TaskID,
|
||||||
|
"attempt": task.Attempt,
|
||||||
|
"lease_expires_at": task.leaseExpiresRaw,
|
||||||
|
"workload": task.Workload,
|
||||||
|
"input": map[string]any{
|
||||||
|
"uri": task.Input.URI,
|
||||||
|
"sha256": task.Input.SHA256,
|
||||||
|
},
|
||||||
|
"parameters": task.Parameters,
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
taskJSONPath := filepath.Join(taskDir, "task.json")
|
||||||
|
if err := os.WriteFile(taskJSONPath, payloadBytes, 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args := append([]string{}, r.command[1:]...)
|
||||||
|
args = append(args,
|
||||||
|
"--task-json", taskJSONPath,
|
||||||
|
"--task-dir", taskDir,
|
||||||
|
"--output", manifestPath,
|
||||||
|
)
|
||||||
|
// #nosec G204 -- the command comes from the operator-configured TASK_RUNNER.
|
||||||
|
command := exec.CommandContext(context.Background(), r.command[0], args...)
|
||||||
|
command.Dir = taskDir
|
||||||
|
command.Env = append(os.Environ(), extraEnv...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
command.Stderr = &stderr
|
||||||
|
if err := command.Run(); err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return nil, runnerExitError(exitErr.ExitCode(), stderr.String())
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("task runner could not be started: %w", err)
|
||||||
|
}
|
||||||
|
// #nosec G304 -- the manifest path is inside the worker's own task directory.
|
||||||
|
raw, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("task runner produced no result manifest")
|
||||||
|
}
|
||||||
|
var manifest TaskRunnerManifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("task runner produced an invalid result manifest")
|
||||||
|
}
|
||||||
|
if manifest.ArtifactPath == "" || manifest.ContentType == "" {
|
||||||
|
return nil, fmt.Errorf("task runner produced an incomplete result manifest")
|
||||||
|
}
|
||||||
|
info, err := os.Stat(manifest.ArtifactPath)
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("task runner produced no artifact file")
|
||||||
|
}
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Package authctx carries the authenticated requester across the transport and
|
||||||
|
// use-case layers without either one importing the other. The HTTP middleware
|
||||||
|
// stamps a Requester after verifying a user's JWT; the job use cases read it to
|
||||||
|
// record ownership and to enforce that a non-admin only touches their own jobs.
|
||||||
|
package authctx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Requester is the identity behind a request, derived from a verified JWT.
|
||||||
|
// A request authenticated only by the shared worker/service token carries no
|
||||||
|
// Requester at all (From returns ok=false), which is how worker traffic and
|
||||||
|
// legacy unauthenticated-user traffic stay owner-less.
|
||||||
|
type Requester struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Role string
|
||||||
|
Verified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin reports whether the requester may act on any user's jobs.
|
||||||
|
func (r Requester) IsAdmin() bool { return r.Role == "admin" }
|
||||||
|
|
||||||
|
// IsTrusted reports whether workers this requester registers produce results
|
||||||
|
// the coordinator accepts without quorum. Admins and verified contributors are
|
||||||
|
// trusted; a plain unverified user is not.
|
||||||
|
func (r Requester) IsTrusted() bool { return r.IsAdmin() || r.Verified }
|
||||||
|
|
||||||
|
type ctxKey struct{}
|
||||||
|
|
||||||
|
// With returns a copy of ctx carrying r.
|
||||||
|
func With(ctx context.Context, r Requester) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKey{}, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From returns the requester stamped by the middleware, or ok=false when the
|
||||||
|
// request was not authenticated as a user.
|
||||||
|
func From(ctx context.Context) (Requester, bool) {
|
||||||
|
r, ok := ctx.Value(ctxKey{}).(Requester)
|
||||||
|
return r, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Package chunk splits a tabular input into deterministic shards. It is generic
|
||||||
|
// row splitting only — no workload semantics (SMILES, top-k) live here.
|
||||||
|
package chunk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoRows is returned when the input has a header but no data rows: a job with
|
||||||
|
// zero tasks could never complete, so it is rejected at the source.
|
||||||
|
var ErrNoRows = fmt.Errorf("input has no data rows")
|
||||||
|
|
||||||
|
// maxShardBytes bounds the coordinator memory used by one in-progress shard.
|
||||||
|
// The uploaded file may be much larger: it is first stored on disk, then split
|
||||||
|
// in small bounded pieces. Operators can lower rowsPerShard when this limit is
|
||||||
|
// reached rather than exhausting the coordinator process.
|
||||||
|
const maxShardBytes = 64 << 20 // 64 MiB
|
||||||
|
|
||||||
|
// SplitTSV reads a header-plus-rows text stream and cuts it into shards of at
|
||||||
|
// most rowsPerShard data rows. Every shard repeats the header, so a worker can
|
||||||
|
// parse its shard in isolation. emit is called once per shard, in order, with a
|
||||||
|
// reader over that shard's bytes; the reader is valid only for the duration of
|
||||||
|
// the call.
|
||||||
|
//
|
||||||
|
// Splitting is deterministic: the same input and rowsPerShard always produce the
|
||||||
|
// same shards, byte for byte — which is what lets chunk_index refer to a stable
|
||||||
|
// piece and makes a re-run reproducible.
|
||||||
|
//
|
||||||
|
// Only one shard is buffered at a time, so memory is bounded by shard size (a
|
||||||
|
// worker-sized slice of the data), not by the size of the whole dataset.
|
||||||
|
func SplitTSV(r io.Reader, rowsPerShard int, emit func(index int, shard io.Reader) error) error {
|
||||||
|
return splitTSVLimit(r, rowsPerShard, 0, nil, emit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitTSVLimit behaves like SplitTSV but emits no more than maxRows data rows.
|
||||||
|
// A maxRows value of zero means unlimited. This lets an operator make a small,
|
||||||
|
// representative pipeline check without materialising a second dataset file.
|
||||||
|
func SplitTSVLimit(r io.Reader, rowsPerShard, maxRows int, emit func(index int, shard io.Reader) error) error {
|
||||||
|
return splitTSVLimit(r, rowsPerShard, maxRows, nil, emit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitChEMBLTSVLimit is the coordinator's scientific-upload splitter. It
|
||||||
|
// validates the two columns every local SciMesh workload requires before any
|
||||||
|
// shard task is persisted, while generic SplitTSV remains reusable for future
|
||||||
|
// non-chemistry workloads.
|
||||||
|
func SplitChEMBLTSVLimit(r io.Reader, rowsPerShard, maxRows int, emit func(index int, shard io.Reader) error) error {
|
||||||
|
return splitTSVLimit(r, rowsPerShard, maxRows, validateChEMBLHeader, emit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitTSVLimit(r io.Reader, rowsPerShard, maxRows int, validateHeader func([]byte) error, emit func(index int, shard io.Reader) error) error {
|
||||||
|
if rowsPerShard <= 0 {
|
||||||
|
return fmt.Errorf("rowsPerShard must be positive, got %d", rowsPerShard)
|
||||||
|
}
|
||||||
|
if maxRows < 0 {
|
||||||
|
return fmt.Errorf("maxRows must be non-negative, got %d", maxRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
// Allow long lines: a SMILES row can be far wider than bufio's 64 KB default.
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||||
|
|
||||||
|
if !sc.Scan() {
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return fmt.Errorf("read header: %w", err)
|
||||||
|
}
|
||||||
|
return ErrNoRows // completely empty input
|
||||||
|
}
|
||||||
|
header := append([]byte(nil), sc.Bytes()...)
|
||||||
|
if validateHeader != nil {
|
||||||
|
if err := validateHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
buf bytes.Buffer
|
||||||
|
rows int
|
||||||
|
index int
|
||||||
|
)
|
||||||
|
|
||||||
|
// flush emits the buffered shard and resets for the next one.
|
||||||
|
flush := func() error {
|
||||||
|
if err := emit(index, bytes.NewReader(buf.Bytes())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
buf.Reset()
|
||||||
|
rows = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for sc.Scan() {
|
||||||
|
if rows == 0 {
|
||||||
|
if len(header)+1 > maxShardBytes {
|
||||||
|
return fmt.Errorf("TSV header exceeds maximum shard size of %d bytes", maxShardBytes)
|
||||||
|
}
|
||||||
|
buf.Write(header)
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if buf.Len()+len(sc.Bytes())+1 > maxShardBytes {
|
||||||
|
return fmt.Errorf("shard exceeds maximum size of %d bytes; lower rowsPerShard", maxShardBytes)
|
||||||
|
}
|
||||||
|
buf.Write(sc.Bytes())
|
||||||
|
buf.WriteByte('\n')
|
||||||
|
rows++
|
||||||
|
|
||||||
|
if rows == rowsPerShard {
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxRows > 0 && index*rowsPerShard+rows == maxRows {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return fmt.Errorf("read rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A partial final shard still has to go out.
|
||||||
|
if rows > 0 {
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if index == 0 {
|
||||||
|
return ErrNoRows // header only, no data
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateChEMBLHeader(header []byte) error {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, field := range strings.Split(strings.TrimPrefix(string(header), "\ufeff"), "\t") {
|
||||||
|
seen[field] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, ok := seen["chembl_id"]; !ok {
|
||||||
|
return fmt.Errorf("TSV is missing required column chembl_id")
|
||||||
|
}
|
||||||
|
if _, ok := seen["canonical_smiles"]; !ok {
|
||||||
|
return fmt.Errorf("TSV is missing required column canonical_smiles")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package chunk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collect runs SplitTSV and returns every shard as a string.
|
||||||
|
func collect(t *testing.T, input string, rowsPerShard int) []string {
|
||||||
|
t.Helper()
|
||||||
|
var shards []string
|
||||||
|
err := SplitTSV(strings.NewReader(input), rowsPerShard, func(index int, shard io.Reader) error {
|
||||||
|
b, _ := io.ReadAll(shard)
|
||||||
|
if index != len(shards) {
|
||||||
|
t.Fatalf("emit index = %d, want %d (out of order)", index, len(shards))
|
||||||
|
}
|
||||||
|
shards = append(shards, string(b))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SplitTSV: %v", err)
|
||||||
|
}
|
||||||
|
return shards
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitCountsShardsAndRepeatsHeader(t *testing.T) {
|
||||||
|
input := "id\tsmiles\nA\tCC\nB\tCCC\nC\tCCCC\nD\tCCCCC\nE\tCCCCCC\n"
|
||||||
|
shards := collect(t, input, 2)
|
||||||
|
|
||||||
|
if len(shards) != 3 { // 5 rows / 2 per shard = ceil = 3
|
||||||
|
t.Fatalf("got %d shards, want 3", len(shards))
|
||||||
|
}
|
||||||
|
for i, s := range shards {
|
||||||
|
if !strings.HasPrefix(s, "id\tsmiles\n") {
|
||||||
|
t.Errorf("shard %d missing header: %q", i, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shards[0] != "id\tsmiles\nA\tCC\nB\tCCC\n" {
|
||||||
|
t.Errorf("shard 0 = %q", shards[0])
|
||||||
|
}
|
||||||
|
if shards[2] != "id\tsmiles\nE\tCCCCCC\n" { // partial final shard
|
||||||
|
t.Errorf("shard 2 = %q", shards[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitExactMultipleHasNoEmptyTrailingShard(t *testing.T) {
|
||||||
|
input := "h\nr1\nr2\nr3\nr4\n"
|
||||||
|
shards := collect(t, input, 2)
|
||||||
|
if len(shards) != 2 { // exactly 4/2, no empty third shard
|
||||||
|
t.Fatalf("got %d shards, want 2", len(shards))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitIsDeterministic(t *testing.T) {
|
||||||
|
input := "h\n" + strings.Repeat("row\n", 100)
|
||||||
|
a := collect(t, input, 7)
|
||||||
|
b := collect(t, input, 7)
|
||||||
|
if fmt.Sprint(a) != fmt.Sprint(b) {
|
||||||
|
t.Error("two runs produced different shards")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitRejectsHeaderOnly(t *testing.T) {
|
||||||
|
err := SplitTSV(strings.NewReader("id\tsmiles\n"), 10, func(int, io.Reader) error { return nil })
|
||||||
|
if !errors.Is(err, ErrNoRows) {
|
||||||
|
t.Errorf("err = %v, want ErrNoRows", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitRejectsEmptyInput(t *testing.T) {
|
||||||
|
err := SplitTSV(strings.NewReader(""), 10, func(int, io.Reader) error { return nil })
|
||||||
|
if !errors.Is(err, ErrNoRows) {
|
||||||
|
t.Errorf("err = %v, want ErrNoRows", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitRejectsNonPositiveSize(t *testing.T) {
|
||||||
|
err := SplitTSV(strings.NewReader("h\nr\n"), 0, func(int, io.Reader) error { return nil })
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected an error for rowsPerShard = 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitPropagatesEmitError(t *testing.T) {
|
||||||
|
boom := errors.New("boom")
|
||||||
|
err := SplitTSV(strings.NewReader("h\nr1\nr2\n"), 1, func(int, io.Reader) error { return boom })
|
||||||
|
if !errors.Is(err, boom) {
|
||||||
|
t.Errorf("err = %v, want boom", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSingleShardWhenSizeExceedsRows(t *testing.T) {
|
||||||
|
shards := collect(t, "h\nr1\nr2\n", 100)
|
||||||
|
if len(shards) != 1 {
|
||||||
|
t.Fatalf("got %d shards, want 1", len(shards))
|
||||||
|
}
|
||||||
|
if shards[0] != "h\nr1\nr2\n" {
|
||||||
|
t.Errorf("shard 0 = %q", shards[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitLimitUsesOnlyLeadingDataRows(t *testing.T) {
|
||||||
|
input := "h\nr1\nr2\nr3\nr4\nr5\n"
|
||||||
|
var shards []string
|
||||||
|
err := SplitTSVLimit(strings.NewReader(input), 2, 3, func(_ int, shard io.Reader) error {
|
||||||
|
b, _ := io.ReadAll(shard)
|
||||||
|
shards = append(shards, string(b))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := strings.Join(shards, ""), "h\nr1\nr2\nh\nr3\n"; got != want {
|
||||||
|
t.Errorf("limited shards = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChEMBLSplitRejectsMissingRequiredColumns(t *testing.T) {
|
||||||
|
err := SplitChEMBLTSVLimit(strings.NewReader("id\tsmiles\nA\tCC\n"), 1, 0,
|
||||||
|
func(int, io.Reader) error { return nil })
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "chembl_id") {
|
||||||
|
t.Errorf("err = %v, want missing-column error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scanned bytes are reused by bufio; the shard buffer must copy them, or a
|
||||||
|
// later row would corrupt an earlier one. This guards that copy.
|
||||||
|
func TestSplitDoesNotAliasScannerBuffer(t *testing.T) {
|
||||||
|
var got bytes.Buffer
|
||||||
|
_ = SplitTSV(strings.NewReader("h\naaaa\nbbbb\n"), 2, func(_ int, shard io.Reader) error {
|
||||||
|
_, _ = io.Copy(&got, shard)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if want := "h\naaaa\nbbbb\n"; got.String() != want {
|
||||||
|
t.Errorf("got %q, want %q", got.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArtifactKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactInput ArtifactKind = "input"
|
||||||
|
ArtifactShard ArtifactKind = "shard"
|
||||||
|
ArtifactPartialResult ArtifactKind = "partial_result"
|
||||||
|
ArtifactFinalResult ArtifactKind = "final_result"
|
||||||
|
ArtifactLog ArtifactKind = "log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Artifact is a durable file the coordinator owns, described by its metadata.
|
||||||
|
// The bytes live in blob storage under StorageKey; this struct is what the
|
||||||
|
// database persists and what every other layer reasons about.
|
||||||
|
type Artifact struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
JobID uuid.UUID
|
||||||
|
TaskID *uuid.UUID // nil for a job-level input
|
||||||
|
Attempt *int // required for a partial result; nil for non-worker artifacts
|
||||||
|
Kind ArtifactKind
|
||||||
|
Filename string
|
||||||
|
StorageKey string
|
||||||
|
ContentType string
|
||||||
|
SizeBytes int64
|
||||||
|
SHA256 string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArtifact begins an artifact record. Size and checksum are unknown until the
|
||||||
|
// bytes have been streamed to storage, so they are filled in later by SetContent.
|
||||||
|
//
|
||||||
|
// StorageKey is derived from a fresh UUID, never from the client-supplied
|
||||||
|
// filename — that is what stops a "../../etc/passwd" filename from escaping the
|
||||||
|
// storage directory.
|
||||||
|
func NewArtifact(jobID uuid.UUID, taskID *uuid.UUID, kind ArtifactKind,
|
||||||
|
filename, contentType string, now time.Time) (*Artifact, error) {
|
||||||
|
|
||||||
|
if filename == "" || kind == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
id := uuid.New()
|
||||||
|
return &Artifact{
|
||||||
|
ID: id,
|
||||||
|
JobID: jobID,
|
||||||
|
TaskID: taskID,
|
||||||
|
Kind: kind,
|
||||||
|
Filename: filename,
|
||||||
|
StorageKey: id.String(),
|
||||||
|
ContentType: contentType,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContent records the size and checksum measured while streaming the bytes
|
||||||
|
// into storage. Both are computed by the coordinator, never trusted from the
|
||||||
|
// client — the whole point of owning the artifact.
|
||||||
|
func (a *Artifact) SetContent(sha256 string, size int64) {
|
||||||
|
a.SHA256 = sha256
|
||||||
|
a.SizeBytes = size
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewArtifact(t *testing.T) {
|
||||||
|
jobID := uuid.New()
|
||||||
|
taskID := uuid.New()
|
||||||
|
a, err := NewArtifact(jobID, &taskID, ArtifactPartialResult, "result.csv", "text/csv", testNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.JobID != jobID || a.TaskID == nil || *a.TaskID != taskID {
|
||||||
|
t.Error("ownership not recorded")
|
||||||
|
}
|
||||||
|
// Storage key is derived from the artifact id, never the filename — no path
|
||||||
|
// traversal from a hostile "../.." name.
|
||||||
|
if a.StorageKey != a.ID.String() {
|
||||||
|
t.Errorf("storage key = %q, want the artifact id", a.StorageKey)
|
||||||
|
}
|
||||||
|
if a.SizeBytes != 0 || a.SHA256 != "" {
|
||||||
|
t.Error("size and checksum are unknown until SetContent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewArtifactDefaultsContentType(t *testing.T) {
|
||||||
|
a, err := NewArtifact(uuid.New(), nil, ArtifactInput, "data", "", testNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.ContentType != "application/octet-stream" {
|
||||||
|
t.Errorf("content type = %q, want the default", a.ContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewArtifactRejectsBadInput(t *testing.T) {
|
||||||
|
if _, err := NewArtifact(uuid.New(), nil, ArtifactInput, "", "text/csv", testNow); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("empty filename: err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
if _, err := NewArtifact(uuid.New(), nil, "", "f", "text/csv", testNow); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("empty kind: err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactSetContent(t *testing.T) {
|
||||||
|
a, _ := NewArtifact(uuid.New(), nil, ArtifactShard, "shard-0.tsv", "text/csv", testNow)
|
||||||
|
a.SetContent("deadbeef", 42)
|
||||||
|
if a.SHA256 != "deadbeef" || a.SizeBytes != 42 {
|
||||||
|
t.Error("SetContent must record checksum and size")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Business-rule violations. They live in the innermost layer because they
|
||||||
|
// describe what the rules are, not how a transport reports them: the HTTP
|
||||||
|
// adapter maps these to status codes, and nothing here knows 409 exists.
|
||||||
|
//
|
||||||
|
// Always compare with errors.Is — outer layers may wrap these with %w.
|
||||||
|
var (
|
||||||
|
ErrJobNotFound = errors.New("job not found")
|
||||||
|
ErrTaskNotFound = errors.New("task not found")
|
||||||
|
ErrWorkerNotFound = errors.New("worker not found")
|
||||||
|
ErrArtifactNotFound = errors.New("artifact not found")
|
||||||
|
ErrJobNotCancellable = errors.New("job cannot be cancelled")
|
||||||
|
ErrLeaseConflict = errors.New("task leased to another worker")
|
||||||
|
ErrStaleAttempt = errors.New("attempt does not match lease")
|
||||||
|
ErrResultConflict = errors.New("different result already recorded")
|
||||||
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
|
ErrTaskNotLeased = errors.New("task is not currently leased")
|
||||||
|
)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
JobPending JobStatus = "pending"
|
||||||
|
JobRunning JobStatus = "running"
|
||||||
|
JobReducing JobStatus = "reducing"
|
||||||
|
JobCompleted JobStatus = "completed"
|
||||||
|
JobFailed JobStatus = "failed"
|
||||||
|
JobCancelled JobStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Job is one user submission that fans out into one or more tasks.
|
||||||
|
type Job struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
// OwnerID is the userservice user who submitted the job (JWT `sub`). nil
|
||||||
|
// when the job was created without user authentication. Not a foreign key:
|
||||||
|
// users live in a separate service/database.
|
||||||
|
OwnerID *uuid.UUID
|
||||||
|
Workload string
|
||||||
|
InputURI string // external input URI; empty for uploaded datasets
|
||||||
|
InputArtifactID *uuid.UUID // uploaded input artifact; nil for URI submissions
|
||||||
|
ResultArtifactID *uuid.UUID
|
||||||
|
Parameters map[string]any
|
||||||
|
Status JobStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
ReducerStartedAt *time.Time
|
||||||
|
ErrorCode *string
|
||||||
|
ErrorMessage *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUploadedJob builds a job whose input was uploaded to the coordinator. The
|
||||||
|
// job's id is generated here so the input artifact can reference it; the reverse
|
||||||
|
// link (jobs.input_artifact_id) is left unset — the input is found via the
|
||||||
|
// artifact's job_id — which also sidesteps the circular job↔artifact FK.
|
||||||
|
func NewUploadedJob(workload string, params map[string]any, now time.Time) (*Job, error) {
|
||||||
|
if workload == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
return &Job{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Workload: workload,
|
||||||
|
Parameters: params,
|
||||||
|
Status: JobPending,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkSpec describes one piece a job is split into. Callers build these from
|
||||||
|
// whatever chunking strategy the workload uses; the domain only validates them.
|
||||||
|
type ChunkSpec struct {
|
||||||
|
ChunkIndex int
|
||||||
|
Workload string // empty inherits the job's workload
|
||||||
|
InputURI string
|
||||||
|
InputSHA256 string
|
||||||
|
Parameters map[string]any
|
||||||
|
MaxAttempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJobWithTasks builds a job together with all of its tasks, validating the
|
||||||
|
// set as a whole. Returning both from one constructor keeps the invariant
|
||||||
|
// visible: a job without tasks, or with duplicate chunk indexes, cannot exist.
|
||||||
|
func NewJobWithTasks(workload, inputURI string, params map[string]any,
|
||||||
|
chunks []ChunkSpec, now time.Time) (*Job, []*Task, error) {
|
||||||
|
|
||||||
|
if workload == "" || inputURI == "" || len(chunks) == 0 {
|
||||||
|
return nil, nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
job := &Job{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Workload: workload,
|
||||||
|
InputURI: inputURI,
|
||||||
|
Parameters: params,
|
||||||
|
Status: JobPending,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[int]struct{}, len(chunks))
|
||||||
|
tasks := make([]*Task, 0, len(chunks))
|
||||||
|
for _, c := range chunks {
|
||||||
|
if _, dup := seen[c.ChunkIndex]; dup {
|
||||||
|
return nil, nil, ErrInvalidInput // unique (job_id, chunk_index)
|
||||||
|
}
|
||||||
|
seen[c.ChunkIndex] = struct{}{}
|
||||||
|
|
||||||
|
w := c.Workload
|
||||||
|
if w == "" {
|
||||||
|
w = workload
|
||||||
|
}
|
||||||
|
task, err := NewTask(job.ID, c.ChunkIndex, w, c.InputURI, c.InputSHA256,
|
||||||
|
c.Parameters, c.MaxAttempts, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
tasks = append(tasks, task)
|
||||||
|
}
|
||||||
|
return job, tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobProgress is the aggregate view of a job and the state of its tasks.
|
||||||
|
type JobProgress struct {
|
||||||
|
Job Job
|
||||||
|
Total int
|
||||||
|
Pending int
|
||||||
|
Leased int
|
||||||
|
Done int
|
||||||
|
Failed int
|
||||||
|
Cancelled int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveStatus computes what the job's status should be from its task counts,
|
||||||
|
// so the rule lives here rather than in a SQL trigger or a handler.
|
||||||
|
func (p JobProgress) DeriveStatus() JobStatus {
|
||||||
|
switch {
|
||||||
|
case p.Job.Status == JobCancelled:
|
||||||
|
return JobCancelled
|
||||||
|
case p.Job.Status == JobFailed:
|
||||||
|
// A reducer may fail after every shard has completed. That terminal
|
||||||
|
// failure must not be overwritten by an otherwise-complete task count.
|
||||||
|
return JobFailed
|
||||||
|
case p.Job.Status == JobReducing:
|
||||||
|
return JobReducing
|
||||||
|
case p.Total == 0:
|
||||||
|
return JobPending
|
||||||
|
case p.Done == p.Total:
|
||||||
|
return JobCompleted
|
||||||
|
case p.Failed > 0 && p.Done+p.Failed == p.Total:
|
||||||
|
return JobFailed
|
||||||
|
case p.Leased > 0 || p.Done > 0 || p.Failed > 0:
|
||||||
|
return JobRunning
|
||||||
|
default:
|
||||||
|
return JobPending
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewJobWithTasksBuildsBoth(t *testing.T) {
|
||||||
|
job, tasks, err := NewJobWithTasks("similarity_search", "s3://in", nil, []ChunkSpec{
|
||||||
|
{ChunkIndex: 0, InputURI: "s3://c0", InputSHA256: "a"},
|
||||||
|
{ChunkIndex: 1, InputURI: "s3://c1", InputSHA256: "b"},
|
||||||
|
}, testNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tasks) != 2 {
|
||||||
|
t.Fatalf("got %d tasks, want 2", len(tasks))
|
||||||
|
}
|
||||||
|
for _, tk := range tasks {
|
||||||
|
if tk.JobID != job.ID {
|
||||||
|
t.Error("task not linked to job")
|
||||||
|
}
|
||||||
|
if tk.Workload != "similarity_search" {
|
||||||
|
t.Error("task should inherit the job workload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if job.Status != JobPending {
|
||||||
|
t.Errorf("status = %q, want pending", job.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewJobWithTasksRejectsBadInput(t *testing.T) {
|
||||||
|
good := []ChunkSpec{{ChunkIndex: 0, InputURI: "s3://c0", InputSHA256: "a"}}
|
||||||
|
cases := map[string]struct {
|
||||||
|
workload string
|
||||||
|
inputURI string
|
||||||
|
chunks []ChunkSpec
|
||||||
|
}{
|
||||||
|
"empty workload": {"", "s3://in", good},
|
||||||
|
"empty input": {"w", "", good},
|
||||||
|
"no chunks": {"w", "s3://in", nil},
|
||||||
|
"duplicate index": {"w", "s3://in", []ChunkSpec{
|
||||||
|
{ChunkIndex: 0, InputURI: "a", InputSHA256: "x"},
|
||||||
|
{ChunkIndex: 0, InputURI: "b", InputSHA256: "y"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if _, _, err := NewJobWithTasks(c.workload, c.inputURI, nil, c.chunks, testNow); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewJobWithTasksInheritsAndOverridesWorkload(t *testing.T) {
|
||||||
|
_, tasks, err := NewJobWithTasks("base", "s3://in", nil, []ChunkSpec{
|
||||||
|
{ChunkIndex: 0, InputURI: "a", InputSHA256: "x"},
|
||||||
|
{ChunkIndex: 1, InputURI: "b", InputSHA256: "y", Workload: "special"},
|
||||||
|
}, testNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tasks[0].Workload != "base" || tasks[1].Workload != "special" {
|
||||||
|
t.Errorf("workloads = %q, %q", tasks[0].Workload, tasks[1].Workload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveStatus(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
p JobProgress
|
||||||
|
want JobStatus
|
||||||
|
}{
|
||||||
|
{"empty", JobProgress{Total: 0}, JobPending},
|
||||||
|
{"all pending", JobProgress{Total: 3, Pending: 3}, JobPending},
|
||||||
|
{"one leased", JobProgress{Total: 3, Pending: 2, Leased: 1}, JobRunning},
|
||||||
|
{"partly done", JobProgress{Total: 3, Pending: 1, Done: 2}, JobRunning},
|
||||||
|
{"all done", JobProgress{Total: 3, Done: 3}, JobCompleted},
|
||||||
|
{"done and failed", JobProgress{Total: 3, Done: 2, Failed: 1}, JobFailed},
|
||||||
|
{"failed but work remains", JobProgress{Total: 3, Pending: 1, Failed: 2}, JobRunning},
|
||||||
|
{"cancelled job wins over task histogram", JobProgress{Job: Job{Status: JobCancelled}, Total: 3, Done: 1, Cancelled: 2}, JobCancelled},
|
||||||
|
{"persisted reducer failure wins over completed tasks", JobProgress{Job: Job{Status: JobFailed}, Total: 3, Done: 3}, JobFailed},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := c.p.DeriveStatus(); got != c.want {
|
||||||
|
t.Errorf("DeriveStatus() = %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewUploadedJob(t *testing.T) {
|
||||||
|
job, err := NewUploadedJob("w", map[string]any{"k": 1}, testNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if job.Status != JobPending || job.InputURI != "" {
|
||||||
|
t.Error("uploaded job should be pending with no input URI")
|
||||||
|
}
|
||||||
|
if _, err := NewUploadedJob("", nil, testNow); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("empty workload: err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewShardTask(t *testing.T) {
|
||||||
|
art := uuid.New()
|
||||||
|
task, err := NewShardTask(uuid.New(), 2, "w", art, "sha", nil, 0, testNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task.InputArtifactID == nil || *task.InputArtifactID != art {
|
||||||
|
t.Error("shard task must reference its input artifact")
|
||||||
|
}
|
||||||
|
if task.InputURI != "" {
|
||||||
|
t.Error("shard task must not carry a URI")
|
||||||
|
}
|
||||||
|
if task.MaxAttempts != DefaultMaxAttempts {
|
||||||
|
t.Errorf("maxAttempts = %d, want default %d", task.MaxAttempts, DefaultMaxAttempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := []struct {
|
||||||
|
name string
|
||||||
|
art uuid.UUID
|
||||||
|
sha string
|
||||||
|
idx int
|
||||||
|
}{
|
||||||
|
{"nil artifact", uuid.Nil, "sha", 0},
|
||||||
|
{"empty sha", art, "", 0},
|
||||||
|
{"negative index", art, "sha", -1},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if _, err := NewShardTask(uuid.New(), c.idx, "w", c.art, c.sha, nil, 0, testNow); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// Package domain holds SciMesh's entities and the rules that govern them. It
|
||||||
|
// is the innermost layer: it imports nothing from this module and knows nothing
|
||||||
|
// about HTTP, SQL, or configuration. Every state transition a task can undergo
|
||||||
|
// is a method here, so the rules are unit-testable without a database.
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TaskStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskPending TaskStatus = "pending"
|
||||||
|
TaskLeased TaskStatus = "leased"
|
||||||
|
TaskRunning TaskStatus = "running"
|
||||||
|
TaskCompleted TaskStatus = "completed"
|
||||||
|
TaskFailed TaskStatus = "failed"
|
||||||
|
TaskCancelled TaskStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrCodeLeaseExpired marks tasks failed by the reaper rather than by a worker.
|
||||||
|
const ErrCodeLeaseExpired = "lease_expired"
|
||||||
|
|
||||||
|
// ErrCodeQuorumFailed marks a task whose untrusted results never reached a
|
||||||
|
// verifying quorum before its attempts ran out.
|
||||||
|
const ErrCodeQuorumFailed = "quorum_failed"
|
||||||
|
|
||||||
|
// Task is one independently executable chunk of a job.
|
||||||
|
//
|
||||||
|
// Nullable columns are pointers so "no lease" stays distinguishable from
|
||||||
|
// "lease owned by the empty string" — a plain string cannot express both.
|
||||||
|
type Task struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
JobID uuid.UUID
|
||||||
|
ChunkIndex int
|
||||||
|
Workload string
|
||||||
|
InputURI string // external input URI; empty for uploaded shards
|
||||||
|
InputArtifactID *uuid.UUID // coordinator-stored shard; nil for URI inputs
|
||||||
|
InputSHA256 string
|
||||||
|
Parameters map[string]any
|
||||||
|
Status TaskStatus
|
||||||
|
Attempt int
|
||||||
|
MaxAttempts int
|
||||||
|
LeaseOwner *string
|
||||||
|
LeaseExpiresAt *time.Time
|
||||||
|
ResultArtifactID *uuid.UUID
|
||||||
|
Metrics map[string]any
|
||||||
|
ErrorCode *string
|
||||||
|
ErrorMessage *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTask builds a pending task. maxAttempts <= 0 falls back to the default.
|
||||||
|
func NewTask(jobID uuid.UUID, chunkIndex int, workload, inputURI, inputSHA256 string,
|
||||||
|
params map[string]any, maxAttempts int, now time.Time) (*Task, error) {
|
||||||
|
|
||||||
|
if inputURI == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
if inputSHA256 == "" {
|
||||||
|
return nil, ErrInvalidInput // checksum is mandatory: workers verify inputs
|
||||||
|
}
|
||||||
|
if chunkIndex < 0 {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
if maxAttempts <= 0 {
|
||||||
|
maxAttempts = DefaultMaxAttempts
|
||||||
|
}
|
||||||
|
return &Task{
|
||||||
|
ID: uuid.New(),
|
||||||
|
JobID: jobID,
|
||||||
|
ChunkIndex: chunkIndex,
|
||||||
|
Workload: workload,
|
||||||
|
InputURI: inputURI,
|
||||||
|
InputSHA256: inputSHA256,
|
||||||
|
Parameters: params,
|
||||||
|
Status: TaskPending,
|
||||||
|
Attempt: 0,
|
||||||
|
MaxAttempts: maxAttempts,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewShardTask builds a pending task whose input is a coordinator-stored shard
|
||||||
|
// artifact rather than an external URI. The worker fetches it from the
|
||||||
|
// coordinator, so no InputURI is set — inputSHA256 is the shard's checksum.
|
||||||
|
func NewShardTask(jobID uuid.UUID, chunkIndex int, workload string, inputArtifactID uuid.UUID,
|
||||||
|
inputSHA256 string, params map[string]any, maxAttempts int, now time.Time) (*Task, error) {
|
||||||
|
|
||||||
|
if inputArtifactID == uuid.Nil || inputSHA256 == "" || chunkIndex < 0 {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
if maxAttempts <= 0 {
|
||||||
|
maxAttempts = DefaultMaxAttempts
|
||||||
|
}
|
||||||
|
return &Task{
|
||||||
|
ID: uuid.New(),
|
||||||
|
JobID: jobID,
|
||||||
|
ChunkIndex: chunkIndex,
|
||||||
|
Workload: workload,
|
||||||
|
InputArtifactID: &inputArtifactID,
|
||||||
|
InputSHA256: inputSHA256,
|
||||||
|
Parameters: params,
|
||||||
|
Status: TaskPending,
|
||||||
|
Attempt: 0,
|
||||||
|
MaxAttempts: maxAttempts,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMaxAttempts applies when a task does not specify its own ceiling.
|
||||||
|
const DefaultMaxAttempts = 3
|
||||||
|
|
||||||
|
// CanRetry reports whether any attempts remain.
|
||||||
|
func (t *Task) CanRetry() bool { return t.Attempt < t.MaxAttempts }
|
||||||
|
|
||||||
|
// IsLeaseHeldBy reports whether worker currently holds this task at attempt.
|
||||||
|
func (t *Task) IsLeaseHeldBy(worker string, attempt int, now time.Time) bool {
|
||||||
|
return t.LeaseOwner != nil && t.LeaseExpiresAt != nil && now.Before(*t.LeaseExpiresAt) &&
|
||||||
|
*t.LeaseOwner == worker && t.Attempt == attempt &&
|
||||||
|
(t.Status == TaskLeased || t.Status == TaskRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsClaimed projects the task into the trimmed view handed to a worker:
|
||||||
|
// everything needed to execute, nothing it has no business seeing.
|
||||||
|
func (t *Task) AsClaimed() ClaimedTask {
|
||||||
|
ct := ClaimedTask{
|
||||||
|
TaskID: t.ID,
|
||||||
|
JobID: t.JobID,
|
||||||
|
ChunkIndex: t.ChunkIndex,
|
||||||
|
Workload: t.Workload,
|
||||||
|
InputURI: t.InputURI,
|
||||||
|
InputArtifactID: t.InputArtifactID,
|
||||||
|
InputSHA256: t.InputSHA256,
|
||||||
|
Parameters: t.Parameters,
|
||||||
|
Attempt: t.Attempt,
|
||||||
|
}
|
||||||
|
if t.LeaseOwner != nil {
|
||||||
|
ct.LeaseOwner = *t.LeaseOwner
|
||||||
|
}
|
||||||
|
if t.LeaseExpiresAt != nil {
|
||||||
|
ct.LeaseExpiresAt = *t.LeaseExpiresAt
|
||||||
|
}
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyLease is the guard every worker-driven transition shares: the caller
|
||||||
|
// must own the lease and reference the attempt it was granted.
|
||||||
|
func (t *Task) verifyLease(worker string, attempt int, now time.Time) error {
|
||||||
|
// A task is worker-owned while leased or running: the first heartbeat moves
|
||||||
|
// it from leased to running, but ownership rules are identical for both.
|
||||||
|
if t.Status != TaskLeased && t.Status != TaskRunning {
|
||||||
|
return ErrTaskNotLeased
|
||||||
|
}
|
||||||
|
if t.LeaseOwner == nil || *t.LeaseOwner != worker {
|
||||||
|
return ErrLeaseConflict
|
||||||
|
}
|
||||||
|
if t.Attempt != attempt {
|
||||||
|
return ErrStaleAttempt
|
||||||
|
}
|
||||||
|
if t.LeaseExpiresAt == nil || !now.Before(*t.LeaseExpiresAt) {
|
||||||
|
return ErrLeaseConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewLease extends the lease of the worker that holds it. The first heartbeat
|
||||||
|
// also acknowledges start, moving the task from leased to running.
|
||||||
|
func (t *Task) RenewLease(worker string, attempt int, now, until time.Time) error {
|
||||||
|
if err := t.verifyLease(worker, attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.LeaseExpiresAt = &until
|
||||||
|
if t.Status == TaskLeased {
|
||||||
|
t.Status = TaskRunning
|
||||||
|
}
|
||||||
|
t.Version++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteWith records a successful result.
|
||||||
|
//
|
||||||
|
// Idempotency comes first deliberately: a worker whose network dropped will
|
||||||
|
// retry the same manifest, and that must succeed rather than trip the lease
|
||||||
|
// check on a task the coordinator already finished. A *different* manifest for
|
||||||
|
// an already-completed task is a genuine conflict.
|
||||||
|
func (t *Task) CompleteWith(resultArtifactID uuid.UUID, metrics map[string]any,
|
||||||
|
worker string, attempt int, now time.Time) error {
|
||||||
|
|
||||||
|
if resultArtifactID == uuid.Nil {
|
||||||
|
return ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Status == TaskCompleted {
|
||||||
|
if t.Attempt == attempt && t.ResultArtifactID != nil && *t.ResultArtifactID == resultArtifactID {
|
||||||
|
return nil // same attempt, same artifact — replay of a successful call
|
||||||
|
}
|
||||||
|
return ErrResultConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.verifyLease(worker, attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Status = TaskCompleted
|
||||||
|
t.ResultArtifactID = &resultArtifactID
|
||||||
|
t.Metrics = metrics
|
||||||
|
t.CompletedAt = &now
|
||||||
|
t.LeaseOwner = nil
|
||||||
|
t.LeaseExpiresAt = nil
|
||||||
|
t.ErrorCode = nil
|
||||||
|
t.ErrorMessage = nil
|
||||||
|
t.Version++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseAfterVote returns an untrusted worker's task to the queue after its
|
||||||
|
// result was recorded as a quorum vote but quorum was not yet reached, so a
|
||||||
|
// different owner can compute it independently. When no attempts remain the task
|
||||||
|
// fails: its untrusted results could not be verified.
|
||||||
|
func (t *Task) ReleaseAfterVote(worker string, attempt int, now time.Time) error {
|
||||||
|
if t.Status == TaskCompleted {
|
||||||
|
return nil // settled by a concurrent quorum
|
||||||
|
}
|
||||||
|
if err := t.verifyLease(worker, attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.LeaseOwner = nil
|
||||||
|
t.LeaseExpiresAt = nil
|
||||||
|
t.Version++
|
||||||
|
|
||||||
|
if t.CanRetry() {
|
||||||
|
t.Status = TaskPending
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code, msg := ErrCodeQuorumFailed, "untrusted results did not reach quorum"
|
||||||
|
t.ErrorCode = &code
|
||||||
|
t.ErrorMessage = &msg
|
||||||
|
t.Status = TaskFailed
|
||||||
|
t.CompletedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail records a worker-reported failure. A retryable failure with attempts
|
||||||
|
// left returns the task to the queue; otherwise it terminates as failed.
|
||||||
|
func (t *Task) Fail(worker string, attempt int, code, message string, retryable bool, now time.Time) error {
|
||||||
|
if err := t.verifyLease(worker, attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.ErrorCode = &code
|
||||||
|
t.ErrorMessage = &message
|
||||||
|
t.LeaseOwner = nil
|
||||||
|
t.LeaseExpiresAt = nil
|
||||||
|
t.Version++
|
||||||
|
|
||||||
|
if retryable && t.CanRetry() {
|
||||||
|
t.Status = TaskPending
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Status = TaskFailed
|
||||||
|
t.CompletedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpireLease is applied by the reaper when a lease elapses without a
|
||||||
|
// heartbeat: requeue while attempts remain, otherwise fail terminally.
|
||||||
|
func (t *Task) ExpireLease(now time.Time) {
|
||||||
|
// Both a leased and a running task can go silent and must be reclaimed.
|
||||||
|
if t.Status != TaskLeased && t.Status != TaskRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.LeaseOwner = nil
|
||||||
|
t.LeaseExpiresAt = nil
|
||||||
|
t.Version++
|
||||||
|
|
||||||
|
if t.CanRetry() {
|
||||||
|
t.Status = TaskPending
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, msg := ErrCodeLeaseExpired, "lease expired after the final attempt"
|
||||||
|
t.ErrorCode = &code
|
||||||
|
t.ErrorMessage = &msg
|
||||||
|
t.Status = TaskFailed
|
||||||
|
t.CompletedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel prevents any further worker transition for a task that has not
|
||||||
|
// reached a terminal result. A cancelled lease deliberately becomes invalid:
|
||||||
|
// a worker still running locally must not upload or complete after its job was
|
||||||
|
// stopped by the operator.
|
||||||
|
func (t *Task) Cancel(now time.Time) bool {
|
||||||
|
if t.Status == TaskCompleted || t.Status == TaskFailed || t.Status == TaskCancelled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.Status = TaskCancelled
|
||||||
|
t.LeaseOwner = nil
|
||||||
|
t.LeaseExpiresAt = nil
|
||||||
|
t.ErrorCode = nil
|
||||||
|
t.ErrorMessage = nil
|
||||||
|
t.CompletedAt = &now
|
||||||
|
t.Version++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimedTask is the worker-facing projection of a leased task. Input is either
|
||||||
|
// an external URI or a coordinator-stored shard (InputArtifactID set); the
|
||||||
|
// transport turns the latter into a coordinator download URL.
|
||||||
|
type ClaimedTask struct {
|
||||||
|
TaskID uuid.UUID
|
||||||
|
JobID uuid.UUID
|
||||||
|
ChunkIndex int
|
||||||
|
Workload string
|
||||||
|
InputURI string
|
||||||
|
InputArtifactID *uuid.UUID
|
||||||
|
InputSHA256 string
|
||||||
|
Parameters map[string]any
|
||||||
|
Attempt int
|
||||||
|
LeaseOwner string
|
||||||
|
LeaseExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResultManifest is a completed task's output, ordered for the stitcher. It
|
||||||
|
// points at the coordinator-owned result artifact rather than a worker URI.
|
||||||
|
type ResultManifest struct {
|
||||||
|
TaskID uuid.UUID
|
||||||
|
ChunkIndex int
|
||||||
|
ResultArtifactID uuid.UUID
|
||||||
|
Metrics map[string]any
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testNow = time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
testLater = testNow.Add(time.Hour)
|
||||||
|
testWorker = "worker-1"
|
||||||
|
testResult = uuid.New()
|
||||||
|
testResultAlt = uuid.New()
|
||||||
|
)
|
||||||
|
|
||||||
|
// leasedTask builds a task already leased to testWorker at the given attempt.
|
||||||
|
func leasedTask(attempt, maxAttempts int) *Task {
|
||||||
|
owner := testWorker
|
||||||
|
expires := testLater
|
||||||
|
return &Task{
|
||||||
|
ID: uuid.New(),
|
||||||
|
JobID: uuid.New(),
|
||||||
|
Status: TaskLeased,
|
||||||
|
Attempt: attempt,
|
||||||
|
MaxAttempts: maxAttempts,
|
||||||
|
LeaseOwner: &owner,
|
||||||
|
LeaseExpiresAt: &expires,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteWithRecordsResult(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
|
||||||
|
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != TaskCompleted {
|
||||||
|
t.Errorf("status = %q, want completed", task.Status)
|
||||||
|
}
|
||||||
|
if task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
|
||||||
|
t.Error("lease must be released on completion")
|
||||||
|
}
|
||||||
|
if task.CompletedAt == nil || !task.CompletedAt.Equal(testNow) {
|
||||||
|
t.Error("completed_at must be stamped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A worker whose network dropped retries the same manifest; that must succeed
|
||||||
|
// rather than fail on the lease it has already given up.
|
||||||
|
func TestCompleteWithIsIdempotentForSameManifest(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
|
||||||
|
t.Fatalf("first call: %v", err)
|
||||||
|
}
|
||||||
|
versionAfterFirst := task.Version
|
||||||
|
|
||||||
|
if err := task.CompleteWith(testResult, nil, testWorker, 1, testLater); err != nil {
|
||||||
|
t.Fatalf("replay must be idempotent, got %v", err)
|
||||||
|
}
|
||||||
|
if task.Version != versionAfterFirst {
|
||||||
|
t.Error("replay must not mutate the task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteWithRejectsDifferentManifest(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
|
||||||
|
t.Fatalf("first call: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := task.CompleteWith(testResultAlt, nil, testWorker, 1, testLater)
|
||||||
|
if !errors.Is(err, ErrResultConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrResultConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteWithRejectsForeignWorker(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
|
||||||
|
err := task.CompleteWith(testResult, nil, "worker-2", 1, testNow)
|
||||||
|
if !errors.Is(err, ErrLeaseConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrLeaseConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteWithRejectsStaleAttempt(t *testing.T) {
|
||||||
|
task := leasedTask(2, 3) // task is on attempt 2
|
||||||
|
|
||||||
|
err := task.CompleteWith(testResult, nil, testWorker, 1, testNow) // worker thinks it is 1
|
||||||
|
if !errors.Is(err, ErrStaleAttempt) {
|
||||||
|
t.Errorf("err = %v, want ErrStaleAttempt", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailRequeuesWhileAttemptsRemain(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
|
||||||
|
if err := task.Fail(testWorker, 1, "boom", "exploded", true, testNow); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != TaskPending {
|
||||||
|
t.Errorf("status = %q, want pending", task.Status)
|
||||||
|
}
|
||||||
|
if task.LeaseOwner != nil {
|
||||||
|
t.Error("lease must be released so another worker can claim it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailTerminatesOnFinalAttempt(t *testing.T) {
|
||||||
|
task := leasedTask(3, 3) // no attempts left
|
||||||
|
|
||||||
|
if err := task.Fail(testWorker, 3, "boom", "exploded", true, testNow); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != TaskFailed {
|
||||||
|
t.Errorf("status = %q, want failed", task.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailIsTerminalWhenNotRetryable(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3) // attempts remain, but the error is fatal
|
||||||
|
|
||||||
|
if err := task.Fail(testWorker, 1, "bad_input", "checksum mismatch", false, testNow); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != TaskFailed {
|
||||||
|
t.Errorf("status = %q, want failed", task.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the MVP acceptance criterion: a dead worker must not strand its task.
|
||||||
|
func TestExpireLeaseRequeuesWhileAttemptsRemain(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
|
||||||
|
task.ExpireLease(testNow)
|
||||||
|
|
||||||
|
if task.Status != TaskPending {
|
||||||
|
t.Errorf("status = %q, want pending", task.Status)
|
||||||
|
}
|
||||||
|
if task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
|
||||||
|
t.Error("expired lease must be cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpireLeaseFailsAfterFinalAttempt(t *testing.T) {
|
||||||
|
task := leasedTask(3, 3)
|
||||||
|
|
||||||
|
task.ExpireLease(testNow)
|
||||||
|
|
||||||
|
if task.Status != TaskFailed {
|
||||||
|
t.Errorf("status = %q, want failed", task.Status)
|
||||||
|
}
|
||||||
|
if task.ErrorCode == nil || *task.ErrorCode != ErrCodeLeaseExpired {
|
||||||
|
t.Error("expected a lease_expired error code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpireLeaseIgnoresUnleasedTasks(t *testing.T) {
|
||||||
|
task := &Task{Status: TaskCompleted, Attempt: 1, MaxAttempts: 3}
|
||||||
|
|
||||||
|
task.ExpireLease(testNow)
|
||||||
|
|
||||||
|
if task.Status != TaskCompleted {
|
||||||
|
t.Errorf("status = %q, completed tasks must be untouched", task.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelInvalidatesLeaseButPreservesTerminalTask(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
if !task.Cancel(testNow) {
|
||||||
|
t.Fatal("leased task should be cancelled")
|
||||||
|
}
|
||||||
|
if task.Status != TaskCancelled || task.LeaseOwner != nil || task.LeaseExpiresAt != nil {
|
||||||
|
t.Errorf("cancelled task = %+v", task)
|
||||||
|
}
|
||||||
|
if task.Cancel(testLater) {
|
||||||
|
t.Error("cancelled task must not be changed twice")
|
||||||
|
}
|
||||||
|
completed := &Task{Status: TaskCompleted}
|
||||||
|
if completed.Cancel(testNow) {
|
||||||
|
t.Error("completed task must remain terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstHeartbeatMovesLeasedToRunning(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
until := testLater.Add(time.Hour)
|
||||||
|
|
||||||
|
if err := task.RenewLease(testWorker, 1, testNow, until); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task.Status != TaskRunning {
|
||||||
|
t.Errorf("status = %q, want running after first heartbeat", task.Status)
|
||||||
|
}
|
||||||
|
// A second heartbeat keeps it running.
|
||||||
|
if err := task.RenewLease(testWorker, 1, testNow, until); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task.Status != TaskRunning {
|
||||||
|
t.Errorf("status = %q, want running", task.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunningTaskCanBeCompletedAndExpired(t *testing.T) {
|
||||||
|
// Complete works from running.
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
_ = task.RenewLease(testWorker, 1, testNow, testLater) // -> running
|
||||||
|
if err := task.CompleteWith(testResult, nil, testWorker, 1, testNow); err != nil {
|
||||||
|
t.Errorf("complete from running: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire reclaims a running task too.
|
||||||
|
task2 := leasedTask(1, 3)
|
||||||
|
_ = task2.RenewLease(testWorker, 1, testNow, testLater) // -> running
|
||||||
|
task2.ExpireLease(testLater)
|
||||||
|
if task2.Status != TaskPending {
|
||||||
|
t.Errorf("status = %q, want pending after a running lease expires", task2.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenewLeaseExtendsOnlyForHolder(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
until := testLater.Add(time.Hour)
|
||||||
|
|
||||||
|
if err := task.RenewLease(testWorker, 1, testNow, until); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !task.LeaseExpiresAt.Equal(until) {
|
||||||
|
t.Error("lease must be extended")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := task.RenewLease("worker-2", 1, testNow, until); !errors.Is(err, ErrLeaseConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrLeaseConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredLeaseRejectsRenewalCompletionAndFailure(t *testing.T) {
|
||||||
|
task := leasedTask(1, 3)
|
||||||
|
expired := testLater.Add(time.Nanosecond)
|
||||||
|
|
||||||
|
if err := task.RenewLease(testWorker, 1, expired, expired.Add(time.Minute)); !errors.Is(err, ErrLeaseConflict) {
|
||||||
|
t.Errorf("renew expired lease: err = %v, want ErrLeaseConflict", err)
|
||||||
|
}
|
||||||
|
if err := task.CompleteWith(testResult, nil, testWorker, 1, expired); !errors.Is(err, ErrLeaseConflict) {
|
||||||
|
t.Errorf("complete expired lease: err = %v, want ErrLeaseConflict", err)
|
||||||
|
}
|
||||||
|
if err := task.Fail(testWorker, 1, "timeout", "expired", true, expired); !errors.Is(err, ErrLeaseConflict) {
|
||||||
|
t.Errorf("fail expired lease: err = %v, want ErrLeaseConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkerStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WorkerOnline WorkerStatus = "online"
|
||||||
|
WorkerBusy WorkerStatus = "busy"
|
||||||
|
WorkerOffline WorkerStatus = "offline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkerTrust says whether a worker's results are accepted directly or must
|
||||||
|
// clear quorum cross-checking.
|
||||||
|
type WorkerTrust string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// WorkerTrusted — lab machine (shared token) or a verified/admin contributor.
|
||||||
|
WorkerTrusted WorkerTrust = "trusted"
|
||||||
|
// WorkerUntrusted — a plain enthusiast; results are quarantined until quorum.
|
||||||
|
WorkerUntrusted WorkerTrust = "untrusted"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Worker is a registered process/machine allowed to claim tasks. Its
|
||||||
|
// capabilities are the allowlisted workload names it can run; the coordinator
|
||||||
|
// never hands it a task outside that set.
|
||||||
|
type Worker struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
Capabilities []string
|
||||||
|
Status WorkerStatus
|
||||||
|
// OwnerID is the userservice user who registered this worker; nil for a
|
||||||
|
// worker registered with the shared service token.
|
||||||
|
OwnerID *uuid.UUID
|
||||||
|
// TrustLevel decides whether this worker's results need quorum.
|
||||||
|
TrustLevel WorkerTrust
|
||||||
|
LastHeartbeatAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorker registers a worker. A worker with no capabilities could never be
|
||||||
|
// handed a task, so an empty set is rejected rather than silently stored.
|
||||||
|
//
|
||||||
|
// Trust defaults to WorkerTrusted (the shared-token lab worker); the caller
|
||||||
|
// overrides it for a volunteer registered through the userservice.
|
||||||
|
func NewWorker(name string, capabilities []string, now time.Time) (*Worker, error) {
|
||||||
|
if len(capabilities) == 0 {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
return &Worker{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: name,
|
||||||
|
Capabilities: capabilities,
|
||||||
|
Status: WorkerOnline,
|
||||||
|
TrustLevel: WorkerTrusted,
|
||||||
|
LastHeartbeatAt: now,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWorker(t *testing.T) {
|
||||||
|
w, err := NewWorker("lab-01", []string{"similarity_search"}, testNow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Status != WorkerOnline {
|
||||||
|
t.Errorf("status = %q, want online", w.Status)
|
||||||
|
}
|
||||||
|
if w.ID.String() == "" {
|
||||||
|
t.Error("worker must get an id")
|
||||||
|
}
|
||||||
|
if !w.LastHeartbeatAt.Equal(testNow) || !w.CreatedAt.Equal(testNow) {
|
||||||
|
t.Error("timestamps must be stamped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWorkerRejectsNoCapabilities(t *testing.T) {
|
||||||
|
if _, err := NewWorker("lab-01", nil, testNow); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
if _, err := NewWorker("lab-01", []string{}, testNow); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Errorf("empty slice: err = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Clock: the real implementation of the usecase.Clock port. It lives out here
|
||||||
|
// because reading the system clock is infrastructure; tests substitute a fixed one.
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type System struct{}
|
||||||
|
|
||||||
|
func NewClock() System { return System{} }
|
||||||
|
|
||||||
|
// Now returns UTC so every timestamp the coordinator writes is comparable
|
||||||
|
// regardless of the host's timezone.
|
||||||
|
func (System) Now() time.Time { return time.Now().UTC() }
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Config: coordinator settings, read only from the environment, so the same
|
||||||
|
// binary behaves identically in CI, local, and prod.
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultEnvFile is loaded by Load unless ENV_FILE points elsewhere.
|
||||||
|
const defaultEnvFile = ".env"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
// HTTP listen address, e.g. ":8080".
|
||||||
|
Addr string
|
||||||
|
// PostgreSQL connection string (pgx format / libpq URL).
|
||||||
|
DatabaseURL string
|
||||||
|
// Shared bearer token workers must present. Empty disables auth (dev only).
|
||||||
|
Token string
|
||||||
|
// Local operator UI credential. Empty disables the embedded UI entirely.
|
||||||
|
UIToken string
|
||||||
|
// Shared HS256 secret used to verify userservice-issued JWTs. When set, a
|
||||||
|
// submitter may authenticate with a JWT (in addition to workers using the
|
||||||
|
// shared token) and their jobs are stamped with owner_id. Empty disables
|
||||||
|
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
|
||||||
|
// userservice's JWT_SECRET.
|
||||||
|
JWTSecret string
|
||||||
|
// Base URL of the userservice, e.g. http://userservice:8081. When set
|
||||||
|
// together with JWTSecret, the operator UI authenticates via userservice
|
||||||
|
// login/registration (cookie session) instead of the static UI_AUTH_TOKEN
|
||||||
|
// basic auth. Empty keeps the basic-auth UI.
|
||||||
|
UserserviceURL string
|
||||||
|
// Browser-facing base URLs used to render the "add your machine" command on
|
||||||
|
// the UI. They must be reachable from a user's own machine, which is not
|
||||||
|
// necessarily the in-cluster address the coordinator uses for UserserviceURL.
|
||||||
|
// PublicCoordinatorURL empty lets the page fall back to its own origin;
|
||||||
|
// PublicUserserviceURL empty falls back to UserserviceURL.
|
||||||
|
PublicCoordinatorURL string
|
||||||
|
PublicUserserviceURL string
|
||||||
|
|
||||||
|
// Minimum log level: debug, info, warn, error.
|
||||||
|
LogLevel string
|
||||||
|
// Path to a rotated log file. Empty logs to stdout only.
|
||||||
|
LogFile string
|
||||||
|
// Directory where artifact bytes are stored.
|
||||||
|
StorageDir string
|
||||||
|
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
|
||||||
|
// disables the docs route; the UI shows a hint page instead.
|
||||||
|
DocsDir string
|
||||||
|
// Upper bound on an uploaded dataset or artifact body, in bytes.
|
||||||
|
MaxUploadBytes int64
|
||||||
|
|
||||||
|
// Connection pool upper bound.
|
||||||
|
DBMaxConns int32
|
||||||
|
// How long to keep retrying the initial database connection at startup
|
||||||
|
// before giving up. Covers a Postgres container that is still booting.
|
||||||
|
DBConnectTimeout time.Duration
|
||||||
|
// Per-request context timeout applied to handlers and DB calls.
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
|
||||||
|
// Suggested heartbeat cadence returned to workers on registration.
|
||||||
|
HeartbeatInterval time.Duration
|
||||||
|
// Default lease length handed out on claim.
|
||||||
|
LeaseDuration time.Duration
|
||||||
|
// Default attempt ceiling for newly created tasks.
|
||||||
|
DefaultMaxAttempts int
|
||||||
|
// How many distinct owners must agree on an untrusted result before it is
|
||||||
|
// accepted (trusted workers are accepted directly).
|
||||||
|
QuorumSize int
|
||||||
|
// How often the background lease-reaper runs.
|
||||||
|
ReaperInterval time.Duration
|
||||||
|
// A worker silent for longer than this is marked offline by the reaper.
|
||||||
|
WorkerOfflineAfter time.Duration
|
||||||
|
// Whether the binary applies its embedded schema migrations on startup.
|
||||||
|
// On by default so a downloaded binary provisions its own database; set
|
||||||
|
// AUTO_MIGRATE=false when an operator manages migrations out of band.
|
||||||
|
AutoMigrate bool
|
||||||
|
// DatabaseEngine selects the storage backend: "sqlite" (embedded, the
|
||||||
|
// single-binary default) or "postgres" (cluster deployments). The
|
||||||
|
// postgres engine requires DATABASE_URL.
|
||||||
|
DatabaseEngine string
|
||||||
|
// DBPath is the sqlite database file (engine=sqlite only).
|
||||||
|
DBPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the environment and fails fast on anything required-but-missing
|
||||||
|
// or malformed, so a misconfigured process never limps along half-wired.
|
||||||
|
//
|
||||||
|
// A .env file (path overridable via ENV_FILE) is loaded first as a local-dev
|
||||||
|
// convenience. It only fills variables the environment does not already define.
|
||||||
|
func LoadConfig() (Config, error) {
|
||||||
|
envFile := os.Getenv("ENV_FILE")
|
||||||
|
if envFile == "" {
|
||||||
|
envFile = defaultEnvFile
|
||||||
|
}
|
||||||
|
// godotenv.Load never overwrites variables already present in the
|
||||||
|
// environment, so an orchestrator's values always beat the file. A missing
|
||||||
|
// file is expected in production, where env vars are injected directly.
|
||||||
|
if err := godotenv.Load(envFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return Config{}, fmt.Errorf("load env file %q: %w", envFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Addr: getEnv("COORDINATOR_ADDR", ":8080"),
|
||||||
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
|
// COORDINATOR_TOKEN is the contract name; WORKER_AUTH_TOKEN is the
|
||||||
|
// former name, still honoured so existing .env files keep working.
|
||||||
|
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
|
||||||
|
UIToken: os.Getenv("UI_AUTH_TOKEN"),
|
||||||
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
UserserviceURL: os.Getenv("USERSERVICE_URL"),
|
||||||
|
PublicCoordinatorURL: os.Getenv("PUBLIC_COORDINATOR_URL"),
|
||||||
|
PublicUserserviceURL: getEnv("PUBLIC_USERSERVICE_URL", os.Getenv("USERSERVICE_URL")),
|
||||||
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
|
LogFile: os.Getenv("LOG_FILE"),
|
||||||
|
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||||
|
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
|
||||||
|
MaxUploadBytes: 1 << 30, // 1 GiB
|
||||||
|
DBMaxConns: 10,
|
||||||
|
DBConnectTimeout: 30 * time.Second,
|
||||||
|
RequestTimeout: 15 * time.Second,
|
||||||
|
HeartbeatInterval: 15 * time.Second,
|
||||||
|
LeaseDuration: 2 * time.Minute,
|
||||||
|
DefaultMaxAttempts: 3,
|
||||||
|
QuorumSize: 2,
|
||||||
|
ReaperInterval: 30 * time.Second,
|
||||||
|
WorkerOfflineAfter: 1 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DatabaseEngine = getEnv("SCIMESH_DB", "sqlite")
|
||||||
|
switch cfg.DatabaseEngine {
|
||||||
|
case "sqlite", "postgres":
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("SCIMESH_DB must be sqlite or postgres")
|
||||||
|
}
|
||||||
|
cfg.DBPath = getEnv("SCIMESH_DB_PATH", filepath.Join(cfg.StorageDir, "scimesh.db"))
|
||||||
|
|
||||||
|
if cfg.DatabaseEngine == "postgres" && cfg.DatabaseURL == "" {
|
||||||
|
return Config{}, fmt.Errorf("DATABASE_URL is required for the postgres engine")
|
||||||
|
}
|
||||||
|
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
|
||||||
|
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
|
||||||
|
}
|
||||||
|
// A short secret makes the HMAC brute-forceable; refuse a weak one rather
|
||||||
|
// than verify tokens against it.
|
||||||
|
if cfg.JWTSecret != "" && len(cfg.JWTSecret) < 32 {
|
||||||
|
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.DBConnectTimeout, err = getEnvDuration("DB_CONNECT_TIMEOUT", cfg.DBConnectTimeout); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.MaxUploadBytes, err = getEnvInt64("MAX_UPLOAD_BYTES", cfg.MaxUploadBytes); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.RequestTimeout, err = getEnvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.HeartbeatInterval, err = getEnvDuration("HEARTBEAT_INTERVAL", cfg.HeartbeatInterval); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.LeaseDuration, err = getEnvDuration("LEASE_DURATION", cfg.LeaseDuration); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.ReaperInterval, err = getEnvDuration("REAPER_INTERVAL", cfg.ReaperInterval); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.WorkerOfflineAfter, err = getEnvDuration("WORKER_OFFLINE_AFTER", cfg.WorkerOfflineAfter); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.DefaultMaxAttempts, err = getEnvInt("DEFAULT_MAX_ATTEMPTS", cfg.DefaultMaxAttempts); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.QuorumSize, err = getEnvInt("QUORUM_SIZE", cfg.QuorumSize); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.QuorumSize < 1 {
|
||||||
|
return Config{}, fmt.Errorf("QUORUM_SIZE must be positive")
|
||||||
|
}
|
||||||
|
if cfg.DefaultMaxAttempts < 1 {
|
||||||
|
return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive")
|
||||||
|
}
|
||||||
|
cfg.AutoMigrate = true
|
||||||
|
if raw := os.Getenv("AUTO_MIGRATE"); raw != "" {
|
||||||
|
parsed, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("AUTO_MIGRATE must be true or false")
|
||||||
|
}
|
||||||
|
cfg.AutoMigrate = parsed
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, def int) (int, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt32(key string, def int32) (int32, error) {
|
||||||
|
n, err := getEnvInt(key, int(def))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// On 64-bit builds int is wider than int32, so an oversized value would
|
||||||
|
// wrap silently — DB_MAX_CONNS=2147483648 becoming a negative pool size.
|
||||||
|
if n < math.MinInt32 || n > math.MaxInt32 {
|
||||||
|
return 0, fmt.Errorf("%s: %d is out of range for int32", key, n)
|
||||||
|
}
|
||||||
|
return int32(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt64(key string, def int64) (int64, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvDuration(key string, def time.Duration) (time.Duration, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfigRejectsSharedUIAndWorkerToken(t *testing.T) {
|
||||||
|
t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "missing.env"))
|
||||||
|
t.Setenv("DATABASE_URL", "postgres://test")
|
||||||
|
t.Setenv("COORDINATOR_TOKEN", "shared-secret")
|
||||||
|
t.Setenv("UI_AUTH_TOKEN", "shared-secret")
|
||||||
|
|
||||||
|
_, err := LoadConfig()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "must differ") {
|
||||||
|
t.Fatalf("LoadConfig error = %v, want distinct-token error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigAllowsDistinctUIAndWorkerTokens(t *testing.T) {
|
||||||
|
t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "missing.env"))
|
||||||
|
t.Setenv("DATABASE_URL", "postgres://test")
|
||||||
|
t.Setenv("COORDINATOR_TOKEN", "worker-secret")
|
||||||
|
t.Setenv("UI_AUTH_TOKEN", "ui-secret")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Token != "worker-secret" || cfg.UIToken != "ui-secret" {
|
||||||
|
t.Fatalf("unexpected tokens: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigRejectsNonPositiveDefaultMaxAttempts(t *testing.T) {
|
||||||
|
t.Setenv("ENV_FILE", filepath.Join(t.TempDir(), "missing.env"))
|
||||||
|
t.Setenv("DATABASE_URL", "postgres://test")
|
||||||
|
t.Setenv("DEFAULT_MAX_ATTEMPTS", "0")
|
||||||
|
|
||||||
|
_, err := LoadConfig()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "DEFAULT_MAX_ATTEMPTS") {
|
||||||
|
t.Fatalf("LoadConfig error = %v, want default-attempt validation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// DB: the PostgreSQL connection pool.
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPool builds the single shared pool. The caller owns its lifetime and must
|
||||||
|
// Close() it on shutdown.
|
||||||
|
func NewPool(ctx context.Context, cfg Config, log *slog.Logger) (*pgxpool.Pool, error) {
|
||||||
|
poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
poolCfg.MaxConns = cfg.DBMaxConns
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// pgxpool.New is lazy, so a ping is needed to actually reach the server.
|
||||||
|
// It is retried because at startup — especially under docker-compose, where
|
||||||
|
// the coordinator can boot before Postgres is accepting connections — a
|
||||||
|
// service should wait for its database rather than crash-loop.
|
||||||
|
if err := pingWithRetry(ctx, pool, cfg.DBConnectTimeout, log); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pingWithRetry waits for the database to accept connections, backing off
|
||||||
|
// between attempts until the budget elapses or ctx is cancelled.
|
||||||
|
//
|
||||||
|
// Unlike the transaction retry in storage/postgres, this retries *any* ping
|
||||||
|
// error: at startup a "connection refused" is the expected, retryable state,
|
||||||
|
// not an anomaly.
|
||||||
|
func pingWithRetry(ctx context.Context, pool *pgxpool.Pool, budget time.Duration, log *slog.Logger) error {
|
||||||
|
b := backoff.NewExponentialBackOff()
|
||||||
|
b.InitialInterval = 200 * time.Millisecond
|
||||||
|
b.MaxInterval = 3 * time.Second
|
||||||
|
b.MaxElapsedTime = budget
|
||||||
|
|
||||||
|
attempt := 0
|
||||||
|
return backoff.RetryNotify(
|
||||||
|
func() error {
|
||||||
|
// A bounded per-attempt timeout so one hung dial cannot eat the
|
||||||
|
// whole budget in a single try.
|
||||||
|
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return pool.Ping(pingCtx)
|
||||||
|
},
|
||||||
|
backoff.WithContext(b, ctx),
|
||||||
|
func(err error, next time.Duration) {
|
||||||
|
attempt++
|
||||||
|
log.Warn("database not ready, retrying",
|
||||||
|
"attempt", attempt, "retry_in", next.String(), "err", err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLogger builds the process logger.
|
||||||
|
//
|
||||||
|
// It always writes JSON to stdout, so `docker logs` and any 12-factor log
|
||||||
|
// collector keep working. When LogFile is set it *also* writes to a
|
||||||
|
// size-rotated file, so logs survive a container rebuild instead of vanishing
|
||||||
|
// with the previous stdout stream. Rotation is delegated to lumberjack rather
|
||||||
|
// than hand-rolled.
|
||||||
|
//
|
||||||
|
// The returned Closer flushes and closes the file; call it on shutdown.
|
||||||
|
func NewLogger(cfg Config) (*slog.Logger, io.Closer, error) {
|
||||||
|
opts := &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}
|
||||||
|
|
||||||
|
var (
|
||||||
|
out io.Writer = os.Stdout
|
||||||
|
closer io.Closer = noopCloser{}
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.LogFile != "" {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0o750); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("create log directory: %w", err)
|
||||||
|
}
|
||||||
|
rotator := &lumberjack.Logger{
|
||||||
|
Filename: cfg.LogFile,
|
||||||
|
MaxSize: 50, // megabytes before a rotation
|
||||||
|
MaxBackups: 5, // keep this many rotated files
|
||||||
|
MaxAge: 30, // days
|
||||||
|
Compress: true,
|
||||||
|
}
|
||||||
|
// Tee to both: the console stays live while the file is the durable copy.
|
||||||
|
out = io.MultiWriter(os.Stdout, rotator)
|
||||||
|
closer = rotator
|
||||||
|
}
|
||||||
|
|
||||||
|
return slog.New(slog.NewJSONHandler(out, opts)), closer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLevel(s string) slog.Level {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "warn", "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopCloser struct{}
|
||||||
|
|
||||||
|
func (noopCloser) Close() error { return nil }
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Server: the HTTP listener and the background lease reaper, both shut down
|
||||||
|
// cleanly on a signal.
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const shutdownGrace = 15 * time.Second
|
||||||
|
|
||||||
|
// Run serves handler until ctx is cancelled, then drains in-flight requests.
|
||||||
|
func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error {
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffered so this goroutine can exit even when nobody reads the channel
|
||||||
|
// (the ctx.Done branch below) — an unbuffered send would leak it forever.
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Info("coordinator listening", "addr", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Info("shutdown signal received")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh context: ctx is already cancelled, and reusing it would abort the
|
||||||
|
// very requests we are trying to let finish.
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
||||||
|
defer cancel()
|
||||||
|
return srv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunReaper periodically reclaims tasks whose lease elapsed, so a worker that
|
||||||
|
// died without a heartbeat cannot strand its task in 'leased' forever.
|
||||||
|
// RunPeriodic invokes fn on an interval until ctx is done, logging how many rows
|
||||||
|
// each tick affected. It backs the background reapers (expired leases, offline
|
||||||
|
// workers) — each is a set-based UPDATE that is safe to run repeatedly and
|
||||||
|
// concurrently across coordinators.
|
||||||
|
func RunPeriodic(ctx context.Context, log *slog.Logger, name string, interval time.Duration,
|
||||||
|
fn func(context.Context) (int64, error)) {
|
||||||
|
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
n, err := fn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug(name+" skipped", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Info(name, "count", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
// Package memstore holds in-memory implementations of the usecase ports for
|
||||||
|
// tests: they exercise use-case orchestration without a database or filesystem.
|
||||||
|
// The real invariants that depend on Postgres (SKIP LOCKED, row locking) are
|
||||||
|
// covered separately by the integration tests.
|
||||||
|
package memstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clock returns a fixed, advanceable time.
|
||||||
|
type Clock struct{ t time.Time }
|
||||||
|
|
||||||
|
func NewClock(t time.Time) *Clock { return &Clock{t: t} }
|
||||||
|
func (c *Clock) Now() time.Time { return c.t }
|
||||||
|
func (c *Clock) Advance(d time.Duration) { c.t = c.t.Add(d) }
|
||||||
|
|
||||||
|
// Tx is a no-op transaction manager: the in-memory stores need no atomicity to
|
||||||
|
// be observed, so it simply runs the function.
|
||||||
|
type Tx struct{}
|
||||||
|
|
||||||
|
func (Tx) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error { return fn(ctx) }
|
||||||
|
|
||||||
|
// --- TaskRepo ------------------------------------------------------------
|
||||||
|
|
||||||
|
type TaskRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tasks map[uuid.UUID]*domain.Task
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskRepo() *TaskRepo { return &TaskRepo{tasks: map[uuid.UUID]*domain.Task{}} }
|
||||||
|
|
||||||
|
var _ usecase.TaskRepository = (*TaskRepo)(nil)
|
||||||
|
|
||||||
|
// clone returns a copy so a caller's mutations do not touch stored state until
|
||||||
|
// Update — mirroring how a repository hands back detached entities.
|
||||||
|
func clone(t *domain.Task) *domain.Task { cp := *t; return &cp }
|
||||||
|
|
||||||
|
func (r *TaskRepo) put(t *domain.Task) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.tasks[t.ID] = clone(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domain.Task, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
var cands []*domain.Task
|
||||||
|
for _, t := range r.tasks {
|
||||||
|
if t.Status != domain.TaskPending || t.Attempt >= t.MaxAttempts {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(f.Workloads) > 0 && !contains(f.Workloads, t.Workload) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cands = append(cands, t)
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
sort.Slice(cands, func(i, j int) bool {
|
||||||
|
if cands[i].CreatedAt.Equal(cands[j].CreatedAt) {
|
||||||
|
return cands[i].ChunkIndex < cands[j].ChunkIndex
|
||||||
|
}
|
||||||
|
return cands[i].CreatedAt.Before(cands[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t := cands[0]
|
||||||
|
t.Status = domain.TaskLeased
|
||||||
|
t.Attempt++
|
||||||
|
owner := f.Owner
|
||||||
|
t.LeaseOwner = &owner
|
||||||
|
t.LeaseExpiresAt = &f.LeaseUntil
|
||||||
|
if t.StartedAt == nil {
|
||||||
|
t.StartedAt = &f.Now
|
||||||
|
}
|
||||||
|
t.Version++
|
||||||
|
return clone(t), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
t, ok := r.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrTaskNotFound
|
||||||
|
}
|
||||||
|
return clone(t), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) GetForUpdate(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||||
|
return r.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) Update(ctx context.Context, t *domain.Task) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
stored, ok := r.tasks[t.ID]
|
||||||
|
if !ok || stored.Version != t.Version-1 {
|
||||||
|
return domain.ErrLeaseConflict // vanished or advanced under us
|
||||||
|
}
|
||||||
|
r.tasks[t.ID] = clone(t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) InsertBatch(ctx context.Context, tasks []*domain.Task) error {
|
||||||
|
for _, t := range tasks {
|
||||||
|
r.put(t)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) ListCompleted(ctx context.Context, jobID uuid.UUID) ([]*domain.Task, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var out []*domain.Task
|
||||||
|
for _, t := range r.tasks {
|
||||||
|
if t.JobID == jobID && t.Status == domain.TaskCompleted {
|
||||||
|
out = append(out, clone(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ChunkIndex < out[j].ChunkIndex })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) CountByStatus(ctx context.Context, jobID uuid.UUID) (map[domain.TaskStatus]int, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
counts := map[domain.TaskStatus]int{}
|
||||||
|
for _, t := range r.tasks {
|
||||||
|
if t.JobID == jobID {
|
||||||
|
counts[t.Status]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) CancelByJob(_ context.Context, jobID uuid.UUID, now time.Time) (int64, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var cancelled int64
|
||||||
|
for _, task := range r.tasks {
|
||||||
|
if task.JobID == jobID && task.Cancel(now) {
|
||||||
|
cancelled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cancelled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) ExpireLeases(ctx context.Context, now time.Time) ([]uuid.UUID, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
affected := make([]uuid.UUID, 0)
|
||||||
|
for _, t := range r.tasks {
|
||||||
|
if (t.Status == domain.TaskLeased || t.Status == domain.TaskRunning) &&
|
||||||
|
t.LeaseExpiresAt != nil && t.LeaseExpiresAt.Before(now) {
|
||||||
|
t.ExpireLease(now)
|
||||||
|
affected = append(affected, t.JobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JobRepo -------------------------------------------------------------
|
||||||
|
|
||||||
|
type JobRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
jobs map[uuid.UUID]*domain.Job
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobRepo() *JobRepo { return &JobRepo{jobs: map[uuid.UUID]*domain.Job{}} }
|
||||||
|
|
||||||
|
var _ usecase.JobRepository = (*JobRepo)(nil)
|
||||||
|
|
||||||
|
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cp := *j
|
||||||
|
r.jobs[j.ID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
j, ok := r.jobs[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
cp := *j
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status domain.JobStatus, completedAt *time.Time) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
j, ok := r.jobs[id]
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
j.Status = status
|
||||||
|
j.CompletedAt = completedAt
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) ClaimReduction(_ context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
j, ok := r.jobs[id]
|
||||||
|
if !ok {
|
||||||
|
return false, domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
if j.Status != domain.JobReducing || j.ReducerStartedAt != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
j.ReducerStartedAt = &startedAt
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) CompleteWithResult(_ context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
j, ok := r.jobs[id]
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
j.ResultArtifactID = &resultArtifactID
|
||||||
|
j.Status = domain.JobCompleted
|
||||||
|
j.CompletedAt = &completedAt
|
||||||
|
j.ReducerStartedAt = nil
|
||||||
|
j.ErrorCode = nil
|
||||||
|
j.ErrorMessage = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) FailReduction(_ context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
j, ok := r.jobs[id]
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
j.Status = domain.JobFailed
|
||||||
|
j.CompletedAt = &completedAt
|
||||||
|
j.ReducerStartedAt = nil
|
||||||
|
j.ErrorCode = &code
|
||||||
|
j.ErrorMessage = &message
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WorkerRepo ----------------------------------------------------------
|
||||||
|
|
||||||
|
type WorkerRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
workers map[uuid.UUID]*domain.Worker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkerRepo() *WorkerRepo { return &WorkerRepo{workers: map[uuid.UUID]*domain.Worker{}} }
|
||||||
|
|
||||||
|
var _ usecase.WorkerRepository = (*WorkerRepo)(nil)
|
||||||
|
|
||||||
|
func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cp := *w
|
||||||
|
r.workers[w.ID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Worker, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
w, ok := r.workers[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrWorkerNotFound
|
||||||
|
}
|
||||||
|
cp := *w
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) Touch(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if w, ok := r.workers[id]; ok {
|
||||||
|
w.LastHeartbeatAt = at
|
||||||
|
w.Status = domain.WorkerOnline
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var n int64
|
||||||
|
for _, w := range r.workers {
|
||||||
|
if w.Status != domain.WorkerOffline && w.LastHeartbeatAt.Before(cutoff) {
|
||||||
|
w.Status = domain.WorkerOffline
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ArtifactRepo --------------------------------------------------------
|
||||||
|
|
||||||
|
type ArtifactRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
arts map[uuid.UUID]*domain.Artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArtifactRepo() *ArtifactRepo { return &ArtifactRepo{arts: map[uuid.UUID]*domain.Artifact{}} }
|
||||||
|
|
||||||
|
var _ usecase.ArtifactRepository = (*ArtifactRepo)(nil)
|
||||||
|
|
||||||
|
func (r *ArtifactRepo) Insert(ctx context.Context, a *domain.Artifact) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
cp := *a
|
||||||
|
r.arts[a.ID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtifactRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
a, ok := r.arts[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
cp := *a
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtifactRepo) FindPartialResult(_ context.Context, taskID uuid.UUID, attempt int) (*domain.Artifact, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, a := range r.arts {
|
||||||
|
if a.TaskID != nil && *a.TaskID == taskID && a.Kind == domain.ArtifactPartialResult &&
|
||||||
|
a.Attempt != nil && *a.Attempt == attempt {
|
||||||
|
return cloneArtifact(a), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneArtifact(a *domain.Artifact) *domain.Artifact {
|
||||||
|
cp := *a
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BlobStore -----------------------------------------------------------
|
||||||
|
|
||||||
|
type BlobStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
blobs map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBlobStore() *BlobStore { return &BlobStore{blobs: map[string][]byte{}} }
|
||||||
|
|
||||||
|
var _ usecase.BlobStore = (*BlobStore)(nil)
|
||||||
|
|
||||||
|
func (b *BlobStore) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.blobs[key] = data
|
||||||
|
b.mu.Unlock()
|
||||||
|
return hex.EncodeToString(sum[:]), int64(len(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlobStore) Open(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
data, ok := b.blobs[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
return io.NopCloser(bytes.NewReader(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BlobStore) Delete(ctx context.Context, key string) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
delete(b.blobs, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has reports whether a blob exists — handy for asserting cleanup in tests.
|
||||||
|
func (b *BlobStore) Has(key string) bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
_, ok := b.blobs[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(ss []string, s string) bool {
|
||||||
|
for _, x := range ss {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskResultRepo is an in-memory usecase.TaskResultRepository: one vote per
|
||||||
|
// (task, owner).
|
||||||
|
type TaskResultRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
votes map[uuid.UUID]map[uuid.UUID]string // taskID -> ownerID -> sha256
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskResultRepo() *TaskResultRepo {
|
||||||
|
return &TaskResultRepo{votes: make(map[uuid.UUID]map[uuid.UUID]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskResultRepo) RecordVote(_ context.Context, taskID, ownerID uuid.UUID, sha256 string, _ uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.votes[taskID] == nil {
|
||||||
|
r.votes[taskID] = make(map[uuid.UUID]string)
|
||||||
|
}
|
||||||
|
r.votes[taskID][ownerID] = sha256
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskResultRepo) CountAgreeing(_ context.Context, taskID uuid.UUID, sha256 string) (int, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
n := 0
|
||||||
|
for _, s := range r.votes[taskID] {
|
||||||
|
if s == sha256 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package memstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UIReadRepo is the in-memory read projection used by HTTP/UI tests.
|
||||||
|
type UIReadRepo struct {
|
||||||
|
jobs *JobRepo
|
||||||
|
tasks *TaskRepo
|
||||||
|
workers *WorkerRepo
|
||||||
|
artifacts *ArtifactRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUIReadRepo(j *JobRepo, t *TaskRepo, w *WorkerRepo, a *ArtifactRepo) *UIReadRepo {
|
||||||
|
return &UIReadRepo{j, t, w, a}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.UIReadRepository = (*UIReadRepo)(nil)
|
||||||
|
|
||||||
|
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||||
|
return r.jobs.Get(ctx, id)
|
||||||
|
}
|
||||||
|
func (r *UIReadRepo) ListJobs(_ context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
r.jobs.mu.Lock()
|
||||||
|
defer r.jobs.mu.Unlock()
|
||||||
|
out := make([]domain.Job, 0, len(r.jobs.jobs))
|
||||||
|
for _, job := range r.jobs.jobs {
|
||||||
|
if owner != nil && (job.OwnerID == nil || *job.OwnerID != *owner) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, *job)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||||
|
return out[i].ID.String() > out[j].ID.String()
|
||||||
|
}
|
||||||
|
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||||
|
})
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (r *UIReadRepo) ListTasksByJob(_ context.Context, jobID uuid.UUID) ([]domain.Task, error) {
|
||||||
|
r.tasks.mu.Lock()
|
||||||
|
defer r.tasks.mu.Unlock()
|
||||||
|
out := []domain.Task{}
|
||||||
|
for _, task := range r.tasks.tasks {
|
||||||
|
if task.JobID == jobID {
|
||||||
|
out = append(out, *clone(task))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ChunkIndex < out[j].ChunkIndex })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UIReadRepo) ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error) {
|
||||||
|
out := make(map[uuid.UUID][]domain.Task, len(jobIDs))
|
||||||
|
for _, id := range jobIDs {
|
||||||
|
tasks, err := r.ListTasksByJob(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[id] = tasks
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (r *UIReadRepo) ListWorkers(_ context.Context, limit int) ([]domain.Worker, error) {
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
r.workers.mu.Lock()
|
||||||
|
defer r.workers.mu.Unlock()
|
||||||
|
out := []domain.Worker{}
|
||||||
|
for _, worker := range r.workers.workers {
|
||||||
|
copy := *worker
|
||||||
|
copy.Capabilities = append([]string(nil), worker.Capabilities...)
|
||||||
|
out = append(out, copy)
|
||||||
|
}
|
||||||
|
sortWorkers(out)
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UIReadRepo) ListWorkersByOwner(_ context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
r.workers.mu.Lock()
|
||||||
|
defer r.workers.mu.Unlock()
|
||||||
|
out := []domain.Worker{}
|
||||||
|
for _, worker := range r.workers.workers {
|
||||||
|
if worker.OwnerID == nil || *worker.OwnerID != owner {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copy := *worker
|
||||||
|
copy.Capabilities = append([]string(nil), worker.Capabilities...)
|
||||||
|
out = append(out, copy)
|
||||||
|
}
|
||||||
|
sortWorkers(out)
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortWorkers orders workers most-recently-seen first, breaking ties on id so
|
||||||
|
// the order is deterministic across calls.
|
||||||
|
func sortWorkers(out []domain.Worker) {
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].LastHeartbeatAt.Equal(out[j].LastHeartbeatAt) {
|
||||||
|
return out[i].ID.String() > out[j].ID.String()
|
||||||
|
}
|
||||||
|
return out[i].LastHeartbeatAt.After(out[j].LastHeartbeatAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (r *UIReadRepo) ListArtifactsByJob(_ context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
|
||||||
|
r.artifacts.mu.Lock()
|
||||||
|
defer r.artifacts.mu.Unlock()
|
||||||
|
out := []domain.Artifact{}
|
||||||
|
for _, artifact := range r.artifacts.arts {
|
||||||
|
if artifact.JobID == jobID {
|
||||||
|
out = append(out, *artifact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||||
|
return out[i].ID.String() < out[j].ID.String()
|
||||||
|
}
|
||||||
|
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stats is a point-in-time snapshot of the coordinator's domain state: counts of
|
||||||
|
// tasks, jobs, and workers keyed by their status. Maps are expected to be
|
||||||
|
// zero-filled by the provider so every known status is always present, giving
|
||||||
|
// the dashboard flat zero lines instead of gaps.
|
||||||
|
type Stats struct {
|
||||||
|
Tasks map[string]int
|
||||||
|
Jobs map[string]int
|
||||||
|
Workers map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsFunc returns the current snapshot. It is called on every scrape, so it
|
||||||
|
// must be a cheap aggregate query.
|
||||||
|
type StatsFunc func(context.Context) (Stats, error)
|
||||||
|
|
||||||
|
// RegisterBusiness registers a collector that reports domain-state gauges
|
||||||
|
// (scimesh_tasks/jobs/workers by status) sourced from collect on each scrape.
|
||||||
|
// Deriving the gauges at scrape time keeps them fresh without a background
|
||||||
|
// goroutine, and a failed query simply yields no samples for that scrape.
|
||||||
|
func (m *Metrics) RegisterBusiness(collect StatsFunc) {
|
||||||
|
m.reg.MustRegister(&businessCollector{
|
||||||
|
collect: collect,
|
||||||
|
tasks: prometheus.NewDesc("scimesh_tasks", "Tasks by status.", []string{"status"}, nil),
|
||||||
|
jobs: prometheus.NewDesc("scimesh_jobs", "Jobs by status.", []string{"status"}, nil),
|
||||||
|
workers: prometheus.NewDesc("scimesh_workers", "Workers by status.", []string{"status"}, nil),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type businessCollector struct {
|
||||||
|
collect StatsFunc
|
||||||
|
tasks, jobs, workers *prometheus.Desc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *businessCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||||
|
ch <- c.tasks
|
||||||
|
ch <- c.jobs
|
||||||
|
ch <- c.workers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *businessCollector) Collect(ch chan<- prometheus.Metric) {
|
||||||
|
// A bounded query so one slow scrape cannot stall Prometheus.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
s, err := c.collect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return // no samples this scrape; Prometheus keeps the last value
|
||||||
|
}
|
||||||
|
emit(ch, c.tasks, s.Tasks)
|
||||||
|
emit(ch, c.jobs, s.Jobs)
|
||||||
|
emit(ch, c.workers, s.Workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emit(ch chan<- prometheus.Metric, desc *prometheus.Desc, counts map[string]int) {
|
||||||
|
for status, n := range counts {
|
||||||
|
ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, float64(n), status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scrape(t *testing.T, m *Metrics) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
|
||||||
|
m.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusinessCollectorEmitsGauges(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
m.RegisterBusiness(func(context.Context) (Stats, error) {
|
||||||
|
return Stats{
|
||||||
|
Tasks: map[string]int{"pending": 3, "running": 1, "completed": 0},
|
||||||
|
Jobs: map[string]int{"running": 2},
|
||||||
|
Workers: map[string]int{"online": 4},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
body := scrape(t, m)
|
||||||
|
for _, want := range []string{
|
||||||
|
`scimesh_tasks{status="pending"} 3`,
|
||||||
|
`scimesh_tasks{status="completed"} 0`,
|
||||||
|
`scimesh_jobs{status="running"} 2`,
|
||||||
|
`scimesh_workers{status="online"} 4`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("metrics missing %q\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusinessCollectorSkipsOnError(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
m.RegisterBusiness(func(context.Context) (Stats, error) {
|
||||||
|
return Stats{}, errors.New("db down")
|
||||||
|
})
|
||||||
|
if strings.Contains(scrape(t, m), "scimesh_tasks") {
|
||||||
|
t.Error("a failed snapshot must emit no business samples")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Package metrics exposes Prometheus instrumentation for the coordinator: an
|
||||||
|
// HTTP RED middleware (rate, errors, duration) plus the standard Go runtime and
|
||||||
|
// process collectors, all on a private registry so nothing leaks in from global
|
||||||
|
// state.
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Metrics struct {
|
||||||
|
reg *prometheus.Registry
|
||||||
|
requests *prometheus.CounterVec
|
||||||
|
duration *prometheus.HistogramVec
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the registry and registers the runtime, process, and HTTP metrics.
|
||||||
|
func New() *Metrics {
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
reg.MustRegister(
|
||||||
|
collectors.NewGoCollector(),
|
||||||
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
|
)
|
||||||
|
|
||||||
|
requests := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "scimesh",
|
||||||
|
Subsystem: "http",
|
||||||
|
Name: "requests_total",
|
||||||
|
Help: "HTTP requests, labelled by method, normalized route, and status.",
|
||||||
|
}, []string{"method", "route", "status"})
|
||||||
|
|
||||||
|
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "scimesh",
|
||||||
|
Subsystem: "http",
|
||||||
|
Name: "request_duration_seconds",
|
||||||
|
Help: "HTTP request duration in seconds.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"method", "route"})
|
||||||
|
|
||||||
|
reg.MustRegister(requests, duration)
|
||||||
|
return &Metrics{reg: reg, requests: requests, duration: duration}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves the metrics in Prometheus text format.
|
||||||
|
func (m *Metrics) Handler() http.Handler {
|
||||||
|
return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry exposes the registry so callers can register extra collectors.
|
||||||
|
func (m *Metrics) Registry() *prometheus.Registry { return m.reg }
|
||||||
|
|
||||||
|
// Middleware records one request into the RED metrics. It normalizes the path
|
||||||
|
// so per-id routes collapse to a single low-cardinality label.
|
||||||
|
func (m *Metrics) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
|
||||||
|
route := normalizeRoute(r.URL.Path)
|
||||||
|
m.requests.WithLabelValues(r.Method, route, strconv.Itoa(rec.status)).Inc()
|
||||||
|
m.duration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) WriteHeader(code int) {
|
||||||
|
s.status = code
|
||||||
|
s.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||||
|
|
||||||
|
// normalizeRoute collapses uuid and numeric path segments to {id}, keeping the
|
||||||
|
// route label cardinality bounded (otherwise every job/task id would be its own
|
||||||
|
// time series).
|
||||||
|
func normalizeRoute(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
segs := strings.Split(path, "/")
|
||||||
|
for i, s := range segs {
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uuidRe.MatchString(s) || isAllDigits(s) {
|
||||||
|
segs[i] = "{id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(segs, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllDigits(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s != ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeRoute(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"/health": "/health",
|
||||||
|
"/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301": "/jobs/{id}",
|
||||||
|
"/tasks/3f2504e0-4f89-41d3-9a0c-0305e82c3301/result": "/tasks/{id}/result",
|
||||||
|
"/ui/jobs/12345": "/ui/jobs/{id}",
|
||||||
|
"/": "/",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := normalizeRoute(in); got != want {
|
||||||
|
t.Errorf("normalizeRoute(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareAndHandler(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
h := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301", nil)
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
// Scrape and confirm the request was recorded under the normalized route.
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
greq, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
|
||||||
|
m.Handler().ServeHTTP(rec, greq)
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `scimesh_http_requests_total{method="POST",route="/jobs/{id}",status="201"}`) {
|
||||||
|
t.Errorf("requests_total not recorded as expected; body:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "go_goroutines") {
|
||||||
|
t.Error("Go runtime collector not registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Package reducer contains deterministic, coordinator-side result reductions.
|
||||||
|
package reducer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReduceOrderedConcat concatenates worker partial tables in shard order into a
|
||||||
|
// single table with one header. Every partial must carry the same header as the
|
||||||
|
// first partial and rows of the same width; anything else fails the job closed.
|
||||||
|
func ReduceOrderedConcat(partials []io.Reader) ([]byte, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
writer := csv.NewWriter(&out)
|
||||||
|
var firstHeader []string
|
||||||
|
for _, partial := range partials {
|
||||||
|
reader := csv.NewReader(partial)
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read partial header: %w", err)
|
||||||
|
}
|
||||||
|
if len(header) == 0 {
|
||||||
|
return nil, fmt.Errorf("partial result has an empty header")
|
||||||
|
}
|
||||||
|
if firstHeader == nil {
|
||||||
|
firstHeader = header
|
||||||
|
if err := writer.Write(header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if !equalStrings(header, firstHeader) {
|
||||||
|
return nil, fmt.Errorf("partial result has an inconsistent header")
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
row, err := reader.Read()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read partial row: %w", err)
|
||||||
|
}
|
||||||
|
if len(row) != len(header) {
|
||||||
|
return nil, fmt.Errorf("partial result has a row with an inconsistent width")
|
||||||
|
}
|
||||||
|
if err := writer.Write(row); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.Flush()
|
||||||
|
if err := writer.Error(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package reducer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReduceOrderedConcatJoinsPartialsInOrderWithOneHeader(t *testing.T) {
|
||||||
|
first := strings.NewReader("chembl_id,canonical_smiles\nA,CC\nB,CCC\n")
|
||||||
|
second := strings.NewReader("chembl_id,canonical_smiles\nC,CCCC\n")
|
||||||
|
|
||||||
|
output, err := ReduceOrderedConcat([]io.Reader{first, second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "chembl_id,canonical_smiles\nA,CC\nB,CCC\nC,CCCC\n"
|
||||||
|
if string(output) != want {
|
||||||
|
t.Fatalf("output = %q, want %q", output, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReduceOrderedConcatIsDeterministicAcrossInputOrder(t *testing.T) {
|
||||||
|
left := strings.NewReader("id,rows\nA,1\nB,2\n")
|
||||||
|
right := strings.NewReader("id,rows\nC,3\n")
|
||||||
|
|
||||||
|
first, err := ReduceOrderedConcat([]io.Reader{left, right})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
left, right = strings.NewReader("id,rows\nA,1\nB,2\n"), strings.NewReader("id,rows\nC,3\n")
|
||||||
|
second, err := ReduceOrderedConcat([]io.Reader{left, right})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(first) != string(second) {
|
||||||
|
t.Fatalf("concat is not deterministic: %q != %q", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReduceOrderedConcatRejectsInconsistentHeaders(t *testing.T) {
|
||||||
|
first := strings.NewReader("a,b\n1,2\n")
|
||||||
|
second := strings.NewReader("a,c\n1,2\n")
|
||||||
|
if _, err := ReduceOrderedConcat([]io.Reader{first, second}); err == nil {
|
||||||
|
t.Fatal("inconsistent headers must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReduceOrderedConcatRejectsRaggedRows(t *testing.T) {
|
||||||
|
partial := strings.NewReader("a,b\n1,2,3\n")
|
||||||
|
if _, err := ReduceOrderedConcat([]io.Reader{partial}); err == nil {
|
||||||
|
t.Fatal("ragged rows must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReduceOrderedConcatEmptyPartials(t *testing.T) {
|
||||||
|
output, err := ReduceOrderedConcat(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(output) != 0 {
|
||||||
|
t.Fatalf("empty input must produce empty output, got %q", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Package reducer contains deterministic, coordinator-side result reductions.
|
||||||
|
package reducer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
var searchHeader = []string{"rank", "chembl_id", "canonical_smiles", "similarity"}
|
||||||
|
|
||||||
|
type similarityMatch struct {
|
||||||
|
similarity float64
|
||||||
|
id string
|
||||||
|
smiles string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReduceSimilaritySearch streams worker-local top-k CSVs into the exact global
|
||||||
|
// top-k. Each partial is validated before it can affect the final artifact.
|
||||||
|
func ReduceSimilaritySearch(partials []io.Reader, parameters map[string]any) ([]byte, error) {
|
||||||
|
topK, err := positiveInt(parameters["top_k"], 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
direction, err := thresholdDirection(parameters["threshold_direction"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := &matchHeap{direction: direction}
|
||||||
|
for _, partial := range partials {
|
||||||
|
if err := readPartial(partial, direction, func(match similarityMatch) {
|
||||||
|
if len(h.items) < topK {
|
||||||
|
heapPush(h, match)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if better(match, h.items[0], direction) {
|
||||||
|
h.items[0] = match
|
||||||
|
heapDown(h, 0)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := append([]similarityMatch(nil), h.items...)
|
||||||
|
sort.Slice(matches, func(i, j int) bool { return better(matches[i], matches[j], direction) })
|
||||||
|
var out bytes.Buffer
|
||||||
|
writer := csv.NewWriter(&out)
|
||||||
|
if err := writer.Write(searchHeader); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index, match := range matches {
|
||||||
|
if err := writer.Write([]string{
|
||||||
|
strconv.Itoa(index + 1), match.id, match.smiles, fmt.Sprintf("%.6f", match.similarity),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.Flush()
|
||||||
|
if err := writer.Error(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPartial(input io.Reader, direction string, consume func(similarityMatch)) error {
|
||||||
|
reader := csv.NewReader(input)
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read partial header: %w", err)
|
||||||
|
}
|
||||||
|
if !equalStrings(header, searchHeader) {
|
||||||
|
return fmt.Errorf("partial result has an invalid CSV header")
|
||||||
|
}
|
||||||
|
var previous *similarityMatch
|
||||||
|
for rank := 1; ; rank++ {
|
||||||
|
row, err := reader.Read()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read partial row: %w", err)
|
||||||
|
}
|
||||||
|
if len(row) != len(searchHeader) || row[0] != strconv.Itoa(rank) {
|
||||||
|
return fmt.Errorf("partial result has an invalid rank")
|
||||||
|
}
|
||||||
|
score, err := strconv.ParseFloat(row[3], 64)
|
||||||
|
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) || score < 0 || score > 1 {
|
||||||
|
return fmt.Errorf("partial result has an invalid similarity")
|
||||||
|
}
|
||||||
|
match := similarityMatch{similarity: score, id: row[1], smiles: row[2]}
|
||||||
|
if previous != nil && better(match, *previous, direction) {
|
||||||
|
return fmt.Errorf("partial result is not sorted deterministically")
|
||||||
|
}
|
||||||
|
previous = &match
|
||||||
|
consume(match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func positiveInt(value any, fallback int) (int, error) {
|
||||||
|
if value == nil {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
switch n := value.(type) {
|
||||||
|
case int:
|
||||||
|
if n > 0 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
case int64:
|
||||||
|
if n > 0 && n <= math.MaxInt {
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
if n > 0 && n == math.Trunc(n) && n <= math.MaxInt {
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("top_k must be a positive integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func thresholdDirection(value any) (string, error) {
|
||||||
|
if value == nil {
|
||||||
|
return "greater", nil
|
||||||
|
}
|
||||||
|
direction, ok := value.(string)
|
||||||
|
if !ok || (direction != "greater" && direction != "less") {
|
||||||
|
return "", fmt.Errorf("threshold_direction must be greater or less")
|
||||||
|
}
|
||||||
|
return direction, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func better(left, right similarityMatch, direction string) bool {
|
||||||
|
if left.similarity != right.similarity {
|
||||||
|
if direction == "less" {
|
||||||
|
return left.similarity < right.similarity
|
||||||
|
}
|
||||||
|
return left.similarity > right.similarity
|
||||||
|
}
|
||||||
|
if left.id != right.id {
|
||||||
|
return left.id < right.id
|
||||||
|
}
|
||||||
|
return left.smiles < right.smiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(left, right []string) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index := range left {
|
||||||
|
if left[index] != right[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchHeap keeps the worst retained match at index zero.
|
||||||
|
type matchHeap struct {
|
||||||
|
items []similarityMatch
|
||||||
|
direction string
|
||||||
|
}
|
||||||
|
|
||||||
|
func heapPush(h *matchHeap, value similarityMatch) {
|
||||||
|
h.items = append(h.items, value)
|
||||||
|
for child := len(h.items) - 1; child > 0; {
|
||||||
|
parent := (child - 1) / 2
|
||||||
|
if !better(h.items[parent], h.items[child], h.direction) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h.items[parent], h.items[child] = h.items[child], h.items[parent]
|
||||||
|
child = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func heapDown(h *matchHeap, parent int) {
|
||||||
|
for {
|
||||||
|
child := parent*2 + 1
|
||||||
|
if child >= len(h.items) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if right := child + 1; right < len(h.items) && better(h.items[child], h.items[right], h.direction) {
|
||||||
|
child = right
|
||||||
|
}
|
||||||
|
if !better(h.items[parent], h.items[child], h.direction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.items[parent], h.items[child] = h.items[child], h.items[parent]
|
||||||
|
parent = child
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package reducer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReduceSimilaritySearchKeepsExactCrossShardRanking(t *testing.T) {
|
||||||
|
first := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,B,CCC,0.50000048\n2,C,CCCC,0.1\n")
|
||||||
|
second := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.50000049\n")
|
||||||
|
|
||||||
|
output, err := ReduceSimilaritySearch([]io.Reader{first, second}, map[string]any{"top_k": 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.500000\n2,B,CCC,0.500000\n"
|
||||||
|
if string(output) != want {
|
||||||
|
t.Fatalf("output = %q, want %q", output, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReduceSimilaritySearchSupportsLeastSimilarDirection(t *testing.T) {
|
||||||
|
partial := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.1\n2,B,CCC,0.8\n")
|
||||||
|
output, err := ReduceSimilaritySearch([]io.Reader{partial}, map[string]any{
|
||||||
|
"top_k": 1, "threshold_direction": "less",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := string(output), "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.100000\n"; got != want {
|
||||||
|
t.Fatalf("output = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReduceSimilaritySearchRejectsMalformedPartial(t *testing.T) {
|
||||||
|
partial := strings.NewReader("rank,chembl_id,canonical_smiles,similarity\n2,A,CC,0.1\n")
|
||||||
|
if _, err := ReduceSimilaritySearch([]io.Reader{partial}, nil); err == nil {
|
||||||
|
t.Fatal("expected malformed rank error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Package setup implements the `coordinator setup` wizard: database reachability
|
||||||
|
// and creation, embedded schema migration, secret generation, and .env writing.
|
||||||
|
// The wizard never logs or echoes secrets.
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures one wizard run.
|
||||||
|
type Options struct {
|
||||||
|
// DatabaseURL is the target coordinator database (pgx/libpq URL).
|
||||||
|
DatabaseURL string
|
||||||
|
// AdminDatabaseURL, when set, is used to create a missing target database.
|
||||||
|
// Defaults to the target URL with the database name replaced by "postgres".
|
||||||
|
AdminDatabaseURL string
|
||||||
|
// EnvFile is where the generated settings are written (default ".env").
|
||||||
|
EnvFile string
|
||||||
|
// Force overwrites an existing EnvFile.
|
||||||
|
Force bool
|
||||||
|
// Yes disables interactive prompts; missing values fail instead.
|
||||||
|
Yes bool
|
||||||
|
// ConnectTimeout bounds the reachability check.
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
// Out receives progress and summary output; In feeds interactive answers.
|
||||||
|
Out io.Writer
|
||||||
|
In io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the wizard and returns a summary of what was done.
|
||||||
|
func Run(ctx context.Context, options Options) (string, error) {
|
||||||
|
if options.DatabaseURL == "" {
|
||||||
|
return "", fmt.Errorf("DATABASE_URL is required (or pass --db)")
|
||||||
|
}
|
||||||
|
if options.EnvFile == "" {
|
||||||
|
options.EnvFile = ".env"
|
||||||
|
}
|
||||||
|
if options.ConnectTimeout <= 0 {
|
||||||
|
options.ConnectTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if options.Out == nil {
|
||||||
|
options.Out = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
report := func(format string, args ...any) {
|
||||||
|
_, _ = fmt.Fprintf(options.Out, format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
report("SciMesh coordinator setup")
|
||||||
|
report("")
|
||||||
|
|
||||||
|
// 1. Reachability, with optional database creation.
|
||||||
|
target, err := pgx.ParseConfig(options.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("DATABASE_URL is not a valid postgres URL: %w", err)
|
||||||
|
}
|
||||||
|
if err := probeDatabase(ctx, target, options.ConnectTimeout); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if !errors.As(err, &pgErr) || pgErr.Code != "3D000" {
|
||||||
|
return "", fmt.Errorf("cannot reach the coordinator database: %w", err)
|
||||||
|
}
|
||||||
|
report("database %q does not exist yet", target.Database)
|
||||||
|
admin, err := resolveAdminConfig(options, target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := createDatabase(ctx, admin, target.Database, options.ConnectTimeout); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot create database %q: %w", target.Database, err)
|
||||||
|
}
|
||||||
|
report("created database %q", target.Database)
|
||||||
|
}
|
||||||
|
report("database %q is reachable", target.Database)
|
||||||
|
|
||||||
|
// 2. Apply the embedded schema migrations (idempotent).
|
||||||
|
if err := postgres.Migrate(ctx, options.DatabaseURL, nil); err != nil {
|
||||||
|
return "", fmt.Errorf("apply schema migrations: %w", err)
|
||||||
|
}
|
||||||
|
report("schema migrations applied")
|
||||||
|
|
||||||
|
// 3. JWT secret: reuse the environment value when strong, else generate.
|
||||||
|
secret := os.Getenv("JWT_SECRET")
|
||||||
|
if secret != "" && len(secret) < 32 {
|
||||||
|
return "", fmt.Errorf("JWT_SECRET must be at least 32 bytes")
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
generated, err := generateSecret()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate JWT_SECRET: %w", err)
|
||||||
|
}
|
||||||
|
secret = generated
|
||||||
|
report("generated a fresh JWT_SECRET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Write the .env file.
|
||||||
|
storageDir := os.Getenv("COORDINATOR_STORAGE_DIR")
|
||||||
|
if storageDir == "" {
|
||||||
|
storageDir = "./data"
|
||||||
|
}
|
||||||
|
if err := writeEnvFile(options, secret, storageDir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Summary.
|
||||||
|
var summary strings.Builder
|
||||||
|
fmt.Fprintf(&summary, "Setup complete.\n\n")
|
||||||
|
fmt.Fprintf(&summary, "Ready:\n")
|
||||||
|
fmt.Fprintf(&summary, " - database %s is reachable and migrated\n", target.Database)
|
||||||
|
fmt.Fprintf(&summary, " - settings written to %s (chmod 0600)\n", options.EnvFile)
|
||||||
|
fmt.Fprintf(&summary, "\nStart the coordinator:\n")
|
||||||
|
fmt.Fprintf(&summary, " ENV_FILE=%s ./coordinator\n", options.EnvFile)
|
||||||
|
fmt.Fprintf(&summary, "\nOptional — userservice for UI logins (must share JWT_SECRET):\n")
|
||||||
|
fmt.Fprintf(&summary, " cd users && JWT_SECRET=%q docker compose up -d\n", secret)
|
||||||
|
fmt.Fprintf(&summary, " then set USERSERVICE_URL=http://localhost:8081 and BOOTSTRAP_ADMIN_EMAIL/PASSWORD\n")
|
||||||
|
fmt.Fprintf(&summary, "\nThe wizard cannot run PostgreSQL or the userservice for you; the\n")
|
||||||
|
fmt.Fprintf(&summary, "commands above are the supported way to start them.\n")
|
||||||
|
return summary.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeDatabase verifies the target database accepts connections.
|
||||||
|
func probeDatabase(ctx context.Context, config *pgx.ConnConfig, timeout time.Duration) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := pgx.ConnectConfig(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.Close(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAdminConfig picks the maintenance connection used to create
|
||||||
|
// databases. pgx's ConnConfig.ConnString() caches the original URL, so the
|
||||||
|
// config itself (not a re-rendered string) is what the caller connects with.
|
||||||
|
func resolveAdminConfig(options Options, target *pgx.ConnConfig) (*pgx.ConnConfig, error) {
|
||||||
|
if options.AdminDatabaseURL != "" {
|
||||||
|
config, err := pgx.ParseConfig(options.AdminDatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("--admin-db is not a valid postgres URL: %w", err)
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
admin := *target
|
||||||
|
admin.Database = "postgres"
|
||||||
|
return &admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDatabase creates the named database through the maintenance connection.
|
||||||
|
func createDatabase(ctx context.Context, admin *pgx.ConnConfig, name string, timeout time.Duration) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := pgx.ConnectConfig(ctx, admin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close(ctx) }()
|
||||||
|
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||||
|
if _, err := conn.Exec(ctx, "CREATE DATABASE "+quoted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSecret returns 32 random bytes as lowercase hex.
|
||||||
|
func generateSecret() (string, error) {
|
||||||
|
buffer := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buffer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEnvFile writes the settings, refusing to clobber without --force.
|
||||||
|
func writeEnvFile(options Options, secret, storageDir string) error {
|
||||||
|
path := filepath.Clean(options.EnvFile)
|
||||||
|
if _, err := os.Stat(path); err == nil && !options.Force {
|
||||||
|
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
|
||||||
|
}
|
||||||
|
content := strings.Join([]string{
|
||||||
|
"DATABASE_URL=" + options.DatabaseURL,
|
||||||
|
"JWT_SECRET=" + secret,
|
||||||
|
"COORDINATOR_STORAGE_DIR=" + storageDir,
|
||||||
|
"", // trailing newline
|
||||||
|
}, "\n")
|
||||||
|
// #nosec G703 -- the env file path is operator-supplied (--env-file / ENV_FILE).
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("chmod %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeDatabaseURL hides the password for logging.
|
||||||
|
func SanitizeDatabaseURL(raw string) string {
|
||||||
|
at := strings.LastIndex(raw, "@")
|
||||||
|
if at < 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
if strings.HasPrefix(raw, "postgres://") || strings.HasPrefix(raw, "postgresql://") {
|
||||||
|
start = len("postgres://")
|
||||||
|
}
|
||||||
|
colon := strings.Index(raw[start:at], ":")
|
||||||
|
if colon < 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
colon += start
|
||||||
|
return raw[:colon] + ":***@" + raw[at+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// prompt asks a question and returns the trimmed answer ("" on EOF).
|
||||||
|
func prompt(options Options, question, fallback string) string {
|
||||||
|
_, _ = fmt.Fprintf(options.Out, "%s [%s]: ", question, fallback)
|
||||||
|
reader := bufio.NewReader(options.In)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
answer := strings.TrimSpace(line)
|
||||||
|
if answer == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return answer
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunProvisionsDatabaseSchemaAndEnvFile(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
base := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
// The wizard must create a *missing* database through the admin URL.
|
||||||
|
slash := strings.LastIndex(base, "/")
|
||||||
|
target := base[:slash+1] + "scimesh_setup_test"
|
||||||
|
admin := base[:slash+1] + "postgres"
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
conn, err := pgx.Connect(ctx, admin)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close(ctx) }()
|
||||||
|
_, _ = conn.Exec(ctx, `DROP DATABASE IF EXISTS "scimesh_setup_test"`)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
envPath := filepath.Join(t.TempDir(), ".env")
|
||||||
|
var output strings.Builder
|
||||||
|
options := Options{
|
||||||
|
DatabaseURL: target,
|
||||||
|
AdminDatabaseURL: admin,
|
||||||
|
EnvFile: envPath,
|
||||||
|
Force: true,
|
||||||
|
Yes: true,
|
||||||
|
ConnectTimeout: 10 * time.Second,
|
||||||
|
Out: &output,
|
||||||
|
In: strings.NewReader(""),
|
||||||
|
}
|
||||||
|
summary, err := Run(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup run: %v", err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"created database", "schema migrations applied"} {
|
||||||
|
if !strings.Contains(output.String(), expected) {
|
||||||
|
t.Errorf("progress output is missing %q:\n%s", expected, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"Setup complete", "Start the coordinator"} {
|
||||||
|
if !strings.Contains(summary, expected) {
|
||||||
|
t.Errorf("summary is missing %q:\n%s", expected, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The database now exists, is migrated, and the env file is written.
|
||||||
|
conn, err := pgx.Connect(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect to provisioned database: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close(ctx) }()
|
||||||
|
var watermark int64
|
||||||
|
if err := conn.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
|
||||||
|
t.Fatalf("read schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
if watermark < 13 {
|
||||||
|
t.Errorf("schema watermark = %d, want >= 13", watermark)
|
||||||
|
}
|
||||||
|
envContent, err := os.ReadFile(envPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := string(envContent)
|
||||||
|
if !strings.Contains(env, "DATABASE_URL="+target) || !strings.Contains(env, "JWT_SECRET=") {
|
||||||
|
t.Errorf("env file is incomplete:\n%s", env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second run is idempotent: no create-database error, same outcome.
|
||||||
|
if _, err := Run(ctx, options); err != nil {
|
||||||
|
t.Fatalf("second setup run: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateSecretIsRandomAndStrong(t *testing.T) {
|
||||||
|
first, err := generateSecret()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := generateSecret()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(first) != 64 || len(second) != 64 {
|
||||||
|
t.Fatalf("secrets must be 32 random bytes as hex, got %d and %d", len(first), len(second))
|
||||||
|
}
|
||||||
|
if first == second {
|
||||||
|
t.Fatal("two generated secrets must differ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvFileContentsAndPermissions(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := writeEnvFile(Options{
|
||||||
|
EnvFile: path,
|
||||||
|
DatabaseURL: "postgres://scimesh@localhost/scimesh",
|
||||||
|
}, "s3cr3t", "./data"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "DATABASE_URL=postgres://scimesh@localhost/scimesh\nJWT_SECRET=s3cr3t\nCOORDINATOR_STORAGE_DIR=./data\n"
|
||||||
|
if got := string(content); got != want {
|
||||||
|
t.Fatalf("env file = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("env file mode = %o, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvFileRefusesWithoutForce(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "a", "./data"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err == nil {
|
||||||
|
t.Fatal("second write without --force must fail")
|
||||||
|
}
|
||||||
|
if err := writeEnvFile(Options{EnvFile: path, Force: true, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err != nil {
|
||||||
|
t.Fatalf("write with --force: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptReadsAnswer(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
answer := prompt(Options{Out: &out, In: strings.NewReader("postgres://custom\n")}, "Database URL", "default")
|
||||||
|
if answer != "postgres://custom" {
|
||||||
|
t.Fatalf("answer = %q, want the typed value", answer)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "Database URL [default]:") {
|
||||||
|
t.Fatalf("prompt output = %q", out.String())
|
||||||
|
}
|
||||||
|
fallback := prompt(Options{Out: &out, In: strings.NewReader("\n")}, "Question", "fb")
|
||||||
|
if fallback != "fb" {
|
||||||
|
t.Fatalf("empty answer must fall back, got %q", fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeDatabaseURL(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"postgres://scimesh:hunter2@localhost:5432/scimesh?sslmode=disable": "postgres://scimesh:***@localhost:5432/scimesh?sslmode=disable",
|
||||||
|
"postgresql://scimesh@localhost/scimesh": "postgresql://scimesh@localhost/scimesh",
|
||||||
|
"not-a-url": "not-a-url",
|
||||||
|
}
|
||||||
|
for raw, want := range cases {
|
||||||
|
got := SanitizeDatabaseURL(raw)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("sanitize(%q) = %q, want %q", raw, got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "hunter2") {
|
||||||
|
t.Errorf("sanitize(%q) leaked the password", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Package blob stores artifact bytes on the local filesystem. It implements
|
||||||
|
// usecase.BlobStore; no other layer knows where or how the bytes are kept.
|
||||||
|
package blob
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FSStore keeps each artifact as one file under dir, named by its storage key.
|
||||||
|
type FSStore struct {
|
||||||
|
dir string
|
||||||
|
staging string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.BlobStore = (*FSStore)(nil)
|
||||||
|
|
||||||
|
// NewFSStore prepares the storage and staging directories. Staging lives inside
|
||||||
|
// dir so a finished file can be renamed into place on the same filesystem —
|
||||||
|
// rename is only atomic within one filesystem.
|
||||||
|
func NewFSStore(dir string) (*FSStore, error) {
|
||||||
|
staging := filepath.Join(dir, ".staging")
|
||||||
|
if err := os.MkdirAll(staging, 0o750); err != nil {
|
||||||
|
return nil, fmt.Errorf("create blob dirs: %w", err)
|
||||||
|
}
|
||||||
|
return &FSStore{dir: dir, staging: staging}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put streams r to a staging file while hashing it, then atomically renames it
|
||||||
|
// into place. A caller that dies mid-upload leaves at most a staging temp file,
|
||||||
|
// never a half-written artifact that looks complete.
|
||||||
|
func (s *FSStore) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) {
|
||||||
|
if err := checkKey(key); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(s.staging, key+"-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("create staging file: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
// On any failure past this point, do not leave the temp file behind.
|
||||||
|
defer func() {
|
||||||
|
if tmpName != "" {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
// Tee the stream: one copy to disk, one to the hasher, in a single pass so
|
||||||
|
// the bytes are never held in memory or read twice.
|
||||||
|
size, err := io.Copy(io.MultiWriter(tmp, h), &ctxReader{ctx: ctx, r: r})
|
||||||
|
if err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return "", 0, fmt.Errorf("write artifact: %w", err)
|
||||||
|
}
|
||||||
|
// fsync before rename so a crash cannot leave a renamed-but-empty file.
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return "", 0, fmt.Errorf("sync artifact: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return "", 0, fmt.Errorf("close artifact: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
final := filepath.Join(s.dir, key)
|
||||||
|
if err := os.Rename(tmpName, final); err != nil {
|
||||||
|
return "", 0, fmt.Errorf("commit artifact: %w", err)
|
||||||
|
}
|
||||||
|
tmpName = "" // committed — the deferred cleanup must not delete it now
|
||||||
|
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open returns the artifact bytes for streaming to a client. The caller closes.
|
||||||
|
func (s *FSStore) Open(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||||
|
if err := checkKey(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// checkKey has rejected any traversal, so the joined path stays under s.dir.
|
||||||
|
f, err := os.Open(filepath.Join(s.dir, key)) //nolint:gosec // key validated by checkKey
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a stored blob. Absence is not an error: cleaning up after a
|
||||||
|
// failed metadata insert must be idempotent.
|
||||||
|
func (s *FSStore) Delete(ctx context.Context, key string) error {
|
||||||
|
if err := checkKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(s.dir, key)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkKey rejects anything that could escape the storage directory. Keys are
|
||||||
|
// coordinator-generated UUIDs, so this is defence in depth, not the only guard.
|
||||||
|
func checkKey(key string) error {
|
||||||
|
if key == "" || strings.ContainsAny(key, `/\`) || strings.Contains(key, "..") {
|
||||||
|
return fmt.Errorf("invalid storage key %q", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctxReader aborts a copy when the request context is cancelled, so a stalled
|
||||||
|
// or disconnected upload does not tie up a file handle indefinitely.
|
||||||
|
type ctxReader struct {
|
||||||
|
ctx context.Context
|
||||||
|
r io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ctxReader) Read(p []byte) (int, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return c.r.Read(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package blob
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newStore(t *testing.T) *FSStore {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewFSStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFSStore: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutComputesChecksumAndSize(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
data := bytes.Repeat([]byte("chembl-row\n"), 10000) // ~110 KB, streamed
|
||||||
|
|
||||||
|
sum, size, err := s.Put(context.Background(), "key-1", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := sha256.Sum256(data)
|
||||||
|
if sum != hex.EncodeToString(want[:]) {
|
||||||
|
t.Errorf("sha256 = %s, want %s", sum, hex.EncodeToString(want[:]))
|
||||||
|
}
|
||||||
|
if size != int64(len(data)) {
|
||||||
|
t.Errorf("size = %d, want %d", size, len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutThenOpenRoundTrips(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
data := []byte("partial result csv\n1,2,3\n")
|
||||||
|
|
||||||
|
if _, _, err := s.Put(context.Background(), "key-2", bytes.NewReader(data)); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := s.Open(context.Background(), "key-2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
got, _ := io.ReadAll(rc)
|
||||||
|
if !bytes.Equal(got, data) {
|
||||||
|
t.Errorf("round-trip mismatch: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutLeavesNoStagingFileBehind(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
if _, _, err := s.Put(context.Background(), "key-3", strings.NewReader("x")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _ := os.ReadDir(s.staging)
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("staging dir not empty after a successful put: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutFailureLeavesNoArtifactOrStaging(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
// A reader that errors partway through simulates a dropped upload.
|
||||||
|
r := io.MultiReader(strings.NewReader("half"), &erroringReader{})
|
||||||
|
|
||||||
|
if _, _, err := s.Put(context.Background(), "key-4", r); err == nil {
|
||||||
|
t.Fatal("expected an error from a failing reader")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(s.dir, "key-4")); !os.IsNotExist(err) {
|
||||||
|
t.Error("a failed put must not leave a committed artifact")
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(s.staging); len(entries) != 0 {
|
||||||
|
t.Errorf("a failed put must not leave staging files: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutRejectsUnsafeKeys(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
for _, key := range []string{"", "../escape", "a/b", `a\b`, "with..dots"} {
|
||||||
|
if _, _, err := s.Put(context.Background(), key, strings.NewReader("x")); err == nil {
|
||||||
|
t.Errorf("key %q should have been rejected", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutHonoursContextCancellation(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // already cancelled before the copy starts
|
||||||
|
|
||||||
|
if _, _, err := s.Put(ctx, "key-5", strings.NewReader("data")); err == nil {
|
||||||
|
t.Fatal("expected cancellation to abort the put")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(s.dir, "key-5")); !os.IsNotExist(err) {
|
||||||
|
t.Error("a cancelled put must not leave an artifact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type erroringReader struct{}
|
||||||
|
|
||||||
|
func (*erroringReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactRepo implements usecase.ArtifactRepository.
|
||||||
|
type ArtifactRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArtifactRepo(pool *pgxpool.Pool) *ArtifactRepo {
|
||||||
|
return &ArtifactRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.ArtifactRepository = (*ArtifactRepo)(nil)
|
||||||
|
|
||||||
|
var artifactColumns = []string{
|
||||||
|
"id", "job_id", "task_id", "attempt", "kind", "filename", "storage_key",
|
||||||
|
"content_type", "size_bytes", "sha256", "created_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtifactRepo) Insert(ctx context.Context, a *domain.Artifact) error {
|
||||||
|
sql, args, err := psql.Insert("artifacts").
|
||||||
|
Columns(artifactColumns...).
|
||||||
|
Values(a.ID, a.JobID, a.TaskID, a.Attempt, string(a.Kind), a.Filename, a.StorageKey,
|
||||||
|
a.ContentType, a.SizeBytes, a.SHA256, a.CreatedAt).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert artifact: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtifactRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error) {
|
||||||
|
sql, args, err := psql.Select(artifactColumns...).
|
||||||
|
From("artifacts").
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
a domain.Artifact
|
||||||
|
kind string
|
||||||
|
)
|
||||||
|
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
|
||||||
|
&a.ID, &a.JobID, &a.TaskID, &a.Attempt, &kind, &a.Filename, &a.StorageKey,
|
||||||
|
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get artifact: %w", err)
|
||||||
|
}
|
||||||
|
a.Kind = domain.ArtifactKind(kind)
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ArtifactRepo) FindPartialResult(ctx context.Context, taskID uuid.UUID, attempt int) (*domain.Artifact, error) {
|
||||||
|
sql, args, err := psql.Select(artifactColumns...).
|
||||||
|
From("artifacts").
|
||||||
|
Where(sq.Eq{
|
||||||
|
"task_id": taskID,
|
||||||
|
"attempt": attempt,
|
||||||
|
"kind": string(domain.ArtifactPartialResult),
|
||||||
|
}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
a domain.Artifact
|
||||||
|
kind string
|
||||||
|
)
|
||||||
|
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
|
||||||
|
&a.ID, &a.JobID, &a.TaskID, &a.Attempt, &kind, &a.Filename, &a.StorageKey,
|
||||||
|
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find partial result: %w", err)
|
||||||
|
}
|
||||||
|
a.Kind = domain.ArtifactKind(kind)
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import sq "github.com/Masterminds/squirrel"
|
||||||
|
|
||||||
|
// psql is the shared statement builder, fixed to PostgreSQL $N placeholders so
|
||||||
|
// no call site repeats PlaceholderFormat(sq.Dollar).
|
||||||
|
//
|
||||||
|
// Not everything goes through it. Two genuinely set-based statements stay as
|
||||||
|
// raw SQL — claimNext (a FOR UPDATE SKIP LOCKED CTE) and expireLeases (CASE
|
||||||
|
// logic in the SET) — because a builder would obscure them, not clarify them.
|
||||||
|
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// Integration tests run against a real PostgreSQL instance supplied through
|
||||||
|
// TEST_DATABASE_URL. The spec forbids mocks or SQLite here: the guarantees
|
||||||
|
// being verified — FOR UPDATE SKIP LOCKED, optimistic concurrency, transaction
|
||||||
|
// rollback — are properties of Postgres, not of our Go code.
|
||||||
|
//
|
||||||
|
// docker compose up -d
|
||||||
|
// TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable' \
|
||||||
|
// go test -tags=integration ./internal/storage/postgres/ -v
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedJob creates a job with n pending tasks and removes them afterwards, so
|
||||||
|
// tests stay independent of each other and of leftovers from earlier runs.
|
||||||
|
func seedJob(t *testing.T, pool *pgxpool.Pool, n int) (*domain.Job, []*domain.Task) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
chunks := make([]domain.ChunkSpec, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
chunks = append(chunks, domain.ChunkSpec{
|
||||||
|
ChunkIndex: i,
|
||||||
|
InputURI: fmt.Sprintf("s3://chunk-%d", i),
|
||||||
|
InputSHA256: fmt.Sprintf("sha-%d", i),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
job, tasks, err := domain.NewJobWithTasks("similarity_search", "s3://ds", nil, chunks, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, taskRepo, tx := NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool)
|
||||||
|
err = tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
if err := jobs.Insert(ctx, job); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return taskRepo.InsertBatch(ctx, tasks)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
// ON DELETE CASCADE removes the tasks with it.
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM jobs WHERE id = $1`, job.ID)
|
||||||
|
})
|
||||||
|
return job, tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateJobPersistsEveryTask(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
job, _ := seedJob(t, pool, 3)
|
||||||
|
|
||||||
|
counts, err := NewTaskRepo(pool).CountByStatus(context.Background(), job.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if counts[domain.TaskPending] != 3 {
|
||||||
|
t.Errorf("pending = %d, want 3", counts[domain.TaskPending])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimReductionIsAtomic(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
job, _ := seedJob(t, pool, 1)
|
||||||
|
repo := NewJobRepo(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := repo.UpdateStatus(ctx, job.ID, domain.JobReducing, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
claimed int
|
||||||
|
)
|
||||||
|
for range 8 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ok, err := repo.ClaimReduction(context.Background(), job.ID, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("claim reduction: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
mu.Lock()
|
||||||
|
claimed++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if claimed != 1 {
|
||||||
|
t.Fatalf("reducer claims = %d, want 1", claimed)
|
||||||
|
}
|
||||||
|
stored, err := repo.Get(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Status != domain.JobReducing || stored.ReducerStartedAt == nil {
|
||||||
|
t.Fatalf("stored reduction state = %+v", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIReadRepoListsReducerFields(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
job, _ := seedJob(t, pool, 1)
|
||||||
|
jobs := NewJobRepo(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := jobs.UpdateStatus(ctx, job.ID, domain.JobReducing, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed {
|
||||||
|
t.Fatalf("claim reduction = (%v, %v)", claimed, err)
|
||||||
|
}
|
||||||
|
listed, err := NewUIReadRepo(pool).ListJobs(ctx, nil, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list UI jobs: %v", err)
|
||||||
|
}
|
||||||
|
for _, item := range listed {
|
||||||
|
if item.ID != job.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.Status != domain.JobReducing || item.ReducerStartedAt == nil {
|
||||||
|
t.Fatalf("UI reducer projection = %+v", item)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatalf("seeded job %s is missing from UI list", job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A job must land whole or not at all: a half-created job leaves chunks no
|
||||||
|
// worker could ever complete.
|
||||||
|
func TestCreateJobRollsBackOnFailure(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
chunks := []domain.ChunkSpec{{ChunkIndex: 0, InputURI: "s3://c0", InputSHA256: "sha0"}}
|
||||||
|
job, tasks, err := domain.NewJobWithTasks("similarity_search", "s3://ds", nil, chunks, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, taskRepo, tx := NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool)
|
||||||
|
boom := errors.New("boom")
|
||||||
|
err = tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
if err := jobs.Insert(ctx, job); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := taskRepo.InsertBatch(ctx, tasks); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return boom // fail after both writes
|
||||||
|
})
|
||||||
|
if !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("err = %v, want boom", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := jobs.Get(ctx, job.ID); !errors.Is(err, domain.ErrJobNotFound) {
|
||||||
|
t.Errorf("job survived the rollback: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The acceptance criterion: N workers claiming at once must each get a
|
||||||
|
// different task, and no task may be handed out twice.
|
||||||
|
func TestConcurrentClaimGivesEachTaskToExactlyOneWorker(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
const tasks = 8
|
||||||
|
job, _ := seedJob(t, pool, tasks)
|
||||||
|
|
||||||
|
repo := NewTaskRepo(pool)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
claimed = make(map[uuid.UUID]string)
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
// More workers than tasks. With SKIP LOCKED, a concurrent caller can
|
||||||
|
// transiently see no eligible row while every remaining row is locked by a
|
||||||
|
// different claim statement. Poll briefly, as a real worker does, before
|
||||||
|
// treating the queue as empty. This verifies the actual contract: tasks are
|
||||||
|
// unique and all eventually become claimable without lock contention.
|
||||||
|
for i := 0; i < tasks*2; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for attempt := 0; attempt < 20; attempt++ {
|
||||||
|
task, err := repo.ClaimNext(context.Background(), usecase.ClaimFilter{
|
||||||
|
Owner: fmt.Sprintf("worker-%d", n),
|
||||||
|
Now: now,
|
||||||
|
LeaseUntil: now.Add(time.Minute),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("claim: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task == nil || task.JobID != job.ID {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if prev, dup := claimed[task.ID]; dup {
|
||||||
|
t.Errorf("task %s handed to both %s and worker-%d", task.ID, prev, n)
|
||||||
|
}
|
||||||
|
claimed[task.ID] = fmt.Sprintf("worker-%d", n)
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(claimed) != tasks {
|
||||||
|
t.Errorf("claimed %d tasks, want %d", len(claimed), tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimNextReturnsNilOnEmptyQueue(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Drain everything first, then ask once more.
|
||||||
|
repo := NewTaskRepo(pool)
|
||||||
|
for {
|
||||||
|
task, err := repo.ClaimNext(context.Background(), usecase.ClaimFilter{
|
||||||
|
Owner: "drainer", Now: now, LeaseUntil: now.Add(time.Minute),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("drain: %v", err)
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := repo.ClaimNext(context.Background(), usecase.ClaimFilter{
|
||||||
|
Owner: "worker-1", Now: now, LeaseUntil: now.Add(time.Minute),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim: %v", err)
|
||||||
|
}
|
||||||
|
if task != nil {
|
||||||
|
t.Errorf("expected nil on an empty queue, got %s", task.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelJobCancelsEveryUnfinishedTask(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, _ := seedJob(t, pool, 3)
|
||||||
|
clk := fixedClock{now: time.Now().UTC()}
|
||||||
|
uc := usecase.NewCancelJob(NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool), clk)
|
||||||
|
|
||||||
|
cancelled, err := uc.Execute(ctx, job.ID)
|
||||||
|
if err != nil || cancelled != 3 {
|
||||||
|
t.Fatalf("cancel = (%d, %v), want (3, nil)", cancelled, err)
|
||||||
|
}
|
||||||
|
stored, err := NewJobRepo(pool).Get(ctx, job.ID)
|
||||||
|
if err != nil || stored.Status != domain.JobCancelled {
|
||||||
|
t.Fatalf("job after cancel = (%+v, %v)", stored, err)
|
||||||
|
}
|
||||||
|
counts, err := NewTaskRepo(pool).CountByStatus(ctx, job.ID)
|
||||||
|
if err != nil || counts[domain.TaskCancelled] != 3 {
|
||||||
|
t.Fatalf("cancelled tasks = %d, err = %v", counts[domain.TaskCancelled], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateRejectsStaleVersion(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, _ := seedJob(t, pool, 1)
|
||||||
|
|
||||||
|
repo, tx := NewTaskRepo(pool), NewTxManager(pool)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{
|
||||||
|
Owner: "worker-1", Now: now, LeaseUntil: now.Add(time.Minute),
|
||||||
|
})
|
||||||
|
if err != nil || task == nil || task.JobID != job.ID {
|
||||||
|
t.Skipf("could not claim this job's task (got %v, %v)", task, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale copy: same row, but the version it remembers is behind.
|
||||||
|
stale := *task
|
||||||
|
stale.Version = task.Version // pretend the caller mutated it once
|
||||||
|
|
||||||
|
err = tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
fresh, err := repo.GetForUpdate(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fresh.RenewLease("worker-1", fresh.Attempt, now, now.Add(2*time.Minute)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return repo.Update(ctx, fresh)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legitimate update failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the stale copy's version is behind by one; its write must be refused.
|
||||||
|
stale.Version++ // as a domain method would have done
|
||||||
|
if err := repo.Update(ctx, &stale); !errors.Is(err, domain.ErrLeaseConflict) {
|
||||||
|
t.Errorf("stale update err = %v, want ErrLeaseConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCompletedIsOrderedByChunkIndex(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, tasks := seedJob(t, pool, 4)
|
||||||
|
|
||||||
|
repo, artifacts, tx := NewTaskRepo(pool), NewArtifactRepo(pool), NewTxManager(pool)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Complete them out of order to prove the ordering comes from SQL.
|
||||||
|
for _, i := range []int{2, 0, 3, 1} {
|
||||||
|
task := tasks[i]
|
||||||
|
err := tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
// A completed task must reference a real result artifact (FK + check).
|
||||||
|
taskID := task.ID
|
||||||
|
art, err := domain.NewArtifact(job.ID, &taskID, domain.ArtifactPartialResult,
|
||||||
|
fmt.Sprintf("result-%d.csv", task.ChunkIndex), "text/csv", now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
art.SetContent(fmt.Sprintf("rsha-%d", task.ChunkIndex), 1)
|
||||||
|
attempt := 1
|
||||||
|
art.Attempt = &attempt
|
||||||
|
if err := artifacts.Insert(ctx, art); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh, err := repo.GetForUpdate(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
owner := "worker-1"
|
||||||
|
fresh.Status = domain.TaskLeased
|
||||||
|
fresh.Attempt = attempt
|
||||||
|
fresh.LeaseOwner = &owner
|
||||||
|
expires := now.Add(time.Minute)
|
||||||
|
fresh.LeaseExpiresAt = &expires
|
||||||
|
if err := fresh.CompleteWith(art.ID, nil, owner, fresh.Attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return repo.Update(ctx, fresh)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("complete chunk %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done, err := repo.ListCompleted(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(done) != 4 {
|
||||||
|
t.Fatalf("got %d completed, want 4", len(done))
|
||||||
|
}
|
||||||
|
for i, task := range done {
|
||||||
|
if task.ChunkIndex != i {
|
||||||
|
t.Errorf("position %d holds chunk_index %d — order is not deterministic", i, task.ChunkIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A worker whose network dropped resends the same manifest. That must succeed:
|
||||||
|
// the entity is unchanged, so nothing is written, and the optimistic-concurrency
|
||||||
|
// guard must not turn the replay into a conflict.
|
||||||
|
func TestCompleteTaskReplayIsIdempotent(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, _ := seedJob(t, pool, 1)
|
||||||
|
|
||||||
|
tasks, jobs, artifacts, tx := NewTaskRepo(pool), NewJobRepo(pool), NewArtifactRepo(pool), NewTxManager(pool)
|
||||||
|
workers, results := NewWorkerRepo(pool), NewTaskResultRepo(pool)
|
||||||
|
clk := fixedClock{now: time.Now().UTC()}
|
||||||
|
uc := usecase.NewCompleteTask(tasks, jobs, artifacts, workers, results, tx, clk, 2, integrationCatalog())
|
||||||
|
|
||||||
|
claimed, err := tasks.ClaimNext(ctx, usecase.ClaimFilter{
|
||||||
|
Owner: "worker-1", Now: clk.now, LeaseUntil: clk.now.Add(time.Minute),
|
||||||
|
})
|
||||||
|
if err != nil || claimed == nil || claimed.JobID != job.ID {
|
||||||
|
t.Skipf("could not claim this job's task (got %v, %v)", claimed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A partial-result artifact the coordinator stored for this task.
|
||||||
|
art := seedArtifact(t, pool, job.ID, &claimed.ID, domain.ArtifactPartialResult)
|
||||||
|
|
||||||
|
in := usecase.CompleteTaskInput{
|
||||||
|
TaskID: claimed.ID, WorkerID: "worker-1", Attempt: claimed.Attempt,
|
||||||
|
ResultArtifactID: art.ID,
|
||||||
|
}
|
||||||
|
if _, err := uc.Execute(ctx, in); err != nil {
|
||||||
|
t.Fatalf("first submission: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := uc.Execute(ctx, in); err != nil {
|
||||||
|
t.Errorf("replay must be idempotent, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialResultIsUniquePerTaskAttempt(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, tasks := seedJob(t, pool, 1)
|
||||||
|
taskID := tasks[0].ID
|
||||||
|
first := seedArtifact(t, pool, job.ID, &taskID, domain.ArtifactPartialResult)
|
||||||
|
second, err := domain.NewArtifact(job.ID, &taskID, domain.ArtifactPartialResult, "retry.csv", "text/csv", time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attempt := 1
|
||||||
|
second.Attempt = &attempt
|
||||||
|
second.SetContent("other-sha", 5)
|
||||||
|
if err := NewArtifactRepo(pool).Insert(ctx, second); err == nil {
|
||||||
|
t.Fatalf("second partial artifact for %s/%d was accepted after %s", taskID, attempt, first.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixedClock struct{ now time.Time }
|
||||||
|
|
||||||
|
func (c fixedClock) Now() time.Time { return c.now }
|
||||||
|
|
||||||
|
// seedArtifact inserts an artifact and returns it, cleaned up with its job.
|
||||||
|
func seedArtifact(t *testing.T, pool *pgxpool.Pool, jobID uuid.UUID, taskID *uuid.UUID, kind domain.ArtifactKind) *domain.Artifact {
|
||||||
|
t.Helper()
|
||||||
|
art, err := domain.NewArtifact(jobID, taskID, kind, "f.csv", "text/csv", time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build artifact: %v", err)
|
||||||
|
}
|
||||||
|
art.SetContent(fmt.Sprintf("sha-%s", art.ID), 3)
|
||||||
|
if kind == domain.ArtifactPartialResult {
|
||||||
|
attempt := 1
|
||||||
|
art.Attempt = &attempt
|
||||||
|
}
|
||||||
|
if err := NewArtifactRepo(pool).Insert(context.Background(), art); err != nil {
|
||||||
|
t.Fatalf("insert artifact: %v", err)
|
||||||
|
}
|
||||||
|
return art
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerRepoRoundTrip(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewWorkerRepo(pool)
|
||||||
|
|
||||||
|
w, err := domain.NewWorker("lab-int", []string{"similarity_search", "similarity_graph"}, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.Insert(ctx, w); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM workers WHERE id = $1`, w.ID) })
|
||||||
|
|
||||||
|
got, err := repo.Get(ctx, w.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != domain.WorkerOnline || len(got.Capabilities) != 2 {
|
||||||
|
t.Errorf("round-trip mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
// capabilities must survive the jsonb round-trip.
|
||||||
|
if got.Capabilities[0] != "similarity_search" {
|
||||||
|
t.Errorf("capabilities = %v", got.Capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.Get(ctx, uuid.New()); !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||||
|
t.Errorf("missing worker err = %v, want ErrWorkerNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerLivenessAndOfflineReaper(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewWorkerRepo(pool)
|
||||||
|
|
||||||
|
w, err := domain.NewWorker("liveness", []string{"similarity_search"}, time.Now().UTC().Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.Insert(ctx, w); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM workers WHERE id = $1`, w.ID) })
|
||||||
|
|
||||||
|
// A fresh heartbeat bumps it online.
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := repo.Touch(ctx, w.ID, now); err != nil {
|
||||||
|
t.Fatalf("touch: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := repo.Get(ctx, w.ID); got.Status != domain.WorkerOnline {
|
||||||
|
t.Errorf("status = %q, want online after touch", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touching an unregistered id is a harmless no-op.
|
||||||
|
if err := repo.Touch(ctx, uuid.New(), now); err != nil {
|
||||||
|
t.Errorf("touch of unknown worker returned %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reaper marks it offline once its heartbeat is older than the cutoff.
|
||||||
|
n, err := repo.MarkStaleOffline(ctx, now.Add(time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mark offline: %v", err)
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
t.Errorf("marked %d offline, want at least 1", n)
|
||||||
|
}
|
||||||
|
if got, _ := repo.Get(ctx, w.ID); got.Status != domain.WorkerOffline {
|
||||||
|
t.Errorf("status = %q, want offline after reaper", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactRepoRoundTrip(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, _ := seedJob(t, pool, 1)
|
||||||
|
|
||||||
|
art := seedArtifact(t, pool, job.ID, nil, domain.ArtifactInput)
|
||||||
|
got, err := NewArtifactRepo(pool).Get(ctx, art.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Kind != domain.ArtifactInput || got.StorageKey != art.StorageKey || got.SizeBytes != 3 {
|
||||||
|
t.Errorf("round-trip mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
if _, err := NewArtifactRepo(pool).Get(ctx, uuid.New()); !errors.Is(err, domain.ErrArtifactNotFound) {
|
||||||
|
t.Errorf("missing artifact err = %v, want ErrArtifactNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialResultArtifactRoundTripsAttempt(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, tasks := seedJob(t, pool, 1)
|
||||||
|
taskID := tasks[0].ID
|
||||||
|
art, err := domain.NewArtifact(job.ID, &taskID, domain.ArtifactPartialResult,
|
||||||
|
"result.csv", "text/csv", time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attempt := 2
|
||||||
|
art.Attempt = &attempt
|
||||||
|
art.SetContent("sha", 3)
|
||||||
|
repo := NewArtifactRepo(pool)
|
||||||
|
if err := repo.Insert(ctx, art); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
got, err := repo.Get(ctx, art.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Attempt == nil || *got.Attempt != attempt {
|
||||||
|
t.Fatalf("attempt = %v, want %d", got.Attempt, attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A shard task stores its input as an artifact and no URI: this exercises the
|
||||||
|
// nullable input_uri column, the input_artifact_id round-trip, and the
|
||||||
|
// ck_tasks_has_input check that requires one or the other.
|
||||||
|
func TestShardTaskRoundTrip(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
job, err := domain.NewUploadedJob("similarity_search", nil, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jobs, taskRepo, tx := NewJobRepo(pool), NewTaskRepo(pool), NewTxManager(pool)
|
||||||
|
if err := jobs.Insert(ctx, job); err != nil {
|
||||||
|
t.Fatalf("insert job: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM jobs WHERE id = $1`, job.ID) })
|
||||||
|
|
||||||
|
shard := seedArtifact(t, pool, job.ID, nil, domain.ArtifactShard)
|
||||||
|
task, err := domain.NewShardTask(job.ID, 0, "similarity_search", shard.ID, shard.SHA256, nil, 0, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
return taskRepo.InsertBatch(ctx, []*domain.Task{task})
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert shard task: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := taskRepo.Get(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.InputArtifactID == nil || *got.InputArtifactID != shard.ID {
|
||||||
|
t.Errorf("input_artifact_id did not round-trip: %v", got.InputArtifactID)
|
||||||
|
}
|
||||||
|
if got.InputURI != "" {
|
||||||
|
t.Errorf("shard task input_uri = %q, want empty (NULL)", got.InputURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpireLeasesRequeuesElapsedTasks(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
job, _ := seedJob(t, pool, 1)
|
||||||
|
|
||||||
|
repo := NewTaskRepo(pool)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
|
||||||
|
// Lease it with an expiry already in the past.
|
||||||
|
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{
|
||||||
|
Owner: "dead-worker", Now: past, LeaseUntil: past.Add(time.Minute),
|
||||||
|
})
|
||||||
|
if err != nil || task == nil || task.JobID != job.ID {
|
||||||
|
t.Skipf("could not claim this job's task (got %v, %v)", task, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.ExpireLeases(ctx, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("expire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := repo.CountByStatus(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if counts[domain.TaskPending] != 1 {
|
||||||
|
t.Errorf("pending = %d, want 1 — a dead worker must not strand its task", counts[domain.TaskPending])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateProvisionsAndIsIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
if err := Migrate(ctx, url, nil); err != nil {
|
||||||
|
t.Fatalf("first migrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := Migrate(ctx, url, nil); err != nil {
|
||||||
|
t.Fatalf("second migrate (idempotent): %v", err)
|
||||||
|
}
|
||||||
|
pool := testPool(t)
|
||||||
|
migrations, err := listMigrations()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var watermark int64
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
|
||||||
|
t.Fatalf("read schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
if watermark != int64(len(migrations)) {
|
||||||
|
t.Errorf("schema watermark = %d, want %d", watermark, len(migrations))
|
||||||
|
}
|
||||||
|
var hasJobs bool
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'jobs')",
|
||||||
|
).Scan(&hasJobs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hasJobs {
|
||||||
|
t.Error("jobs table was not created by the embedded migrations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func integrationCatalog() *workloads.Catalog {
|
||||||
|
catalog, err := workloads.Load()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobRepo implements usecase.JobRepository.
|
||||||
|
type JobRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobRepo(pool *pgxpool.Pool) *JobRepo {
|
||||||
|
return &JobRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.JobRepository = (*JobRepo)(nil)
|
||||||
|
|
||||||
|
var jobColumns = []string{
|
||||||
|
"id", "workload", "input_uri", "parameters", "status", "created_at", "completed_at",
|
||||||
|
"input_artifact_id", "result_artifact_id", "error_code", "error_message", "reducer_started_at",
|
||||||
|
"owner_id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert runs inside the caller's transaction, alongside the job's tasks — that
|
||||||
|
// is what makes "all tasks or none" hold.
|
||||||
|
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
|
||||||
|
sql, args, err := psql.Insert("jobs").
|
||||||
|
Columns("id", "workload", "input_uri", "parameters", "status", "created_at", "owner_id").
|
||||||
|
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt, j.OwnerID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||||
|
sql, args, err := psql.Select(jobColumns...).
|
||||||
|
From("jobs").
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
j domain.Job
|
||||||
|
status string
|
||||||
|
)
|
||||||
|
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
|
||||||
|
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
|
||||||
|
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
|
||||||
|
&j.OwnerID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
j.Status = domain.JobStatus(status)
|
||||||
|
return &j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) ClaimReduction(ctx context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
|
||||||
|
sql, args, err := psql.Update("jobs").
|
||||||
|
Set("reducer_started_at", startedAt).
|
||||||
|
Where(sq.Eq{"id": id, "status": string(domain.JobReducing), "reducer_started_at": nil}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) CompleteWithResult(ctx context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
|
||||||
|
sql, args, err := psql.Update("jobs").
|
||||||
|
SetMap(map[string]any{
|
||||||
|
"status": string(domain.JobCompleted),
|
||||||
|
"result_artifact_id": resultArtifactID,
|
||||||
|
"completed_at": completedAt,
|
||||||
|
"reducer_started_at": nil,
|
||||||
|
"error_code": nil,
|
||||||
|
"error_message": nil,
|
||||||
|
}).
|
||||||
|
Where(sq.Eq{"id": id, "status": string(domain.JobReducing)}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) FailReduction(ctx context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
|
||||||
|
sql, args, err := psql.Update("jobs").
|
||||||
|
SetMap(map[string]any{
|
||||||
|
"status": string(domain.JobFailed),
|
||||||
|
"completed_at": completedAt,
|
||||||
|
"error_code": code,
|
||||||
|
"error_message": message,
|
||||||
|
"reducer_started_at": nil,
|
||||||
|
}).
|
||||||
|
Where(sq.Eq{"id": id, "status": string(domain.JobReducing)}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID,
|
||||||
|
status domain.JobStatus, completedAt *time.Time) error {
|
||||||
|
|
||||||
|
sql, args, err := psql.Update("jobs").
|
||||||
|
SetMap(map[string]any{
|
||||||
|
"status": string(status),
|
||||||
|
"completed_at": completedAt,
|
||||||
|
}).
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationFiles embed.FS
|
||||||
|
|
||||||
|
var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.(up|down)\.sql$`)
|
||||||
|
|
||||||
|
// migration is one parsed embedded migration file.
|
||||||
|
type migration struct {
|
||||||
|
version int
|
||||||
|
name string
|
||||||
|
sql string
|
||||||
|
}
|
||||||
|
|
||||||
|
// listMigrations parses and orders the embedded .up.sql files by version.
|
||||||
|
func listMigrations() ([]migration, error) {
|
||||||
|
entries, err := migrationFiles.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read embedded migrations: %w", err)
|
||||||
|
}
|
||||||
|
up := map[int]migration{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
match := migrationNamePattern.FindStringSubmatch(entry.Name())
|
||||||
|
if match == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match[2] != "up" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
version, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
if _, duplicate := up[version]; duplicate {
|
||||||
|
return nil, fmt.Errorf("migration version %d is duplicated", version)
|
||||||
|
}
|
||||||
|
body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read migration %q: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
up[version] = migration{version: version, name: entry.Name(), sql: string(body)}
|
||||||
|
}
|
||||||
|
if len(up) == 0 {
|
||||||
|
return nil, fmt.Errorf("no .up.sql migrations are embedded")
|
||||||
|
}
|
||||||
|
versions := make([]int, 0, len(up))
|
||||||
|
for version := range up {
|
||||||
|
versions = append(versions, version)
|
||||||
|
}
|
||||||
|
sort.Ints(versions)
|
||||||
|
migrations := make([]migration, 0, len(versions))
|
||||||
|
for _, version := range versions {
|
||||||
|
migrations = append(migrations, up[version])
|
||||||
|
}
|
||||||
|
for index, item := range migrations {
|
||||||
|
if item.version != index+1 {
|
||||||
|
return nil, fmt.Errorf("embedded migrations are not contiguous: version %d at position %d", item.version, index+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies every embedded migration above the recorded schema version,
|
||||||
|
// so the binary provisions its own schema. It is idempotent and interoperates
|
||||||
|
// with the golang-migrate CLI: both tools use the same schema_migrations
|
||||||
|
// watermark table (single row: version + dirty flag), and a PostgreSQL
|
||||||
|
// advisory lock serializes concurrent migrators. Each migration file runs as
|
||||||
|
// its own transaction (the files carry explicit BEGIN/COMMIT, matching the
|
||||||
|
// golang-migrate format the CLI and CI still use).
|
||||||
|
func Migrate(ctx context.Context, databaseURL string, log *slog.Logger) error {
|
||||||
|
migrations, err := listMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
connConfig, err := pgx.ParseConfig(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse database url: %w", err)
|
||||||
|
}
|
||||||
|
// Migration files contain multiple statements (BEGIN...COMMIT), which the
|
||||||
|
// extended query protocol rejects; run them with the simple protocol.
|
||||||
|
connConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
|
||||||
|
conn, err := pgx.ConnectConfig(ctx, connConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect for migration: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close(ctx) }()
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock(82473911)"); err != nil {
|
||||||
|
return fmt.Errorf("acquire migration lock: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _, _ = conn.Exec(ctx, "SELECT pg_advisory_unlock(82473911)") }()
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_migrations (version bigint PRIMARY KEY, dirty boolean NOT NULL DEFAULT false)",
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("ensure schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var applied int64
|
||||||
|
if err := conn.QueryRow(ctx,
|
||||||
|
"SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
|
||||||
|
).Scan(&applied); err != nil {
|
||||||
|
return fmt.Errorf("read applied schema version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range migrations {
|
||||||
|
if int64(item.version) <= applied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log.Info("applying migration", "version", item.version, "file", item.name)
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(ctx, item.sql); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", item.name, err)
|
||||||
|
}
|
||||||
|
// Advance the watermark to the single-row golang-migrate layout.
|
||||||
|
tag, err := conn.Exec(ctx,
|
||||||
|
"UPDATE schema_migrations SET version = $1, dirty = false", item.version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", item.name, err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
if _, err := conn.Exec(ctx,
|
||||||
|
"INSERT INTO schema_migrations (version, dirty) VALUES ($1, false)",
|
||||||
|
item.version,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", item.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListMigrationsParsesAndOrdersEmbeddedFiles(t *testing.T) {
|
||||||
|
migrations, err := listMigrations()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list migrations: %v", err)
|
||||||
|
}
|
||||||
|
if len(migrations) == 0 {
|
||||||
|
t.Fatal("no embedded migrations")
|
||||||
|
}
|
||||||
|
for index, item := range migrations {
|
||||||
|
if item.version != index+1 {
|
||||||
|
t.Errorf("migration %d has version %d, want contiguous ordering", index, item.version)
|
||||||
|
}
|
||||||
|
if item.name != expectedMigrationName(item.version) {
|
||||||
|
t.Errorf("migration %d file is %q, want %q", item.version, item.name, expectedMigrationName(item.version))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.sql) == "" {
|
||||||
|
t.Errorf("migration %d is empty", item.version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedMigrationName(version int) string {
|
||||||
|
switch version {
|
||||||
|
case 1:
|
||||||
|
return "0001_init.up.sql"
|
||||||
|
case 2:
|
||||||
|
return "0002_workers.up.sql"
|
||||||
|
case 3:
|
||||||
|
return "0003_artifacts.up.sql"
|
||||||
|
case 4:
|
||||||
|
return "0004_result_artifact.up.sql"
|
||||||
|
case 5:
|
||||||
|
return "0005_uploaded_input.up.sql"
|
||||||
|
case 6:
|
||||||
|
return "0006_task_running_enum.up.sql"
|
||||||
|
case 7:
|
||||||
|
return "0007_task_running_lease.up.sql"
|
||||||
|
case 8:
|
||||||
|
return "0008_artifact_attempt.up.sql"
|
||||||
|
case 9:
|
||||||
|
return "0009_unique_partial_result_attempt.up.sql"
|
||||||
|
case 10:
|
||||||
|
return "0010_job_reduction.up.sql"
|
||||||
|
case 11:
|
||||||
|
return "0011_job_owner.up.sql"
|
||||||
|
case 12:
|
||||||
|
return "0012_worker_trust.up.sql"
|
||||||
|
case 13:
|
||||||
|
return "0013_task_results.up.sql"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS tasks;
|
||||||
|
DROP TABLE IF EXISTS jobs;
|
||||||
|
DROP TYPE IF EXISTS task_status;
|
||||||
|
DROP TYPE IF EXISTS job_status;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TYPE job_status AS ENUM ('pending','running','completed','failed','cancelled');
|
||||||
|
CREATE TYPE task_status AS ENUM ('pending','leased','completed','failed','cancelled');
|
||||||
|
|
||||||
|
-- One user submission, possibly split into several tasks.
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
workload text NOT NULL,
|
||||||
|
input_uri text NOT NULL,
|
||||||
|
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status job_status NOT NULL DEFAULT 'pending',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
completed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One independently executable chunk.
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
|
chunk_index integer NOT NULL,
|
||||||
|
workload text NOT NULL,
|
||||||
|
input_uri text NOT NULL,
|
||||||
|
input_sha256 text NOT NULL,
|
||||||
|
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status task_status NOT NULL DEFAULT 'pending',
|
||||||
|
attempt integer NOT NULL DEFAULT 0,
|
||||||
|
max_attempts integer NOT NULL DEFAULT 3,
|
||||||
|
lease_owner text,
|
||||||
|
lease_expires_at timestamptz,
|
||||||
|
result_uri text,
|
||||||
|
result_sha256 text,
|
||||||
|
metrics jsonb,
|
||||||
|
error_code text,
|
||||||
|
error_message text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
started_at timestamptz,
|
||||||
|
completed_at timestamptz,
|
||||||
|
version integer NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT uq_tasks_job_chunk UNIQUE (job_id, chunk_index),
|
||||||
|
CONSTRAINT ck_tasks_attempt CHECK (attempt >= 0),
|
||||||
|
CONSTRAINT ck_tasks_max_attempts CHECK (max_attempts > 0),
|
||||||
|
-- A completed task must carry its result manifest.
|
||||||
|
CONSTRAINT ck_tasks_completed_result CHECK (
|
||||||
|
status <> 'completed' OR (result_uri IS NOT NULL AND result_sha256 IS NOT NULL)
|
||||||
|
),
|
||||||
|
-- A leased task must carry its lease.
|
||||||
|
CONSTRAINT ck_tasks_leased_owner CHECK (
|
||||||
|
status <> 'leased' OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Claim path: find the oldest pending task fast.
|
||||||
|
CREATE INDEX ix_tasks_claim ON tasks (status, lease_expires_at, created_at);
|
||||||
|
CREATE INDEX ix_tasks_job ON tasks (job_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS workers;
|
||||||
|
DROP TYPE IF EXISTS worker_status;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TYPE worker_status AS ENUM ('online','busy','offline');
|
||||||
|
|
||||||
|
-- A registered process/machine that can claim tasks. Registration returns the
|
||||||
|
-- id; liveness is tracked by last_heartbeat_at.
|
||||||
|
CREATE TABLE workers (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
capabilities jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
status worker_status NOT NULL DEFAULT 'online',
|
||||||
|
last_heartbeat_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Liveness sweep: find workers that have gone quiet.
|
||||||
|
CREATE INDEX ix_workers_liveness ON workers (status, last_heartbeat_at);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE tasks DROP COLUMN IF EXISTS input_artifact_id;
|
||||||
|
ALTER TABLE tasks DROP COLUMN IF EXISTS result_artifact_id;
|
||||||
|
ALTER TABLE jobs DROP COLUMN IF EXISTS input_artifact_id;
|
||||||
|
ALTER TABLE jobs DROP COLUMN IF EXISTS result_artifact_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS artifacts;
|
||||||
|
DROP TYPE IF EXISTS artifact_kind;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TYPE artifact_kind AS ENUM ('input','shard','partial_result','final_result','log');
|
||||||
|
|
||||||
|
-- A durable file the coordinator owns: input, shard, partial/final result, log.
|
||||||
|
-- The database is the source of truth; files are found through this metadata,
|
||||||
|
-- never by scanning directories.
|
||||||
|
CREATE TABLE artifacts (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
|
task_id uuid REFERENCES tasks(id) ON DELETE CASCADE, -- null for job-level inputs
|
||||||
|
kind artifact_kind NOT NULL,
|
||||||
|
filename text NOT NULL,
|
||||||
|
storage_key text NOT NULL UNIQUE, -- coordinator-generated, never a client path
|
||||||
|
content_type text NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
|
||||||
|
sha256 text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_artifacts_job ON artifacts (job_id);
|
||||||
|
CREATE INDEX ix_artifacts_task ON artifacts (task_id);
|
||||||
|
|
||||||
|
-- Jobs and tasks reference their artifacts. Nullable during the transition from
|
||||||
|
-- URI-based inputs/results to artifact-based ones.
|
||||||
|
ALTER TABLE jobs ADD COLUMN input_artifact_id uuid REFERENCES artifacts(id);
|
||||||
|
ALTER TABLE jobs ADD COLUMN result_artifact_id uuid REFERENCES artifacts(id);
|
||||||
|
ALTER TABLE tasks ADD COLUMN input_artifact_id uuid REFERENCES artifacts(id);
|
||||||
|
ALTER TABLE tasks ADD COLUMN result_artifact_id uuid REFERENCES artifacts(id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_completed_result;
|
||||||
|
ALTER TABLE tasks ADD COLUMN result_uri text;
|
||||||
|
ALTER TABLE tasks ADD COLUMN result_sha256 text;
|
||||||
|
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_completed_result CHECK (
|
||||||
|
status <> 'completed' OR (result_uri IS NOT NULL AND result_sha256 IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Results are now coordinator-owned artifacts, not worker-supplied URIs.
|
||||||
|
-- Drop the URI-based completion guard and columns, and require a completed task
|
||||||
|
-- to reference its result artifact instead (PLAN.md §6.2).
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_completed_result;
|
||||||
|
ALTER TABLE tasks DROP COLUMN IF EXISTS result_uri;
|
||||||
|
ALTER TABLE tasks DROP COLUMN IF EXISTS result_sha256;
|
||||||
|
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_completed_result CHECK (
|
||||||
|
status <> 'completed' OR result_artifact_id IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_has_input;
|
||||||
|
|
||||||
|
-- Restoring NOT NULL requires the columns to be populated; safe on a fresh DB.
|
||||||
|
ALTER TABLE tasks ALTER COLUMN input_uri SET NOT NULL;
|
||||||
|
ALTER TABLE jobs ALTER COLUMN input_uri SET NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Inputs can now arrive as uploaded artifacts (POST /jobs/upload), not only as
|
||||||
|
-- external URIs. Relax the URI requirement and require every task to have an
|
||||||
|
-- input one way or the other.
|
||||||
|
ALTER TABLE jobs ALTER COLUMN input_uri DROP NOT NULL;
|
||||||
|
ALTER TABLE tasks ALTER COLUMN input_uri DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_has_input CHECK (
|
||||||
|
input_uri IS NOT NULL OR input_artifact_id IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- PostgreSQL cannot drop a single enum value without recreating the type and
|
||||||
|
-- rewriting every dependent column. Leaving 'running' in place is harmless: no
|
||||||
|
-- code writes it after the down of 0007 restores the leased-only transitions.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 'running' means the worker has acknowledged start via its first heartbeat.
|
||||||
|
-- Kept in its own migration, without an explicit transaction: an enum value
|
||||||
|
-- added in a transaction cannot be USED in that same transaction, and the next
|
||||||
|
-- migration references it.
|
||||||
|
ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'running';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_leased_owner;
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_leased_owner CHECK (
|
||||||
|
status <> 'leased' OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- A running task holds a lease just like a leased one, so the lease-integrity
|
||||||
|
-- check must cover both states.
|
||||||
|
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS ck_tasks_leased_owner;
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT ck_tasks_leased_owner CHECK (
|
||||||
|
status NOT IN ('leased','running') OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE artifacts DROP CONSTRAINT IF EXISTS ck_artifact_attempt_positive;
|
||||||
|
ALTER TABLE artifacts DROP CONSTRAINT IF EXISTS ck_partial_result_attempt;
|
||||||
|
ALTER TABLE artifacts DROP COLUMN IF EXISTS attempt;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- A partial result belongs to the lease attempt that uploaded it. Without this
|
||||||
|
-- binding a worker holding a later retry could complete a task with stale bytes
|
||||||
|
-- uploaded by an expired attempt of that same task.
|
||||||
|
ALTER TABLE artifacts ADD COLUMN attempt integer;
|
||||||
|
|
||||||
|
-- A completed task never gets a later lease, so its current attempt is also
|
||||||
|
-- the attempt that produced the stored result.
|
||||||
|
UPDATE artifacts AS a
|
||||||
|
SET attempt = t.attempt
|
||||||
|
FROM tasks AS t
|
||||||
|
WHERE a.task_id = t.id
|
||||||
|
AND a.kind = 'partial_result'::artifact_kind
|
||||||
|
AND t.status = 'completed'::task_status
|
||||||
|
AND a.attempt IS NULL;
|
||||||
|
|
||||||
|
-- For unfinished tasks the old schema cannot tell which attempt uploaded a
|
||||||
|
-- partial result. Keeping it would let a later retry claim stale bytes, so the
|
||||||
|
-- worker must upload again. Blob garbage is harmless and follows the existing
|
||||||
|
-- coordinator-owned storage cleanup policy.
|
||||||
|
DELETE FROM artifacts AS a
|
||||||
|
USING tasks AS t
|
||||||
|
WHERE a.task_id = t.id
|
||||||
|
AND a.kind = 'partial_result'::artifact_kind
|
||||||
|
AND t.status <> 'completed'::task_status
|
||||||
|
AND a.attempt IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE artifacts ADD CONSTRAINT ck_partial_result_attempt
|
||||||
|
CHECK (kind <> 'partial_result'::artifact_kind OR attempt IS NOT NULL);
|
||||||
|
ALTER TABLE artifacts ADD CONSTRAINT ck_artifact_attempt_positive
|
||||||
|
CHECK (attempt IS NULL OR attempt > 0);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS uq_partial_result_task_attempt;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Old deployments can contain more than one partial result because earlier
|
||||||
|
-- versions accepted repeated PUTs. Preserve the one referenced by a completed
|
||||||
|
-- task and discard stale rows; unfinished tasks must upload again after a
|
||||||
|
-- deploy, just as they do after a lost lease.
|
||||||
|
DELETE FROM artifacts AS a
|
||||||
|
USING tasks AS t
|
||||||
|
WHERE a.task_id = t.id
|
||||||
|
AND a.kind = 'partial_result'::artifact_kind
|
||||||
|
AND t.status <> 'completed'::task_status;
|
||||||
|
|
||||||
|
DELETE FROM artifacts AS a
|
||||||
|
USING tasks AS t
|
||||||
|
WHERE a.task_id = t.id
|
||||||
|
AND a.kind = 'partial_result'::artifact_kind
|
||||||
|
AND t.status = 'completed'::task_status
|
||||||
|
AND a.id <> t.result_artifact_id;
|
||||||
|
|
||||||
|
-- One lease attempt has one durable partial result. This makes an upload retry
|
||||||
|
-- idempotent and prevents repeated uploads from accumulating orphan artifacts.
|
||||||
|
CREATE UNIQUE INDEX uq_partial_result_task_attempt
|
||||||
|
ON artifacts (task_id, attempt)
|
||||||
|
WHERE kind = 'partial_result'::artifact_kind;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE jobs DROP COLUMN IF EXISTS error_message;
|
||||||
|
ALTER TABLE jobs DROP COLUMN IF EXISTS error_code;
|
||||||
|
ALTER TABLE jobs DROP COLUMN IF EXISTS reducer_started_at;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- PostgreSQL enum values must be committed before they are used by a later
|
||||||
|
-- transaction, so this migration intentionally has no BEGIN/COMMIT wrapper.
|
||||||
|
ALTER TYPE job_status ADD VALUE IF NOT EXISTS 'reducing';
|
||||||
|
|
||||||
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS error_code text;
|
||||||
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS error_message text;
|
||||||
|
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS reducer_started_at timestamptz;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user