260 lines
11 KiB
Markdown
260 lines
11 KiB
Markdown
# Worker implementation: feedback for the next iteration
|
|
|
|
## Purpose
|
|
|
|
This is a technical debrief of the first `Workers` implementation. It is not a
|
|
blame document. Its purpose is to give an AI-assisted developer a compact set
|
|
of rules that prevents the same integration defects from returning when the Go
|
|
coordinator and PostgreSQL queue are implemented.
|
|
|
|
The worker must be treated as one participant in a distributed protocol, not
|
|
as an isolated Python script. A change is only complete when the worker,
|
|
coordinator API, durable storage, documentation and tests agree on the same
|
|
contract.
|
|
|
|
## What was good
|
|
|
|
- The worker was separated into configuration, daemon, coordinator client,
|
|
artifact transport, runner and models. That is a sound seam for later Go
|
|
integration.
|
|
- Input checksum validation, task-attempt directories, lease heartbeats,
|
|
bounded failure messages, jittered polling, and mock-based tests were good
|
|
instincts.
|
|
- The daemon does not talk to PostgreSQL directly. Keeping queue ownership in
|
|
the coordinator is the right boundary.
|
|
|
|
Keep these properties. The corrections below are about completing the protocol
|
|
rather than changing that general direction.
|
|
|
|
## Findings from the review
|
|
|
|
### 1. A worker-local result URI was presented as a completed result
|
|
|
|
**Initial behaviour.** The worker submitted a `worker://...` URI after local
|
|
execution.
|
|
|
|
**Why this is wrong.** The coordinator, reducer and UI cannot read a file that
|
|
exists only on a worker machine. A task cannot be considered complete merely
|
|
because a worker created a local CSV. The result disappears when that worker
|
|
is removed or its workspace is cleaned.
|
|
|
|
**Correct rule.** Upload every output artifact to coordinator-managed durable
|
|
storage first. Only then submit a manifest containing the URI returned by the
|
|
upload endpoint, SHA-256 and content type. A result endpoint must reject a
|
|
`file://` or `worker://` URI.
|
|
|
|
**Required order.**
|
|
|
|
```text
|
|
run task locally
|
|
-> compute SHA-256
|
|
-> PUT artifact to coordinator storage
|
|
-> coordinator returns durable URI
|
|
-> POST result manifest
|
|
-> coordinator marks task completed
|
|
```
|
|
|
|
**Regression test.** Test that a completion payload contains the URI returned
|
|
by `ArtifactClient.upload`, and that the worker performs no final `submit` if
|
|
an upload fails.
|
|
|
|
Status: fixed in `Workers`; enforce the same rule server-side.
|
|
|
|
### 2. Failures were sent to the success endpoint
|
|
|
|
**Initial behaviour.** Exception handling reported a payload with a failed
|
|
status to `/tasks/{id}/result`.
|
|
|
|
**Why this is wrong.** Completion and failure have different queue semantics.
|
|
A completion means the result is durable and can trigger reduction. A failure
|
|
may require a retry, backoff, attempt increment, or a terminal error. Combining
|
|
them makes it too easy for the coordinator to accept an invalid state
|
|
transition.
|
|
|
|
**Correct rule.** Use `POST /tasks/{id}/result` only for a successful,
|
|
fully-uploaded result. Use `POST /tasks/{id}/failure` for a safe failure
|
|
report. Include `worker_id`, `attempt`, a stable `error_code`, and a short
|
|
sanitised `error_message`.
|
|
|
|
**Regression test.** Make a runner raise; assert that `/failure` is called and
|
|
`/result` is never called.
|
|
|
|
Status: fixed in `Workers`; the Go service must validate the transition.
|
|
|
|
### 3. A heartbeat renewed the lease but the new expiry was discarded
|
|
|
|
**Initial behaviour.** The client treated a successful heartbeat as a boolean
|
|
and retained the old `lease_expires_at` value.
|
|
|
|
**Why this is wrong.** The worker used that stale timestamp to schedule future
|
|
heartbeats. Long-running work could then incorrectly decide that a valid lease
|
|
was expired, or choose an unsafe heartbeat interval.
|
|
|
|
**Correct rule.** A successful heartbeat must return the new canonical
|
|
`lease_expires_at` timestamp. Replace the stored expiry immediately and derive
|
|
the next heartbeat delay from it. Treat a missing or malformed expiry as a
|
|
protocol error.
|
|
|
|
**Regression test.** Return two different expiries from a fake coordinator;
|
|
verify that the second scheduling calculation uses the renewed value.
|
|
|
|
Status: fixed in `Workers`; documented as a required coordinator response.
|
|
|
|
### 4. The API contract was implemented in code before it was made explicit
|
|
|
|
**Symptom.** The client and task documents initially omitted or disagreed on
|
|
artifact upload, failure reporting and the heartbeat response.
|
|
|
|
**Why this is dangerous.** Two developers can write apparently reasonable
|
|
code that never interoperates. This is especially likely across Python and Go,
|
|
where type systems do not share the contract automatically.
|
|
|
|
**Correct rule.** Before coding an endpoint, update the contract table and
|
|
provide request and response examples. State:
|
|
|
|
- method and path;
|
|
- authentication and worker identity;
|
|
- mandatory JSON/body fields and types;
|
|
- success statuses and response body;
|
|
- idempotency behaviour;
|
|
- invalid ownership, stale attempt and expired-lease responses;
|
|
- whether the endpoint changes task state.
|
|
|
|
The source of truth is [PLAN.md](../PLAN.md) plus the task-specific database
|
|
and worker documents. If a code change alters the protocol, update all of
|
|
them in the same commit.
|
|
|
|
### 5. Client-side checks were treated as sufficient protection
|
|
|
|
**Problem.** Python checks for input hash, task attempt and lease timing are
|
|
helpful but are not authoritative. A buggy, stale or malicious worker can
|
|
still send a request.
|
|
|
|
**Correct rule.** The Go coordinator must make all authoritative decisions in
|
|
one database transaction: task is leased, `lease_owner` equals the caller,
|
|
attempt matches, lease is unexpired, and the requested state transition is
|
|
allowed. The worker is a client; it is never the queue authority.
|
|
|
|
**Server tests.** Verify that another worker, a stale attempt and an expired
|
|
lease cannot upload artifacts, heartbeat, complete or fail the task.
|
|
|
|
### 6. Happy-path testing hid cross-boundary defects
|
|
|
|
**Problem.** Local mocks can make an endpoint mismatch invisible: the mock
|
|
accepts a payload that the actual Go coordinator has not implemented.
|
|
|
|
**Correct rule.** Keep fast unit tests, but add contract and integration tests
|
|
as soon as the coordinator exists:
|
|
|
|
- Python worker against a disposable Go coordinator and PostgreSQL database;
|
|
- claim → heartbeat → download → upload → result lifecycle;
|
|
- runner failure → failure endpoint → retry/terminal policy;
|
|
- duplicate delivery of result and failure payloads;
|
|
- coordinator restart with a leased task;
|
|
- unauthorized and cross-origin artifact cases.
|
|
|
|
Every bug fixed at a boundary needs a regression test at that boundary, not
|
|
only an internal unit test.
|
|
|
|
### 7. Transport security must be deliberate
|
|
|
|
**What must not happen.** A coordinator bearer token must not follow an HTTP
|
|
redirect to arbitrary object storage or another host.
|
|
|
|
**Correct rule.** Attach the coordinator bearer token only to same-origin
|
|
requests. Strip it on cross-origin redirects. Validate that coordinator URLs
|
|
are absolute `http` or `https` URLs. Never place a permanent token in task
|
|
payloads, logs or artifact URIs.
|
|
|
|
Status: same-origin redirect protection and upload URL validation are now in
|
|
the Python worker. The coordinator still needs its own authentication and
|
|
authorization rules.
|
|
|
|
### 8. Idempotency and retries need a written policy, not assumptions
|
|
|
|
**Problem.** Network timeouts make it unknowable whether the coordinator
|
|
processed a request. Retrying can create duplicate artifacts or incompatible
|
|
state changes unless both sides define what is safe.
|
|
|
|
**Correct rule.** The tuple `(task_id, attempt, worker_id)` identifies the
|
|
lease. Make completion, failure and artifact upload idempotent for that lease.
|
|
The coordinator should return a stable success response for an identical
|
|
duplicate and a conflict for a different worker or attempt. Do not quietly
|
|
overwrite an artifact belonging to a different attempt.
|
|
|
|
**Do not do this.** Do not implement a retry loop that blindly repeats every
|
|
POST because it "usually works". Classify timeout/5xx, conflict, validation,
|
|
and authorization responses first.
|
|
|
|
### 9. Readability matters at protocol boundaries
|
|
|
|
**Observed risk.** Dense single-line payloads and broad exception blocks make
|
|
it hard to review fields such as `attempt`, artifact checksum or result URI.
|
|
These are correctness fields, not cosmetic details.
|
|
|
|
**Correct rule.** Use named payload builders or multi-line dictionaries for
|
|
network messages. Keep methods short enough that the lifecycle is visible in
|
|
order: claim, lease, download, validate, run, upload, submit/fail, cleanup.
|
|
Use broad exception handling only at the daemon boundary, then report a
|
|
sanitised error. Do not silently swallow failures that should stop a task.
|
|
|
|
## Mandatory pre-PR checklist for a worker change
|
|
|
|
### Contract
|
|
|
|
- [ ] The task/response JSON matches the documented API exactly.
|
|
- [ ] Worker ID, task ID and attempt are present where ownership is required.
|
|
- [ ] Heartbeat returns and the worker uses the renewed expiry.
|
|
- [ ] Result, failure and artifact endpoints have different, documented roles.
|
|
- [ ] Success and error status codes are handled intentionally.
|
|
|
|
### Correctness
|
|
|
|
- [ ] The runner computes only within its task-attempt directory.
|
|
- [ ] Input SHA-256 is checked before workload execution.
|
|
- [ ] Every output is uploaded before completion is submitted.
|
|
- [ ] Completion contains a durable URI, SHA-256 and content type.
|
|
- [ ] No result is submitted after a lost heartbeat/lease.
|
|
- [ ] The task's own identifier and attempt are never guessed or replaced.
|
|
|
|
### Resilience and security
|
|
|
|
- [ ] Transient coordinator failures use bounded backoff and do not spin.
|
|
- [ ] Failure reports have a bounded, sanitised message; no traceback,
|
|
workspace path, bearer token or secret is included.
|
|
- [ ] Authorization is sent only to the coordinator origin.
|
|
- [ ] Duplicate delivery and stale attempts have defined outcomes.
|
|
- [ ] Cleanup does not delete another task's or another attempt's files.
|
|
|
|
### Validation
|
|
|
|
- [ ] Unit tests cover successful run, checksum mismatch, runner failure,
|
|
upload failure, heartbeat failure and empty runner output.
|
|
- [ ] A test asserts no `worker://` or `file://` URI reaches completion.
|
|
- [ ] A test asserts that failure uses `/failure`, never `/result`.
|
|
- [ ] A test asserts that the renewed heartbeat expiry is used.
|
|
- [ ] Formatting, type checking and the full test suite pass.
|
|
- [ ] If the protocol changed, Go coordinator integration tests pass too.
|
|
|
|
## How to approach the next task
|
|
|
|
1. Read the relevant `CTX-*` section in [PLAN.md](../PLAN.md), plus the
|
|
worker and database task documents.
|
|
2. Write down the exact request/response and state transition before changing
|
|
Python or Go code.
|
|
3. Implement the smallest vertical slice across both boundaries.
|
|
4. Add a regression test for the unhappy path first discovered by review.
|
|
5. Review the diff specifically for data ownership, lease/attempt checks,
|
|
durable artifacts, retry behaviour and secrets in logs.
|
|
6. Only then expand functionality or refactor.
|
|
|
|
## Short version for an AI coding prompt
|
|
|
|
> SciMesh workers are untrusted clients of a coordinator-owned queue. Never
|
|
> complete a task until outputs are durably uploaded through the coordinator.
|
|
> Keep success and failure endpoints separate. Every lease-sensitive request
|
|
> carries worker ID and attempt, and the coordinator validates both
|
|
> transactionally. A heartbeat returns a new expiry that the worker must use.
|
|
> Treat retries as an idempotency design problem, not as a blind loop. Update
|
|
> code, API documentation and regression tests together.
|