102 lines
4.5 KiB
Markdown
102 lines
4.5 KiB
Markdown
# T7.3 — Leases and fencing
|
|||
|
|
|
||
|
|
| Field | Value |
|
||
|
|
|---|---|
|
||
|
|
| Phase | P7 — Distribution |
|
||
|
|
| Size | L — over 3 days |
|
||
|
|
| Status | Not started |
|
||
|
|
| Flags | — |
|
||
|
|
| Spec | inlined below |
|
||
|
|
| Blocks | — |
|
||
|
|
|
||
|
|
## Goal
|
||
|
|
|
||
|
|
TTL leases with heartbeat renewal and monotonic fence tokens, so a partitioned
|
||
|
|
worker cannot write under an expired claim.
|
||
|
|
|
||
|
|
## Facts (inlined — no spec read needed)
|
||
|
|
|
||
|
|
- Runs are claimed by workers under a lease with a TTL. **A worker that dies has
|
||
|
|
its runs reclaimed after expiry.**
|
||
|
|
- Lease renewal is a heartbeat on the run record.
|
||
|
|
- **`Suspended` runs release their lease entirely** rather than heartbeating
|
||
|
|
through a human's lunch break. A run awaiting human approval or a webhook must
|
||
|
|
release its worker; holding an executor slot across a human decision does not
|
||
|
|
scale past a handful of concurrent runs.
|
||
|
|
- **Fencing tokens on every lease.** A partitioned worker that resumes must not
|
||
|
|
write under an expired claim, and a **monotonic fence in the run record makes
|
||
|
|
that a rejected write rather than a silent double-execution**.
|
||
|
|
- Coordination backend: Postgres advisory locks, or Redis if lease churn
|
||
|
|
dominates. Redis is deferred until that churn is real.
|
||
|
|
|
||
|
|
## Steps
|
||
|
|
|
||
|
|
1. Add to the run record: `lease_owner`, `lease_expires_at`, `fence: u64`.
|
||
|
|
2. Claim: atomic conditional update — claim only if unowned or expired.
|
||
|
|
Increment `fence` on every successful claim. The fence is monotonic per run.
|
||
|
|
3. The worker carries its fence value in memory and includes it on **every**
|
||
|
|
write. Writes compare against the stored fence and are rejected when lower.
|
||
|
|
4. Heartbeat renews `lease_expires_at` on an interval well inside the TTL. A
|
||
|
|
failed renewal cancels the local `RunScope` (T1.1) rather than continuing
|
||
|
|
optimistically.
|
||
|
|
5. On transition to `Suspended`, release the lease outright — clear the owner and
|
||
|
|
stop heartbeating. On resume, re-claim, which takes a new fence.
|
||
|
|
6. Reclaim path: a run whose lease expired is claimable by any worker.
|
||
|
|
7. Test the full sequence: partition, expiry, reclaim elsewhere, reconnect the
|
||
|
|
original, assert its writes are **rejected by fence**.
|
||
|
|
|
||
|
|
## Acceptance
|
||
|
|
|
||
|
|
- Partition a worker, let its lease expire, reclaim the run elsewhere, then
|
||
|
|
reconnect the original — its writes are **rejected by fence, not merely late**.
|
||
|
|
|
||
|
|
## Verify
|
||
|
|
|
||
|
|
**Harness:** two worker processes against one Postgres, plus `turmoil` or an
|
||
|
|
iptables-style partition for the network half. `tokio::time::pause` cannot be
|
||
|
|
used across processes — use a short real TTL instead.
|
||
|
|
|
||
|
|
**Integration test** — `tests/it_leases_fencing.rs`:
|
||
|
|
1. Worker A claims run R (fence = 1) and begins executing.
|
||
|
|
2. Partition A from the database. Let the lease expire.
|
||
|
|
3. Worker B reclaims R; assert its fence is **2**.
|
||
|
|
4. Heal the partition. Worker A attempts a write under fence 1.
|
||
|
|
5. Assert the write is **rejected by the fence check** — and assert the rejection
|
||
|
|
reason is the fence, not a timestamp comparison and not a generic conflict.
|
||
|
|
6. Assert R completed **exactly once**: check the external side-effect ledger for
|
||
|
|
a single entry.
|
||
|
|
7. Assert A's failed heartbeat **cancelled its local `RunScope`**, so it stopped
|
||
|
|
working rather than continuing optimistically.
|
||
|
|
8. `Suspended`: transition a run to `Suspended`; assert the lease is **released**
|
||
|
|
(owner cleared) and that no heartbeat is emitted while suspended. Resume and
|
||
|
|
assert a **new, higher** fence.
|
||
|
|
9. Monotonicity: 100 claim/expire cycles; assert the fence never decreases or
|
||
|
|
repeats.
|
||
|
|
|
||
|
|
**Command:** `cargo test -p distribution leases -- --test-threads=1`
|
||
|
|
|
||
|
|
**False pass:**
|
||
|
|
- Step 5 satisfied by a timestamp check ("your lease expired"). Under clock skew
|
||
|
|
that check can pass for a stale worker; the **fence comparison** is what makes
|
||
|
|
it clock-independent. Assert the reason.
|
||
|
|
- Step 6 omitted: both workers can execute, both can be "correct", and the
|
||
|
|
double-execution is only visible in the external ledger.
|
||
|
|
- A per-worker fence counter rather than per-run monotonic — step 9 with two
|
||
|
|
workers alternating catches it.
|
||
|
|
- Step 8 verified by reading a flag rather than asserting heartbeats stop.
|
||
|
|
|
||
|
|
## Traps
|
||
|
|
|
||
|
|
- Checking only `lease_expires_at` at write time. Clock skew makes "not expired
|
||
|
|
yet" a lie, and the fence is what makes the check independent of clocks.
|
||
|
|
- Heartbeating through `Suspended`. It burns a worker slot for the duration of a
|
||
|
|
human decision.
|
||
|
|
- A per-worker fence rather than per-run monotonic. Two workers then produce
|
||
|
|
incomparable tokens.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
Background (not required to do this task):
|
||
|
|
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.2, §7, §9.4 ·
|
||
|
|
[rust-agentic-task.md](../../../rust-agentic-task.md)
|