Skip to content

Commit e38e5c8

Browse files
committed
fix(worker): treat 409 complete-conflict as lost-lease + atomic memory settle + honest absent-fence test
1 parent 32cd4a6 commit e38e5c8

4 files changed

Lines changed: 155 additions & 28 deletions

File tree

runtime/state/src/memory.rs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -446,16 +446,19 @@ impl StateBackend for InMemoryBackend {
446446
// 2. A stale fence after a reclaim — `reclaim_expired_leases` clears
447447
// `worker_id` but LEAVES `lease_fence` set, so the old fence lingers.
448448
// Either case now returns `Ok(false)`. A mismatched fence, an absent
449-
// item, or one already settled (removed) also returns `false`. The
450-
// get-then-remove is two DashMap ops rather than one atomic step; that is
451-
// acceptable for the in-memory dev/test backend (the SQLite backends are
452-
// the production path where atomicity is guaranteed by the single UPDATE).
453-
match self.work_items.get(&item_id) {
454-
Some(entry) if entry.worker_id.is_some() && entry.lease_fence == lease_fence => {}
455-
_ => return Ok(false),
456-
}
457-
self.work_items.remove(&item_id);
458-
Ok(true)
449+
// item, or one already settled (removed) also returns `false`.
450+
//
451+
// `remove_if` makes the guard check and the delete a SINGLE atomic step
452+
// (it holds the shard lock across the predicate and removal), closing the
453+
// get-then-remove TOCTOU where two concurrent settles could both observe a
454+
// claimed+matching item and both remove it. It returns `Some` iff the
455+
// predicate held and the entry was removed, so exactly one racer settles.
456+
Ok(self
457+
.work_items
458+
.remove_if(&item_id, |_k, entry| {
459+
entry.worker_id.is_some() && entry.lease_fence == lease_fence
460+
})
461+
.is_some())
459462
}
460463

461464
async fn commit_turn(

runtime/state/tests/memory_backend.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,59 @@ async fn fenced_complete_settles_claimed_item_with_matching_fence() {
345345
);
346346
}
347347

348+
/// Atomicity: many concurrent fenced completes of the SAME claimed item with the
349+
/// SAME matching fence must yield EXACTLY ONE `true`. `complete_work_item_fenced`
350+
/// uses DashMap's `remove_if`, so the claimed+fence-match check and the delete are
351+
/// one atomic step — the get-then-remove TOCTOU where two racers both observe the
352+
/// item and both remove it (double-settle) cannot happen.
353+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
354+
async fn fenced_complete_is_atomic_under_concurrency() {
355+
use std::sync::Arc;
356+
use tokio::sync::Barrier;
357+
358+
let backend = Arc::new(InMemoryBackend::new());
359+
let exec_id = ExecutionId::new();
360+
let item_id = backend
361+
.enqueue_work_item(pending_item(&exec_id))
362+
.await
363+
.unwrap();
364+
let claimed = backend
365+
.claim_work_item("w1", &["default"])
366+
.await
367+
.unwrap()
368+
.unwrap();
369+
let fence = claimed.lease_fence;
370+
assert!(fence > 0);
371+
372+
// Release all racers simultaneously (the barrier maximizes the overlap on the
373+
// single contended item) and have each present the matching fence.
374+
const RACERS: usize = 16;
375+
let barrier = Arc::new(Barrier::new(RACERS));
376+
let mut handles = Vec::with_capacity(RACERS);
377+
for _ in 0..RACERS {
378+
let backend = Arc::clone(&backend);
379+
let barrier = Arc::clone(&barrier);
380+
handles.push(tokio::spawn(async move {
381+
barrier.wait().await;
382+
backend
383+
.complete_work_item_fenced(item_id, fence)
384+
.await
385+
.unwrap()
386+
}));
387+
}
388+
389+
let mut settled = 0usize;
390+
for h in handles {
391+
if h.await.unwrap() {
392+
settled += 1;
393+
}
394+
}
395+
assert_eq!(
396+
settled, 1,
397+
"exactly one concurrent fenced complete may settle the item (atomic check-and-delete)"
398+
);
399+
}
400+
348401
#[tokio::test]
349402
async fn test_patch_append_array() {
350403
let backend = InMemoryBackend::new();

sdk/python/jamjet/cli/main.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,20 +1850,36 @@ async def _heartbeat_loop() -> None:
18501850
gen_ai_model_field = output.get("gen_ai_model") or None
18511851
finish_reason_field = output.get("finish_reason") or None
18521852

1853-
await client.complete_work_item(
1854-
item_id,
1855-
exec_id,
1856-
node_id,
1857-
output,
1858-
state_patch,
1859-
duration_ms,
1860-
gen_ai_model=gen_ai_model_field,
1861-
finish_reason=finish_reason_field,
1862-
# Echo the lease fence from the claim so the runtime can fence
1863-
# the completion (reject a stale/reclaimed lease). Omitted when
1864-
# 0/absent, keeping the unfenced fallback backward-compatible.
1865-
lease_fence=lease_fence,
1866-
)
1853+
try:
1854+
await client.complete_work_item(
1855+
item_id,
1856+
exec_id,
1857+
node_id,
1858+
output,
1859+
state_patch,
1860+
duration_ms,
1861+
gen_ai_model=gen_ai_model_field,
1862+
finish_reason=finish_reason_field,
1863+
# Echo the lease fence from the claim so the runtime can fence
1864+
# the completion (reject a stale/reclaimed lease). Omitted when
1865+
# 0/absent, keeping the unfenced fallback backward-compatible.
1866+
lease_fence=lease_fence,
1867+
)
1868+
except Exception as complete_exc:
1869+
# A 409 means our echoed fence no longer matches: the lease was
1870+
# reclaimed and a NEW worker owns this item now. This (stale)
1871+
# worker must NOT fail_work_item — that would clobber the
1872+
# reclaimed work the new claimant is running and defeat the fence.
1873+
# Treat the lost lease as a no-op and move on. httpx surfaces the
1874+
# rejection as HTTPStatusError carrying .response.status_code.
1875+
status = getattr(getattr(complete_exc, "response", None), "status_code", None)
1876+
if status == 409:
1877+
console.print(f"[yellow]Completion rejected; lease lost[/yellow] id={item_id}")
1878+
if once:
1879+
return
1880+
continue
1881+
# Any other completion error keeps the existing fail behavior.
1882+
raise
18671883
console.print(f"[green]Completed[/green] id={item_id} node=[bold]{node_id}[/bold] {duration_ms}ms")
18681884
except Exception as exc:
18691885
console.print(f"[red]Failed[/red] node={node_id}: {exc}")

sdk/python/tests/test_worker.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
from __future__ import annotations
1515

16+
import httpx
17+
1618
from jamjet.cli.main import _worker_loop # noqa: E402
1719

1820
# ── Test handler functions ────────────────────────────────────────────────────
@@ -97,6 +99,30 @@ async def heartbeat_work_item(self, item_id: str, worker_id: str, lease_fence: i
9799
self.heartbeat_calls.append({"item_id": item_id, "lease_fence": lease_fence})
98100

99101

102+
def _http_status_error(status: int) -> httpx.HTTPStatusError:
103+
"""Build the exact exception JamjetClient.complete_work_item raises on a non-2xx
104+
response: httpx's HTTPStatusError carrying `.response.status_code` (the engine
105+
returns 409 when the echoed lease fence no longer matches)."""
106+
req = httpx.Request("POST", "http://runtime/work-items/wi/complete")
107+
resp = httpx.Response(status, request=req)
108+
return httpx.HTTPStatusError(f"HTTP {status}", request=req, response=resp)
109+
110+
111+
class _Conflict409Client(_StubClient):
112+
"""Stub whose complete_work_item raises a 409 — i.e. the lease was reclaimed and
113+
a NEW worker now owns the item; this (stale) worker's settle is rejected."""
114+
115+
async def complete_work_item(self, *args: object, **kwargs: object) -> None:
116+
raise _http_status_error(409)
117+
118+
119+
class _ServerError500Client(_StubClient):
120+
"""Stub whose complete_work_item raises a NON-409 error (transient server fault)."""
121+
122+
async def complete_work_item(self, *args: object, **kwargs: object) -> None:
123+
raise _http_status_error(500)
124+
125+
100126
# ── Fixtures / constants ──────────────────────────────────────────────────────
101127

102128
_ADD_ITEM: dict = {
@@ -275,17 +301,46 @@ async def test_worker_echoes_lease_fence_on_complete() -> None:
275301

276302

277303
async def test_worker_absent_fence_forwarded_as_zero() -> None:
278-
"""An item with no real fence (lease_fence=0) forwards 0; the client omits it,
279-
keeping the unfenced fallback backward-compatible."""
280-
stub = _StubClient(claimed_item=_ADD_ITEM)
304+
"""When the claim response OMITS the lease_fence key ENTIRELY (not even an
305+
explicit 0), the worker must still forward 0 — the client then drops it, keeping
306+
the unfenced fallback backward-compatible.
307+
308+
Regression: this previously claimed an item built from _ADD_ITEM, which already
309+
carries an explicit lease_fence=0, so it only exercised the explicit-0 path and
310+
never the missing-key path it is named for. Build an item without the key."""
311+
no_fence_item = {k: v for k, v in _ADD_ITEM.items() if k != "lease_fence"}
312+
assert "lease_fence" not in no_fence_item, "the key must be truly absent from the claim"
313+
stub = _StubClient(claimed_item=no_fence_item)
281314
await _worker_loop(stub, "test-worker", ["python_tool"], once=True)
282315

283316
assert len(stub.complete_calls) == 1
284317
assert stub.complete_calls[0]["lease_fence"] == 0, (
285-
"absent fence is forwarded as 0 and dropped by the client (unfenced path)"
318+
"an absent fence is forwarded as 0 and dropped by the client (unfenced path)"
286319
)
287320

288321

322+
async def test_worker_409_complete_is_lost_lease_noop() -> None:
323+
"""A 409 from complete_work_item means our lease fence no longer matches: the
324+
item was reclaimed and a NEW worker owns it now. The stale worker must treat the
325+
rejection as a lost-lease no-op — it must NOT call fail_work_item, because that
326+
would clobber the reclaimed work the new claimant is running. With --once it
327+
returns cleanly."""
328+
stub = _Conflict409Client(claimed_item=_FENCED_ITEM)
329+
await _worker_loop(stub, "test-worker", ["python_tool"], once=True)
330+
331+
assert len(stub.fail_calls) == 0, "a 409 lost-lease must NOT call fail_work_item"
332+
333+
334+
async def test_worker_non_409_complete_error_still_fails() -> None:
335+
"""A NON-409 completion error keeps the existing behavior: it falls through to
336+
fail_work_item (the lease is still ours; the settle just did not land)."""
337+
stub = _ServerError500Client(claimed_item=_FENCED_ITEM)
338+
await _worker_loop(stub, "test-worker", ["python_tool"], once=True)
339+
340+
assert len(stub.fail_calls) == 1, "a non-409 completion error must still call fail_work_item"
341+
assert stub.fail_calls[0]["item_id"] == "wi-006"
342+
343+
289344
async def test_worker_dispatch_tool_calls_return_reaches_state() -> None:
290345
"""2j-4 G2: the agent loop's dispatch_tool_calls runs on the worker and its
291346
{"messages": [...]} return is posted as the state_patch — so the accumulated

0 commit comments

Comments
 (0)