Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa76133efc | ||
|
|
7d8998408c | ||
|
|
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 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,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,8 @@ 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/
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help demo-ui demo-down demo-logs
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' \
|
||||||
|
'SciMesh developer commands:' \
|
||||||
|
' make demo-ui Start the local UI pipeline demo with 2 workers.' \
|
||||||
|
' make demo-ui WORKERS=3 Start the demo with 3 local workers.' \
|
||||||
|
' make demo-logs Follow coordinator logs for the demo.' \
|
||||||
|
' make demo-down Stop demo containers and workers.' \
|
||||||
|
'' \
|
||||||
|
'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
|
||||||
|
demo-ui:
|
||||||
|
$(MAKE) -C coordinator demo-ui
|
||||||
|
|
||||||
|
demo-down:
|
||||||
|
$(MAKE) -C coordinator demo-down
|
||||||
|
|
||||||
|
demo-logs:
|
||||||
|
$(MAKE) -C coordinator demo-logs
|
||||||
@@ -66,7 +66,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;
|
- user accounts, multi-tenancy, billing, or sophisticated authorization
|
||||||
|
(planned after the first release in CTX-15);
|
||||||
- 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;
|
||||||
@@ -780,6 +781,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 +821,71 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Suggested assignment bundles
|
## 10. Suggested assignment bundles
|
||||||
@@ -944,12 +1014,15 @@ 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.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# 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 scientific-workload framework for molecular datasets. Its public CLI
|
||||||
|
runs exact similarity search and sparse similarity-graph construction locally in
|
||||||
|
one Python process; it creates no dense similarity matrix. The Go/PostgreSQL
|
||||||
|
coordinator and Python worker can run a shard-based `similarity-search`
|
||||||
|
pipeline locally. After every shard succeeds, the coordinator deterministically
|
||||||
|
merges its candidates into one final global top-k CSV. See
|
||||||
|
[`STATUS.md`](STATUS.md).
|
||||||
|
|
||||||
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 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`.
|
||||||
|
|
||||||
@@ -38,6 +44,27 @@ scimesh similarity-search --help
|
|||||||
scimesh similarity-graph --help
|
scimesh similarity-graph --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Manual pipeline demo
|
||||||
|
|
||||||
|
To inspect the coordinator, Web UI, and distributed `similarity-search`
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:18080/ui` and sign in with username `operator` and
|
||||||
|
password `demo-ui-secret`. The command starts PostgreSQL, the coordinator, and
|
||||||
|
two local reference workers. 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. 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 +132,11 @@ 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.
|
||||||
|
|
||||||
|
## 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-07-27
|
||||||
**Branch baseline:** `planning` at `13f9a0b`
|
**Branch baseline:** `main` at `f5ead0a` (team and scaling-roadmap documentation)
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
@@ -15,39 +15,51 @@ 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 Python worker now
|
||||||
|
uses the live coordinator contract. Completed similarity-search shard results
|
||||||
|
are reduced once into a checksum-protected final CSV, which is downloadable
|
||||||
|
through the coordinator. The full Go checks (including a fresh migration and
|
||||||
|
real PostgreSQL smoke test) passed on 2026-07-24.
|
||||||
|
|
||||||
|
A User Service is being developed on a separate programmer branch. It is not
|
||||||
|
yet merged, reviewed, or integrated with the coordinator/worker contract.
|
||||||
|
|
||||||
## 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 | Applied by the Compose migration service. |
|
||||||
| CTX-03 Transactional queue | Not started | Depends on CTX-02. |
|
| CTX-03 Transactional queue | Implemented | Real-PostgreSQL integration tests cover atomic claims and concurrency. |
|
||||||
| 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 | Implemented | Worker completed a real uploaded shard via HTTP on 2026-07-23. |
|
||||||
| 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 are in `scimesh/distributed/`. |
|
||||||
| CTX-08 Distributed similarity-search | Not started | Local reference exists. |
|
| CTX-08 Distributed similarity-search | Implemented | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. |
|
||||||
| 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 top-k reducer, sanitized reducer failure, final artifact persistence, `result_uri`, and final CSV download. |
|
||||||
| CTX-10 Distributed similarity-graph | Not started | Local reference exists. |
|
| CTX-10 Distributed similarity-graph | Not started | Local reference exists. |
|
||||||
| CTX-11 Dashboard/operator view | Not started | Deferred until API and reducer work. |
|
| CTX-11 Dashboard/operator view | Implemented | Protected live control room: recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, and bounded polling. |
|
||||||
| CTX-12 Reliability, security, CI | Not started | Final milestone. |
|
| CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. |
|
||||||
|
| CTX-15 User Service and access control | In progress (separate branch) | Proposed implementation is under development; API, security review, tests, and integration are pending. |
|
||||||
|
|
||||||
## 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 currently accepts both underscore API workload
|
||||||
- No Go module, PostgreSQL migrations, runtime configuration, or integration
|
names and hyphenated CLI names while the contract is consolidated.
|
||||||
environment exists yet.
|
- A real-stack worker test uses a small `query_smiles` shard. The Python
|
||||||
- Local worker unit tests do not prove interoperability with a live coordinator.
|
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.
|
||||||
|
|
||||||
## 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,52 @@
|
|||||||
|
# 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 . .
|
||||||
|
|
||||||
|
# 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" \
|
||||||
|
-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,154 @@
|
|||||||
|
.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 demo-ui demo-down 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
|
||||||
|
|
||||||
|
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 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-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 migrations -database "$(DATABASE_URL)" up
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
migrate -path 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,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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"
|
||||||
|
httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() 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))
|
||||||
|
|
||||||
|
cfg, err := infra.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
boot.Error("load config", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
pool, err := infra.NewPool(ctx, cfg, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("connect database", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
blobStore, err := blob.NewFSStore(cfg.StorageDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("init blob storage", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
clk = infra.NewClock()
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
||||||
|
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
||||||
|
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize),
|
||||||
|
ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk),
|
||||||
|
FailTask: usecase.NewFailTask(taskRepo, jobRepo, workerRepo, tx, clk),
|
||||||
|
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),
|
||||||
|
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 pool.Close() would pull
|
||||||
|
// connections out from under them.
|
||||||
|
expireLeases := usecase.NewExpireLeases(taskRepo, jobRepo, tx, clk)
|
||||||
|
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.
|
||||||
|
statsRepo := postgres.NewStatsRepo(pool)
|
||||||
|
m := metrics.New()
|
||||||
|
m.RegisterBusiness(func(ctx context.Context) (metrics.Stats, error) {
|
||||||
|
tasks, jobs, workers, err := statsRepo.Counts(ctx)
|
||||||
|
return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err
|
||||||
|
})
|
||||||
|
|
||||||
|
// pool.Ping 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, pool.Ping)
|
||||||
|
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 pool.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
|
||||||
|
}
|
||||||
@@ -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,66 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# One-shot: applies migrations, then exits. Schema changes stay an explicit
|
||||||
|
# deployment step — the coordinator binary never migrates on startup.
|
||||||
|
migrate:
|
||||||
|
image: migrate/migrate:v4.17.1
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./migrations:/migrations:ro
|
||||||
|
command:
|
||||||
|
- -path=/migrations
|
||||||
|
- -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh}?sslmode=disable
|
||||||
|
- up
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
coordinator:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
# Start only once the schema exists, otherwise the first query fails.
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
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:-}
|
||||||
|
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,33 @@
|
|||||||
|
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/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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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/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/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/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/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.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=
|
||||||
@@ -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,221 @@
|
|||||||
|
// 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"
|
||||||
|
"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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"),
|
||||||
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
|
LogFile: os.Getenv("LOG_FILE"),
|
||||||
|
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
return Config{}, fmt.Errorf("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,117 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
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,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,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,659 @@
|
|||||||
|
//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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,82 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transient PostgreSQL failures. Under concurrent claiming these are expected
|
||||||
|
// rather than exceptional: two coordinators touching neighbouring rows can
|
||||||
|
// deadlock or fail to serialize, and the correct response is to try again.
|
||||||
|
const (
|
||||||
|
codeSerializationFailure = "40001"
|
||||||
|
codeDeadlockDetected = "40P01"
|
||||||
|
codeTooManyConnections = "53300"
|
||||||
|
codeCannotConnectNow = "57P03"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Retry budget: short and bounded. A worker polling for tasks would rather get
|
||||||
|
// a fast error and poll again than have its request hang for half a minute.
|
||||||
|
const (
|
||||||
|
retryInitialInterval = 50 * time.Millisecond
|
||||||
|
retryMaxInterval = 1 * time.Second
|
||||||
|
retryMaxElapsedTime = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// isTransient reports whether err is worth retrying.
|
||||||
|
//
|
||||||
|
// The default is *not* to retry: a constraint violation or a syntax error will
|
||||||
|
// fail identically every time, and retrying it only multiplies the damage.
|
||||||
|
func isTransient(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A cancelled caller does not want another attempt.
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
switch pgErr.Code {
|
||||||
|
case codeSerializationFailure, codeDeadlockDetected,
|
||||||
|
codeTooManyConnections, codeCannotConnectNow:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection-level trouble (dropped socket, closed pool). pgconn knows
|
||||||
|
// whether the query could have been executed before the failure — retrying
|
||||||
|
// a maybe-executed write would risk duplicating it.
|
||||||
|
return pgconn.SafeToRetry(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withRetry runs op, retrying only transient database failures with
|
||||||
|
// exponential backoff and jitter, and giving up as soon as ctx is done.
|
||||||
|
//
|
||||||
|
// Jitter matters here: without it, several coordinators that collide once will
|
||||||
|
// retry in lockstep and collide again at exactly the same moment.
|
||||||
|
func withRetry(ctx context.Context, op func(context.Context) error) error {
|
||||||
|
b := backoff.NewExponentialBackOff()
|
||||||
|
b.InitialInterval = retryInitialInterval
|
||||||
|
b.MaxInterval = retryMaxInterval
|
||||||
|
b.MaxElapsedTime = retryMaxElapsedTime
|
||||||
|
// RandomizationFactor defaults to 0.5, which is the jitter.
|
||||||
|
|
||||||
|
return backoff.Retry(func() error {
|
||||||
|
err := op(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isTransient(err) {
|
||||||
|
return backoff.Permanent(err) // stop now, do not burn the budget
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}, backoff.WithContext(b, ctx))
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsTransient(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, false},
|
||||||
|
{"serialization failure", &pgconn.PgError{Code: codeSerializationFailure}, true},
|
||||||
|
{"deadlock", &pgconn.PgError{Code: codeDeadlockDetected}, true},
|
||||||
|
{"too many connections", &pgconn.PgError{Code: codeTooManyConnections}, true},
|
||||||
|
// A unique-violation repeats identically forever — retrying is pointless.
|
||||||
|
{"unique violation", &pgconn.PgError{Code: "23505"}, false},
|
||||||
|
{"syntax error", &pgconn.PgError{Code: "42601"}, false},
|
||||||
|
{"context cancelled", context.Canceled, false},
|
||||||
|
{"deadline exceeded", context.DeadlineExceeded, false},
|
||||||
|
{"unknown error", errors.New("boom"), false},
|
||||||
|
// Wrapping must not hide the cause: errors.As walks the chain.
|
||||||
|
{"wrapped deadlock", errors2Wrap(&pgconn.PgError{Code: codeDeadlockDetected}), true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := isTransient(tt.err); got != tt.want {
|
||||||
|
t.Errorf("isTransient(%v) = %v, want %v", tt.err, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errors2Wrap(err error) error {
|
||||||
|
return errors.Join(errors.New("query failed"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithRetrySucceedsAfterTransientFailures(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
err := withRetry(context.Background(), func(context.Context) error {
|
||||||
|
calls++
|
||||||
|
if calls < 3 {
|
||||||
|
return &pgconn.PgError{Code: codeSerializationFailure}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if calls != 3 {
|
||||||
|
t.Errorf("calls = %d, want 3", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithRetryStopsOnPermanentError(t *testing.T) {
|
||||||
|
permanent := &pgconn.PgError{Code: "23505"} // unique violation
|
||||||
|
calls := 0
|
||||||
|
|
||||||
|
err := withRetry(context.Background(), func(context.Context) error {
|
||||||
|
calls++
|
||||||
|
return permanent
|
||||||
|
})
|
||||||
|
|
||||||
|
if !errors.Is(err, permanent) {
|
||||||
|
t.Errorf("err = %v, want the original error", err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("calls = %d, want 1 — a permanent error must not be retried", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithRetryHonoursContextCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
start := time.Now()
|
||||||
|
err := withRetry(ctx, func(context.Context) error {
|
||||||
|
calls++
|
||||||
|
return &pgconn.PgError{Code: codeDeadlockDetected}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error once the context expired")
|
||||||
|
}
|
||||||
|
// Must abort at the deadline, not run the full 5s retry budget.
|
||||||
|
if elapsed := time.Since(start); elapsed > time.Second {
|
||||||
|
t.Errorf("took %v, expected to stop at the context deadline", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Known statuses per entity, so counts are zero-filled and every status is
|
||||||
|
// always present in the metrics (a flat 0 line beats a gap on the dashboard).
|
||||||
|
var (
|
||||||
|
taskStatuses = []string{string(domain.TaskPending), string(domain.TaskLeased), string(domain.TaskRunning), string(domain.TaskCompleted), string(domain.TaskFailed), string(domain.TaskCancelled)}
|
||||||
|
jobStatuses = []string{string(domain.JobPending), string(domain.JobRunning), string(domain.JobReducing), string(domain.JobCompleted), string(domain.JobFailed), string(domain.JobCancelled)}
|
||||||
|
workerStatuses = []string{string(domain.WorkerOnline), string(domain.WorkerBusy), string(domain.WorkerOffline)}
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsRepo answers the aggregate status counts the business metrics report. It
|
||||||
|
// runs one cheap GROUP BY per entity; the collector calls this on every scrape.
|
||||||
|
type StatsRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatsRepo(pool *pgxpool.Pool) *StatsRepo {
|
||||||
|
return &StatsRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts returns status->count maps for tasks, jobs, and workers, each
|
||||||
|
// zero-filled across its known statuses.
|
||||||
|
func (r *StatsRepo) Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error) {
|
||||||
|
if tasks, err = r.countByStatus(ctx, "tasks", taskStatuses); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if jobs, err = r.countByStatus(ctx, "jobs", jobStatuses); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if workers, err = r.countByStatus(ctx, "workers", workerStatuses); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return tasks, jobs, workers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatsRepo) countByStatus(ctx context.Context, table string, known []string) (map[string]int, error) {
|
||||||
|
out := make(map[string]int, len(known))
|
||||||
|
for _, s := range known {
|
||||||
|
out[s] = 0 // zero-fill
|
||||||
|
}
|
||||||
|
// table is a fixed internal constant, never user input — safe to format.
|
||||||
|
rows, err := r.pool.Query(ctx, fmt.Sprintf("SELECT status, count(*) FROM %s GROUP BY status", table))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("count %s by status: %w", table, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var status string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&status, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[status] = n // an unknown status still shows up, which is a useful signal
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskRepo implements usecase.TaskRepository.
|
||||||
|
type TaskRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskRepo(pool *pgxpool.Pool) *TaskRepo {
|
||||||
|
return &TaskRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.TaskRepository = (*TaskRepo)(nil)
|
||||||
|
|
||||||
|
// taskColumns is the single source of truth for the shape scanTask expects.
|
||||||
|
// Every query that returns a task selects exactly this list, in this order —
|
||||||
|
// three hand-written column lists would drift apart within a week.
|
||||||
|
var taskColumns = []string{
|
||||||
|
"id", "job_id", "chunk_index", "workload", "input_uri", "input_artifact_id", "input_sha256",
|
||||||
|
"parameters", "status", "attempt", "max_attempts", "lease_owner", "lease_expires_at",
|
||||||
|
"result_artifact_id", "metrics", "error_code", "error_message",
|
||||||
|
"created_at", "started_at", "completed_at", "version",
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskColumnList is the same set as a comma string, for the raw claim query's
|
||||||
|
// RETURNING clause, which the builder does not touch.
|
||||||
|
var taskColumnList = strings.Join(taskColumns, ", ")
|
||||||
|
|
||||||
|
// scanTask maps one row onto an entity.
|
||||||
|
//
|
||||||
|
// status is read into a plain string rather than domain.TaskStatus: pgx does
|
||||||
|
// not know the task_status enum, and going through string keeps the driver out
|
||||||
|
// of the domain's type system.
|
||||||
|
func scanTask(row pgx.Row) (*domain.Task, error) {
|
||||||
|
var (
|
||||||
|
t domain.Task
|
||||||
|
status string
|
||||||
|
// input_uri is nullable now (uploaded shards have none), so it cannot
|
||||||
|
// scan straight into a string; NULL becomes the empty InputURI.
|
||||||
|
inputURI *string
|
||||||
|
)
|
||||||
|
err := row.Scan(
|
||||||
|
&t.ID, &t.JobID, &t.ChunkIndex, &t.Workload, &inputURI, &t.InputArtifactID, &t.InputSHA256,
|
||||||
|
&t.Parameters, &status, &t.Attempt, &t.MaxAttempts, &t.LeaseOwner, &t.LeaseExpiresAt,
|
||||||
|
&t.ResultArtifactID, &t.Metrics, &t.ErrorCode, &t.ErrorMessage,
|
||||||
|
&t.CreatedAt, &t.StartedAt, &t.CompletedAt, &t.Version,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if inputURI != nil {
|
||||||
|
t.InputURI = *inputURI
|
||||||
|
}
|
||||||
|
t.Status = domain.TaskStatus(status)
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimNextSQL leases one task in a single statement.
|
||||||
|
//
|
||||||
|
// Left as raw SQL on purpose: it is a data-modifying CTE with FOR UPDATE SKIP
|
||||||
|
// LOCKED, which no query builder expresses — and which is the whole point.
|
||||||
|
// SKIP LOCKED is what makes concurrent coordinators safe: each process locks a
|
||||||
|
// different candidate row instead of queueing on the same one, so no task is
|
||||||
|
// ever handed to two workers and no claim blocks behind another. Splitting this
|
||||||
|
// into SELECT + UPDATE would reintroduce exactly that race.
|
||||||
|
var claimNextSQL = `
|
||||||
|
WITH candidate AS (
|
||||||
|
SELECT id AS cid
|
||||||
|
FROM tasks
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND attempt < max_attempts
|
||||||
|
AND (cardinality($1::text[]) = 0 OR workload = ANY($1))
|
||||||
|
AND ($5::uuid IS NULL OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM task_results tr
|
||||||
|
WHERE tr.task_id = tasks.id AND tr.owner_id = $5))
|
||||||
|
ORDER BY created_at, chunk_index
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
UPDATE tasks
|
||||||
|
SET status = 'leased',
|
||||||
|
attempt = attempt + 1,
|
||||||
|
lease_owner = $2,
|
||||||
|
lease_expires_at = $3,
|
||||||
|
started_at = COALESCE(started_at, $4),
|
||||||
|
version = version + 1
|
||||||
|
FROM candidate
|
||||||
|
WHERE tasks.id = candidate.cid
|
||||||
|
RETURNING ` + taskColumnList
|
||||||
|
|
||||||
|
// ClaimNext atomically leases the next eligible task.
|
||||||
|
func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domain.Task, error) {
|
||||||
|
workloads := f.Workloads
|
||||||
|
if workloads == nil {
|
||||||
|
workloads = []string{} // NULL would make the cardinality() guard fail
|
||||||
|
}
|
||||||
|
|
||||||
|
var task *domain.Task
|
||||||
|
err := withRetry(ctx, func(ctx context.Context) error {
|
||||||
|
row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now, f.VoterOwner)
|
||||||
|
t, err := scanTask(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
task = nil
|
||||||
|
return nil // an empty queue is a normal state, not a failure
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
task = t
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reads a task without locking its row.
|
||||||
|
func (r *TaskRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||||
|
sql, args, err := psql.Select(taskColumns...).
|
||||||
|
From("tasks").
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, err := scanTask(conn(ctx, r.pool).QueryRow(ctx, sql, args...))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrTaskNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetForUpdate reads a task and holds its row lock until the caller's
|
||||||
|
// transaction ends, so read-modify-write use cases cannot interleave.
|
||||||
|
func (r *TaskRepo) GetForUpdate(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||||
|
sql, args, err := psql.Select(taskColumns...).
|
||||||
|
From("tasks").
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
Suffix("FOR UPDATE").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, err := scanTask(conn(ctx, r.pool).QueryRow(ctx, sql, args...))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrTaskNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update writes the mutated entity back under optimistic concurrency. The entity
|
||||||
|
// has already incremented its Version in memory, so the new value goes into SET
|
||||||
|
// while the WHERE guard matches against the previous one (Version-1).
|
||||||
|
func (r *TaskRepo) Update(ctx context.Context, t *domain.Task) error {
|
||||||
|
sql, args, err := psql.Update("tasks").
|
||||||
|
SetMap(map[string]any{
|
||||||
|
"status": string(t.Status),
|
||||||
|
"attempt": t.Attempt,
|
||||||
|
"lease_owner": t.LeaseOwner,
|
||||||
|
"lease_expires_at": t.LeaseExpiresAt,
|
||||||
|
"result_artifact_id": t.ResultArtifactID,
|
||||||
|
"metrics": t.Metrics,
|
||||||
|
"error_code": t.ErrorCode,
|
||||||
|
"error_message": t.ErrorMessage,
|
||||||
|
"started_at": t.StartedAt,
|
||||||
|
"completed_at": t.CompletedAt,
|
||||||
|
"version": t.Version,
|
||||||
|
}).
|
||||||
|
Where(sq.Eq{"id": t.ID, "version": t.Version - 1}).
|
||||||
|
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 {
|
||||||
|
// Either the row vanished or someone else advanced its version while we
|
||||||
|
// held a stale copy. Both mean this write must not land.
|
||||||
|
return domain.ErrLeaseConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertBatch writes every task in one round trip. It runs inside the caller's
|
||||||
|
// transaction, which is what makes "all tasks or none" hold.
|
||||||
|
func (r *TaskRepo) InsertBatch(ctx context.Context, tasks []*domain.Task) error {
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := &pgx.Batch{}
|
||||||
|
for _, t := range tasks {
|
||||||
|
sql, args, err := psql.Insert("tasks").
|
||||||
|
Columns("id", "job_id", "chunk_index", "workload", "input_uri", "input_artifact_id",
|
||||||
|
"input_sha256", "parameters", "status", "attempt", "max_attempts", "created_at", "version").
|
||||||
|
// input_uri is stored NULL (not "") when empty, so the ck_tasks_has_input
|
||||||
|
// check actually bites: a task with neither a URI nor an artifact fails.
|
||||||
|
Values(t.ID, t.JobID, t.ChunkIndex, t.Workload, nullIfEmpty(t.InputURI), t.InputArtifactID,
|
||||||
|
t.InputSHA256, jsonbOrEmpty(t.Parameters), string(t.Status), t.Attempt, t.MaxAttempts, t.CreatedAt, t.Version).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
batch.Queue(sql, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := conn(ctx, r.pool).SendBatch(ctx, batch)
|
||||||
|
for range tasks {
|
||||||
|
if _, err := results.Exec(); err != nil {
|
||||||
|
_ = results.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCompleted returns results in chunk order, which the stitcher relies on:
|
||||||
|
// a non-deterministic order would make the merged output depend on which worker
|
||||||
|
// happened to finish first.
|
||||||
|
func (r *TaskRepo) ListCompleted(ctx context.Context, jobID uuid.UUID) ([]*domain.Task, error) {
|
||||||
|
sql, args, err := psql.Select(taskColumns...).
|
||||||
|
From("tasks").
|
||||||
|
Where(sq.Eq{"job_id": jobID, "status": "completed"}).
|
||||||
|
OrderBy("chunk_index").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tasks []*domain.Task
|
||||||
|
for rows.Next() {
|
||||||
|
t, err := scanTask(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tasks = append(tasks, t)
|
||||||
|
}
|
||||||
|
return tasks, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepo) CountByStatus(ctx context.Context, jobID uuid.UUID) (map[domain.TaskStatus]int, error) {
|
||||||
|
sql, args, err := psql.Select("status", "count(*)").
|
||||||
|
From("tasks").
|
||||||
|
Where(sq.Eq{"job_id": jobID}).
|
||||||
|
GroupBy("status").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
counts := make(map[domain.TaskStatus]int)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
status string
|
||||||
|
n int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&status, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
counts[domain.TaskStatus(status)] = n
|
||||||
|
}
|
||||||
|
return counts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelByJobSQL mirrors domain.Task.Cancel in one set-based update. It runs in
|
||||||
|
// the same transaction as the job-status update, so no claimable shard remains
|
||||||
|
// after an operator receives a successful cancellation response.
|
||||||
|
const cancelByJobSQL = `
|
||||||
|
UPDATE tasks
|
||||||
|
SET status = 'cancelled'::task_status,
|
||||||
|
lease_owner = NULL,
|
||||||
|
lease_expires_at = NULL,
|
||||||
|
error_code = NULL,
|
||||||
|
error_message = NULL,
|
||||||
|
completed_at = $2,
|
||||||
|
version = version + 1
|
||||||
|
WHERE job_id = $1
|
||||||
|
AND status IN ('pending','leased','running')`
|
||||||
|
|
||||||
|
func (r *TaskRepo) CancelByJob(ctx context.Context, jobID uuid.UUID, now time.Time) (int64, error) {
|
||||||
|
tag, err := conn(ctx, r.pool).Exec(ctx, cancelByJobSQL, jobID, now)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// expireLeasesSQL applies the lease-expiry rule set-based, mirroring
|
||||||
|
// domain.Task.ExpireLease: requeue while attempts remain, otherwise fail.
|
||||||
|
//
|
||||||
|
// Left as raw SQL: the branching lives in CASE expressions inside the SET, which
|
||||||
|
// a builder cannot express more clearly than this. It is one statement rather
|
||||||
|
// than a load-decide-save loop because several coordinators run it concurrently;
|
||||||
|
// an atomic UPDATE makes the duplicate work harmless — the loser updates zero rows.
|
||||||
|
var expireLeasesSQL = `
|
||||||
|
UPDATE tasks
|
||||||
|
SET status = CASE WHEN attempt < max_attempts THEN 'pending'::task_status
|
||||||
|
ELSE 'failed'::task_status END,
|
||||||
|
lease_owner = NULL,
|
||||||
|
lease_expires_at = NULL,
|
||||||
|
error_code = CASE WHEN attempt >= max_attempts THEN $2 ELSE error_code END,
|
||||||
|
error_message = CASE WHEN attempt >= max_attempts
|
||||||
|
THEN 'lease expired after the final attempt'
|
||||||
|
ELSE error_message END,
|
||||||
|
completed_at = CASE WHEN attempt >= max_attempts THEN $1 ELSE completed_at END,
|
||||||
|
version = version + 1
|
||||||
|
WHERE status IN ('leased','running') AND lease_expires_at < $1
|
||||||
|
RETURNING job_id`
|
||||||
|
|
||||||
|
func (r *TaskRepo) ExpireLeases(ctx context.Context, now time.Time) ([]uuid.UUID, error) {
|
||||||
|
var affected []uuid.UUID
|
||||||
|
err := withRetry(ctx, func(ctx context.Context) error {
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, expireLeasesSQL, now, domain.ErrCodeLeaseExpired)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
affected = affected[:0]
|
||||||
|
for rows.Next() {
|
||||||
|
var jobID uuid.UUID
|
||||||
|
if err := rows.Scan(&jobID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected = append(affected, jobID)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
return affected, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskResultRepo records and tallies quorum votes for untrusted task results.
|
||||||
|
type TaskResultRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskResultRepo(pool *pgxpool.Pool) *TaskResultRepo {
|
||||||
|
return &TaskResultRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordVote stores (or replaces) one owner's vote for a task's result.
|
||||||
|
func (r *TaskResultRepo) RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error {
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO task_results (task_id, owner_id, result_sha256, result_artifact_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (task_id, owner_id) DO UPDATE
|
||||||
|
SET result_sha256 = EXCLUDED.result_sha256,
|
||||||
|
result_artifact_id = EXCLUDED.result_artifact_id,
|
||||||
|
created_at = now()`
|
||||||
|
if _, err := conn(ctx, r.pool).Exec(ctx, sql, taskID, ownerID, sha256, artifactID); err != nil {
|
||||||
|
return fmt.Errorf("record vote: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAgreeing returns how many distinct owners have voted for the given result
|
||||||
|
// hash on this task — the size of the agreeing set the quorum is measured
|
||||||
|
// against.
|
||||||
|
func (r *TaskResultRepo) CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) {
|
||||||
|
const sql = `SELECT count(DISTINCT owner_id) FROM task_results WHERE task_id = $1 AND result_sha256 = $2`
|
||||||
|
var n int
|
||||||
|
if err := conn(ctx, r.pool).QueryRow(ctx, sql, taskID, sha256).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("count agreeing: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Package postgres implements the usecase repository ports on PostgreSQL.
|
||||||
|
// SQL and pgx types never escape this package.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// querier is satisfied by both *pgxpool.Pool and pgx.Tx, letting every
|
||||||
|
// repository method run identically inside or outside a transaction.
|
||||||
|
type querier interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// txKey is an unexported struct type, so no other package can collide with it
|
||||||
|
// or reach the transaction we stash in the context.
|
||||||
|
type txKey struct{}
|
||||||
|
|
||||||
|
// TxManager implements usecase.TxManager.
|
||||||
|
type TxManager struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTxManager(pool *pgxpool.Pool) *TxManager {
|
||||||
|
return &TxManager{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithinTx runs fn inside one transaction, committing on success and rolling
|
||||||
|
// back on any error or panic.
|
||||||
|
//
|
||||||
|
// The transaction travels in the context rather than in fn's signature, which
|
||||||
|
// is what lets the usecase layer express "do these repository calls atomically"
|
||||||
|
// without its port ever mentioning pgx.
|
||||||
|
// Retrying happens here, around the whole transaction, and deliberately not
|
||||||
|
// inside the repositories. Once Postgres aborts a transaction with a
|
||||||
|
// serialization failure or deadlock, every further statement in it fails too —
|
||||||
|
// replaying a single query would accomplish nothing. The unit of retry is
|
||||||
|
// Begin → fn → Commit.
|
||||||
|
//
|
||||||
|
// This is safe because fn re-reads its rows (via GetForUpdate) on each attempt,
|
||||||
|
// so a retry starts from the current state rather than stale entities.
|
||||||
|
func (m *TxManager) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||||
|
if _, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
|
||||||
|
// Already inside a transaction — join it. Retrying here would be wrong
|
||||||
|
// twice over: the outer transaction owns the retry, and re-running fn
|
||||||
|
// alone cannot undo what the outer one already wrote.
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return withRetry(ctx, func(ctx context.Context) error {
|
||||||
|
return m.runTx(ctx, fn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TxManager) runTx(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||||
|
tx, err := m.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Rollback after a successful Commit is a no-op, so this defer is safe and
|
||||||
|
// also covers the panic path.
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
if err := fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonbOrEmpty keeps a nil map from reaching a NOT NULL jsonb column. pgx
|
||||||
|
// encodes a nil map as SQL NULL rather than omitting the column, so the
|
||||||
|
// DEFAULT '{}' never gets a chance to apply.
|
||||||
|
func jsonbOrEmpty(m map[string]any) map[string]any {
|
||||||
|
if m == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullIfEmpty maps "" to a SQL NULL, so an absent optional string is stored as
|
||||||
|
// NULL rather than an empty string that would defeat a NOT-NULL-or check.
|
||||||
|
func nullIfEmpty(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// conn returns the transaction bound to ctx, or the pool when there is none.
|
||||||
|
func conn(ctx context.Context, pool *pgxpool.Pool) querier {
|
||||||
|
if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UIReadRepo contains bounded, deterministic read queries for the operator UI.
|
||||||
|
type UIReadRepo struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
func NewUIReadRepo(pool *pgxpool.Pool) *UIReadRepo { return &UIReadRepo{pool: pool} }
|
||||||
|
|
||||||
|
var _ usecase.UIReadRepository = (*UIReadRepo)(nil)
|
||||||
|
|
||||||
|
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||||
|
job, err := NewJobRepo(r.pool).Get(ctx, id)
|
||||||
|
return job, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UIReadRepo) ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
q := psql.Select(jobColumns...).From("jobs")
|
||||||
|
if owner != nil {
|
||||||
|
q = q.Where(sq.Eq{"owner_id": *owner})
|
||||||
|
}
|
||||||
|
sql, args, err := q.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list jobs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
jobs := make([]domain.Job, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var j domain.Job
|
||||||
|
var status string
|
||||||
|
if err := rows.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,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
j.Status = domain.JobStatus(status)
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
return jobs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UIReadRepo) ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error) {
|
||||||
|
sql, args, err := psql.Select(taskColumns...).From("tasks").Where(sq.Eq{"job_id": jobID}).OrderBy("chunk_index ASC").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tasks: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
tasks := make([]domain.Task, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
task, err := scanTask(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tasks = append(tasks, *task)
|
||||||
|
}
|
||||||
|
return tasks, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
if len(jobIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
sql, args, err := psql.Select(taskColumns...).From("tasks").
|
||||||
|
Where(sq.Eq{"job_id": jobIDs}).OrderBy("job_id ASC", "chunk_index ASC").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tasks by jobs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
task, err := scanTask(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[task.JobID] = append(out[task.JobID], *task)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UIReadRepo) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
sql, args, err := psql.Select(workerColumns...).From("workers").OrderBy("last_heartbeat_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list workers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
workers := make([]domain.Worker, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
worker, err := scanWorker(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
workers = append(workers, *worker)
|
||||||
|
}
|
||||||
|
return workers, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UIReadRepo) ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
|
||||||
|
sql, args, err := psql.Select(artifactColumns...).From("artifacts").Where(sq.Eq{"job_id": jobID}).OrderBy("created_at ASC", "id ASC").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list artifacts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
artifacts := make([]domain.Artifact, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var a domain.Artifact
|
||||||
|
var kind string
|
||||||
|
if err := rows.Scan(&a.ID, &a.JobID, &a.TaskID, &a.Attempt, &kind, &a.Filename, &a.StorageKey, &a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.Kind = domain.ArtifactKind(kind)
|
||||||
|
artifacts = append(artifacts, a)
|
||||||
|
}
|
||||||
|
return artifacts, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkerRepo implements usecase.WorkerRepository.
|
||||||
|
type WorkerRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkerRepo(pool *pgxpool.Pool) *WorkerRepo {
|
||||||
|
return &WorkerRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var workerColumns = []string{"id", "name", "capabilities", "status", "owner_id", "trust_level", "last_heartbeat_at", "created_at", "updated_at"}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error {
|
||||||
|
sql, args, err := psql.Insert("workers").
|
||||||
|
Columns(workerColumns...).
|
||||||
|
// capabilities is a jsonb column; pgx marshals the []string to a JSON array.
|
||||||
|
Values(w.ID, w.Name, w.Capabilities, string(w.Status), w.OwnerID, string(w.TrustLevel),
|
||||||
|
w.LastHeartbeatAt, w.CreatedAt, w.UpdatedAt).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
|
||||||
|
return fmt.Errorf("insert worker: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Worker, error) {
|
||||||
|
sql, args, err := psql.Select(workerColumns...).
|
||||||
|
From("workers").
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := scanWorker(conn(ctx, r.pool).QueryRow(ctx, sql, args...))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, domain.ErrWorkerNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get worker: %w", err)
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) Touch(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||||
|
sql, args, err := psql.Update("workers").
|
||||||
|
SetMap(map[string]any{"last_heartbeat_at": at, "status": "online", "updated_at": at}).
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A worker that never registered simply matches no row; that is not an error.
|
||||||
|
if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
|
||||||
|
return fmt.Errorf("touch worker: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||||
|
sql, args, err := psql.Update("workers").
|
||||||
|
SetMap(map[string]any{"status": "offline", "updated_at": cutoff}).
|
||||||
|
Where(sq.Lt{"last_heartbeat_at": cutoff}).
|
||||||
|
Where(sq.NotEq{"status": "offline"}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("mark stale workers offline: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWorker(row pgx.Row) (*domain.Worker, error) {
|
||||||
|
var (
|
||||||
|
w domain.Worker
|
||||||
|
status string
|
||||||
|
trust string
|
||||||
|
)
|
||||||
|
if err := row.Scan(&w.ID, &w.Name, &w.Capabilities, &status, &w.OwnerID, &trust,
|
||||||
|
&w.LastHeartbeatAt, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.Status = domain.WorkerStatus(status)
|
||||||
|
w.TrustLevel = domain.WorkerTrust(trust)
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Package token verifies the HS256 JWTs minted by the userservice. The
|
||||||
|
// coordinator only ever *verifies* — it never issues — so this is a deliberately
|
||||||
|
// small counterpart to the userservice's issuer. Verification is local: the
|
||||||
|
// shared secret is enough, with no runtime call back to the userservice.
|
||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims is the subset of a userservice token the coordinator cares about.
|
||||||
|
type Claims struct {
|
||||||
|
UserID uuid.UUID
|
||||||
|
Role string
|
||||||
|
Verified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier checks tokens against the shared HS256 secret.
|
||||||
|
type Verifier struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVerifier returns a Verifier, or nil when secret is empty — a nil Verifier
|
||||||
|
// means user-JWT auth is disabled and only the shared service token is accepted.
|
||||||
|
func NewVerifier(secret string) *Verifier {
|
||||||
|
if secret == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Verifier{secret: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type claims struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the signature and expiry and returns the identity. It pins the
|
||||||
|
// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
|
||||||
|
// key — the classic algorithm-substitution attack.
|
||||||
|
func (v *Verifier) Verify(raw string) (Claims, error) {
|
||||||
|
var c claims
|
||||||
|
_, err := jwt.ParseWithClaims(raw, &c, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return v.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Claims{}, err
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(c.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return Claims{}, fmt.Errorf("token subject is not a uuid: %w", err)
|
||||||
|
}
|
||||||
|
return Claims{UserID: id, Role: c.Role, Verified: c.Verified}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const secret = "coordinator-verify-secret-32-bytes!!"
|
||||||
|
|
||||||
|
func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
return signVerified(t, method, key, sub, role, false, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signVerified(t *testing.T, method jwt.SigningMethod, key any, sub, role string, verified bool, exp time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
tok := jwt.NewWithClaims(method, claims{
|
||||||
|
Role: role,
|
||||||
|
Verified: verified,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: sub,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(exp),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
raw, err := tok.SignedString(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCarriesVerifiedClaim(t *testing.T) {
|
||||||
|
v := NewVerifier(secret)
|
||||||
|
raw := signVerified(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", true, time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
claims, err := v.Verify(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if !claims.Verified {
|
||||||
|
t.Error("verified claim not read from token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewVerifierNilWhenNoSecret(t *testing.T) {
|
||||||
|
if NewVerifier("") != nil {
|
||||||
|
t.Error("empty secret must yield a nil verifier (auth disabled)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRoundTrip(t *testing.T) {
|
||||||
|
v := NewVerifier(secret)
|
||||||
|
id := uuid.New()
|
||||||
|
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), id.String(), "admin", time.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
claims, err := v.Verify(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != id {
|
||||||
|
t.Errorf("UserID = %v, want %v", claims.UserID, id)
|
||||||
|
}
|
||||||
|
if claims.Role != "admin" {
|
||||||
|
t.Errorf("Role = %q, want admin", claims.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsExpired(t *testing.T) {
|
||||||
|
v := NewVerifier(secret)
|
||||||
|
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(-time.Minute))
|
||||||
|
if _, err := v.Verify(raw); err == nil {
|
||||||
|
t.Error("expired token accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsWrongSecret(t *testing.T) {
|
||||||
|
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(time.Hour))
|
||||||
|
if _, err := NewVerifier("another-secret-also-at-least-32-byte").Verify(raw); err == nil {
|
||||||
|
t.Error("token verified under the wrong secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsNoneAlg(t *testing.T) {
|
||||||
|
raw := sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, uuid.New().String(), "admin", time.Now().Add(time.Hour))
|
||||||
|
if _, err := NewVerifier(secret).Verify(raw); err == nil {
|
||||||
|
t.Error("none-signed token accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsNonUUIDSubject(t *testing.T) {
|
||||||
|
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), "not-a-uuid", "user", time.Now().Add(time.Hour))
|
||||||
|
if _, err := NewVerifier(secret).Verify(raw); err == nil {
|
||||||
|
t.Error("non-uuid subject accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wire formats. Keeping them separate from domain entities means the API
|
||||||
|
// contract can evolve without reshaping the database, and nothing internal
|
||||||
|
// (version counters, other workers' errors) leaks by accident.
|
||||||
|
|
||||||
|
type createJobRequest struct {
|
||||||
|
Workload string `json:"workload"`
|
||||||
|
InputURI string `json:"input_uri"`
|
||||||
|
Parameters map[string]any `json:"parameters"`
|
||||||
|
Chunks []chunkDTO `json:"chunks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chunkDTO struct {
|
||||||
|
ChunkIndex int `json:"chunk_index"`
|
||||||
|
Workload string `json:"workload"`
|
||||||
|
InputURI string `json:"input_uri"`
|
||||||
|
InputSHA256 string `json:"input_sha256"`
|
||||||
|
Parameters map[string]any `json:"parameters"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
// Accepted per the contract for forward compatibility; not yet persisted.
|
||||||
|
CPUCount int `json:"cpu_count"`
|
||||||
|
MemoryMB int `json:"memory_mb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerResponse struct {
|
||||||
|
WorkerID uuid.UUID `json:"worker_id"`
|
||||||
|
HeartbeatIntervalSeconds int `json:"heartbeat_interval_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type claimRequest struct {
|
||||||
|
WorkerID string `json:"worker_id"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
// Accepted per the contract; the coordinator leases one task per call.
|
||||||
|
MaxConcurrency int `json:"max_concurrency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type heartbeatRequest struct {
|
||||||
|
WorkerID string `json:"worker_id"`
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resultRequest struct {
|
||||||
|
WorkerID string `json:"worker_id"`
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
Result resultManifest `json:"result"`
|
||||||
|
Metrics map[string]any `json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultManifest references the artifact the worker already uploaded. sha256 and
|
||||||
|
// content_type are accepted for the worker's own cross-checking; the coordinator
|
||||||
|
// trusts its own stored metadata, not these.
|
||||||
|
type resultManifest struct {
|
||||||
|
ArtifactID uuid.UUID `json:"artifact_id"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type failureRequest struct {
|
||||||
|
WorkerID string `json:"worker_id"`
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
ErrorMessage string `json:"error_message"`
|
||||||
|
Retryable bool `json:"retryable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jobResponse struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskResponse struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
JobID uuid.UUID `json:"job_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputRef struct {
|
||||||
|
URI string `json:"uri"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type claimedTaskResponse struct {
|
||||||
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
|
JobID uuid.UUID `json:"job_id"`
|
||||||
|
ChunkIndex int `json:"chunk_index"`
|
||||||
|
Workload string `json:"workload"`
|
||||||
|
Input inputRef `json:"input"`
|
||||||
|
Parameters map[string]any `json:"parameters"`
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
LeaseExpiresAt time.Time `json:"lease_expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadJobResponse struct {
|
||||||
|
JobID uuid.UUID `json:"job_id"`
|
||||||
|
TaskCount int `json:"task_count"`
|
||||||
|
InputArtifactID uuid.UUID `json:"input_artifact_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jobProgressResponse struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
Leased int `json:"leased"`
|
||||||
|
Done int `json:"completed"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Cancelled int `json:"cancelled"`
|
||||||
|
ResultURI string `json:"result_uri,omitempty"`
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadArtifactResponse struct {
|
||||||
|
ArtifactID uuid.UUID `json:"artifact_id"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toClaimedTaskResponse(c domain.ClaimedTask) claimedTaskResponse {
|
||||||
|
// A shard's input lives in the coordinator; hand the worker a URL to fetch
|
||||||
|
// it from. A URI-based task keeps its external URI.
|
||||||
|
uri := c.InputURI
|
||||||
|
if c.InputArtifactID != nil {
|
||||||
|
uri = "/tasks/" + c.TaskID.String() + "/input"
|
||||||
|
}
|
||||||
|
return claimedTaskResponse{
|
||||||
|
TaskID: c.TaskID,
|
||||||
|
JobID: c.JobID,
|
||||||
|
ChunkIndex: c.ChunkIndex,
|
||||||
|
Workload: c.Workload,
|
||||||
|
Input: inputRef{URI: uri, SHA256: c.InputSHA256},
|
||||||
|
Parameters: c.Parameters,
|
||||||
|
Attempt: c.Attempt,
|
||||||
|
LeaseExpiresAt: c.LeaseExpiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toJobProgressResponse(p domain.JobProgress) jobProgressResponse {
|
||||||
|
out := jobProgressResponse{
|
||||||
|
ID: p.Job.ID,
|
||||||
|
Status: string(p.DeriveStatus()),
|
||||||
|
Total: p.Total,
|
||||||
|
Pending: p.Pending,
|
||||||
|
Leased: p.Leased,
|
||||||
|
Done: p.Done,
|
||||||
|
Failed: p.Failed,
|
||||||
|
Cancelled: p.Cancelled,
|
||||||
|
}
|
||||||
|
if p.Job.ResultArtifactID != nil && out.Status == string(domain.JobCompleted) {
|
||||||
|
out.ResultURI = "/jobs/" + p.Job.ID.String() + "/result"
|
||||||
|
}
|
||||||
|
if p.Job.ErrorCode != nil {
|
||||||
|
out.ErrorCode = *p.Job.ErrorCode
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobProgressResponseExposesFinalResultOnlyWhenCompleted(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
result := uuid.New()
|
||||||
|
progress := domain.JobProgress{Job: domain.Job{
|
||||||
|
ID: id, Status: domain.JobCompleted, ResultArtifactID: &result,
|
||||||
|
}, Total: 1, Done: 1}
|
||||||
|
if got, want := toJobProgressResponse(progress).ResultURI, "/jobs/"+id.String()+"/result"; got != want {
|
||||||
|
t.Fatalf("result URI = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Job.Status = domain.JobReducing
|
||||||
|
if got := toJobProgressResponse(progress).ResultURI; got != "" {
|
||||||
|
t.Fatalf("reducing job exposes result URI %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxJSONBody caps a JSON request body. The DTOs are tiny; anything larger is a
|
||||||
|
// mistake or an attack, and must not be read into memory unbounded.
|
||||||
|
const maxJSONBody = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
func decodeJSON(r *http.Request, dst any) error {
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxJSONBody))
|
||||||
|
// Reject unknown fields: silently ignoring a misspelled "worker_ID" would
|
||||||
|
// surface later as a baffling validation failure.
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
return errors.New("request body must contain exactly one JSON value")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError translates domain errors into status codes. This mapping is the
|
||||||
|
// only place in the codebase that knows HTTP status codes exist — the inner
|
||||||
|
// layers speak only in business terms.
|
||||||
|
func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
reqID := requestIDFrom(r.Context())
|
||||||
|
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrInvalidInput):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
case errors.Is(err, domain.ErrJobNotFound), errors.Is(err, domain.ErrTaskNotFound),
|
||||||
|
errors.Is(err, domain.ErrWorkerNotFound), errors.Is(err, domain.ErrArtifactNotFound):
|
||||||
|
status = http.StatusNotFound
|
||||||
|
case errors.Is(err, domain.ErrLeaseConflict),
|
||||||
|
errors.Is(err, domain.ErrStaleAttempt),
|
||||||
|
errors.Is(err, domain.ErrResultConflict),
|
||||||
|
errors.Is(err, domain.ErrTaskNotLeased),
|
||||||
|
errors.Is(err, domain.ErrJobNotCancellable):
|
||||||
|
status = http.StatusConflict
|
||||||
|
case errors.Is(err, usecase.ErrNotImplemented):
|
||||||
|
status = http.StatusNotImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
// 501 says "this endpoint has no implementation yet" — that leaks nothing and
|
||||||
|
// is far more useful than a generic failure, which sent one debugging session
|
||||||
|
// hunting a database problem that did not exist.
|
||||||
|
if status == http.StatusNotImplemented {
|
||||||
|
writeJSON(w, status, errorResponse{Error: "not implemented", RequestID: reqID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status >= 500 {
|
||||||
|
// Never echo an internal error: it can carry table names, query
|
||||||
|
// fragments, and values. The request ID is the bridge to the logs.
|
||||||
|
s.log.Error("request failed", "request_id", reqID, "path", r.URL.Path, "err", err)
|
||||||
|
writeJSON(w, status, errorResponse{Error: "internal error", RequestID: reqID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, status, errorResponse{Error: err.Error(), RequestID: reqID})
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every handler follows the same shape: decode, map to a use-case input,
|
||||||
|
// execute, translate. Anything resembling a rule belongs one layer inward.
|
||||||
|
|
||||||
|
func (s *Server) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var req createJobRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
in := usecase.CreateJobInput{
|
||||||
|
Workload: req.Workload,
|
||||||
|
InputURI: req.InputURI,
|
||||||
|
Parameters: req.Parameters,
|
||||||
|
}
|
||||||
|
for _, c := range req.Chunks {
|
||||||
|
in.Chunks = append(in.Chunks, usecase.ChunkInput(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := s.uc.CreateJob.Execute(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, jobResponse{ID: job.ID, Status: string(job.Status)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var req registerRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the worker's trust tier from how the caller authenticated:
|
||||||
|
// - shared service token (no requester) -> trusted lab worker
|
||||||
|
// - verified/admin user JWT -> trusted volunteer
|
||||||
|
// - plain user JWT -> untrusted (quarantined)
|
||||||
|
in := usecase.RegisterWorkerInput{
|
||||||
|
Name: req.Name,
|
||||||
|
Capabilities: req.Capabilities,
|
||||||
|
TrustLevel: domain.WorkerTrusted,
|
||||||
|
}
|
||||||
|
if requester, ok := authctx.From(ctx); ok {
|
||||||
|
id := requester.UserID
|
||||||
|
in.OwnerID = &id
|
||||||
|
if !requester.IsTrusted() {
|
||||||
|
in.TrustLevel = domain.WorkerUntrusted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
worker, err := s.uc.RegisterWorker.Execute(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, registerResponse{
|
||||||
|
WorkerID: worker.ID,
|
||||||
|
HeartbeatIntervalSeconds: int(s.heartbeatInterval.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var req claimRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := uuid.Parse(req.WorkerID); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claimed, err := s.uc.ClaimTask.Execute(ctx, usecase.ClaimTaskInput{
|
||||||
|
WorkerID: req.WorkerID,
|
||||||
|
Workloads: req.Capabilities,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claimed == nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent) // empty queue, not an error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toClaimedTaskResponse(*claimed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
taskID, ok := s.pathUUID(w, r, "task_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req heartbeatRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claimed, err := s.uc.RenewLease.Execute(ctx, usecase.RenewLeaseInput{
|
||||||
|
TaskID: taskID,
|
||||||
|
WorkerID: req.WorkerID,
|
||||||
|
Attempt: req.Attempt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toClaimedTaskResponse(*claimed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleResult(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
taskID, ok := s.pathUUID(w, r, "task_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req resultRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := s.uc.CompleteTask.Execute(ctx, usecase.CompleteTaskInput{
|
||||||
|
TaskID: taskID,
|
||||||
|
WorkerID: req.WorkerID,
|
||||||
|
Attempt: req.Attempt,
|
||||||
|
ResultArtifactID: req.Result.ArtifactID,
|
||||||
|
Metrics: req.Metrics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.uc.ReduceJob != nil {
|
||||||
|
if err := s.uc.ReduceJob.Execute(ctx, task.JobID); err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, taskResponse{ID: task.ID, JobID: task.JobID, Status: string(task.Status)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFailure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
taskID, ok := s.pathUUID(w, r, "task_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req failureRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := s.uc.FailTask.Execute(ctx, usecase.FailTaskInput{
|
||||||
|
TaskID: taskID,
|
||||||
|
WorkerID: req.WorkerID,
|
||||||
|
Attempt: req.Attempt,
|
||||||
|
ErrorCode: req.ErrorCode,
|
||||||
|
ErrorMessage: req.ErrorMessage,
|
||||||
|
Retryable: req.Retryable,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, taskResponse{ID: task.ID, JobID: task.JobID, Status: string(task.Status)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultChunkRows is the shard size used when a request omits chunk_rows.
|
||||||
|
const defaultChunkRows = 1000
|
||||||
|
|
||||||
|
// handleUploadDataset accepts a multipart submission — the dataset file plus the
|
||||||
|
// workload/parameters/chunk_rows/max_rows fields — and hands the file, streamed, to the
|
||||||
|
// chunker. The text fields MUST precede the file part: the file is streamed, not
|
||||||
|
// buffered, so by the time it arrives the other fields are already parsed.
|
||||||
|
func (s *Server) handleUploadDataset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, s.maxUploadBytes)
|
||||||
|
mr, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
workload string
|
||||||
|
params map[string]any
|
||||||
|
rows = defaultChunkRows
|
||||||
|
maxRows int
|
||||||
|
result usecase.SubmitDatasetResult
|
||||||
|
gotDataset bool
|
||||||
|
gotWorkload bool
|
||||||
|
gotParams bool
|
||||||
|
gotRows bool
|
||||||
|
gotMaxRows bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch part.FormName() {
|
||||||
|
case "workload":
|
||||||
|
if gotDataset || gotWorkload {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(part, 1<<10))
|
||||||
|
workload = strings.TrimSpace(string(b))
|
||||||
|
gotWorkload = true
|
||||||
|
case "parameters":
|
||||||
|
if gotDataset || gotParams {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(part, 1<<16))
|
||||||
|
if len(b) > 0 {
|
||||||
|
if err := json.Unmarshal(b, ¶ms); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gotParams = true
|
||||||
|
case "chunk_rows":
|
||||||
|
if gotDataset || gotRows {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(part, 32))
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows = n
|
||||||
|
gotRows = true
|
||||||
|
case "max_rows":
|
||||||
|
if gotDataset || gotMaxRows {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(part, 32))
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxRows = n
|
||||||
|
gotMaxRows = true
|
||||||
|
case "file", "dataset":
|
||||||
|
if gotDataset || workload == "" {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filename := part.FileName()
|
||||||
|
if filename == "" {
|
||||||
|
filename = "dataset"
|
||||||
|
}
|
||||||
|
result, err = s.uc.SubmitDataset.Execute(r.Context(), usecase.SubmitDatasetInput{
|
||||||
|
Workload: workload,
|
||||||
|
Parameters: params,
|
||||||
|
RowsPerShard: rows,
|
||||||
|
MaxRows: maxRows,
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: part.Header.Get("Content-Type"),
|
||||||
|
Body: part,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gotDataset = true
|
||||||
|
default:
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = part.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gotDataset {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput) // no file part
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, uploadJobResponse{
|
||||||
|
JobID: result.JobID,
|
||||||
|
TaskCount: result.TaskCount,
|
||||||
|
InputArtifactID: result.InputArtifactID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetTaskInput streams a task's input shard back to the worker.
|
||||||
|
func (s *Server) handleGetTaskInput(w http.ResponseWriter, r *http.Request) {
|
||||||
|
taskID, ok := s.pathUUID(w, r, "task_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
art, body, err := s.uc.GetTaskInput.Execute(r.Context(), taskID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = body.Close() }()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", art.ContentType)
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(art.SizeBytes, 10))
|
||||||
|
w.Header().Set("X-Checksum-SHA256", art.SHA256)
|
||||||
|
_, _ = io.Copy(w, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUploadArtifact streams a worker's partial result into blob storage. It
|
||||||
|
// deliberately does not use the short request timeout — a large shard upload
|
||||||
|
// would trip it — and reads identity from headers per the contract (§5.5).
|
||||||
|
func (s *Server) handleUploadArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
taskID, ok := s.pathUUID(w, r, "task_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempt, err := strconv.Atoi(r.Header.Get("X-Task-Attempt"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, s.maxUploadBytes)
|
||||||
|
|
||||||
|
art, err := s.uc.UploadArtifact.Execute(r.Context(), usecase.UploadArtifactInput{
|
||||||
|
TaskID: taskID,
|
||||||
|
WorkerID: r.Header.Get("X-Worker-ID"),
|
||||||
|
Attempt: attempt,
|
||||||
|
Filename: r.PathValue("filename"),
|
||||||
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
|
Body: r.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, uploadArtifactResponse{
|
||||||
|
ArtifactID: art.ID,
|
||||||
|
URI: "/artifacts/" + art.ID.String() + "/download",
|
||||||
|
SHA256: art.SHA256,
|
||||||
|
SizeBytes: art.SizeBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDownloadArtifact streams an artifact's bytes back to the caller.
|
||||||
|
func (s *Server) handleDownloadArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
artifactID, ok := s.pathUUID(w, r, "artifact_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
art, body, err := s.uc.DownloadArtifact.Execute(r.Context(), artifactID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = body.Close() }()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", art.ContentType)
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(art.SizeBytes, 10))
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", art.Filename))
|
||||||
|
w.Header().Set("X-Checksum-SHA256", art.SHA256)
|
||||||
|
_, _ = io.Copy(w, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
jobID, ok := s.pathUUID(w, r, "job_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
progress, err := s.uc.GetJobStatus.Execute(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toJobProgressResponse(progress))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetJobResult(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
jobID, ok := s.pathUUID(w, r, "job_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
art, body, err := s.uc.GetJobResult.Execute(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = body.Close() }()
|
||||||
|
w.Header().Set("Content-Type", art.ContentType)
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(art.SizeBytes, 10))
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", art.Filename))
|
||||||
|
_, _ = io.Copy(w, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelJob stops all non-terminal shards for an operator-requested job.
|
||||||
|
// It is available to both the bearer API and the separately authenticated UI.
|
||||||
|
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
jobID, ok := s.pathUUID(w, r, "job_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancelled, err := s.uc.CancelJob.Execute(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"job_id": jobID,
|
||||||
|
"status": domain.JobCancelled,
|
||||||
|
"cancelled_tasks": cancelled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func (s *Server) reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(r.Context(), s.requestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pathUUID(w http.ResponseWriter, r *http.Request, name string) (uuid.UUID, bool) {
|
||||||
|
id, err := uuid.Parse(r.PathValue(name))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const requestIDKey ctxKey = "request_id"
|
||||||
|
|
||||||
|
// withRequestID stamps every request with an ID for correlated logs and error
|
||||||
|
// bodies. It wraps the auth middleware rather than the other way round, so even
|
||||||
|
// a rejected request carries an ID the caller can quote in a bug report.
|
||||||
|
func withRequestID(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := newRequestID()
|
||||||
|
w.Header().Set("X-Request-ID", id)
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestIDFrom(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestID() string {
|
||||||
|
var b [8]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuth enforces the shared bearer token every worker presents.
|
||||||
|
// An empty token disables the check (local development only).
|
||||||
|
// withAuth authenticates a request one of two ways. Workers (and legacy
|
||||||
|
// submitters) present the shared service token. When user-JWT auth is enabled
|
||||||
|
// (verifier != nil), a submitter may instead present a userservice JWT; on
|
||||||
|
// success the requester is stamped into the context so the job use cases can
|
||||||
|
// record owner_id and enforce ownership. An empty token with no verifier
|
||||||
|
// disables auth entirely (dev only).
|
||||||
|
func withAuth(token string, verifier *tokenpkg.Verifier) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if token == "" && verifier == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
|
||||||
|
// Shared service token: constant-time compare so a byte-by-byte
|
||||||
|
// early exit cannot leak the token through response timing.
|
||||||
|
if token != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(token)) == 1 {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise try a user JWT, if that path is configured.
|
||||||
|
if verifier != nil && presented != "" {
|
||||||
|
if claims, err := verifier.Verify(presented); err == nil {
|
||||||
|
ctx := authctx.With(r.Context(), authctx.Requester{
|
||||||
|
UserID: claims.UserID,
|
||||||
|
Role: claims.Role,
|
||||||
|
Verified: claims.Verified,
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "unauthorized",
|
||||||
|
RequestID: requestIDFrom(r.Context()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withBasicAuth protects the local operator UI with a credential distinct from
|
||||||
|
// the worker bearer token. The username is intentionally ignored; the password
|
||||||
|
// is the configured UI token. Basic Auth is suitable only for localhost or a
|
||||||
|
// TLS-terminating trusted reverse proxy.
|
||||||
|
func withBasicAuth(token string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, password, ok := r.BasicAuth()
|
||||||
|
if !ok || subtle.ConstantTimeCompare([]byte(password), []byte(token)) != 1 {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="SciMesh UI", charset="UTF-8"`)
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{Error: "unauthorized", RequestID: requestIDFrom(r.Context())})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSameOrigin rejects browser form/fetch writes initiated by another origin.
|
||||||
|
// A missing Origin is allowed for direct local tools; authenticated UI pages use
|
||||||
|
// the browser-supplied Origin header on state-changing requests.
|
||||||
|
func withSameOrigin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin != "" {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if origin != scheme+"://"+r.Host {
|
||||||
|
writeJSON(w, http.StatusForbidden, errorResponse{Error: "cross-origin request rejected", RequestID: requestIDFrom(r.Context())})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusRecorder captures the status code for the access log.
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) WriteHeader(code int) {
|
||||||
|
s.status = code
|
||||||
|
s.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAccessLog records one structured line per request — the minimum needed to
|
||||||
|
// debug a distributed system after the fact.
|
||||||
|
func withAccessLog(log *slog.Logger) func(http.Handler) http.Handler {
|
||||||
|
return func(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)
|
||||||
|
log.Info("request",
|
||||||
|
"request_id", requestIDFrom(r.Context()),
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", rec.status,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chain applies middleware so that the first argument is the outermost layer.
|
||||||
|
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
|
||||||
|
for i := len(mw) - 1; i >= 0; i-- {
|
||||||
|
h = mw[i](h)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Package http adapts the use-case layer to HTTP. Handlers decode requests,
|
||||||
|
// map them onto use-case inputs, and translate results and errors back — no
|
||||||
|
// business rules live here.
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/metrics"
|
||||||
|
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UseCases collects everything the transport needs. Depending on concrete
|
||||||
|
// use-case types (not one fat interface) keeps each handler's dependency
|
||||||
|
// explicit and the wiring visible in the composition root.
|
||||||
|
type UseCases struct {
|
||||||
|
RegisterWorker *usecase.RegisterWorker
|
||||||
|
CreateJob *usecase.CreateJob
|
||||||
|
SubmitDataset *usecase.SubmitDataset
|
||||||
|
ClaimTask *usecase.ClaimTask
|
||||||
|
RenewLease *usecase.RenewLease
|
||||||
|
CompleteTask *usecase.CompleteTask
|
||||||
|
ReduceJob *usecase.ReduceJob
|
||||||
|
FailTask *usecase.FailTask
|
||||||
|
GetJobStatus *usecase.GetJobStatus
|
||||||
|
GetJobResult *usecase.GetJobResult
|
||||||
|
CancelJob *usecase.CancelJob
|
||||||
|
UploadArtifact *usecase.UploadArtifact
|
||||||
|
DownloadArtifact *usecase.DownloadArtifact
|
||||||
|
GetTaskInput *usecase.GetTaskInput
|
||||||
|
Dashboard *usecase.Dashboard
|
||||||
|
PreviewArtifact *usecase.PreviewArtifact
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
uc UseCases
|
||||||
|
log *slog.Logger
|
||||||
|
requestTimeout time.Duration
|
||||||
|
heartbeatInterval time.Duration
|
||||||
|
maxUploadBytes int64
|
||||||
|
// verifier validates userservice JWTs. nil disables user-JWT auth, leaving
|
||||||
|
// only the shared service token — the pre-userservice behaviour.
|
||||||
|
verifier *tokenpkg.Verifier
|
||||||
|
// userserviceURL is the base URL the UI proxies login/registration to. Empty
|
||||||
|
// keeps the static basic-auth UI.
|
||||||
|
userserviceURL string
|
||||||
|
// httpClient makes the login/register calls to the userservice.
|
||||||
|
httpClient *http.Client
|
||||||
|
// metrics holds the Prometheus registry and HTTP instrumentation.
|
||||||
|
metrics *metrics.Metrics
|
||||||
|
// ready probes downstream dependencies (the database) for /health. Kept as
|
||||||
|
// a func so the transport layer never imports pgx.
|
||||||
|
ready func(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||||
|
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error) *Server {
|
||||||
|
if m == nil {
|
||||||
|
m = metrics.New()
|
||||||
|
}
|
||||||
|
return &Server{
|
||||||
|
uc: uc,
|
||||||
|
log: log,
|
||||||
|
requestTimeout: requestTimeout,
|
||||||
|
heartbeatInterval: heartbeatInterval,
|
||||||
|
maxUploadBytes: maxUploadBytes,
|
||||||
|
verifier: tokenpkg.NewVerifier(jwtSecret),
|
||||||
|
userserviceURL: strings.TrimRight(userserviceURL, "/"),
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
metrics: m,
|
||||||
|
ready: ready,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiSessionMode reports whether the operator UI authenticates via userservice
|
||||||
|
// login (cookie session) rather than the static basic-auth token. It needs both
|
||||||
|
// a verifier (to check the JWT locally) and a userservice URL (to issue it).
|
||||||
|
func (s *Server) uiSessionMode() bool {
|
||||||
|
return s.verifier != nil && s.userserviceURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler builds the router. Go 1.22's ServeMux matches on method and path
|
||||||
|
// wildcards, so no third-party router is needed.
|
||||||
|
func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||||
|
protected := http.NewServeMux()
|
||||||
|
protected.HandleFunc("POST /workers/register", s.handleRegister)
|
||||||
|
protected.HandleFunc("POST /jobs", s.handleCreateJob)
|
||||||
|
protected.HandleFunc("POST /jobs/upload", s.handleUploadDataset)
|
||||||
|
protected.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
|
||||||
|
protected.HandleFunc("GET /jobs/{job_id}/result", s.handleGetJobResult)
|
||||||
|
protected.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
|
||||||
|
protected.HandleFunc("POST /tasks/claim", s.handleClaim)
|
||||||
|
protected.HandleFunc("GET /tasks/{task_id}/input", s.handleGetTaskInput)
|
||||||
|
protected.HandleFunc("POST /tasks/{task_id}/heartbeat", s.handleHeartbeat)
|
||||||
|
protected.HandleFunc("POST /tasks/{task_id}/result", s.handleResult)
|
||||||
|
protected.HandleFunc("POST /tasks/{task_id}/failure", s.handleFailure)
|
||||||
|
protected.HandleFunc("PUT /tasks/{task_id}/artifacts/{filename}", s.handleUploadArtifact)
|
||||||
|
protected.HandleFunc("GET /artifacts/{artifact_id}/download", s.handleDownloadArtifact)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
|
// Unauthenticated like /health, so a Prometheus scraper needs no credential.
|
||||||
|
mux.Handle("GET /metrics", s.metrics.Handler())
|
||||||
|
|
||||||
|
hasBasicAuth := len(uiToken) > 0 && uiToken[0] != ""
|
||||||
|
if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) {
|
||||||
|
ui := http.NewServeMux()
|
||||||
|
|
||||||
|
// The operator application routes, all requiring an authenticated caller.
|
||||||
|
app := []struct {
|
||||||
|
pattern string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"GET /ui", s.handleUIHome},
|
||||||
|
{"GET /ui/jobs/new", s.handleUINewJob},
|
||||||
|
{"GET /ui/jobs/{job_id}", s.handleUIJob},
|
||||||
|
{"GET /ui/api/overview", s.handleUIOverviewJSON},
|
||||||
|
{"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
|
||||||
|
{"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob},
|
||||||
|
{"POST /ui/api/jobs/upload", s.handleUploadDataset},
|
||||||
|
{"GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload},
|
||||||
|
{"GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview},
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.uiSessionMode() {
|
||||||
|
// Public auth pages — reachable without a session so a user can log in.
|
||||||
|
ui.HandleFunc("GET /ui/login", s.handleUILoginForm)
|
||||||
|
ui.HandleFunc("POST /ui/login", s.handleUILogin)
|
||||||
|
ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm)
|
||||||
|
ui.HandleFunc("POST /ui/register", s.handleUIRegister)
|
||||||
|
ui.HandleFunc("POST /ui/logout", s.handleUILogout)
|
||||||
|
gate := withUISession(s.verifier)
|
||||||
|
for _, rt := range app {
|
||||||
|
ui.Handle(rt.pattern, gate(rt.handler))
|
||||||
|
}
|
||||||
|
ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile)))
|
||||||
|
// Admin panel: session + admin role.
|
||||||
|
ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin))
|
||||||
|
ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin))
|
||||||
|
} else {
|
||||||
|
for _, rt := range app {
|
||||||
|
ui.HandleFunc(rt.pattern, rt.handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
common := []func(http.Handler) http.Handler{withRequestID, withAccessLog(s.log)}
|
||||||
|
if !s.uiSessionMode() {
|
||||||
|
common = append(common, withBasicAuth(uiToken[0]))
|
||||||
|
}
|
||||||
|
common = append(common, withSameOrigin)
|
||||||
|
mux.Handle("/ui", chain(ui, common...))
|
||||||
|
mux.Handle("/ui/", chain(ui, common...))
|
||||||
|
} else {
|
||||||
|
// More specific than the protected catch-all: UI absence is not an auth
|
||||||
|
// failure and does not disclose that a UI feature is configured elsewhere.
|
||||||
|
mux.HandleFunc("/ui", http.NotFound)
|
||||||
|
mux.HandleFunc("/ui/", http.NotFound)
|
||||||
|
}
|
||||||
|
mux.Handle("/", chain(protected,
|
||||||
|
withRequestID, // outermost: every response gets an ID,
|
||||||
|
withAccessLog(s.log), // including the 401s below
|
||||||
|
withAuth(token, s.verifier),
|
||||||
|
))
|
||||||
|
// Measure every request once, outermost, with a normalized route label.
|
||||||
|
return s.metrics.Middleware(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth reports readiness. It probes the database so an orchestrator
|
||||||
|
// learns the difference between "process is up" and "process can serve".
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.ready != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.ready(ctx); err != nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,741 @@
|
|||||||
|
package http_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
|
||||||
|
coordhttp "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
const token = "secret"
|
||||||
|
const uiToken = "ui-secret"
|
||||||
|
|
||||||
|
type env struct {
|
||||||
|
ts *httptest.Server
|
||||||
|
blobs *memstore.BlobStore
|
||||||
|
workerID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnv(t *testing.T, ready func(context.Context) error) *env {
|
||||||
|
return newEnvWithUIToken(t, ready, uiToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configuredUIToken string) *env {
|
||||||
|
t.Helper()
|
||||||
|
tasks := memstore.NewTaskRepo()
|
||||||
|
jobs := memstore.NewJobRepo()
|
||||||
|
work := memstore.NewWorkerRepo()
|
||||||
|
arts := memstore.NewArtifactRepo()
|
||||||
|
blobs := memstore.NewBlobStore()
|
||||||
|
clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC))
|
||||||
|
tx := memstore.Tx{}
|
||||||
|
lease := 2 * time.Minute
|
||||||
|
downloadArtifact := usecase.NewDownloadArtifact(arts, blobs)
|
||||||
|
|
||||||
|
uc := coordhttp.UseCases{
|
||||||
|
RegisterWorker: usecase.NewRegisterWorker(work, clk),
|
||||||
|
CreateJob: usecase.NewCreateJob(jobs, tasks, tx, clk),
|
||||||
|
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3),
|
||||||
|
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease),
|
||||||
|
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
|
||||||
|
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2),
|
||||||
|
ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk),
|
||||||
|
FailTask: usecase.NewFailTask(tasks, jobs, work, tx, clk),
|
||||||
|
GetJobStatus: usecase.NewGetJobStatus(jobs, tasks),
|
||||||
|
GetJobResult: usecase.NewGetJobResult(jobs, downloadArtifact),
|
||||||
|
CancelJob: usecase.NewCancelJob(jobs, tasks, tx, clk),
|
||||||
|
UploadArtifact: usecase.NewUploadArtifact(tasks, work, arts, blobs, tx, clk),
|
||||||
|
DownloadArtifact: downloadArtifact,
|
||||||
|
GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs),
|
||||||
|
Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)),
|
||||||
|
PreviewArtifact: usecase.NewPreviewArtifact(memstore.NewUIReadRepo(jobs, tasks, work, arts), blobs),
|
||||||
|
}
|
||||||
|
worker, err := uc.RegisterWorker.Execute(context.Background(), usecase.RegisterWorkerInput{
|
||||||
|
Name: "test-worker", Capabilities: []string{"w", "similarity-search"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register test worker: %v", err)
|
||||||
|
}
|
||||||
|
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", "", nil, ready)
|
||||||
|
ts := httptest.NewServer(srv.Handler(token, configuredUIToken))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthy(context.Context) error { return nil }
|
||||||
|
|
||||||
|
// do sends an authenticated JSON request and returns status + decoded body.
|
||||||
|
func (e *env) do(t *testing.T, method, path, body string) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
body = strings.ReplaceAll(body, `"worker_id":"w1"`, `"worker_id":"`+e.workerID+`"`)
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), method, e.ts.URL+path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var m map[string]any
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
return resp.StatusCode, m
|
||||||
|
}
|
||||||
|
|
||||||
|
// get issues an unauthenticated GET and returns the response, failing on error.
|
||||||
|
func (e *env) get(t *testing.T, path string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+path, nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthOK(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
resp := e.get(t, "/health") // unauthenticated
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIRequiresDistinctCredentialAndRendersDashboard(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
request := func() *http.Request {
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui", nil)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(request())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("no UI auth: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := request()
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("worker token authorized UI: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = request()
|
||||||
|
req.SetBasicAuth("operator", uiToken)
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("UI status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "SciMesh control room") {
|
||||||
|
t.Errorf("dashboard body missing title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIOverviewReturnsLiveSafeProjection(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
code, _ := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("create job: %d", code)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/api/overview", nil)
|
||||||
|
req.SetBasicAuth("operator", uiToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var overview map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK || overview["active_jobs"].(float64) != 1 || overview["online_workers"].(float64) != 1 {
|
||||||
|
t.Fatalf("overview = (%d, %v)", resp.StatusCode, overview)
|
||||||
|
}
|
||||||
|
if _, leaked := overview["worker_auth_token"]; leaked {
|
||||||
|
t.Fatal("overview must not expose authentication configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIDisabledReturnsNotFound(t *testing.T) {
|
||||||
|
e := newEnvWithUIToken(t, healthy, "")
|
||||||
|
resp := e.get(t, "/ui")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("disabled UI = %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIRejectsCrossOriginUpload(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/ui/api/jobs/upload", strings.NewReader("dataset=x"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://attacker.example")
|
||||||
|
req.SetBasicAuth("operator", uiToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("cross-origin upload = %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIUploadDatasetCreatesJob(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("workload", "similarity-search")
|
||||||
|
_ = mw.WriteField("parameters", `{"query_smiles":"CCO","top_k":20,"progress_every":0}`)
|
||||||
|
_ = mw.WriteField("chunk_rows", "1000")
|
||||||
|
file, err := mw.CreateFormFile("file", "chembl.tsv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(file, "chembl_id\tcanonical_smiles\nCHEMBL1\tCCO\n")
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/ui/api/jobs/upload", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.SetBasicAuth("operator", uiToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
result, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("UI upload = %d: %s", resp.StatusCode, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelJobStopsUnfinishedTasks(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c0","input_sha256":"sha"},{"chunk_index":1,"input_uri":"s3://c1","input_sha256":"sha"}]}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", code)
|
||||||
|
}
|
||||||
|
jobID := job["id"].(string)
|
||||||
|
if code, body := e.do(t, "POST", "/jobs/"+jobID+"/cancel", ""); code != http.StatusOK || body["cancelled_tasks"].(float64) != 2 {
|
||||||
|
t.Fatalf("cancel = (%d, %v)", code, body)
|
||||||
|
}
|
||||||
|
if code, progress := e.do(t, "GET", "/jobs/"+jobID, ""); code != http.StatusOK || progress["status"] != "cancelled" || progress["cancelled"].(float64) != 2 {
|
||||||
|
t.Fatalf("cancelled job progress = (%d, %v)", code, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUICancelJobUsesOperatorCredential(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c0","input_sha256":"sha"}]}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", code)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/ui/api/jobs/"+job["id"].(string)+"/cancel", nil)
|
||||||
|
req.SetBasicAuth("operator", uiToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("UI cancel = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIJobAndArtifactAreScopedToTheirJob(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
code, job := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d", code)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/jobs/"+job["id"].(string), nil)
|
||||||
|
req.SetBasicAuth("operator", uiToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("detail: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Content-Security-Policy"); got == "" {
|
||||||
|
t.Error("missing UI CSP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIArtifactDownloadRejectsAnotherJobsArtifact(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
code, _ := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("first job: %d", code)
|
||||||
|
}
|
||||||
|
_, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
|
||||||
|
artifactID := e.putArtifact(t, claim["task_id"].(string), "w1", int(claim["attempt"].(float64)), "result")
|
||||||
|
code, second := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("second job: %d", code)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/jobs/"+second["id"].(string)+"/artifacts/"+artifactID, nil)
|
||||||
|
req.SetBasicAuth("operator", uiToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("cross-job artifact = %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthUnavailableWhenDBDown(t *testing.T) {
|
||||||
|
e := newEnv(t, func(context.Context) error { return context.DeadlineExceeded })
|
||||||
|
resp := e.get(t, "/health")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRequired(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
send := func(authz string) int {
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/tasks/claim",
|
||||||
|
strings.NewReader(`{"worker_id":"w1"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if authz != "" {
|
||||||
|
req.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
if code := send(""); code != 401 {
|
||||||
|
t.Errorf("no token: status = %d, want 401", code)
|
||||||
|
}
|
||||||
|
if code := send("Bearer nope"); code != 401 {
|
||||||
|
t.Errorf("wrong token: status = %d, want 401", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterWorker(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
code, body := e.do(t, "POST", "/workers/register", `{"name":"lab","capabilities":["w"]}`)
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("status = %d, want 201", code)
|
||||||
|
}
|
||||||
|
if body["worker_id"] == nil || body["heartbeat_interval_seconds"] == nil {
|
||||||
|
t.Errorf("missing fields in %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsNoCapabilities(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
if code, _ := e.do(t, "POST", "/workers/register", `{"name":"lab"}`); code != 400 {
|
||||||
|
t.Errorf("status = %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimRequiresRegisteredWorkerAndUsesStoredCapabilities(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/claim", `{"worker_id":"not-a-uuid"}`); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid worker id claim = %d, want 400", code)
|
||||||
|
}
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/claim", `{"worker_id":"11111111-1111-4111-8111-111111111111"}`); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unregistered worker claim = %d, want 404", code)
|
||||||
|
}
|
||||||
|
if code, _ := e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in","chunks":[{"chunk_index":0,"input_uri":"s3://c","input_sha256":"sha"}]}`); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create job = %d", code)
|
||||||
|
}
|
||||||
|
code, worker := e.do(t, "POST", "/workers/register", `{"name":"search-only","capabilities":["similarity-search"]}`)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("register = %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/claim", `{"worker_id":"`+worker["worker_id"].(string)+`","capabilities":["w"]}`); code != http.StatusNoContent {
|
||||||
|
t.Fatalf("forged capability claim = %d, want 204", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFullLifecycle(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
|
||||||
|
// Create a one-chunk job.
|
||||||
|
code, job := e.do(t, "POST", "/jobs", `{
|
||||||
|
"workload":"w","input_uri":"s3://in",
|
||||||
|
"chunks":[{"chunk_index":0,"input_uri":"s3://c0","input_sha256":"sha"}]}`)
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("create job: %d", code)
|
||||||
|
}
|
||||||
|
jobID := job["id"].(string)
|
||||||
|
|
||||||
|
// Claim it.
|
||||||
|
code, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("claim: %d", code)
|
||||||
|
}
|
||||||
|
taskID := claim["task_id"].(string)
|
||||||
|
attempt := int(claim["attempt"].(float64))
|
||||||
|
|
||||||
|
// Heartbeat.
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/"+taskID+"/heartbeat",
|
||||||
|
`{"worker_id":"w1","attempt":`+itoa(attempt)+`}`); code != 200 {
|
||||||
|
t.Fatalf("heartbeat: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload a result artifact (PUT, headers carry identity).
|
||||||
|
artID := e.putArtifact(t, taskID, "w1", attempt, "q,m\nA,B\n")
|
||||||
|
|
||||||
|
// Submit the result by artifact id.
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/"+taskID+"/result",
|
||||||
|
`{"worker_id":"w1","attempt":`+itoa(attempt)+`,"result":{"artifact_id":"`+artID+`"}}`); code != 200 {
|
||||||
|
t.Fatalf("result: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is now completed.
|
||||||
|
code, prog := e.do(t, "GET", "/jobs/"+jobID, "")
|
||||||
|
if code != 200 || prog["status"] != "completed" {
|
||||||
|
t.Errorf("job status = %v (code %d), want completed", prog["status"], code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilaritySearchLifecyclePublishesFinalResult(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
code, job := e.uploadDataset(t, "similarity-search", 10, "chembl_id\tcanonical_smiles\nA\tCC\n")
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload job: %d", code)
|
||||||
|
}
|
||||||
|
jobID := job["job_id"].(string)
|
||||||
|
code, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["similarity-search"]}`)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("claim: %d", code)
|
||||||
|
}
|
||||||
|
taskID := claim["task_id"].(string)
|
||||||
|
attempt := int(claim["attempt"].(float64))
|
||||||
|
artifactID := e.putArtifact(t, taskID, "w1", attempt, "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.900000\n")
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/"+taskID+"/result",
|
||||||
|
`{"worker_id":"w1","attempt":`+itoa(attempt)+`,"result":{"artifact_id":"`+artifactID+`"}}`); code != http.StatusOK {
|
||||||
|
t.Fatalf("complete: %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, progress := e.do(t, "GET", "/jobs/"+jobID, "")
|
||||||
|
if code != http.StatusOK || progress["status"] != "completed" || progress["result_uri"] != "/jobs/"+jobID+"/result" {
|
||||||
|
t.Fatalf("progress = (%d, %v)", code, progress)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+progress["result_uri"].(string), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK || string(body) != "rank,chembl_id,canonical_smiles,similarity\n1,A,CC,0.900000\n" {
|
||||||
|
t.Fatalf("final result = (%d, %q)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiRequest, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/jobs/"+jobID, nil)
|
||||||
|
uiRequest.SetBasicAuth("operator", uiToken)
|
||||||
|
uiResponse, err := http.DefaultClient.Do(uiRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer uiResponse.Body.Close()
|
||||||
|
uiBody, _ := io.ReadAll(uiResponse.Body)
|
||||||
|
if uiResponse.StatusCode != http.StatusOK || !strings.Contains(string(uiBody), "Final result ready") || !strings.Contains(string(uiBody), "Preview CSV") || !strings.Contains(string(uiBody), "Processing speed") {
|
||||||
|
t.Fatalf("final UI = (%d, %q)", uiResponse.StatusCode, uiBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonRequest, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/api/jobs/"+jobID, nil)
|
||||||
|
jsonRequest.SetBasicAuth("operator", uiToken)
|
||||||
|
jsonResponse, err := http.DefaultClient.Do(jsonRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer jsonResponse.Body.Close()
|
||||||
|
var detail map[string]any
|
||||||
|
if err := json.NewDecoder(jsonResponse.Body).Decode(&detail); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if jsonResponse.StatusCode != http.StatusOK || detail["final_result_available"] != true {
|
||||||
|
t.Fatalf("final UI JSON = (%d, %v)", jsonResponse.StatusCode, detail)
|
||||||
|
}
|
||||||
|
artifacts := detail["artifacts"].([]any)
|
||||||
|
var finalID string
|
||||||
|
for _, raw := range artifacts {
|
||||||
|
artifact := raw.(map[string]any)
|
||||||
|
if artifact["kind"] == "final_result" && artifact["downloadable"] == true {
|
||||||
|
finalID = artifact["id"].(string)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if finalID == "" {
|
||||||
|
t.Fatalf("artifacts = %v, want downloadable final result", artifacts)
|
||||||
|
}
|
||||||
|
var inputID string
|
||||||
|
for _, raw := range artifacts {
|
||||||
|
artifact := raw.(map[string]any)
|
||||||
|
if artifact["kind"] == "input" {
|
||||||
|
inputID = artifact["id"].(string)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inputID == "" {
|
||||||
|
t.Fatalf("artifacts = %v, want input artifact", artifacts)
|
||||||
|
}
|
||||||
|
inputRequest, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/jobs/"+jobID+"/artifacts/"+inputID, nil)
|
||||||
|
inputRequest.SetBasicAuth("operator", uiToken)
|
||||||
|
inputResponse, err := http.DefaultClient.Do(inputRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer inputResponse.Body.Close()
|
||||||
|
if inputResponse.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("UI input download = %d, want 404", inputResponse.StatusCode)
|
||||||
|
}
|
||||||
|
previewRequest, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+"/ui/jobs/"+jobID+"/artifacts/"+finalID+"/preview", nil)
|
||||||
|
previewRequest.SetBasicAuth("operator", uiToken)
|
||||||
|
previewResponse, err := http.DefaultClient.Do(previewRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer previewResponse.Body.Close()
|
||||||
|
previewBody, _ := io.ReadAll(previewResponse.Body)
|
||||||
|
if previewResponse.StatusCode != http.StatusOK || !strings.Contains(string(previewBody), "Final result preview") || !strings.Contains(string(previewBody), "0.900000") {
|
||||||
|
t.Fatalf("final preview = (%d, %q)", previewResponse.StatusCode, previewBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForeignArtifactResultConflict(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
e.do(t, "POST", "/jobs", `{"workload":"w","input_uri":"s3://in",
|
||||||
|
"chunks":[{"chunk_index":0,"input_uri":"s3://c0","input_sha256":"sha"},
|
||||||
|
{"chunk_index":1,"input_uri":"s3://c1","input_sha256":"sha"}]}`)
|
||||||
|
|
||||||
|
_, cA := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
|
||||||
|
_, cB := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
|
||||||
|
taskA, attA := cA["task_id"].(string), int(cA["attempt"].(float64))
|
||||||
|
taskB, attB := cB["task_id"].(string), int(cB["attempt"].(float64))
|
||||||
|
artA := e.putArtifact(t, taskA, "w1", attA, "data")
|
||||||
|
|
||||||
|
// Complete taskB with taskA's artifact → 409.
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/"+taskB+"/result",
|
||||||
|
`{"worker_id":"w1","attempt":`+itoa(attB)+`,"result":{"artifact_id":"`+artA+`"}}`); code != 409 {
|
||||||
|
t.Errorf("cross-task result: status = %d, want 409", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadDatasetChunksAndServesInput(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
tsv := "chembl_id\tcanonical_smiles\nA\tCC\nB\tCCC\nC\tCCCC\nD\tCCCCC\nE\tCCCCCC\n"
|
||||||
|
|
||||||
|
code, body := e.uploadDataset(t, "similarity-search", 2, tsv)
|
||||||
|
if code != 201 {
|
||||||
|
t.Fatalf("upload: status = %d", code)
|
||||||
|
}
|
||||||
|
if int(body["task_count"].(float64)) != 3 {
|
||||||
|
t.Fatalf("task_count = %v, want 3", body["task_count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim a shard, follow its input.uri, and pull the shard bytes.
|
||||||
|
_, claim := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","capabilities":["w"]}`)
|
||||||
|
input := claim["input"].(map[string]any)
|
||||||
|
uri := input["uri"].(string)
|
||||||
|
if !strings.HasPrefix(uri, "/tasks/") || !strings.HasSuffix(uri, "/input") {
|
||||||
|
t.Fatalf("input.uri = %q", uri)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "GET", e.ts.URL+uri, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get input: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("get input: status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
shard, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.HasPrefix(string(shard), "chembl_id\tcanonical_smiles\n") {
|
||||||
|
t.Errorf("shard missing header: %q", shard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadDatasetLimitsRows(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("workload", "similarity-search")
|
||||||
|
_ = mw.WriteField("parameters", `{"query_smiles":"CCO"}`)
|
||||||
|
_ = mw.WriteField("chunk_rows", "2")
|
||||||
|
_ = mw.WriteField("max_rows", "3")
|
||||||
|
fw, _ := mw.CreateFormFile("file", "chembl.tsv")
|
||||||
|
_, _ = io.Copy(fw, strings.NewReader("chembl_id\tcanonical_smiles\nA\tCC\nB\tCCC\nC\tCCCC\nD\tCCCCC\n"))
|
||||||
|
_ = mw.Close()
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/jobs/upload", &buf)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var result map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
if resp.StatusCode != http.StatusCreated || result["task_count"].(float64) != 2 {
|
||||||
|
t.Fatalf("limited upload = (%d, %v)", resp.StatusCode, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadDatasetRejectsMissingChEMBLColumns(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("workload", "similarity-search")
|
||||||
|
_ = mw.WriteField("parameters", `{"query_smiles":"CCO"}`)
|
||||||
|
_ = mw.WriteField("chunk_rows", "2")
|
||||||
|
fw, _ := mw.CreateFormFile("file", "not-chembl.tsv")
|
||||||
|
_, _ = io.Copy(fw, strings.NewReader("id\tsmiles\nA\tCC\n"))
|
||||||
|
_ = mw.Close()
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/jobs/upload", &buf)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("missing ChEMBL columns = %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorMappings(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
zero := "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
if code, _ := e.do(t, "GET", "/jobs/"+zero, ""); code != 404 {
|
||||||
|
t.Errorf("unknown job: %d, want 404", code)
|
||||||
|
}
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/not-a-uuid/heartbeat", `{"worker_id":"w1","attempt":1}`); code != 400 {
|
||||||
|
t.Errorf("malformed uuid: %d, want 400", code)
|
||||||
|
}
|
||||||
|
if code, _ := e.do(t, "POST", "/tasks/claim", `{"worker_id":"w1","totally_unknown":1}`); code != 400 {
|
||||||
|
t.Errorf("unknown field: %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONRejectsTrailingValue(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
if code, _ := e.do(t, "POST", "/workers/register",
|
||||||
|
`{"name":"lab","capabilities":["w"]} {}`); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadDatasetRejectsAmbiguousMultipartInput(t *testing.T) {
|
||||||
|
e := newEnv(t, healthy)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("workload", "similarity-search")
|
||||||
|
_ = mw.WriteField("parameters", `{"query_smiles":"CCO"}`)
|
||||||
|
_ = mw.WriteField("chunk_rows", "not-a-number")
|
||||||
|
fw, _ := mw.CreateFormFile("file", "chembl.tsv")
|
||||||
|
_, _ = io.Copy(fw, strings.NewReader("chembl_id\tcanonical_smiles\nA\tCC\n"))
|
||||||
|
_ = mw.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/jobs/upload", &buf)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
func (e *env) putArtifact(t *testing.T, taskID, worker string, attempt int, data string) string {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "PUT",
|
||||||
|
e.ts.URL+"/tasks/"+taskID+"/artifacts/r.csv", strings.NewReader(data))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "text/csv")
|
||||||
|
if worker == "w1" {
|
||||||
|
worker = e.workerID
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Worker-ID", worker)
|
||||||
|
req.Header.Set("X-Task-Attempt", itoa(attempt))
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("put artifact: status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
return m["artifact_id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *env) uploadDataset(t *testing.T, workload string, rows int, tsv string) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
_ = mw.WriteField("workload", workload)
|
||||||
|
_ = mw.WriteField("parameters", `{"query_smiles":"CCO"}`)
|
||||||
|
_ = mw.WriteField("chunk_rows", itoa(rows))
|
||||||
|
fw, _ := mw.CreateFormFile("file", "chembl.tsv")
|
||||||
|
_, _ = io.Copy(fw, strings.NewReader(tsv))
|
||||||
|
_ = mw.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(context.Background(), "POST", e.ts.URL+"/jobs/upload", &buf)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var m map[string]any
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
return resp.StatusCode, m
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string { return strconv.Itoa(n) }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{{define "admin.html"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Admin · SciMesh</title>
|
||||||
|
<style>
|
||||||
|
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:820px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.top{display:flex;justify-content:space-between;align-items:center;gap:12px}.eyebrow{margin:0;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:6px 0 0;color:#f4f8ff;font-size:clamp(1.8rem,4vw,2.6rem);letter-spacing:-.04em}.lead{max-width:640px;margin:10px 0 0;color:#aabed9}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card h2{margin:0 0 4px;color:#f1f6ff;font-size:1.1rem}.card p{margin:0;color:#9fb3cf;font-size:.92rem}label{display:block;margin:16px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer}.btn-primary{background:#67e3b8;color:#062018}.btn-muted{background:#23344d;color:#dce8ff}.notice{margin-top:16px;border-radius:10px;padding:11px 13px;font-weight:700}.ok{background:#123f34;color:#76efb5}.err{background:#552334;color:#ff9bad}.muted{color:#8ba2c2}.hint{margin-top:4px;color:#92a9c6;font-size:.85rem}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="top">
|
||||||
|
<div><p class="eyebrow">Admin panel</p><h1>User & run control</h1></div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><a href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||||
|
</header>
|
||||||
|
<p class="lead">Signed in as <strong>{{.Role}}</strong>. Promote or verify a user by their id, and control every job from the dashboard.</p>
|
||||||
|
|
||||||
|
{{if .Msg}}<div class="notice ok">{{.Msg}}</div>{{end}}
|
||||||
|
{{if .Error}}<div class="notice err">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Manage a user</h2>
|
||||||
|
<p>Paste the user id (the JWT <code>sub</code> / the value shown at registration). Actions are applied immediately.</p>
|
||||||
|
<form method="post" action="/ui/admin/user-action">
|
||||||
|
<label for="user_id">User id</label>
|
||||||
|
<input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required>
|
||||||
|
<p class="hint">Promote makes them an admin; Verify marks them a trusted contributor (their workers skip quorum).</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
|
||||||
|
<button class="btn btn-muted" name="action" value="demote" type="submit">Remove admin</button>
|
||||||
|
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
|
||||||
|
<button class="btn btn-muted" name="action" value="unverify" type="submit">Unverify</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Jobs & tasks</h2>
|
||||||
|
<p>As an admin you already see <strong>every user's jobs</strong> on the dashboard, with per-task status and job cancellation. A regular user sees only their own.</p>
|
||||||
|
<div class="actions"><a class="btn btn-muted" href="/ui" style="text-decoration:none">Open the dashboard →</a></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "artifact-preview.html"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SciMesh · artifact preview</title>
|
||||||
|
<style>
|
||||||
|
:root{color:#e4eeff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}body{margin:0;background:radial-gradient(circle at 10% -5%,#173f76 0,transparent 34rem),#08111f}.page{max-width:1180px;margin:auto;padding:32px 20px 56px}a{color:#8ab5ff}.back{text-decoration:none}h1{margin:18px 0 4px;color:#f4f8ff;font-size:1.6rem;word-break:break-word}.muted{color:#9cb0cb}.notice{margin:16px 0;padding:15px 17px;border:1px solid #aa8844;border-radius:10px;background:#302610;color:#f2dd9a}.table-wrap{overflow-x:auto;border:1px solid #294662;border-radius:10px;background:#0d1a2cdc;margin-top:16px}table{width:100%;border-collapse:collapse}td,th{padding:10px 12px;border-bottom:1px solid #203a55;text-align:left;vertical-align:top;white-space:pre-wrap;word-break:break-word}th{color:#9cb9dc;font-size:.78rem;text-transform:uppercase;letter-spacing:.06em;background:#10253d}tr:last-child td{border:0}.empty{padding:24px;text-align:center;color:#9cb0cb}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<a class="back" href="/ui/jobs/{{.JobID}}">← Back to job</a>
|
||||||
|
<h1>Preview: {{.Filename}}</h1>
|
||||||
|
{{if .Diagnostic}}
|
||||||
|
<p class="muted">Diagnostic preview — a shard-level partial result, not the final molecular-search answer. At most {{.RowLimit}} rows and {{.ByteLimit}} bytes are read from storage.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">Final result preview. At most {{.RowLimit}} rows and {{.ByteLimit}} bytes are read from storage.</p>
|
||||||
|
{{end}}
|
||||||
|
{{if not .Previewable}}
|
||||||
|
<div class="notice">{{.Reason}}</div>
|
||||||
|
{{else}}
|
||||||
|
{{if .Truncated}}<div class="notice">Truncated: showing at most the first {{.RowLimit}} rows or {{.ByteLimit}} bytes. Download the artifact for its full contents.</div>{{end}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<tr>{{range .Headers}}<th>{{.}}</th>{{end}}</tr>
|
||||||
|
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{else}}<tr><td class="empty" colspan="99">No data rows.</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{define "dashboard.html"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SciMesh control room</title>
|
||||||
|
<style>
|
||||||
|
:root{color:#dce8ff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% -10%,#163d77 0,transparent 32rem),radial-gradient(circle at 95% 5%,#123e39 0,transparent 29rem),#08111f}.page{max-width:1240px;margin:auto;padding:28px 22px 64px}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:22px}.eyebrow{margin:0;color:#78a9ff;font-size:.77rem;font-weight:750;letter-spacing:.14em;text-transform:uppercase}.title{max-width:700px;margin:8px 0;font-size:clamp(2rem,5vw,3.6rem);line-height:1.04;letter-spacing:-.055em}.lead{max-width:690px;margin:0;color:#aabbd5;font-size:1.05rem}.button{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:10px;padding:12px 16px;background:#4f8cff;color:#071224;font:inherit;font-weight:800;text-decoration:none;box-shadow:0 12px 30px #163d7766}.live{display:inline-flex;align-items:center;gap:7px;margin-top:18px;color:#8ba2c2;font-size:.87rem}.pulse{width:8px;height:8px;border-radius:50%;background:#5ee6a6;box-shadow:0 0 0 5px #5ee6a622}.summary{display:grid;grid-template-columns:1.4fr repeat(3,1fr);gap:13px;margin:32px 0}.panel,.metric,.flow-step,.job,.worker{border:1px solid #26415f;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #0000001f}.panel{padding:20px}.pipeline{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:16px}.flow-step{position:relative;padding:14px;min-height:116px}.flow-step:not(:last-child):after{content:"";position:absolute;top:35px;right:-10px;width:10px;height:2px;background:#3c5d83}.flow-step b{display:block;color:#f2f7ff}.flow-step small{display:block;margin-top:6px;color:#91a8c6}.flow-step .dot{display:inline-block;width:9px;height:9px;margin-right:7px;border-radius:50%;background:#5ee6a6}.metric{padding:16px}.metric b{display:block;margin-top:7px;color:#f5f8ff;font-size:2rem;line-height:1}.metric span{color:#9bb0cc;font-size:.84rem}.section-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:36px 0 12px}.section-head h2{margin:0;color:#f3f7ff;font-size:1.18rem}.section-head p{margin:0;color:#8fa6c3;font-size:.9rem}.jobs{display:grid;gap:10px}.job{display:grid;grid-template-columns:minmax(210px,1.35fr) minmax(150px,.9fr) minmax(180px,1fr) auto;gap:18px;align-items:center;padding:17px 18px;text-decoration:none;color:inherit;transition:border-color .15s,transform .15s}.job:hover{border-color:#5d96ee;transform:translateY(-1px)}.job-name{color:#f3f7ff;font-weight:750}.job-id{margin-top:3px;color:#8196b3;font-family:ui-monospace,SFMono-Regular,monospace;font-size:.76rem}.badge{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;font-size:.78rem;font-weight:800}.badge-waiting{background:#23344d;color:#b9cce9}.badge-active{background:#173d77;color:#9fc7ff}.badge-success{background:#123f34;color:#76efb5}.badge-danger{background:#552334;color:#ff9bad}.bar{height:7px;margin-top:8px;overflow:hidden;border-radius:999px;background:#20344e}.bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5a92ff,#61e1bd)}.numbers{color:#afc0d9;font-size:.88rem}.arrow{color:#79aaff;font-size:1.35rem}.workers{display:grid;grid-template-columns:repeat(3,1fr);gap:11px}.worker{padding:15px}.worker-head{display:flex;justify-content:space-between;gap:8px}.worker strong{color:#f3f7ff}.worker small,.worker p{color:#95a9c4}.worker p{margin:12px 0 0}.cap{display:inline-block;margin:4px 5px 0 0;border:1px solid #365576;border-radius:5px;padding:2px 6px;color:#a9c9f4;font:.75rem ui-monospace,SFMono-Regular,monospace}.empty{padding:30px;border:1px dashed #35516f;border-radius:14px;color:#9ab0cb;text-align:center}.offline{color:#faafbd}.sr{position:absolute;width:1px;height:1px;clip:rect(0,0,0,0);overflow:hidden;white-space:nowrap}@media(max-width:820px){.top,.section-head{display:block}.button{margin-top:18px}.summary{grid-template-columns:1fr 1fr}.panel{grid-column:span 2}.pipeline{grid-template-columns:1fr 1fr}.flow-step:not(:last-child):after{display:none}.job{grid-template-columns:1fr 1fr}.arrow{display:none}.workers{grid-template-columns:1fr 1fr}}@media(max-width:540px){.page{padding:22px 14px}.summary,.workers{grid-template-columns:1fr}.panel{grid-column:auto}.pipeline,.job{grid-template-columns:1fr}.title{font-size:2.35rem}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="top">
|
||||||
|
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||||
|
</header>
|
||||||
|
<section class="summary" aria-label="Pipeline summary">
|
||||||
|
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
|
||||||
|
<div class="metric"><span>Active runs</span><b id="active-jobs">{{.ActiveJobs}}</b><small>waiting, running, or merging</small></div>
|
||||||
|
<div class="metric"><span>Available workers</span><b id="online-workers">{{.OnlineWorkers}}</b><small>recently registered</small></div>
|
||||||
|
<div class="metric"><span>Finished runs</span><b id="finished-jobs">{{.FinishedJobs}}</b><small>in the latest 20</small></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section><div class="section-head"><h2>Recent computations</h2><p id="job-count">{{len .Jobs}} shown · newest first</p></div><div id="jobs" class="jobs">{{range .Jobs}}<a class="job" href="/ui/jobs/{{.ID}}"><div><div class="job-name">{{workloadLabel .Workload}}</div><div class="job-id">{{.ID}}</div></div><div><span class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><div class="job-id">{{statusHint .Status}}</div></div><div><div class="numbers"><b>{{.Completed}}</b> / {{.Total}} shards complete{{if gt .Failed 0}} · <span class="offline">{{.Failed}} failed</span>{{end}}</div><div class="bar"><span style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div></div><span class="arrow" aria-hidden="true">→</span></a>{{else}}<div class="empty"><strong>No computations yet.</strong><br>Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.</div>{{end}}</div></section>
|
||||||
|
<section><div class="section-head"><h2>Worker fleet</h2><p>Workers register themselves; this page never controls their processes.</p></div><div id="workers" class="workers">{{range .Workers}}<article class="worker"><div class="worker-head"><strong>{{.Name}}</strong><span class="badge badge-{{workerStatusClass .Status}}">{{workerStatusLabel .Status}}</span></div><small>{{.ID}}</small><p>{{range .Capabilities}}<span class="cap">{{.}}</span>{{end}}</p><p>Last signal · {{time .LastHeartbeatAt}}</p></article>{{else}}<div class="empty"><strong>No worker is registered.</strong><br>Start <code>scimesh-worker</code> in another terminal, then return here.</div>{{end}}</div></section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const statusInfo={pending:['Waiting for a worker','waiting'],leased:['Assigned to a worker','active'],running:['Running','active'],reducing:['Merging results','active'],completed:['Completed','success'],failed:['Needs attention','danger'],cancelled:['Stopped','waiting']};
|
||||||
|
const pct=j=>j.total?Math.min(100,Math.floor((j.completed+j.failed+j.cancelled)*100/j.total)):0;
|
||||||
|
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
|
||||||
|
const renderJobs=jobs=>{const box=document.querySelector('#jobs');box.replaceChildren();if(!jobs.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No computations yet.'),document.createElement('br'),document.createTextNode('Start a small similarity search, then keep one or more workers running to watch this dashboard come alive.'));box.append(empty);return}for(const job of jobs){const info=statusInfo[job.status]||[job.status,'waiting'],link=node('a',undefined,'job');link.href='/ui/jobs/'+encodeURIComponent(job.id);const intro=node('div');intro.append(node('div',job.workload==='similarity-search'?'Molecule similarity search':job.workload,'job-name'),node('div',job.id,'job-id'));const state=node('div');state.append(node('span',info[0],'badge badge-'+info[1]),node('div',job.status==='reducing'?'Every shard is complete; coordinator is ranking the global top-k.':'Live coordinator state','job-id'));const progress=node('div'),numbers=node('div',undefined,'numbers');numbers.append(node('b',String(job.completed)),document.createTextNode(' / '+job.total+' shards complete'));if(job.failed){numbers.append(document.createTextNode(' · '),node('span',job.failed+' failed','offline'))}const bar=node('div',undefined,'bar'),fill=node('span');fill.style.width=pct(job)+'%';bar.append(fill);progress.append(numbers,bar);link.append(intro,state,progress,node('span','→','arrow'));box.append(link)}};
|
||||||
|
const renderWorkers=workers=>{const box=document.querySelector('#workers');box.replaceChildren();if(!workers.length){const empty=node('div',undefined,'empty');empty.append(node('strong','No worker is registered.'),document.createElement('br'),document.createTextNode('Start scimesh-worker in another terminal, then return here.'));box.append(empty);return}for(const worker of workers){const card=node('article',undefined,'worker'),head=node('div',undefined,'worker-head'),left=node('div'),workerInfo=worker.status==='online'?['Available','success']:worker.status==='busy'?['Busy','active']:['Offline','waiting'];left.append(node('strong',worker.name),node('small',worker.id));head.append(left,node('span',workerInfo[0],'badge badge-'+workerInfo[1]));const caps=node('p');for(const capability of worker.capabilities||[])caps.append(node('span',capability,'cap'));card.append(head,caps,node('p','Last signal · '+new Date(worker.last_heartbeat_at).toLocaleString()));box.append(card)}};
|
||||||
|
let timer;const refresh=async()=>{try{const response=await fetch('/ui/api/overview',{headers:{Accept:'application/json'}});if(!response.ok)throw Error();const view=await response.json();document.querySelector('#active-jobs').textContent=view.active_jobs;document.querySelector('#online-workers').textContent=view.online_workers;document.querySelector('#finished-jobs').textContent=view.finished_jobs;document.querySelector('#job-count').textContent=view.jobs.length+' shown · newest first';renderJobs(view.jobs);renderWorkers(view.workers);document.querySelector('#refresh-state').textContent='Live overview · updated just now'}catch(_){document.querySelector('#refresh-state').textContent='Connection interrupted · trying again automatically'}};
|
||||||
|
const start=()=>{if(!timer&&!document.hidden)timer=setInterval(refresh,2000)};document.addEventListener('visibilitychange',()=>{if(document.hidden&&timer){clearInterval(timer);timer=undefined}else start()});start();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "login.html"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in · SciMesh</title>
|
||||||
|
<style>
|
||||||
|
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<p class="eyebrow">SciMesh</p>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<form method="post" action="/ui/login">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
<button class="button" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<p class="alt">No account? <a href="/ui/register">Register</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{define "new-job.html"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>New similarity search · SciMesh</title>
|
||||||
|
<style>
|
||||||
|
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:980px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.back{text-decoration:none}.eyebrow{margin:28px 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0;color:#f4f8ff;font-size:clamp(2rem,5vw,3.25rem);letter-spacing:-.055em}.lead{max-width:720px;margin:10px 0 0;color:#aabed9;font-size:1.06rem}.layout{display:grid;grid-template-columns:1.45fr .8fr;gap:15px;margin-top:28px}.card,.aside,.notice{border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021}.card{padding:22px}.aside,.notice{padding:18px}.aside h2,.notice h2{margin:0;color:#f1f6ff;font-size:1rem}.aside p,.notice p{color:#9fb3cf}.aside ol{margin:13px 0 0;padding-left:20px;color:#aebfda}.aside li{margin:10px 0}label{display:block;margin:18px 0 5px;color:#eaf2ff;font-weight:750}input,select{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus,select:focus{outline:2px solid #5d97f5;outline-offset:1px}input[type=file]{padding:8px}.hint{margin:5px 0;color:#92a9c6;font-size:.87rem}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}.run-preview{margin-top:18px;border:1px solid #2c8c70;border-radius:11px;padding:13px;background:#0c2b2a;color:#a8f1d0}.run-preview strong{color:#e6fff4}.button{display:inline-flex;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.button:disabled{opacity:.6;cursor:wait}.working{margin:14px 0 0;color:#9fc5ff}.error{margin:12px 0 0;color:#ffacba}.hidden{display:none}.cap{display:inline-block;margin:4px 5px 0 0;border:1px solid #375978;border-radius:5px;padding:2px 6px;color:#b5d3f5;font:.76rem ui-monospace,SFMono-Regular,monospace}@media(max-width:720px){.layout,.split{grid-template-columns:1fr}.page{padding:22px 14px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<a class="back" href="/ui">← Back to control room</a><p class="eyebrow">New computation</p><h1>Similarity search, end to end</h1><p class="lead">Upload a ChEMBL-style TSV. Workers calculate shard candidates; after every shard succeeds, SciMesh merges the exact global top-k into one final CSV.</p>
|
||||||
|
<div class="layout"><form id="run" class="card" novalidate><label for="file">ChEMBL TSV</label><input id="file" type="file" name="file" required accept=".tsv,.txt,text/tab-separated-values"><p class="hint">Required columns: <code>chembl_id</code> and <code>canonical_smiles</code>.</p><label for="query-smiles">Target molecule (SMILES)</label><input id="query-smiles" name="query_smiles" required maxlength="200" value="CCO" autocomplete="off"><p class="hint">Use a valid SMILES. The coordinator shares this exact query with every shard.</p><div class="split"><div><label for="top-k">Global top-k</label><input id="top-k" name="top_k" type="number" min="1" max="100000" value="20" required><p class="hint">How many final molecules to retain.</p></div><div><label for="chunk-rows">Rows per shard</label><input id="chunk-rows" name="chunk_rows" type="number" min="1" max="100000" value="1000" required><p class="hint">Smaller shards make more visible tasks.</p></div></div><div class="split"><div><label for="threshold">Similarity threshold <small>(optional)</small></label><input id="threshold" name="threshold" type="number" min="0" max="1" step="0.01" placeholder="For example: 0.70"><p class="hint">Leave blank to rank every valid candidate.</p></div><div><label for="direction">Keep molecules</label><select id="direction" name="threshold_direction"><option value="greater">more similar (≥ threshold)</option><option value="less">less similar (≤ threshold)</option></select><p class="hint">“Less” helps explore dissimilar molecules.</p></div></div><label for="max-rows">Maximum dataset rows <small>(optional quick run)</small></label><input id="max-rows" name="max_rows" type="number" min="1" max="10000000" placeholder="For example: 500"><p class="hint">Only the first N data rows become shards; the original upload remains stored by the coordinator.</p><div id="preview" class="run-preview"><strong>Ready to plan a run.</strong><br>Select a TSV to see the file that will be sent to the coordinator.</div><button class="button" id="submit" type="submit">Create pipeline run →</button><p id="working" class="working hidden" aria-live="polite">Uploading TSV and creating coordinator-owned shards…</p><p id="error" class="error" role="alert"></p></form><aside class="aside"><h2>What you will observe</h2><ol><li><strong>Input accepted</strong><br>TSV is validated and split into durable shard artifacts.</li><li><strong>Workers claim tasks</strong><br>Each worker downloads one shard, fingerprints it, and uploads a partial CSV.</li><li><strong>Global reduction</strong><br>The coordinator compares exact scores from all partial results.</li><li><strong>Final download</strong><br>The result page exposes a checksum-protected global CSV.</li></ol><h2 style="margin-top:26px">Before you submit</h2><p>Keep at least one worker running in another terminal. The browser cannot start or control worker processes.</p><p><span class="cap">similarity-search</span> is currently the only distributed workload available here.</p></aside></div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const form=document.querySelector('#run'),button=document.querySelector('#submit'),working=document.querySelector('#working'),error=document.querySelector('#error'),preview=document.querySelector('#preview'),fileInput=document.querySelector('#file');
|
||||||
|
const bytes=size=>size<1024?size+' B':size<1024*1024?(size/1024).toFixed(1)+' KiB':(size/(1024*1024)).toFixed(1)+' MiB';fileInput.addEventListener('change',()=>{const file=fileInput.files&&fileInput.files[0];preview.replaceChildren();if(!file){preview.append(document.createTextNode('Select a TSV to see the file that will be sent to the coordinator.'));return}const strong=document.createElement('strong');strong.textContent='Source ready: '+file.name;preview.append(strong,document.createElement('br'),document.createTextNode(bytes(file.size)+' · the coordinator will validate its header before creating tasks.'))});
|
||||||
|
form.addEventListener('submit',async event=>{event.preventDefault();error.textContent='';const fields=new FormData(form),file=fields.get('file'),query=String(fields.get('query_smiles')||'').trim(),topK=Number(fields.get('top_k')),chunkRows=Number(fields.get('chunk_rows')),threshold=String(fields.get('threshold')||'').trim(),maxRows=String(fields.get('max_rows')||'').trim();if(!(file instanceof File)||file.size===0){error.textContent='Choose a non-empty TSV file.';return}if(!query||query.length>200||!Number.isInteger(topK)||topK<1||!Number.isInteger(chunkRows)||chunkRows<1){error.textContent='Enter a target SMILES, a positive global top-k, and a positive rows-per-shard value.';return}if(threshold&&(Number.isNaN(Number(threshold))||Number(threshold)<0||Number(threshold)>1)){error.textContent='Similarity threshold must be between 0 and 1.';return}const parameters={query_smiles:query,top_k:topK,threshold_direction:fields.get('threshold_direction'),progress_every:0};if(threshold)parameters.threshold=Number(threshold);const upload=new FormData();upload.append('workload','similarity-search');upload.append('parameters',JSON.stringify(parameters));upload.append('chunk_rows',String(chunkRows));if(maxRows)upload.append('max_rows',maxRows);upload.append('file',file,file.name);button.disabled=true;working.classList.remove('hidden');try{const response=await fetch('/ui/api/jobs/upload',{method:'POST',body:upload}),data=await response.json();if(!response.ok)throw Error(data.error||'Unable to create the job.');location.href='/ui/jobs/'+encodeURIComponent(data.job_id)}catch(err){error.textContent=err.message==='invalid input'?'The coordinator could not accept this run. Check the TSV columns and form values.':err.message;button.disabled=false;working.classList.add('hidden')}});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "profile.html"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Profile · SciMesh</title>
|
||||||
|
<style>
|
||||||
|
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:720px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.top{display:flex;justify-content:space-between;align-items:center;gap:12px}.eyebrow{margin:0;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:6px 0 0;color:#f4f8ff;font-size:clamp(1.8rem,4vw,2.6rem);letter-spacing:-.04em}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer;text-decoration:none}.btn-muted{background:#23344d;color:#dce8ff}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:6px 22px}.err{margin-top:24px;border-radius:10px;padding:12px 14px;background:#552334;color:#ff9bad;font-weight:700}.row{display:flex;justify-content:space-between;gap:16px;padding:15px 0;border-bottom:1px solid #1d3350}.row:last-child{border-bottom:0}.k{color:#9fb3cf}.v{color:#f2f7ff;font-weight:700;text-align:right;word-break:break-all}.mono{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.9rem}.pill{display:inline-block;border-radius:999px;padding:3px 10px;font-size:.82rem;font-weight:800}.pill-yes{background:#123f34;color:#76efb5}.pill-no{background:#23344d;color:#b9cce9}.hint{margin-top:14px;color:#8ba2c2;font-size:.86rem}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="top">
|
||||||
|
<div><p class="eyebrow">Account</p><h1>Your profile</h1></div>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
{{with .Profile}}
|
||||||
|
<section class="card">
|
||||||
|
<div class="row"><span class="k">User id</span><span class="v mono">{{.ID}}</span></div>
|
||||||
|
<div class="row"><span class="k">Email</span><span class="v">{{.Email}}</span></div>
|
||||||
|
<div class="row"><span class="k">Role</span><span class="v">{{.Role}}</span></div>
|
||||||
|
<div class="row"><span class="k">Verified contributor</span><span class="v">{{if .Verified}}<span class="pill pill-yes">yes</span>{{else}}<span class="pill pill-no">no</span>{{end}}</span></div>
|
||||||
|
<div class="row"><span class="k">Member since</span><span class="v mono">{{.CreatedAt}}</span></div>
|
||||||
|
</section>
|
||||||
|
<p class="hint">Your user id is what the coordinator stores as the owner of every job you submit. Give it to an admin to be promoted or verified.</p>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "register.html"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Register · SciMesh</title>
|
||||||
|
<style>
|
||||||
|
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.hint{margin:5px 0 0;color:#92a9c6;font-size:.85rem}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<p class="eyebrow">SciMesh</p>
|
||||||
|
<h1>Create account</h1>
|
||||||
|
<form method="post" action="/ui/register">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="72" required>
|
||||||
|
<p class="hint">At least 8 characters.</p>
|
||||||
|
<button class="button" type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<p class="alt">Already have an account? <a href="/ui/login">Sign in</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var uiFiles embed.FS
|
||||||
|
|
||||||
|
var uiTemplates = template.Must(template.New("ui").Funcs(template.FuncMap{
|
||||||
|
"time": formatUITime,
|
||||||
|
"statusLabel": uiStatusLabel,
|
||||||
|
"statusHint": uiStatusHint,
|
||||||
|
"statusClass": uiStatusClass,
|
||||||
|
"taskErrorLabel": uiTaskErrorLabel,
|
||||||
|
"taskErrorHint": uiTaskErrorHint,
|
||||||
|
"workerStatusLabel": uiWorkerStatusLabel,
|
||||||
|
"workerStatusClass": uiWorkerStatusClass,
|
||||||
|
"workloadLabel": uiWorkloadLabel,
|
||||||
|
"progressPercent": uiProgressPercent,
|
||||||
|
"cancellable": uiCancellable,
|
||||||
|
"bytes": uiBytes,
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
}).ParseFS(uiFiles, "templates/*.html"))
|
||||||
|
|
||||||
|
func formatUITime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return t.UTC().Format("02.01.2006 15:04 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiStatusLabel(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "pending":
|
||||||
|
return "Waiting for a worker"
|
||||||
|
case "leased":
|
||||||
|
return "Assigned to a worker"
|
||||||
|
case "running":
|
||||||
|
return "Running"
|
||||||
|
case "reducing":
|
||||||
|
return "Merging results"
|
||||||
|
case "completed":
|
||||||
|
return "Completed"
|
||||||
|
case "failed":
|
||||||
|
return "Needs attention"
|
||||||
|
case "cancelled":
|
||||||
|
return "Stopped"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiStatusHint(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "pending":
|
||||||
|
return "Waiting for an available worker with the required capability."
|
||||||
|
case "leased":
|
||||||
|
return "A worker has claimed the task and should begin processing shortly."
|
||||||
|
case "running":
|
||||||
|
return "A worker is reading a shard, calculating fingerprints, and uploading its result through the coordinator."
|
||||||
|
case "reducing":
|
||||||
|
return "All shards are complete. The coordinator is merging their candidates into one final CSV."
|
||||||
|
case "completed":
|
||||||
|
return "The final result is ready to download."
|
||||||
|
case "failed":
|
||||||
|
return "One or more shard tasks failed. Open the task list below for details."
|
||||||
|
case "cancelled":
|
||||||
|
return "The operator stopped this job. No new shards can be claimed."
|
||||||
|
default:
|
||||||
|
return "Status reported by the coordinator."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiStatusClass(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "completed":
|
||||||
|
return "success"
|
||||||
|
case "failed":
|
||||||
|
return "danger"
|
||||||
|
case "cancelled":
|
||||||
|
return "waiting"
|
||||||
|
case "running", "leased", "reducing":
|
||||||
|
return "active"
|
||||||
|
default:
|
||||||
|
return "waiting"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiWorkerStatusLabel(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "online":
|
||||||
|
return "Available"
|
||||||
|
case "busy":
|
||||||
|
return "Busy"
|
||||||
|
case "offline":
|
||||||
|
return "Offline"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiWorkerStatusClass(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "online":
|
||||||
|
return "success"
|
||||||
|
case "busy":
|
||||||
|
return "active"
|
||||||
|
default:
|
||||||
|
return "waiting"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiTaskErrorLabel deliberately maps worker implementation errors to an
|
||||||
|
// operator-facing diagnosis. Raw subprocess commands and local paths belong in
|
||||||
|
// the worker terminal, not in the web UI.
|
||||||
|
func uiTaskErrorLabel(errorCode string) string {
|
||||||
|
switch errorCode {
|
||||||
|
case "CalledProcessError":
|
||||||
|
return "Local calculation failed"
|
||||||
|
case "ValueError":
|
||||||
|
return "Task input could not be processed"
|
||||||
|
case "CoordinatorTransientError":
|
||||||
|
return "Coordinator connection was interrupted"
|
||||||
|
case "CoordinatorConflictError":
|
||||||
|
return "Worker lease was no longer valid"
|
||||||
|
case "FileNotFoundError":
|
||||||
|
return "Local task file is missing"
|
||||||
|
default:
|
||||||
|
return errorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiTaskErrorHint(errorCode string) string {
|
||||||
|
switch errorCode {
|
||||||
|
case "CalledProcessError":
|
||||||
|
return "The local SciMesh command stopped before it could upload a result. Check the worker terminal for the original error."
|
||||||
|
case "ValueError":
|
||||||
|
return "The coordinator task or its downloaded input did not meet the worker validation rules."
|
||||||
|
case "CoordinatorTransientError":
|
||||||
|
return "The worker will retry after the coordinator connection is available again."
|
||||||
|
case "CoordinatorConflictError":
|
||||||
|
return "Another worker or a lease timeout changed this task before completion."
|
||||||
|
case "FileNotFoundError":
|
||||||
|
return "The worker could not find one of its local task files. Restart it with an absolute --work-dir."
|
||||||
|
default:
|
||||||
|
return "Check the worker terminal for the original error details."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiWorkloadLabel(workload string) string {
|
||||||
|
switch workload {
|
||||||
|
case "similarity-search", "similarity_search":
|
||||||
|
return "Molecule similarity search"
|
||||||
|
case "similarity-graph", "similarity_graph":
|
||||||
|
return "Molecular similarity graph"
|
||||||
|
default:
|
||||||
|
return workload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiCancellable(status string) bool {
|
||||||
|
return status == "pending" || status == "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiProgressPercent(completed, failed, cancelled, total int) int {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
percent := (completed + failed + cancelled) * 100 / total
|
||||||
|
if percent > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return percent
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiBytes(n int64) string {
|
||||||
|
const kib = 1024
|
||||||
|
if n < kib {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
if n < kib*kib {
|
||||||
|
return fmt.Sprintf("%.1f KiB", float64(n)/kib)
|
||||||
|
}
|
||||||
|
if n < kib*kib*kib {
|
||||||
|
return fmt.Sprintf("%.1f MiB", float64(n)/(kib*kib))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f GiB", float64(n)/(kib*kib*kib))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderUI(w http.ResponseWriter, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
|
||||||
|
if err := uiTemplates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
s.log.Error("render UI", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUIHome(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
view, err := s.uc.Dashboard.Overview(ctx, 20)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderUI(w, "dashboard.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIOverviewJSON is the bounded polling projection used by the operator
|
||||||
|
// dashboard. It intentionally returns only the safe UI read model, never
|
||||||
|
// worker tokens, storage keys, or database entities.
|
||||||
|
func (s *Server) handleUIOverviewJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
view, err := s.uc.Dashboard.Overview(ctx, 20)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUINewJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderUI(w, "new-job.html", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) uiJobID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||||
|
return s.pathUUID(w, r, "job_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUIJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobID, ok := s.uiJobID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
view, err := s.uc.Dashboard.JobDetail(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderUI(w, "job.html", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUIJobJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobID, ok := s.uiJobID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
view, err := s.uc.Dashboard.JobDetail(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUIArtifactDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobID, ok := s.uiJobID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artifactID, err := uuid.Parse(r.PathValue("artifact_id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
belongs, err := s.uc.Dashboard.DownloadableArtifactBelongsToJob(ctx, jobID, artifactID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !belongs {
|
||||||
|
s.writeError(w, r, domain.ErrArtifactNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Reuse the coordinator-owned blob stream after the job-scoped check above.
|
||||||
|
art, body, err := s.uc.DownloadArtifact.Execute(ctx, artifactID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := body.Close(); err != nil {
|
||||||
|
s.log.Warn("close downloaded UI artifact", "artifact_id", artifactID, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
w.Header().Set("Content-Type", art.ContentType)
|
||||||
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": art.Filename}))
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(art.SizeBytes, 10))
|
||||||
|
w.Header().Set("X-Checksum-SHA256", art.SHA256)
|
||||||
|
_, _ = io.Copy(w, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIArtifactPreview renders a bounded CSV preview. Its use case owns
|
||||||
|
// the job-scoped access rule, including the requirement that a final artifact
|
||||||
|
// is the persisted result of a completed job.
|
||||||
|
func (s *Server) handleUIArtifactPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobID, ok := s.uiJobID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
artifactID, err := uuid.Parse(r.PathValue("artifact_id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.uc.PreviewArtifact == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
view, err := s.uc.PreviewArtifact.Execute(ctx, jobID, artifactID)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderUI(w, "artifact-preview.html", view)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminUserActions are the userservice endpoints the admin panel may invoke, by
|
||||||
|
// their path suffix. A whitelist so a crafted form can never proxy an arbitrary
|
||||||
|
// path.
|
||||||
|
var adminUserActions = map[string]bool{
|
||||||
|
"promote": true,
|
||||||
|
"demote": true,
|
||||||
|
"verify": true,
|
||||||
|
"unverify": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAdmin gates a route on the session caller being an admin. It runs
|
||||||
|
// inside withUISession, which has already stamped the requester. A non-admin is
|
||||||
|
// sent back to the dashboard rather than shown the panel.
|
||||||
|
func requireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() {
|
||||||
|
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUIAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
role := ""
|
||||||
|
if req, ok := authctx.From(r.Context()); ok {
|
||||||
|
role = req.Role
|
||||||
|
}
|
||||||
|
s.renderUI(w, "admin.html", map[string]any{
|
||||||
|
"Role": role,
|
||||||
|
"Msg": r.URL.Query().Get("msg"),
|
||||||
|
"Error": r.URL.Query().Get("error"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminUserAction proxies a user-management action to the userservice,
|
||||||
|
// forwarding the admin's session token so the userservice re-checks the role.
|
||||||
|
// The user id and action come from the form, so a single static form action can
|
||||||
|
// drive every operation.
|
||||||
|
func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := strings.TrimSpace(r.FormValue("user_id"))
|
||||||
|
action := r.FormValue("action")
|
||||||
|
|
||||||
|
if !adminUserActions[action] {
|
||||||
|
http.Redirect(w, r, "/ui/admin?error=unknown+action", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := uuid.Parse(userID); err != nil {
|
||||||
|
http.Redirect(w, r, "/ui/admin?error=invalid+user+id", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("admin action proxy", "err", err, "action", action)
|
||||||
|
http.Redirect(w, r, "/ui/admin?error=service+unavailable", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
http.Redirect(w, r, "/ui/admin?msg="+url.QueryEscape(action+" applied"), http.StatusSeeOther)
|
||||||
|
case http.StatusNotFound:
|
||||||
|
http.Redirect(w, r, "/ui/admin?error=user+not+found", http.StatusSeeOther)
|
||||||
|
case http.StatusForbidden, http.StatusUnauthorized:
|
||||||
|
http.Redirect(w, r, "/ui/admin?error=not+authorized", http.StatusSeeOther)
|
||||||
|
default:
|
||||||
|
http.Redirect(w, r, "/ui/admin?error=action+failed", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callUserserviceAuthed makes an authenticated call to the userservice, passing
|
||||||
|
// the caller's JWT through as a bearer token. Used for admin actions; login and
|
||||||
|
// registration use the unauthenticated callUserservice.
|
||||||
|
func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, []byte, error) {
|
||||||
|
// path is not attacker-controlled: the caller composes it only from a
|
||||||
|
// uuid-validated id and an action from a fixed whitelist, and the host is
|
||||||
|
// the operator-configured userservice — so the SSRF taint gosec sees here
|
||||||
|
// cannot reach an arbitrary destination.
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, nil) //nolint:gosec // G704: path is validated, host is config
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
return resp.StatusCode, body, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func adminReq(t *testing.T, role string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
req := newReq(http.MethodGet, "/ui/admin", nil)
|
||||||
|
return req.WithContext(authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: role}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAdminAllowsAdminOnly(t *testing.T) {
|
||||||
|
reached := false
|
||||||
|
h := requireAdmin(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
||||||
|
|
||||||
|
// Admin passes through.
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), adminReq(t, "admin"))
|
||||||
|
if !reached {
|
||||||
|
t.Error("admin must reach the handler")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain user is redirected to the dashboard.
|
||||||
|
reached = false
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, adminReq(t, "user"))
|
||||||
|
if reached {
|
||||||
|
t.Error("non-admin must not reach the handler")
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
|
||||||
|
t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUserActionForwardsBearer(t *testing.T) {
|
||||||
|
targetID := uuid.NewString()
|
||||||
|
var gotAuth, gotPath string
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer stub.Close()
|
||||||
|
s := newLoginServer(stub)
|
||||||
|
|
||||||
|
req := newReq(http.MethodPost, "/ui/admin/user-action",
|
||||||
|
strings.NewReader(url.Values{"user_id": {targetID}, "action": {"promote"}}.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "admin.jwt.token"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUIAdminUserAction(rec, req)
|
||||||
|
|
||||||
|
if gotAuth != "Bearer admin.jwt.token" {
|
||||||
|
t.Errorf("forwarded auth = %q, want the admin bearer", gotAuth)
|
||||||
|
}
|
||||||
|
if gotPath != "/users/"+targetID+"/promote" {
|
||||||
|
t.Errorf("forwarded path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "msg=") {
|
||||||
|
t.Errorf("got %d -> %q, want 303 with a success msg", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUserActionRejectsUnknownAction(t *testing.T) {
|
||||||
|
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("userservice must not be called for an invalid action")
|
||||||
|
})))
|
||||||
|
req := newReq(http.MethodPost, "/ui/admin/user-action",
|
||||||
|
strings.NewReader(url.Values{"user_id": {uuid.NewString()}, "action": {"delete"}}.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUIAdminUserAction(rec, req)
|
||||||
|
if !strings.Contains(rec.Header().Get("Location"), "error=") {
|
||||||
|
t.Errorf("unknown action redirect = %q, want an error", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUserActionRejectsBadID(t *testing.T) {
|
||||||
|
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("userservice must not be called for an invalid id")
|
||||||
|
})))
|
||||||
|
req := newReq(http.MethodPost, "/ui/admin/user-action",
|
||||||
|
strings.NewReader(url.Values{"user_id": {"not-a-uuid"}, "action": {"promote"}}.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUIAdminUserAction(rec, req)
|
||||||
|
if !strings.Contains(rec.Header().Get("Location"), "error=") {
|
||||||
|
t.Errorf("bad id redirect = %q, want an error", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardAdminLinkOnlyForAdmin(t *testing.T) {
|
||||||
|
admin := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
|
||||||
|
if !strings.Contains(admin, "/ui/admin") {
|
||||||
|
t.Error("admin must see the Admin link")
|
||||||
|
}
|
||||||
|
user := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "user"}})
|
||||||
|
if strings.Contains(user, "/ui/admin") {
|
||||||
|
t.Error("a plain user must not see the Admin link")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionCookie holds the userservice JWT for the operator UI. It is httpOnly so
|
||||||
|
// page scripts cannot read the token, and scoped to /ui so it never rides along
|
||||||
|
// with worker API calls.
|
||||||
|
const sessionCookie = "scimesh_session"
|
||||||
|
|
||||||
|
// withUISession gates the operator UI on a valid userservice session cookie.
|
||||||
|
// A missing or invalid token redirects to the login page rather than returning
|
||||||
|
// 401, because the caller here is a browser, not an API client. On success it
|
||||||
|
// stamps the requester so downstream handlers can scope views by owner.
|
||||||
|
func withUISession(v tokenVerifier) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := v.Verify(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
// Expired or tampered: drop the stale cookie and re-authenticate.
|
||||||
|
clearSessionCookie(w, r)
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := authctx.With(r.Context(), authctx.Requester{
|
||||||
|
UserID: claims.UserID,
|
||||||
|
Role: claims.Role,
|
||||||
|
Verified: claims.Verified,
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenVerifier is satisfied by *token.Verifier; taking an interface keeps the
|
||||||
|
// UI auth testable with a stub.
|
||||||
|
type tokenVerifier interface {
|
||||||
|
Verify(raw string) (tokenpkg.Claims, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUILogin exchanges the submitted credentials for a userservice token and
|
||||||
|
// stores it in the session cookie. The coordinator never sees or stores the
|
||||||
|
// password beyond forwarding it once.
|
||||||
|
func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email, password := r.FormValue("email"), r.FormValue("password")
|
||||||
|
|
||||||
|
status, body, err := s.callUserservice(r.Context(), "/login", email, password)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("userservice login call", "err", err)
|
||||||
|
http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
http.Redirect(w, r, "/ui/login?error=invalid+email+or+password", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil || resp.Token == "" {
|
||||||
|
http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, resp.Token)
|
||||||
|
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIRegister creates an account through the userservice, then sends the
|
||||||
|
// user to the login page. The new account is a plain user until an admin
|
||||||
|
// promotes or verifies it.
|
||||||
|
func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email, password := r.FormValue("email"), r.FormValue("password")
|
||||||
|
|
||||||
|
status, _, err := s.callUserservice(r.Context(), "/register", email, password)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("userservice register call", "err", err)
|
||||||
|
http.Redirect(w, r, "/ui/register?error=service+unavailable", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case http.StatusCreated:
|
||||||
|
http.Redirect(w, r, "/ui/login?error=registered,+please+log+in", http.StatusSeeOther)
|
||||||
|
case http.StatusConflict:
|
||||||
|
http.Redirect(w, r, "/ui/register?error=email+already+registered", http.StatusSeeOther)
|
||||||
|
default:
|
||||||
|
http.Redirect(w, r, "/ui/register?error=invalid+email+or+password", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUILogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clearSessionCookie(w, r)
|
||||||
|
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callUserservice POSTs credentials to the userservice and returns its status
|
||||||
|
// and body. It is the only runtime dependency on the userservice — login and
|
||||||
|
// registration; token verification stays local.
|
||||||
|
func (s *Server) callUserservice(ctx context.Context, path, email, password string) (int, []byte, error) {
|
||||||
|
payload, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.userserviceURL+path, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
// Cap the response; login/register bodies are tiny.
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
return resp.StatusCode, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||||
|
// Secure is set under TLS; a local demo runs plain HTTP, where forcing
|
||||||
|
// Secure would stop the browser from ever sending the cookie back.
|
||||||
|
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: token,
|
||||||
|
Path: "/ui",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(24 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: "",
|
||||||
|
Path: "/ui",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newReq builds a request carrying a context, which http.NewRequestWithContext
|
||||||
|
// provides on go1.22 (httptest.NewRequestWithContext needs go1.23).
|
||||||
|
func newReq(method, target string, body io.Reader) *http.Request {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), method, target, body)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubVerifier struct {
|
||||||
|
claims tokenpkg.Claims
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubVerifier) Verify(string) (tokenpkg.Claims, error) { return s.claims, s.err }
|
||||||
|
|
||||||
|
func TestWithUISessionRedirectsWithoutCookie(t *testing.T) {
|
||||||
|
h := withUISession(stubVerifier{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("handler must not run without a session")
|
||||||
|
}))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, newReq(http.MethodGet, "/ui", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("got %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if loc := rec.Header().Get("Location"); loc != "/ui/login" {
|
||||||
|
t.Errorf("redirect = %q, want /ui/login", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithUISessionAcceptsValidCookieAndStampsRequester(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
verifier := stubVerifier{claims: tokenpkg.Claims{UserID: id, Role: "admin", Verified: true}}
|
||||||
|
|
||||||
|
var gotReq authctx.Requester
|
||||||
|
var ok bool
|
||||||
|
h := withUISession(verifier)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
gotReq, ok = authctx.From(r.Context())
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := newReq(http.MethodGet, "/ui", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "valid.jwt"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if !ok || gotReq.UserID != id || gotReq.Role != "admin" || !gotReq.Verified {
|
||||||
|
t.Errorf("requester = %+v (ok=%v), want id=%v admin verified", gotReq, ok, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithUISessionClearsInvalidCookie(t *testing.T) {
|
||||||
|
h := withUISession(stubVerifier{err: errors.New("expired")})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("handler must not run with an invalid token")
|
||||||
|
}))
|
||||||
|
req := newReq(http.MethodGet, "/ui", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "stale.jwt"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("got %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if c := rec.Result().Cookies(); len(c) == 0 || c[0].MaxAge >= 0 {
|
||||||
|
t.Error("stale cookie must be cleared (MaxAge < 0)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLoginServer builds a Server whose userservice calls hit stub.
|
||||||
|
func newLoginServer(stub *httptest.Server) *Server {
|
||||||
|
return &Server{
|
||||||
|
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
userserviceURL: strings.TrimRight(stub.URL, "/"),
|
||||||
|
httpClient: stub.Client(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postForm(path string, form url.Values) *http.Request {
|
||||||
|
req := newReq(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) {
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/login" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"token":"issued.jwt.here"}`))
|
||||||
|
}))
|
||||||
|
defer stub.Close()
|
||||||
|
s := newLoginServer(stub)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}}))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
|
||||||
|
t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" {
|
||||||
|
t.Errorf("session cookie not set: %+v", cookies)
|
||||||
|
}
|
||||||
|
if !cookies[0].HttpOnly {
|
||||||
|
t.Error("session cookie must be httpOnly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUILoginRejectsBadCredentials(t *testing.T) {
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
}))
|
||||||
|
defer stub.Close()
|
||||||
|
s := newLoginServer(stub)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"wrong"}}))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/ui/login?error=") {
|
||||||
|
t.Fatalf("got %d -> %q, want 303 -> /ui/login?error=", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
if len(rec.Result().Cookies()) != 0 {
|
||||||
|
t.Error("no cookie must be set on failed login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUIRegisterConflict(t *testing.T) {
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
}))
|
||||||
|
defer stub.Close()
|
||||||
|
s := newLoginServer(stub)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"dup@b.com"}, "password": {"password123"}}))
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Location"); !strings.Contains(got, "already+registered") {
|
||||||
|
t.Errorf("register conflict redirect = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUILogoutClearsCookie(t *testing.T) {
|
||||||
|
s := &Server{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUILogout(rec, newReq(http.MethodPost, "/ui/logout", nil))
|
||||||
|
|
||||||
|
if rec.Header().Get("Location") != "/ui/login" {
|
||||||
|
t.Errorf("logout redirect = %q", rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
c := rec.Result().Cookies()
|
||||||
|
if len(c) == 0 || c[0].MaxAge >= 0 {
|
||||||
|
t.Error("logout must clear the session cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func render(t *testing.T, name string, data any) string {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := uiTemplates.ExecuteTemplate(&buf, name, data); err != nil {
|
||||||
|
t.Fatalf("render %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardLogoutOnlyInSession(t *testing.T) {
|
||||||
|
withSession := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
|
||||||
|
if !strings.Contains(withSession, "/ui/logout") || !strings.Contains(withSession, "Log out") {
|
||||||
|
t.Error("dashboard must show a logout control in session mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
noSession := render(t, "dashboard.html", usecase.DashboardView{})
|
||||||
|
if strings.Contains(noSession, "/ui/logout") {
|
||||||
|
t.Error("dashboard must not show logout under basic auth (no session)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobLogoutOnlyInSession(t *testing.T) {
|
||||||
|
withSession := render(t, "job.html", usecase.JobDetailView{Session: &usecase.SessionView{Role: "user"}})
|
||||||
|
if !strings.Contains(withSession, "/ui/logout") {
|
||||||
|
t.Error("job page must show a logout control in session mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
noSession := render(t, "job.html", usecase.JobDetailView{})
|
||||||
|
if strings.Contains(noSession, "/ui/logout") {
|
||||||
|
t.Error("job page must not show logout under basic auth (no session)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// profileView is the account data shown on the profile page, mirroring the
|
||||||
|
// userservice /me response.
|
||||||
|
type profileView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIProfile shows the signed-in user's own account. It proxies the
|
||||||
|
// session token to the userservice /me endpoint, which is the authority on the
|
||||||
|
// account (email and created_at are not in the JWT).
|
||||||
|
func (s *Server) handleUIProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/me", c.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("profile /me proxy", "err", err)
|
||||||
|
s.renderUI(w, "profile.html", map[string]any{"Error": "userservice unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status == http.StatusUnauthorized {
|
||||||
|
clearSessionCookie(w, r)
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
s.renderUI(w, "profile.html", map[string]any{"Error": "could not load your account"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p profileView
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
s.renderUI(w, "profile.html", map[string]any{"Error": "could not read your account"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderUI(w, "profile.html", map[string]any{"Profile": p})
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileProxiesMe(t *testing.T) {
|
||||||
|
var gotAuth, gotPath string
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","email":"me@example.com","role":"user","verified":false,"created_at":"2026-07-26T00:00:00Z"}`))
|
||||||
|
}))
|
||||||
|
defer stub.Close()
|
||||||
|
s := newLoginServer(stub)
|
||||||
|
|
||||||
|
req := newReq(http.MethodGet, "/ui/profile", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUIProfile(rec, req)
|
||||||
|
|
||||||
|
if gotAuth != "Bearer my.jwt" || gotPath != "/me" {
|
||||||
|
t.Fatalf("proxy: auth=%q path=%q", gotAuth, gotPath)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "me@example.com") || !strings.Contains(body, "11111111-1111-1111-1111-111111111111") {
|
||||||
|
t.Error("profile page must show the email and id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileRedirectsWithoutCookie(t *testing.T) {
|
||||||
|
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Fatal("must not call userservice without a session")
|
||||||
|
})))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleUIProfile(rec, newReq(http.MethodGet, "/ui/profile", nil))
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("no cookie: got %d, want 303 redirect", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUIStatusPresentation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status string
|
||||||
|
label string
|
||||||
|
class string
|
||||||
|
}{
|
||||||
|
{"pending", "Waiting for a worker", "waiting"},
|
||||||
|
{"running", "Running", "active"},
|
||||||
|
{"reducing", "Merging results", "active"},
|
||||||
|
{"completed", "Completed", "success"},
|
||||||
|
{"failed", "Needs attention", "danger"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.status, func(t *testing.T) {
|
||||||
|
if got := uiStatusLabel(test.status); got != test.label {
|
||||||
|
t.Errorf("label = %q, want %q", got, test.label)
|
||||||
|
}
|
||||||
|
if got := uiStatusClass(test.status); got != test.class {
|
||||||
|
t.Errorf("class = %q, want %q", got, test.class)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIProgressPercent(t *testing.T) {
|
||||||
|
if got := uiProgressPercent(3, 1, 0, 8); got != 50 {
|
||||||
|
t.Errorf("progress = %d, want 50", got)
|
||||||
|
}
|
||||||
|
if got := uiProgressPercent(1, 1, 0, 0); got != 0 {
|
||||||
|
t.Errorf("empty progress = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUITaskErrorPresentationDoesNotExposeCommand(t *testing.T) {
|
||||||
|
if got := uiTaskErrorLabel("CalledProcessError"); got != "Local calculation failed" {
|
||||||
|
t.Errorf("error label = %q", got)
|
||||||
|
}
|
||||||
|
if got := uiTaskErrorHint("CalledProcessError"); got == "" {
|
||||||
|
t.Error("error hint must explain the failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIWorkerStatusPresentation(t *testing.T) {
|
||||||
|
if got := uiWorkerStatusLabel("busy"); got != "Busy" {
|
||||||
|
t.Errorf("busy worker label = %q", got)
|
||||||
|
}
|
||||||
|
if got := uiWorkerStatusClass("busy"); got != "active" {
|
||||||
|
t.Errorf("busy worker class = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadArtifact stores a worker's partial-result bytes and records the metadata.
|
||||||
|
type UploadArtifact struct {
|
||||||
|
tasks TaskRepository
|
||||||
|
workers WorkerRepository
|
||||||
|
artifacts ArtifactRepository
|
||||||
|
blobs BlobStore
|
||||||
|
tx TxManager
|
||||||
|
clk Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUploadArtifact(tasks TaskRepository, workers WorkerRepository, artifacts ArtifactRepository,
|
||||||
|
blobs BlobStore, tx TxManager, clk Clock) *UploadArtifact {
|
||||||
|
return &UploadArtifact{tasks: tasks, workers: workers, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UploadArtifact) Execute(ctx context.Context, in UploadArtifactInput) (*domain.Artifact, error) {
|
||||||
|
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task, err := uc.tasks.Get(ctx, in.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Only the worker holding the current lease at this attempt may upload the
|
||||||
|
// task's output — the coordinator never trusts an ownership claim on faith.
|
||||||
|
if !task.IsLeaseHeldBy(in.WorkerID, in.Attempt, uc.clk.Now()) {
|
||||||
|
return nil, domain.ErrLeaseConflict
|
||||||
|
}
|
||||||
|
// A client can retry a PUT after losing the response. Return the one durable
|
||||||
|
// result for this lease attempt instead of storing duplicate artifacts.
|
||||||
|
existing, err := uc.artifacts.FindPartialResult(ctx, in.TaskID, in.Attempt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID := task.ID
|
||||||
|
art, err := domain.NewArtifact(task.JobID, &taskID, domain.ArtifactPartialResult,
|
||||||
|
in.Filename, in.ContentType, uc.clk.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
attempt := in.Attempt
|
||||||
|
art.Attempt = &attempt
|
||||||
|
|
||||||
|
// Stream to storage first: size and checksum are measured here, by us, not
|
||||||
|
// taken from the worker. A large shard never sits in memory.
|
||||||
|
sum, size, err := uc.blobs.Put(ctx, art.StorageKey, in.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
art.SetContent(sum, size)
|
||||||
|
|
||||||
|
// The stream may take longer than the lease. Lock the task while re-checking
|
||||||
|
// ownership and inserting metadata: completion or another upload cannot race
|
||||||
|
// this final decision. The database unique index is a second line of defence.
|
||||||
|
var durable *domain.Artifact
|
||||||
|
err = uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
current, err := uc.tasks.GetForUpdate(ctx, in.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !current.IsLeaseHeldBy(in.WorkerID, in.Attempt, uc.clk.Now()) {
|
||||||
|
return domain.ErrLeaseConflict
|
||||||
|
}
|
||||||
|
existing, err := uc.artifacts.FindPartialResult(ctx, in.TaskID, in.Attempt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
durable = existing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := uc.artifacts.Insert(ctx, art); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
durable = art
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = uc.blobs.Delete(ctx, art.StorageKey)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if durable != art {
|
||||||
|
// Another request won the race while this stream was being written.
|
||||||
|
_ = uc.blobs.Delete(ctx, art.StorageKey)
|
||||||
|
}
|
||||||
|
return durable, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadArtifact returns an artifact's metadata together with a reader over
|
||||||
|
// its bytes. The caller must close the reader.
|
||||||
|
type DownloadArtifact struct {
|
||||||
|
artifacts ArtifactRepository
|
||||||
|
blobs BlobStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadArtifact(artifacts ArtifactRepository, blobs BlobStore) *DownloadArtifact {
|
||||||
|
return &DownloadArtifact{artifacts: artifacts, blobs: blobs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *DownloadArtifact) Execute(ctx context.Context, id uuid.UUID) (*domain.Artifact, io.ReadCloser, error) {
|
||||||
|
a, err := uc.artifacts.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rc, err := uc.blobs.Open(ctx, a.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return a, rc, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use-case boundary types. Adapters map their wire formats onto these, so the
|
||||||
|
// HTTP shape can change without touching business code.
|
||||||
|
|
||||||
|
type CreateJobInput struct {
|
||||||
|
Workload string
|
||||||
|
InputURI string
|
||||||
|
Parameters map[string]any
|
||||||
|
Chunks []ChunkInput
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChunkInput struct {
|
||||||
|
ChunkIndex int
|
||||||
|
Workload string
|
||||||
|
InputURI string
|
||||||
|
InputSHA256 string
|
||||||
|
Parameters map[string]any
|
||||||
|
MaxAttempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterWorkerInput struct {
|
||||||
|
Name string
|
||||||
|
Capabilities []string
|
||||||
|
// OwnerID is the userservice user registering this worker; nil for a
|
||||||
|
// shared-token registration. TrustLevel is resolved by the transport layer
|
||||||
|
// from how the caller authenticated.
|
||||||
|
OwnerID *uuid.UUID
|
||||||
|
TrustLevel domain.WorkerTrust
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaimTaskInput struct {
|
||||||
|
WorkerID string
|
||||||
|
Workloads []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenewLeaseInput struct {
|
||||||
|
TaskID uuid.UUID
|
||||||
|
WorkerID string
|
||||||
|
Attempt int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompleteTaskInput struct {
|
||||||
|
TaskID uuid.UUID
|
||||||
|
WorkerID string
|
||||||
|
Attempt int
|
||||||
|
ResultArtifactID uuid.UUID
|
||||||
|
Metrics map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitDatasetInput struct {
|
||||||
|
Workload string
|
||||||
|
Parameters map[string]any
|
||||||
|
RowsPerShard int
|
||||||
|
// MaxRows limits how many data rows are turned into shards. Zero means the
|
||||||
|
// whole uploaded dataset; the input artifact itself remains stored intact.
|
||||||
|
MaxRows int
|
||||||
|
Filename string
|
||||||
|
ContentType string
|
||||||
|
Body io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitDatasetResult struct {
|
||||||
|
JobID uuid.UUID
|
||||||
|
TaskCount int
|
||||||
|
InputArtifactID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadArtifactInput struct {
|
||||||
|
TaskID uuid.UUID
|
||||||
|
WorkerID string
|
||||||
|
Attempt int
|
||||||
|
Filename string
|
||||||
|
ContentType string
|
||||||
|
Body io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type FailTaskInput struct {
|
||||||
|
TaskID uuid.UUID
|
||||||
|
WorkerID string
|
||||||
|
Attempt int
|
||||||
|
ErrorCode string
|
||||||
|
ErrorMessage string
|
||||||
|
Retryable bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Job operations: the submitter-facing lifecycle of a whole submission.
|
||||||
|
//
|
||||||
|
// CreateJob register a job and fan it out into tasks
|
||||||
|
// GetJobStatus aggregate progress
|
||||||
|
// ListResults completed manifests, ordered for the stitcher
|
||||||
|
// StitchJob merge partial results into the final artifact
|
||||||
|
|
||||||
|
// --- CreateJob -----------------------------------------------------------
|
||||||
|
|
||||||
|
type CreateJob struct {
|
||||||
|
jobs JobRepository
|
||||||
|
tasks TaskRepository
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateJob(jobs JobRepository, tasks TaskRepository, tx TxManager, clock Clock) *CreateJob {
|
||||||
|
return &CreateJob{jobs: jobs, tasks: tasks, tx: tx, clock: clock}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute builds the job and its tasks, then writes them in one transaction.
|
||||||
|
// The all-or-none guarantee comes from TxManager: a half-created job would
|
||||||
|
// leave chunks no worker could ever complete.
|
||||||
|
func (uc *CreateJob) Execute(ctx context.Context, in CreateJobInput) (*domain.Job, error) {
|
||||||
|
if in.Workload == "similarity-graph" || in.Workload == "similarity_graph" {
|
||||||
|
// CTX-10 must plan triangular block pairs; ordinary independent input
|
||||||
|
// chunks would silently omit every cross-chunk molecular pair.
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if (in.Workload == "similarity-search" || in.Workload == "similarity_search") &&
|
||||||
|
len(in.Chunks) > 1 && in.Parameters["query_id"] != nil {
|
||||||
|
// Resolving once against the source dataset belongs to CTX-07. Letting
|
||||||
|
// each shard resolve it would make most tasks fail or use inconsistent data.
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
chunks := make([]domain.ChunkSpec, 0, len(in.Chunks))
|
||||||
|
for _, c := range in.Chunks {
|
||||||
|
chunks = append(chunks, domain.ChunkSpec(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
job, tasks, err := domain.NewJobWithTasks(in.Workload, in.InputURI, in.Parameters, chunks, uc.clock.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
job.OwnerID = ownerFromContext(ctx)
|
||||||
|
|
||||||
|
err = uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
if err := uc.jobs.Insert(ctx, job); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return uc.tasks.InsertBatch(ctx, tasks)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GetJobStatus --------------------------------------------------------
|
||||||
|
|
||||||
|
type GetJobStatus struct {
|
||||||
|
jobs JobRepository
|
||||||
|
tasks TaskRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CancelJob -----------------------------------------------------------
|
||||||
|
|
||||||
|
type CancelJob struct {
|
||||||
|
jobs JobRepository
|
||||||
|
tasks TaskRepository
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCancelJob(jobs JobRepository, tasks TaskRepository, tx TxManager, clock Clock) *CancelJob {
|
||||||
|
return &CancelJob{jobs: jobs, tasks: tasks, tx: tx, clock: clock}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute stops a job atomically. Completed and finally failed tasks are kept
|
||||||
|
// as historical evidence; all other tasks are cancelled, including leased and
|
||||||
|
// running ones. A repeated cancel of an already cancelled job is idempotent.
|
||||||
|
func (uc *CancelJob) Execute(ctx context.Context, jobID uuid.UUID) (int64, error) {
|
||||||
|
now := uc.clock.Now()
|
||||||
|
var cancelled int64
|
||||||
|
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
job, err := uc.jobs.Get(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if job.Status == domain.JobCancelled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if job.Status == domain.JobReducing || job.Status == domain.JobCompleted || job.Status == domain.JobFailed {
|
||||||
|
return domain.ErrJobNotCancellable
|
||||||
|
}
|
||||||
|
// The lease reaper can be the transition that exhausted the final task.
|
||||||
|
// Check the authoritative task histogram as well as the cached job status,
|
||||||
|
// so a stale status can never turn a failed/completed job into cancelled.
|
||||||
|
counts, err := uc.tasks.CountByStatus(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
derived := progressFrom(*job, counts).DeriveStatus()
|
||||||
|
if derived == domain.JobReducing || derived == domain.JobCompleted || derived == domain.JobFailed {
|
||||||
|
return domain.ErrJobNotCancellable
|
||||||
|
}
|
||||||
|
cancelled, err = uc.tasks.CancelByJob(ctx, jobID, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return uc.jobs.UpdateStatus(ctx, jobID, domain.JobCancelled, &now)
|
||||||
|
})
|
||||||
|
return cancelled, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetJobStatus(jobs JobRepository, tasks TaskRepository) *GetJobStatus {
|
||||||
|
return &GetJobStatus{jobs: jobs, tasks: tasks}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *GetJobStatus) Execute(ctx context.Context, jobID uuid.UUID) (domain.JobProgress, error) {
|
||||||
|
job, err := uc.jobs.Get(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.JobProgress{}, err
|
||||||
|
}
|
||||||
|
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||||
|
return domain.JobProgress{}, err
|
||||||
|
}
|
||||||
|
counts, err := uc.tasks.CountByStatus(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.JobProgress{}, err
|
||||||
|
}
|
||||||
|
return progressFrom(*job, counts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ListResults ---------------------------------------------------------
|
||||||
|
|
||||||
|
type ListResults struct {
|
||||||
|
tasks TaskRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListResults(tasks TaskRepository) *ListResults {
|
||||||
|
return &ListResults{tasks: tasks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute preserves chunk_index order: the stitcher merges these into one
|
||||||
|
// artifact, and a non-deterministic order would make the final result depend on
|
||||||
|
// which worker happened to finish first.
|
||||||
|
func (uc *ListResults) Execute(ctx context.Context, jobID uuid.UUID) ([]domain.ResultManifest, error) {
|
||||||
|
tasks, err := uc.tasks.ListCompleted(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manifests := make([]domain.ResultManifest, 0, len(tasks))
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.ResultArtifactID == nil {
|
||||||
|
continue // a completed task always references its result; skip defensively
|
||||||
|
}
|
||||||
|
manifests = append(manifests, domain.ResultManifest{
|
||||||
|
TaskID: t.ID,
|
||||||
|
ChunkIndex: t.ChunkIndex,
|
||||||
|
ResultArtifactID: *t.ResultArtifactID,
|
||||||
|
Metrics: t.Metrics,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return manifests, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- StitchJob -----------------------------------------------------------
|
||||||
|
|
||||||
|
// StitchJob merges every chunk's partial result into the job's final artifact.
|
||||||
|
// For similarity search that means concatenating each worker's local top-k,
|
||||||
|
// sorting by similarity, and keeping the global top-k — the distributed result
|
||||||
|
// must match what a single local run would produce.
|
||||||
|
type StitchJob struct {
|
||||||
|
results *ListResults
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStitchJob(results *ListResults) *StitchJob {
|
||||||
|
return &StitchJob{results: results}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute returns the URI of the assembled artifact.
|
||||||
|
//
|
||||||
|
// TODO(phase 6): fetch each manifest's CSV, merge, and persist the result.
|
||||||
|
func (uc *StitchJob) Execute(ctx context.Context, jobID uuid.UUID) (string, error) {
|
||||||
|
if _, err := uc.results.Execute(ctx, jobID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "", ErrNotImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
// progressFrom turns a status histogram into the domain's progress view.
|
||||||
|
func progressFrom(job domain.Job, counts map[domain.TaskStatus]int) domain.JobProgress {
|
||||||
|
p := domain.JobProgress{
|
||||||
|
Job: job,
|
||||||
|
Pending: counts[domain.TaskPending],
|
||||||
|
// Leased and running are both "in flight" for progress purposes.
|
||||||
|
Leased: counts[domain.TaskLeased] + counts[domain.TaskRunning],
|
||||||
|
Done: counts[domain.TaskCompleted],
|
||||||
|
Failed: counts[domain.TaskFailed],
|
||||||
|
Cancelled: counts[domain.TaskCancelled],
|
||||||
|
}
|
||||||
|
for _, n := range counts {
|
||||||
|
p.Total += n
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncJobStatus recomputes a job's status from its task counts and persists it.
|
||||||
|
// Shared by CompleteTask and FailTask so both close a job by the same rule —
|
||||||
|
// the rule itself lives in domain.JobProgress.DeriveStatus.
|
||||||
|
func syncJobStatus(ctx context.Context, jobs JobRepository, tasks TaskRepository,
|
||||||
|
jobID uuid.UUID, now time.Time) error {
|
||||||
|
|
||||||
|
counts, err := tasks.CountByStatus(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
job, err := jobs.Get(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status := progressFrom(*job, counts).DeriveStatus()
|
||||||
|
// All worker shards being complete means scientific reduction is ready, not
|
||||||
|
// that the job's final artifact already exists. CTX-09 owns the transition
|
||||||
|
// from reducing to completed after it persists that artifact.
|
||||||
|
if status == domain.JobCompleted && job.Workload == "similarity-search" {
|
||||||
|
status = domain.JobReducing
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedAt *time.Time
|
||||||
|
if status == domain.JobFailed {
|
||||||
|
completedAt = &now
|
||||||
|
}
|
||||||
|
return jobs.UpdateStatus(ctx, jobID, status, completedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncExpiredJobStatuses(ctx context.Context, jobs JobRepository, tasks TaskRepository,
|
||||||
|
jobIDs []uuid.UUID, now time.Time) error {
|
||||||
|
seen := make(map[uuid.UUID]struct{}, len(jobIDs))
|
||||||
|
for _, jobID := range jobIDs {
|
||||||
|
if _, duplicate := seen[jobID]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[jobID] = struct{}{}
|
||||||
|
if err := syncJobStatus(ctx, jobs, tasks, jobID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownerFromContext returns the authenticated user id to stamp on a new job, or
|
||||||
|
// nil when the request was not authenticated as a user — worker or legacy
|
||||||
|
// traffic, or user-JWT auth disabled. A nil owner is stored as NULL.
|
||||||
|
func ownerFromContext(ctx context.Context) *uuid.UUID {
|
||||||
|
if r, ok := authctx.From(ctx); ok {
|
||||||
|
id := r.UserID
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiOwnerFilter returns the owner a UI listing must be restricted to: nil for an
|
||||||
|
// operator/admin or an unauthenticated (basic-auth) session, which see all jobs,
|
||||||
|
// or the caller's id for a plain user, who sees only their own.
|
||||||
|
func uiOwnerFilter(ctx context.Context) *uuid.UUID {
|
||||||
|
r, ok := authctx.From(ctx)
|
||||||
|
if !ok || r.IsAdmin() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := r.UserID
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizeJobAccess enforces that a non-admin user may only act on their own
|
||||||
|
// job. It returns ErrJobNotFound — not a 403 — on a mismatch, so the response
|
||||||
|
// never reveals that another user's job exists.
|
||||||
|
//
|
||||||
|
// Requests with no authenticated user (worker/legacy traffic, or JWT auth
|
||||||
|
// disabled) are not restricted here: the shared service token already gated
|
||||||
|
// them, and worker endpoints legitimately operate across all jobs.
|
||||||
|
func authorizeJobAccess(ctx context.Context, job *domain.Job) error {
|
||||||
|
r, ok := authctx.From(ctx)
|
||||||
|
if !ok || r.IsAdmin() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if job.OwnerID == nil || *job.OwnerID != r.UserID {
|
||||||
|
return domain.ErrJobNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerFromContext(t *testing.T) {
|
||||||
|
if ownerFromContext(context.Background()) != nil {
|
||||||
|
t.Error("no requester must yield a nil owner")
|
||||||
|
}
|
||||||
|
id := uuid.New()
|
||||||
|
ctx := authctx.With(context.Background(), authctx.Requester{UserID: id, Role: "user"})
|
||||||
|
got := ownerFromContext(ctx)
|
||||||
|
if got == nil || *got != id {
|
||||||
|
t.Errorf("owner = %v, want %v", got, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeJobAccess(t *testing.T) {
|
||||||
|
owner := uuid.New()
|
||||||
|
other := uuid.New()
|
||||||
|
job := &domain.Job{ID: uuid.New(), OwnerID: &owner}
|
||||||
|
|
||||||
|
ctxOf := func(id uuid.UUID, role string) context.Context {
|
||||||
|
return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"no requester (worker/legacy) allowed", context.Background(), false},
|
||||||
|
{"owner allowed", ctxOf(owner, "user"), false},
|
||||||
|
{"admin allowed", ctxOf(other, "admin"), false},
|
||||||
|
{"non-owner denied", ctxOf(other, "user"), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := authorizeJobAccess(tc.ctx, job)
|
||||||
|
if tc.wantErr {
|
||||||
|
if !errors.Is(err, domain.ErrJobNotFound) {
|
||||||
|
t.Errorf("got %v, want ErrJobNotFound", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeJobAccessNilOwner(t *testing.T) {
|
||||||
|
// A legacy job with no owner must not be readable by an arbitrary user.
|
||||||
|
job := &domain.Job{ID: uuid.New(), OwnerID: nil}
|
||||||
|
ctx := authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: "user"})
|
||||||
|
if err := authorizeJobAccess(ctx, job); !errors.Is(err, domain.ErrJobNotFound) {
|
||||||
|
t.Errorf("got %v, want ErrJobNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Package usecase holds the application's business operations. Each use case is
|
||||||
|
// a small type with its dependencies injected and a single Execute method.
|
||||||
|
//
|
||||||
|
// The interfaces below are *ports*: they are declared here, by the consumer,
|
||||||
|
// and implemented further out in storage/postgres. That is what keeps the
|
||||||
|
// dependency rule intact — usecase never imports storage or transport.
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaimFilter narrows which task a worker may be handed.
|
||||||
|
type ClaimFilter struct {
|
||||||
|
Workloads []string // workloads this worker can execute
|
||||||
|
Owner string // worker ID taking the lease
|
||||||
|
Now time.Time
|
||||||
|
LeaseUntil time.Time
|
||||||
|
// VoterOwner, when set, excludes tasks this owner has already voted on, so
|
||||||
|
// an untrusted worker never verifies its own chunk twice.
|
||||||
|
VoterOwner *uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskResultRepository records and tallies quorum votes for untrusted results.
|
||||||
|
type TaskResultRepository interface {
|
||||||
|
RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error
|
||||||
|
CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskRepository persists tasks.
|
||||||
|
//
|
||||||
|
// ClaimNext is deliberately coarse: leasing must be a single atomic statement
|
||||||
|
// (SELECT ... FOR UPDATE SKIP LOCKED + UPDATE), so it cannot be decomposed into
|
||||||
|
// Get+Update without losing the guarantee that one task goes to one worker.
|
||||||
|
type TaskRepository interface {
|
||||||
|
// ClaimNext atomically leases one matching pending task.
|
||||||
|
// Returns (nil, nil) when nothing is available.
|
||||||
|
ClaimNext(ctx context.Context, f ClaimFilter) (*domain.Task, error)
|
||||||
|
|
||||||
|
// Get reads a task without locking. Use it for read-only checks (e.g.
|
||||||
|
// verifying lease ownership before a long upload) where holding a row lock
|
||||||
|
// across the operation would be wrong.
|
||||||
|
Get(ctx context.Context, id uuid.UUID) (*domain.Task, error)
|
||||||
|
|
||||||
|
// GetForUpdate reads a task and locks its row for the enclosing
|
||||||
|
// transaction, so read-modify-write use cases stay serialized.
|
||||||
|
GetForUpdate(ctx context.Context, id uuid.UUID) (*domain.Task, error)
|
||||||
|
|
||||||
|
// Update persists a mutated task, honouring its Version for optimistic
|
||||||
|
// concurrency.
|
||||||
|
Update(ctx context.Context, t *domain.Task) error
|
||||||
|
|
||||||
|
InsertBatch(ctx context.Context, tasks []*domain.Task) error
|
||||||
|
|
||||||
|
// ListCompleted returns completed tasks ordered by chunk_index.
|
||||||
|
ListCompleted(ctx context.Context, jobID uuid.UUID) ([]*domain.Task, error)
|
||||||
|
|
||||||
|
// CountByStatus aggregates a job's tasks for progress reporting.
|
||||||
|
CountByStatus(ctx context.Context, jobID uuid.UUID) (map[domain.TaskStatus]int, error)
|
||||||
|
|
||||||
|
// CancelByJob marks every non-terminal task as cancelled and invalidates its
|
||||||
|
// lease. It returns how many tasks changed.
|
||||||
|
CancelByJob(ctx context.Context, jobID uuid.UUID, now time.Time) (int64, error)
|
||||||
|
|
||||||
|
// ExpireLeases applies the lease-expiry rule to every elapsed task and returns
|
||||||
|
// the distinct jobs whose aggregate status may have changed.
|
||||||
|
ExpireLeases(ctx context.Context, now time.Time) ([]uuid.UUID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobRepository persists jobs.
|
||||||
|
type JobRepository interface {
|
||||||
|
Insert(ctx context.Context, j *domain.Job) error
|
||||||
|
Get(ctx context.Context, id uuid.UUID) (*domain.Job, error)
|
||||||
|
UpdateStatus(ctx context.Context, id uuid.UUID, status domain.JobStatus, completedAt *time.Time) error
|
||||||
|
ClaimReduction(ctx context.Context, id uuid.UUID, startedAt time.Time) (bool, error)
|
||||||
|
CompleteWithResult(ctx context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error
|
||||||
|
FailReduction(ctx context.Context, id uuid.UUID, code, message string, completedAt time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerRepository persists the worker registry.
|
||||||
|
type WorkerRepository interface {
|
||||||
|
Insert(ctx context.Context, w *domain.Worker) error
|
||||||
|
Get(ctx context.Context, id uuid.UUID) (*domain.Worker, error)
|
||||||
|
// Touch records liveness for a heartbeating worker, marking it online. A
|
||||||
|
// no-op for an id that is not a registered worker.
|
||||||
|
Touch(ctx context.Context, id uuid.UUID, at time.Time) error
|
||||||
|
// MarkStaleOffline flips every worker last seen before cutoff to offline and
|
||||||
|
// reports how many changed.
|
||||||
|
MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactRepository persists artifact metadata. The bytes live in a BlobStore;
|
||||||
|
// this keeps only the record that points at them.
|
||||||
|
type ArtifactRepository interface {
|
||||||
|
Insert(ctx context.Context, a *domain.Artifact) error
|
||||||
|
Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error)
|
||||||
|
// FindPartialResult returns the durable result already uploaded for one task
|
||||||
|
// attempt. A nil artifact means the attempt has not uploaded one yet.
|
||||||
|
FindPartialResult(ctx context.Context, taskID uuid.UUID, attempt int) (*domain.Artifact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobStore holds artifact bytes, addressed by an opaque storage key. It streams
|
||||||
|
// in both directions so a large shard never has to sit in memory, and reports
|
||||||
|
// the checksum and size it measured while writing — the coordinator's own
|
||||||
|
// numbers, not the client's claim.
|
||||||
|
type BlobStore interface {
|
||||||
|
Put(ctx context.Context, key string, r io.Reader) (sha256 string, size int64, err error)
|
||||||
|
Open(ctx context.Context, key string) (io.ReadCloser, error)
|
||||||
|
// Delete removes a stored blob. Used to clean up after a metadata insert
|
||||||
|
// fails, so a committed blob never outlives its (absent) record.
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TxManager runs a function inside one database transaction. The transaction
|
||||||
|
// travels in the context, so repositories pick it up without this port ever
|
||||||
|
// mentioning pgx.
|
||||||
|
type TxManager interface {
|
||||||
|
WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock supplies the current time. Injecting it keeps lease and expiry rules
|
||||||
|
// testable without sleeping or freezing the system clock.
|
||||||
|
type Clock interface {
|
||||||
|
Now() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotImplemented marks scaffold code with no body yet. Unlike the errors in
|
||||||
|
// domain, it describes the state of this codebase, not a business rule.
|
||||||
|
var ErrNotImplemented = errors.New("not implemented")
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The preview is deliberately a diagnostic aid, never a full artifact
|
||||||
|
// viewer. These limits bound both memory use and storage reads.
|
||||||
|
const (
|
||||||
|
previewMaxRows = 30
|
||||||
|
previewMaxBytes = 64 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactPreviewView is the safe, bounded data rendered by the operator UI.
|
||||||
|
// It deliberately contains neither storage keys nor worker-local details.
|
||||||
|
type ArtifactPreviewView struct {
|
||||||
|
JobID string
|
||||||
|
ArtifactID string
|
||||||
|
Filename string
|
||||||
|
Diagnostic bool
|
||||||
|
Previewable bool
|
||||||
|
Reason string
|
||||||
|
Headers []string
|
||||||
|
Rows [][]string
|
||||||
|
Truncated bool
|
||||||
|
RowLimit int
|
||||||
|
ByteLimit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewArtifact reads the beginning of a job-scoped CSV result. Partial
|
||||||
|
// results are diagnostic; a final result is available only after the reducer
|
||||||
|
// has persisted it as this job's completed result.
|
||||||
|
type PreviewArtifact struct {
|
||||||
|
read UIReadRepository
|
||||||
|
blobs BlobStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPreviewArtifact(read UIReadRepository, blobs BlobStore) *PreviewArtifact {
|
||||||
|
return &PreviewArtifact{read: read, blobs: blobs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PreviewArtifact) Execute(ctx context.Context, jobID, artifactID uuid.UUID) (ArtifactPreviewView, error) {
|
||||||
|
job, err := p.read.GetJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return ArtifactPreviewView{}, err
|
||||||
|
}
|
||||||
|
// Another user's job (and not admin): report not-found, matching the
|
||||||
|
// artifact-absent response so nothing about it leaks.
|
||||||
|
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||||
|
return ArtifactPreviewView{}, domain.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
artifacts, err := p.read.ListArtifactsByJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return ArtifactPreviewView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var artifact *domain.Artifact
|
||||||
|
for i := range artifacts {
|
||||||
|
if artifacts[i].ID == artifactID {
|
||||||
|
artifact = &artifacts[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if artifact == nil || !previewableArtifact(*job, *artifact) {
|
||||||
|
// Use one response for an unknown artifact, another job's artifact, and
|
||||||
|
// an artifact that is not yet public. This avoids leaking its state.
|
||||||
|
return ArtifactPreviewView{}, domain.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
view := ArtifactPreviewView{
|
||||||
|
JobID: jobID.String(),
|
||||||
|
ArtifactID: artifact.ID.String(),
|
||||||
|
Filename: artifact.Filename,
|
||||||
|
Diagnostic: artifact.Kind == domain.ArtifactPartialResult,
|
||||||
|
RowLimit: previewMaxRows,
|
||||||
|
ByteLimit: previewMaxBytes,
|
||||||
|
}
|
||||||
|
if !isCSVArtifact(artifact) {
|
||||||
|
view.Reason = "This artifact is not a CSV file, so it cannot be shown as text here. Download it instead."
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
if artifact.SizeBytes == 0 {
|
||||||
|
view.Reason = "This artifact is empty."
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := p.blobs.Open(ctx, artifact.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
return ArtifactPreviewView{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = body.Close() }()
|
||||||
|
|
||||||
|
limited := &io.LimitedReader{R: body, N: previewMaxBytes}
|
||||||
|
reader := csv.NewReader(limited)
|
||||||
|
reader.FieldsPerRecord = -1 // a byte limit may end inside a record
|
||||||
|
|
||||||
|
headers, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
view.Reason = "This artifact could not be read as CSV."
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
view.Headers = append([]string(nil), headers...)
|
||||||
|
view.Rows = make([][]string, 0, previewMaxRows)
|
||||||
|
for len(view.Rows) < previewMaxRows {
|
||||||
|
record, readErr := reader.Read()
|
||||||
|
if readErr != nil {
|
||||||
|
if !errors.Is(readErr, io.EOF) {
|
||||||
|
view.Truncated = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
view.Rows = append(view.Rows, append([]string(nil), record...))
|
||||||
|
}
|
||||||
|
|
||||||
|
if artifact.SizeBytes > previewMaxBytes {
|
||||||
|
view.Truncated = true
|
||||||
|
} else if len(view.Rows) == previewMaxRows {
|
||||||
|
if _, readErr := reader.Read(); readErr == nil {
|
||||||
|
view.Truncated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.Previewable = true
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewableArtifact(job domain.Job, artifact domain.Artifact) bool {
|
||||||
|
if artifact.Kind == domain.ArtifactPartialResult {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return artifact.Kind == domain.ArtifactFinalResult &&
|
||||||
|
job.Status == domain.JobCompleted &&
|
||||||
|
job.ResultArtifactID != nil &&
|
||||||
|
*job.ResultArtifactID == artifact.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCSVArtifact(artifact *domain.Artifact) bool {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(artifact.ContentType)
|
||||||
|
if err == nil && strings.EqualFold(mediaType, "text/csv") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(strings.ToLower(artifact.Filename), ".csv")
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package usecase_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPreviewHarness() (*usecase.PreviewArtifact, *memstore.JobRepo, *memstore.ArtifactRepo, *memstore.BlobStore) {
|
||||||
|
jobs := memstore.NewJobRepo()
|
||||||
|
tasks := memstore.NewTaskRepo()
|
||||||
|
workers := memstore.NewWorkerRepo()
|
||||||
|
artifacts := memstore.NewArtifactRepo()
|
||||||
|
blobs := memstore.NewBlobStore()
|
||||||
|
return usecase.NewPreviewArtifact(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts), blobs), jobs, artifacts, blobs
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewJob(t *testing.T, jobs *memstore.JobRepo, status domain.JobStatus) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
job := &domain.Job{ID: uuid.New(), Workload: "similarity-search", Status: status, CreatedAt: time.Now().UTC()}
|
||||||
|
if err := jobs.Insert(context.Background(), job); err != nil {
|
||||||
|
t.Fatalf("insert preview job: %v", err)
|
||||||
|
}
|
||||||
|
return job.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewArtifact(t *testing.T, artifacts *memstore.ArtifactRepo, blobs *memstore.BlobStore, jobID uuid.UUID, kind domain.ArtifactKind, filename, contentType, contents string) uuid.UUID {
|
||||||
|
t.Helper()
|
||||||
|
id := uuid.New()
|
||||||
|
sha, size, err := blobs.Put(context.Background(), id.String(), strings.NewReader(contents))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store preview artifact: %v", err)
|
||||||
|
}
|
||||||
|
artifact := &domain.Artifact{ID: id, JobID: jobID, Kind: kind, Filename: filename, StorageKey: id.String(), ContentType: contentType, SizeBytes: size, SHA256: sha, CreatedAt: time.Now().UTC()}
|
||||||
|
if err := artifacts.Insert(context.Background(), artifact); err != nil {
|
||||||
|
t.Fatalf("insert preview artifact: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewArtifactRendersBoundedCSV(t *testing.T) {
|
||||||
|
preview, jobs, artifacts, blobs := newPreviewHarness()
|
||||||
|
jobID := previewJob(t, jobs, domain.JobRunning)
|
||||||
|
var csv strings.Builder
|
||||||
|
csv.WriteString("chembl_id,score\n")
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
csv.WriteString("CHEMBL" + strconv.Itoa(i) + ",0.9\n")
|
||||||
|
}
|
||||||
|
artifactID := previewArtifact(t, artifacts, blobs, jobID, domain.ArtifactPartialResult, "partial.csv", "text/csv; charset=utf-8", csv.String())
|
||||||
|
|
||||||
|
view, err := preview.Execute(context.Background(), jobID, artifactID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("preview: %v", err)
|
||||||
|
}
|
||||||
|
if !view.Previewable || !view.Diagnostic || !view.Truncated || len(view.Rows) != 30 || view.Headers[0] != "chembl_id" || view.Rows[0][0] != "CHEMBL0" {
|
||||||
|
t.Fatalf("unexpected preview: %+v", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewArtifactCapsStorageReadAndHandlesInvalidCSV(t *testing.T) {
|
||||||
|
preview, jobs, artifacts, blobs := newPreviewHarness()
|
||||||
|
jobID := previewJob(t, jobs, domain.JobRunning)
|
||||||
|
// Fewer than 30 oversized records force the byte cap, rather than the row
|
||||||
|
// cap, to stop parsing.
|
||||||
|
large := "id,value\n" + strings.Repeat("row,"+strings.Repeat("x", 5*1024)+"\n", 20)
|
||||||
|
largeID := previewArtifact(t, artifacts, blobs, jobID, domain.ArtifactPartialResult, "large.csv", "text/csv", large)
|
||||||
|
view, err := preview.Execute(context.Background(), jobID, largeID)
|
||||||
|
if err != nil || !view.Previewable || !view.Truncated || len(view.Rows) > 30 {
|
||||||
|
t.Fatalf("large preview = (%+v, %v)", view, err)
|
||||||
|
}
|
||||||
|
invalidID := previewArtifact(t, artifacts, blobs, jobID, domain.ArtifactPartialResult, "broken.csv", "text/csv", "\"unterminated")
|
||||||
|
invalid, err := preview.Execute(context.Background(), jobID, invalidID)
|
||||||
|
if err != nil || invalid.Previewable || invalid.Reason == "" {
|
||||||
|
t.Fatalf("invalid preview = (%+v, %v)", invalid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewArtifactRejectsOtherJobsAndNonResults(t *testing.T) {
|
||||||
|
preview, jobs, artifacts, blobs := newPreviewHarness()
|
||||||
|
jobA := previewJob(t, jobs, domain.JobRunning)
|
||||||
|
jobB := previewJob(t, jobs, domain.JobRunning)
|
||||||
|
partialID := previewArtifact(t, artifacts, blobs, jobA, domain.ArtifactPartialResult, "partial.csv", "text/csv", "a,b\n1,2\n")
|
||||||
|
if _, err := preview.Execute(context.Background(), jobB, partialID); !errors.Is(err, domain.ErrArtifactNotFound) {
|
||||||
|
t.Fatalf("cross-job preview error = %v", err)
|
||||||
|
}
|
||||||
|
inputID := previewArtifact(t, artifacts, blobs, jobA, domain.ArtifactInput, "input.csv", "text/csv", "a,b\n1,2\n")
|
||||||
|
if _, err := preview.Execute(context.Background(), jobA, inputID); !errors.Is(err, domain.ErrArtifactNotFound) {
|
||||||
|
t.Fatalf("input preview error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewArtifactExposesOnlyPersistedCompletedFinalResult(t *testing.T) {
|
||||||
|
preview, jobs, artifacts, blobs := newPreviewHarness()
|
||||||
|
jobID := previewJob(t, jobs, domain.JobReducing)
|
||||||
|
finalID := previewArtifact(t, artifacts, blobs, jobID, domain.ArtifactFinalResult, "final.csv", "text/csv", "rank,chembl_id\n1,CHEMBL1\n")
|
||||||
|
if _, err := preview.Execute(context.Background(), jobID, finalID); !errors.Is(err, domain.ErrArtifactNotFound) {
|
||||||
|
t.Fatalf("uncompleted final preview error = %v", err)
|
||||||
|
}
|
||||||
|
if err := jobs.CompleteWithResult(context.Background(), jobID, finalID, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
view, err := preview.Execute(context.Background(), jobID, finalID)
|
||||||
|
if err != nil || !view.Previewable || view.Diagnostic {
|
||||||
|
t.Fatalf("completed final preview = (%+v, %v)", view, err)
|
||||||
|
}
|
||||||
|
if err := jobs.FailReduction(context.Background(), jobID, "reducer_failed", "final result reduction failed", time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := preview.Execute(context.Background(), jobID, finalID); !errors.Is(err, domain.ErrArtifactNotFound) {
|
||||||
|
t.Fatalf("failed reducer preview error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/reducer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReduceJob turns completed coordinator-owned partial artifacts into one final
|
||||||
|
// artifact. It performs no worker I/O and never trusts a worker URI or path.
|
||||||
|
type ReduceJob struct {
|
||||||
|
jobs JobRepository
|
||||||
|
tasks TaskRepository
|
||||||
|
artifacts ArtifactRepository
|
||||||
|
blobs BlobStore
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReduceJob(jobs JobRepository, tasks TaskRepository, artifacts ArtifactRepository,
|
||||||
|
blobs BlobStore, tx TxManager, clock Clock) *ReduceJob {
|
||||||
|
return &ReduceJob{jobs: jobs, tasks: tasks, artifacts: artifacts, blobs: blobs, tx: tx, clock: clock}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute is idempotent for jobs that are not currently reducing. The worker
|
||||||
|
// completion path may call it after every result; only the last task changes a
|
||||||
|
// similarity-search job into reducing state.
|
||||||
|
func (uc *ReduceJob) Execute(ctx context.Context, jobID uuid.UUID) error {
|
||||||
|
claimed, err := uc.jobs.ClaimReduction(ctx, jobID, uc.clock.Now())
|
||||||
|
if err != nil || !claimed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
job, err := uc.jobs.Get(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if job.Status != domain.JobReducing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if job.Workload != "similarity-search" {
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
completed, err := uc.tasks.ListCompleted(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
if len(completed) == 0 {
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
readers := make([]io.Reader, 0, len(completed))
|
||||||
|
closers := make([]io.Closer, 0, len(completed))
|
||||||
|
for _, task := range completed {
|
||||||
|
if task.ResultArtifactID == nil {
|
||||||
|
closeAll(closers)
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
artifact, err := uc.artifacts.Get(ctx, *task.ResultArtifactID)
|
||||||
|
if err != nil || artifact.JobID != jobID || artifact.TaskID == nil || *artifact.TaskID != task.ID || artifact.Kind != domain.ArtifactPartialResult {
|
||||||
|
closeAll(closers)
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
body, err := uc.blobs.Open(ctx, artifact.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
closeAll(closers)
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
readers = append(readers, body)
|
||||||
|
closers = append(closers, body)
|
||||||
|
}
|
||||||
|
output, reduceErr := reducer.ReduceSimilaritySearch(readers, job.Parameters)
|
||||||
|
closeAll(closers)
|
||||||
|
if reduceErr != nil {
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
final, err := domain.NewArtifact(jobID, nil, domain.ArtifactFinalResult, "similarity-search.csv", "text/csv", uc.clock.Now())
|
||||||
|
if err != nil {
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
sum, size, err := uc.blobs.Put(ctx, final.StorageKey, bytes.NewReader(output))
|
||||||
|
if err != nil {
|
||||||
|
return uc.fail(ctx, jobID)
|
||||||
|
}
|
||||||
|
final.SetContent(sum, size)
|
||||||
|
if err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
if err := uc.artifacts.Insert(ctx, final); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return uc.jobs.CompleteWithResult(ctx, jobID, final.ID, uc.clock.Now())
|
||||||
|
}); err != nil {
|
||||||
|
_ = uc.blobs.Delete(ctx, final.StorageKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *ReduceJob) fail(ctx context.Context, jobID uuid.UUID) error {
|
||||||
|
// The public state carries a stable sanitized failure, never parser/storage
|
||||||
|
// internals that may include local paths or implementation details.
|
||||||
|
return uc.jobs.FailReduction(ctx, jobID, "reducer_failed", "final result reduction failed", uc.clock.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeAll(closers []io.Closer) {
|
||||||
|
for _, closer := range closers {
|
||||||
|
_ = closer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetJobResult struct {
|
||||||
|
jobs JobRepository
|
||||||
|
download *DownloadArtifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetJobResult(jobs JobRepository, download *DownloadArtifact) *GetJobResult {
|
||||||
|
return &GetJobResult{jobs: jobs, download: download}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *GetJobResult) Execute(ctx context.Context, jobID uuid.UUID) (*domain.Artifact, io.ReadCloser, error) {
|
||||||
|
job, err := uc.jobs.Get(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if job.Status != domain.JobCompleted || job.ResultArtifactID == nil {
|
||||||
|
return nil, nil, domain.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
return uc.download.Execute(ctx, *job.ResultArtifactID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task operations: the worker-facing lifecycle of a single chunk.
|
||||||
|
//
|
||||||
|
// ClaimTask lease the next available task
|
||||||
|
// RenewLease extend a held lease (heartbeat)
|
||||||
|
// CompleteTask record a successful result
|
||||||
|
// FailTask record a failure
|
||||||
|
// ExpireLeases reclaim leases that elapsed without a heartbeat
|
||||||
|
|
||||||
|
// --- ClaimTask -----------------------------------------------------------
|
||||||
|
|
||||||
|
type ClaimTask struct {
|
||||||
|
tasks TaskRepository
|
||||||
|
jobs JobRepository
|
||||||
|
workers WorkerRepository
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
leaseDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaimTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock, leaseDuration time.Duration) *ClaimTask {
|
||||||
|
return &ClaimTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock, leaseDuration: leaseDuration}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reclaims elapsed leases first, then hands out one task.
|
||||||
|
//
|
||||||
|
// Sweeping before claiming matters: otherwise a task abandoned by a dead worker
|
||||||
|
// stays invisible until the reaper's next tick, and a waiting worker is told the
|
||||||
|
// queue is empty while work sits idle.
|
||||||
|
//
|
||||||
|
// This use case is thin by design — the atomicity that makes claiming correct
|
||||||
|
// lives in one SQL statement behind ClaimNext, and splitting it across the layer
|
||||||
|
// boundary would break it.
|
||||||
|
func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.ClaimedTask, error) {
|
||||||
|
if in.WorkerID == "" {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
workloads := in.Workloads
|
||||||
|
var voterOwner *uuid.UUID
|
||||||
|
if workerID, err := uuid.Parse(in.WorkerID); err == nil {
|
||||||
|
worker, err := uc.workers.Get(ctx, workerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Bind the caller to the worker it claims as. A JWT-authenticated
|
||||||
|
// volunteer may operate only its own workers; without this the trust
|
||||||
|
// tier would be read off a caller-supplied worker_id, letting anyone who
|
||||||
|
// knows a trusted worker's id claim as it. A shared-token caller (no
|
||||||
|
// requester) is a lab operator and may act as any worker.
|
||||||
|
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// An untrusted volunteer may claim, but never a chunk its owner has
|
||||||
|
// already voted on — so quorum needs genuinely independent computations.
|
||||||
|
if worker.TrustLevel == domain.WorkerUntrusted {
|
||||||
|
voterOwner = worker.OwnerID
|
||||||
|
}
|
||||||
|
// Never trust caller-supplied capabilities: registration is the durable
|
||||||
|
// worker identity and its allowlist.
|
||||||
|
workloads = worker.Capabilities
|
||||||
|
}
|
||||||
|
var claimed *domain.ClaimedTask
|
||||||
|
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
now := uc.clock.Now()
|
||||||
|
affectedJobs, err := uc.tasks.ExpireLeases(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := syncExpiredJobStatuses(ctx, uc.jobs, uc.tasks, affectedJobs, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := uc.tasks.ClaimNext(ctx, ClaimFilter{
|
||||||
|
Workloads: workloads,
|
||||||
|
Owner: in.WorkerID,
|
||||||
|
Now: now,
|
||||||
|
LeaseUntil: now.Add(uc.leaseDuration),
|
||||||
|
VoterOwner: voterOwner,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if task != nil {
|
||||||
|
value := task.AsClaimed()
|
||||||
|
claimed = &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return claimed, nil // nil means an empty queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RenewLease ----------------------------------------------------------
|
||||||
|
|
||||||
|
type RenewLease struct {
|
||||||
|
tasks TaskRepository
|
||||||
|
workers WorkerRepository
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
leaseDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRenewLease(tasks TaskRepository, workers WorkerRepository, tx TxManager,
|
||||||
|
clock Clock, leaseDuration time.Duration) *RenewLease {
|
||||||
|
return &RenewLease{tasks: tasks, workers: workers, tx: tx, clock: clock, leaseDuration: leaseDuration}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute is a read-modify-write, so it runs inside a transaction with the row
|
||||||
|
// locked: two concurrent heartbeats must not interleave into a lost update.
|
||||||
|
// Whether the caller may renew at all is decided by the entity, not here.
|
||||||
|
func (uc *RenewLease) Execute(ctx context.Context, in RenewLeaseInput) (*domain.ClaimedTask, error) {
|
||||||
|
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var claimed domain.ClaimedTask
|
||||||
|
|
||||||
|
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
task, err := uc.tasks.GetForUpdate(ctx, in.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := uc.clock.Now()
|
||||||
|
if err := task.RenewLease(in.WorkerID, in.Attempt, now, now.Add(uc.leaseDuration)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uc.tasks.Update(ctx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
claimed = task.AsClaimed()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort worker liveness, outside the task transaction so it can never
|
||||||
|
// fail the heartbeat. Only registered workers (a UUID worker_id) are tracked.
|
||||||
|
if id, perr := uuid.Parse(in.WorkerID); perr == nil {
|
||||||
|
_ = uc.workers.Touch(ctx, id, uc.clock.Now())
|
||||||
|
}
|
||||||
|
return &claimed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CompleteTask --------------------------------------------------------
|
||||||
|
|
||||||
|
type CompleteTask struct {
|
||||||
|
tasks TaskRepository
|
||||||
|
jobs JobRepository
|
||||||
|
artifacts ArtifactRepository
|
||||||
|
workers WorkerRepository
|
||||||
|
results TaskResultRepository
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
// quorum is how many distinct owners must agree on an untrusted result
|
||||||
|
// before it is accepted; a trusted worker's result is accepted directly.
|
||||||
|
quorum int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts ArtifactRepository,
|
||||||
|
workers WorkerRepository, results TaskResultRepository, tx TxManager, clock Clock, quorum int) *CompleteTask {
|
||||||
|
if quorum < 1 {
|
||||||
|
quorum = 2
|
||||||
|
}
|
||||||
|
return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, workers: workers,
|
||||||
|
results: results, tx: tx, clock: clock, quorum: quorum}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute applies the result and, when that was the job's last outstanding
|
||||||
|
// task, closes the job in the same transaction — so a caller who sees a
|
||||||
|
// completed task never observes its job still marked running.
|
||||||
|
//
|
||||||
|
// Lease ownership, staleness, and idempotent replays are all decided by
|
||||||
|
// Task.CompleteWith; this use case only orchestrates.
|
||||||
|
func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*domain.Task, error) {
|
||||||
|
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out *domain.Task
|
||||||
|
|
||||||
|
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
task, err := uc.tasks.GetForUpdate(ctx, in.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Rule 10: never trust a worker-supplied artifact reference. The result
|
||||||
|
// must be an artifact the coordinator itself stored for *this* task.
|
||||||
|
art, err := uc.verifyResultArtifact(ctx, in.TaskID, in.Attempt, in.ResultArtifactID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
trusted, ownerID, err := uc.workerTrust(ctx, in.WorkerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := uc.clock.Now()
|
||||||
|
|
||||||
|
// Untrusted (volunteer) worker: record a vote and only complete once a
|
||||||
|
// quorum of distinct owners agree; otherwise return the task to the queue.
|
||||||
|
if !trusted {
|
||||||
|
return uc.recordVote(ctx, task, in, art, ownerID, now, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trusted worker (lab token, verified, or admin): accept directly.
|
||||||
|
before := task.Version
|
||||||
|
if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = task
|
||||||
|
// A replay of an already-recorded result leaves the entity untouched.
|
||||||
|
// Writing anyway would fail the optimistic-concurrency guard (the stored
|
||||||
|
// version already equals ours) and turn an idempotent call into a 409.
|
||||||
|
if task.Version == before {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := uc.tasks.Update(ctx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordVote handles an untrusted result: it stores the vote, then completes the
|
||||||
|
// task when the submitter's result hash has reached quorum, or returns the task
|
||||||
|
// to the queue so another owner can compute it independently.
|
||||||
|
func (uc *CompleteTask) recordVote(ctx context.Context, task *domain.Task, in CompleteTaskInput,
|
||||||
|
art *domain.Artifact, ownerID uuid.UUID, now time.Time, out **domain.Task) error {
|
||||||
|
|
||||||
|
*out = task
|
||||||
|
if task.Status == domain.TaskCompleted {
|
||||||
|
return nil // already settled by an earlier quorum; nothing to record
|
||||||
|
}
|
||||||
|
if err := uc.results.RecordVote(ctx, task.ID, ownerID, art.SHA256, in.ResultArtifactID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
agree, err := uc.results.CountAgreeing(ctx, task.ID, art.SHA256)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if agree >= uc.quorum {
|
||||||
|
// The submitter's own (already verified) artifact carries the winning
|
||||||
|
// hash, so complete with it.
|
||||||
|
if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err := task.ReleaseAfterVote(in.WorkerID, in.Attempt, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.tasks.Update(ctx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// workerTrust reports whether the worker's results are accepted directly, and
|
||||||
|
// the owner to attribute a vote to when they are not.
|
||||||
|
func (uc *CompleteTask) workerTrust(ctx context.Context, workerID string) (trusted bool, ownerID uuid.UUID, err error) {
|
||||||
|
// When the worker can't be resolved, default to trusted — the pre-quorum
|
||||||
|
// behaviour. This is safe because completing a task requires holding its
|
||||||
|
// lease, and the lease owner is always a real registered worker whose trust
|
||||||
|
// is therefore known; only an untrusted worker ever takes the quorum path.
|
||||||
|
id, err := uuid.Parse(workerID)
|
||||||
|
if err != nil {
|
||||||
|
// An unparseable worker id means the worker can't be resolved; fall back
|
||||||
|
// to the trusted default rather than surfacing the parse error.
|
||||||
|
return true, uuid.Nil, nil //nolint:nilerr // unresolvable worker → trusted (pre-quorum default)
|
||||||
|
}
|
||||||
|
w, err := uc.workers.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrWorkerNotFound) {
|
||||||
|
return true, uuid.Nil, nil
|
||||||
|
}
|
||||||
|
return false, uuid.Nil, err
|
||||||
|
}
|
||||||
|
if w.TrustLevel != domain.WorkerUntrusted {
|
||||||
|
return true, uuid.Nil, nil
|
||||||
|
}
|
||||||
|
if w.OwnerID == nil {
|
||||||
|
// An untrusted worker always has an owner (it registered via a user JWT);
|
||||||
|
// a missing one is a data error, not a silent trust upgrade.
|
||||||
|
return false, uuid.Nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return false, *w.OwnerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyResultArtifact enforces that the referenced artifact was stored by the
|
||||||
|
// coordinator for this exact task. It stops a worker from completing task B with
|
||||||
|
// an artifact it uploaded for task A, and from naming an id that isn't a result.
|
||||||
|
func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UUID, attempt int, artifactID uuid.UUID) (*domain.Artifact, error) {
|
||||||
|
art, err := uc.artifacts.Get(ctx, artifactID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if art.TaskID == nil || *art.TaskID != taskID || art.Attempt == nil || *art.Attempt != attempt || art.Kind != domain.ArtifactPartialResult {
|
||||||
|
return nil, domain.ErrResultConflict
|
||||||
|
}
|
||||||
|
return art, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FailTask ------------------------------------------------------------
|
||||||
|
|
||||||
|
type FailTask struct {
|
||||||
|
tasks TaskRepository
|
||||||
|
jobs JobRepository
|
||||||
|
workers WorkerRepository
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFailTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock) *FailTask {
|
||||||
|
return &FailTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute delegates the requeue-or-terminate decision to Task.Fail, then keeps
|
||||||
|
// the parent job's status consistent in the same transaction.
|
||||||
|
func (uc *FailTask) Execute(ctx context.Context, in FailTaskInput) (*domain.Task, error) {
|
||||||
|
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out *domain.Task
|
||||||
|
|
||||||
|
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
task, err := uc.tasks.GetForUpdate(ctx, in.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := uc.clock.Now()
|
||||||
|
if err := task.Fail(in.WorkerID, in.Attempt, in.ErrorCode, in.ErrorMessage, in.Retryable, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := uc.tasks.Update(ctx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = task
|
||||||
|
return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ExpireLeases --------------------------------------------------------
|
||||||
|
|
||||||
|
type ExpireLeases struct {
|
||||||
|
tasks TaskRepository
|
||||||
|
jobs JobRepository
|
||||||
|
tx TxManager
|
||||||
|
clock Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExpireLeases(tasks TaskRepository, jobs JobRepository, tx TxManager, clock Clock) *ExpireLeases {
|
||||||
|
return &ExpireLeases{tasks: tasks, jobs: jobs, tx: tx, clock: clock}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute reclaims elapsed tasks and persists the state of every affected job.
|
||||||
|
//
|
||||||
|
// The sweep is one set-based statement rather than a load-decide-save loop:
|
||||||
|
// several coordinators run it concurrently, and a single atomic UPDATE makes
|
||||||
|
// the duplicate work harmless — the loser simply updates 0 rows.
|
||||||
|
func (uc *ExpireLeases) Execute(ctx context.Context) (int64, error) {
|
||||||
|
var affected []uuid.UUID
|
||||||
|
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
now := uc.clock.Now()
|
||||||
|
var err error
|
||||||
|
affected, err = uc.tasks.ExpireLeases(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return syncExpiredJobStatuses(ctx, uc.jobs, uc.tasks, affected, now)
|
||||||
|
})
|
||||||
|
return int64(len(affected)), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UIReadRepository is a read-only projection source for the local operator UI.
|
||||||
|
// It intentionally exposes no storage paths or credentials.
|
||||||
|
type UIReadRepository interface {
|
||||||
|
GetJob(ctx context.Context, jobID uuid.UUID) (*domain.Job, error)
|
||||||
|
// ListJobs returns the most recent jobs. A non-nil owner restricts the list
|
||||||
|
// to that user's jobs; nil returns all (operator/admin view).
|
||||||
|
ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error)
|
||||||
|
ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error)
|
||||||
|
ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error)
|
||||||
|
ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error)
|
||||||
|
ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobCard struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Workload string `json:"workload"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
ReducerStartedAt *time.Time `json:"reducer_started_at,omitempty"`
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
Leased int `json:"leased"`
|
||||||
|
Running int `json:"running"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Cancelled int `json:"cancelled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskCard struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ChunkIndex int `json:"chunk_index"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Attempt int `json:"attempt"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
LeaseOwner string `json:"lease_owner,omitempty"`
|
||||||
|
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParameterCard is an intentionally small allowlist of run configuration that
|
||||||
|
// helps an operator verify what is being computed without exposing arbitrary
|
||||||
|
// job payloads to the browser.
|
||||||
|
type ParameterCard struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtifactCard struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Downloadable bool `json:"downloadable"`
|
||||||
|
Diagnostic bool `json:"diagnostic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerCard struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardView struct {
|
||||||
|
Jobs []JobCard `json:"jobs"`
|
||||||
|
Workers []WorkerCard `json:"workers"`
|
||||||
|
ActiveJobs int `json:"active_jobs"`
|
||||||
|
FinishedJobs int `json:"finished_jobs"`
|
||||||
|
OnlineWorkers int `json:"online_workers"`
|
||||||
|
// Session is the signed-in user, when the UI runs in session mode. nil under
|
||||||
|
// basic auth. Template-only, never serialised to the polling JSON.
|
||||||
|
Session *SessionView `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionView is the minimal identity the UI header needs to show who is signed
|
||||||
|
// in and to offer a logout control.
|
||||||
|
type SessionView struct {
|
||||||
|
Role string
|
||||||
|
Verified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionViewFrom builds the header session info from the request context, or
|
||||||
|
// nil when the caller is not an authenticated user (basic-auth operator).
|
||||||
|
func sessionViewFrom(ctx context.Context) *SessionView {
|
||||||
|
r, ok := authctx.From(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &SessionView{Role: r.Role, Verified: r.Verified}
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobDetailView struct {
|
||||||
|
JobCard
|
||||||
|
Tasks []TaskCard `json:"tasks"`
|
||||||
|
Artifacts []ArtifactCard `json:"artifacts"`
|
||||||
|
Parameters []ParameterCard `json:"parameters"`
|
||||||
|
FinalResultAvailable bool `json:"final_result_available"`
|
||||||
|
Session *SessionView `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dashboard struct{ read UIReadRepository }
|
||||||
|
|
||||||
|
func NewDashboard(read UIReadRepository) *Dashboard { return &Dashboard{read: read} }
|
||||||
|
|
||||||
|
func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, error) {
|
||||||
|
jobs, err := d.read.ListJobs(ctx, uiOwnerFilter(ctx), limit)
|
||||||
|
if err != nil {
|
||||||
|
return DashboardView{}, err
|
||||||
|
}
|
||||||
|
workers, err := d.read.ListWorkers(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return DashboardView{}, err
|
||||||
|
}
|
||||||
|
out := DashboardView{Jobs: make([]JobCard, 0, len(jobs)), Workers: make([]WorkerCard, 0, len(workers))}
|
||||||
|
jobIDs := make([]uuid.UUID, 0, len(jobs))
|
||||||
|
for _, job := range jobs {
|
||||||
|
jobIDs = append(jobIDs, job.ID)
|
||||||
|
}
|
||||||
|
tasksByJob, err := d.read.ListTasksByJobs(ctx, jobIDs)
|
||||||
|
if err != nil {
|
||||||
|
return DashboardView{}, err
|
||||||
|
}
|
||||||
|
for _, job := range jobs {
|
||||||
|
card := jobCard(job, tasksByJob[job.ID])
|
||||||
|
out.Jobs = append(out.Jobs, card)
|
||||||
|
switch card.Status {
|
||||||
|
case string(domain.JobCompleted), string(domain.JobFailed), string(domain.JobCancelled):
|
||||||
|
out.FinishedJobs++
|
||||||
|
default:
|
||||||
|
out.ActiveJobs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, worker := range workers {
|
||||||
|
out.Workers = append(out.Workers, WorkerCard{ID: worker.ID.String(), Name: worker.Name, Status: string(worker.Status), Capabilities: worker.Capabilities, LastHeartbeatAt: worker.LastHeartbeatAt})
|
||||||
|
if worker.Status == domain.WorkerOnline || worker.Status == domain.WorkerBusy {
|
||||||
|
out.OnlineWorkers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Session = sessionViewFrom(ctx)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailView, error) {
|
||||||
|
job, err := d.read.GetJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return JobDetailView{}, err
|
||||||
|
}
|
||||||
|
// A plain user may only open their own job; a mismatch reads as not-found so
|
||||||
|
// the page never reveals another user's job exists.
|
||||||
|
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||||
|
return JobDetailView{}, err
|
||||||
|
}
|
||||||
|
tasks, err := d.read.ListTasksByJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return JobDetailView{}, err
|
||||||
|
}
|
||||||
|
artifacts, err := d.read.ListArtifactsByJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return JobDetailView{}, err
|
||||||
|
}
|
||||||
|
workers, err := d.read.ListWorkers(ctx, 100)
|
||||||
|
if err != nil {
|
||||||
|
return JobDetailView{}, err
|
||||||
|
}
|
||||||
|
workerNames := make(map[string]string, len(workers))
|
||||||
|
for _, worker := range workers {
|
||||||
|
workerNames[worker.ID.String()] = worker.Name
|
||||||
|
}
|
||||||
|
out := JobDetailView{
|
||||||
|
JobCard: jobCard(*job, tasks),
|
||||||
|
Tasks: make([]TaskCard, 0, len(tasks)),
|
||||||
|
Artifacts: make([]ArtifactCard, 0, len(artifacts)),
|
||||||
|
Parameters: uiParameters(job.Parameters),
|
||||||
|
Session: sessionViewFrom(ctx),
|
||||||
|
}
|
||||||
|
for _, task := range tasks {
|
||||||
|
card := TaskCard{ID: task.ID.String(), ChunkIndex: task.ChunkIndex, Status: string(task.Status), Attempt: task.Attempt, MaxAttempts: task.MaxAttempts, LeaseExpiresAt: task.LeaseExpiresAt, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt}
|
||||||
|
if task.LeaseOwner != nil {
|
||||||
|
card.LeaseOwner = workerNames[*task.LeaseOwner]
|
||||||
|
if card.LeaseOwner == "" {
|
||||||
|
card.LeaseOwner = "Worker " + shortID(*task.LeaseOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task.ErrorCode != nil {
|
||||||
|
card.ErrorCode = *task.ErrorCode
|
||||||
|
}
|
||||||
|
if task.ErrorMessage != nil {
|
||||||
|
card.ErrorMessage = *task.ErrorMessage
|
||||||
|
}
|
||||||
|
out.Tasks = append(out.Tasks, card)
|
||||||
|
}
|
||||||
|
for _, artifact := range artifacts {
|
||||||
|
diagnostic := artifact.Kind == domain.ArtifactPartialResult
|
||||||
|
downloadable := previewableArtifact(*job, artifact)
|
||||||
|
out.Artifacts = append(out.Artifacts, ArtifactCard{ID: artifact.ID.String(), Kind: string(artifact.Kind), Filename: artifact.Filename, SizeBytes: artifact.SizeBytes, SHA256: artifact.SHA256, Downloadable: downloadable, Diagnostic: diagnostic})
|
||||||
|
if artifact.Kind == domain.ArtifactFinalResult && downloadable {
|
||||||
|
out.FinalResultAvailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadableArtifactBelongsToJob applies the same policy used by the UI
|
||||||
|
// projection: partial diagnostics and the persisted final result are public to
|
||||||
|
// the operator; source inputs and shards are not exposed through a guessed UI
|
||||||
|
// URL.
|
||||||
|
func (d *Dashboard) DownloadableArtifactBelongsToJob(ctx context.Context, jobID, artifactID uuid.UUID) (bool, error) {
|
||||||
|
job, err := d.read.GetJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Not the caller's job (and not admin): treat as if the artifact is absent.
|
||||||
|
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||||
|
return false, nil //nolint:nilerr // masking the authz error as "not found" is intentional
|
||||||
|
}
|
||||||
|
artifacts, err := d.read.ListArtifactsByJob(ctx, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, a := range artifacts {
|
||||||
|
if a.ID == artifactID && previewableArtifact(*job, a) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobCard(job domain.Job, tasks []domain.Task) JobCard {
|
||||||
|
c := JobCard{ID: job.ID.String(), Workload: job.Workload, CreatedAt: job.CreatedAt, CompletedAt: job.CompletedAt, ReducerStartedAt: job.ReducerStartedAt}
|
||||||
|
if job.ErrorCode != nil {
|
||||||
|
c.ErrorCode = *job.ErrorCode
|
||||||
|
}
|
||||||
|
if job.ErrorMessage != nil {
|
||||||
|
c.ErrorMessage = *job.ErrorMessage
|
||||||
|
}
|
||||||
|
for _, task := range tasks {
|
||||||
|
c.Total++
|
||||||
|
switch task.Status {
|
||||||
|
case domain.TaskPending:
|
||||||
|
c.Pending++
|
||||||
|
case domain.TaskLeased:
|
||||||
|
c.Leased++
|
||||||
|
case domain.TaskRunning:
|
||||||
|
c.Running++
|
||||||
|
case domain.TaskCompleted:
|
||||||
|
c.Completed++
|
||||||
|
case domain.TaskFailed:
|
||||||
|
c.Failed++
|
||||||
|
case domain.TaskCancelled:
|
||||||
|
c.Cancelled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := domain.JobProgress{Job: job, Total: c.Total, Pending: c.Pending, Leased: c.Leased + c.Running, Done: c.Completed, Failed: c.Failed, Cancelled: c.Cancelled}
|
||||||
|
c.Status = string(p.DeriveStatus())
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func uiParameters(parameters map[string]any) []ParameterCard {
|
||||||
|
keys := []struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{"query_smiles", "Target SMILES"},
|
||||||
|
{"query_id", "Target ChEMBL ID"},
|
||||||
|
{"top_k", "Global top-k"},
|
||||||
|
{"threshold", "Similarity threshold"},
|
||||||
|
{"threshold_direction", "Threshold direction"},
|
||||||
|
}
|
||||||
|
out := make([]ParameterCard, 0, len(keys))
|
||||||
|
for _, entry := range keys {
|
||||||
|
value, ok := parameters[entry.key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
formatted, ok := formatUIParameter(value)
|
||||||
|
if ok {
|
||||||
|
out = append(out, ParameterCard{Label: entry.label, Value: formatted})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUIParameter(value any) (string, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed, true
|
||||||
|
case int, int64, float64, bool:
|
||||||
|
return fmt.Sprint(typed), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortID(value string) string {
|
||||||
|
if len(value) <= 8 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:8]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user