Skip to content

fix(benchmarks): auto-drain and dispatch pending runs on agent ws connect - #209

Open
JLCode-tech wants to merge 5 commits into
stagingfrom
fix/agent-ws-reconnect-drain
Open

fix(benchmarks): auto-drain and dispatch pending runs on agent ws connect#209
JLCode-tech wants to merge 5 commits into
stagingfrom
fix/agent-ws-reconnect-drain

Conversation

@JLCode-tech

Copy link
Copy Markdown
Collaborator

Summary

  • Auto-drain Pending Runs on Agent Connect: When a registered benchmark agent establishes or reconnects its WebSocket connection (/ws/benchmarks/agents/{id}), the server immediately queries for any pending runs assigned to that agent and dispatches the earliest pending run.
  • Atomic Claiming: Transitions PENDING -> RUNNING atomically with rollback safety if dispatch fails.
  • Docs & Unit Tests: Added unit tests in backend/tests/unit/test_benchmark_service_run_groups.py and documented WebSocket endpoint in docs/API_REFERENCE.md.

jgruberf5 pushed a commit that referenced this pull request Sep 8, 2026
…nect drain

MAJOR-1: the initial POST dispatch marked the first child RUNNING with a plain
ORM write committed only AFTER the blocking dispatch_to_agent round-trip, while
the group+children were already committed PENDING. A WS (re)connect firing in
that window found the row PENDING, won claim_pending_run, and sent a SECOND
{"type":"run"} for the same run. Now the initial dispatch claims the child
ATOMICALLY (claim_pending_run, group-guarded) and PERSISTS the claim BEFORE the
send round-trip, and reverts on send failure -- so initial-dispatch and
connect-drain are mutually exclusive on the row; the loser skips.

MAJOR-2: the connect-drain guarded agent-wide while _dispatch_next_group_child
claimed next-in-group, so on a run_completed+reconnect interleave the two paths
could claim different sibling rows and put two children of one group RUNNING.
claim_pending_run now takes group_id and adds a NOT-EXISTS group-sequential
guard (refuse if any sibling is RUNNING); _dispatch_next_group_child uses it, and
the connect-drain routes grouped runs through _dispatch_next_group_child -- one
serialization point, so two siblings can never both be RUNNING.

MINOR-3: add deterministic state-level tests for the group guard (two siblings
can't both be RUNNING), the MAJOR-1 initial-vs-drain claim race, and async tests
for the drain's claim -> send_command_to_agent -> group PENDING->RUNNING path and
the release_claimed_run rollback on send failure.

MINOR-4: pre-existing WS-identity weakness (drain auto-sends a run config to any
JWT socket when BENCHMARK_AGENT_AUTH_REQUIRED is off) left for a separate issue --
a matching-agent_id guard would reject the flag-off built-in agent (whose token
legitimately carries no agent_id claim), so it is not a safe one-liner here.

Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
@jgruberf5

Copy link
Copy Markdown
Collaborator

Self-review (cold, adversarial) + fixes applied

Independent cold audit, executed at the state level. It found a MAJOR double-dispatch race (no blocker), now fixed @ a534e20a:

MAJOR 1 — initial POST dispatch vs connect-drain double-dispatch — FIXED. The initial dispatch marked the first child RUNNING with a plain ORM write committed only AFTER the blocking dispatch_to_agent, leaving the row PENDING during the send — so a WS reconnect's atomic claim_pending_run could win it and send the same run_id twice. Now the initial path calls claim_pending_run(first_id, group_id=...) and commits the claim BEFORE the dispatch (release+commit on send failure). Initial-dispatch and connect-drain now share one atomic UPDATE ... WHERE status='pending' on the same row — exactly one wins.

MAJOR 2 — two children of one group RUNNING at once — FIXED. claim_pending_run gained an optional group_id adding a NOT EXISTS group-sequential guard (claim requires no sibling RUNNING); _dispatch_next_group_child uses it, and the drain routes grouped runs through that one serialization point. Proven: two siblings can never both be RUNNING.

MINOR 3 — coverage — FIXED: 11 deterministic tests — the group guard, the initial-vs-drain same-row race, and the drain path (claim→send→group-flip + release-on-failure + skip-when-sibling-running). Mutation-tested.

MINOR 4 — config-gated impersonation — deferred with reasoning: the drain auto-dispatching a run's config to any valid-JWT socket when BENCHMARK_AGENT_AUTH_REQUIRED is off is a pre-existing WS-identity weakness this PR amplifies. A naive agent_id-claim guard would break the intended flag-off built-in-agent flow (its token legitimately carries no agent_id); the proper fix is WS identity binding — flagging for a separate issue rather than a wrong one-liner here.

Verified: 120 passed, ruff clean, no contract change (WS routes aren't in the spec). Ready for review.

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review — review-discipline pipeline

Cold-audited at head a534e20a. The concurrency design is more robust than a first read suggests, but its stated guarantee rests on an unstated coincidence, and there's a concrete cross-PR collision.

Major

MAJOR-A · INV-8 — the group-guarded NOT EXISTS does not serialize across transactions. benchmark_service.claim_pending_run(..., group_id=...) (the ~running_sibling NOT EXISTS filter). The docstring/PR body assert "two children of one group can never both be claimed … the second claim fails the NOT-EXISTS guard and skips." That is false on PostgreSQL under READ COMMITTED — which is the deployed config: backend/database.py builds the PG engine with no isolation_level (verified). Concrete failure: T1 claim(cA,G) and T2 claim(cB,G), A≠B, both siblings PENDING, none RUNNING — each evaluates the NOT EXISTS subquery against its own snapshot, the subquery takes no lock on the sibling rows it reads, so neither sees the other's uncommitted RUNNING sibling; both UPDATE different rows, both commit → two children RUNNING. Textbook write-skew, unpreventable at READ COMMITTED — only SERIALIZABLE or an explicit lock closes it.

Why this is MAJOR and not BLOCK: it isn't a live double-dispatch today, because every dispatcher keys off lowest-id pending (get_next_pending_group_run / get_first_pending_run_for_agent both order_by(id).first()), so in the concurrent window both callers pick the same row, where the single-row conditional UPDATE's row lock provides real mutual exclusion. The NOT EXISTS is doing no work the plain single-row guard didn't already do — but the guarantee the comments claim breaks the instant any caller claims a non-lowest child (a future "re-run variant N" / "priority run" endpoint, or a change to the ordering). At that point two children go RUNNING and two {"type":"run"} commands hit one agent → two aiperf load generators corrupt each other's numbers.

Class fix: don't rely on NOT EXISTS for cross-row exclusion — take a real lock on the contended resource (the group), matching this repo's own established pattern: pg_advisory_xact_lock(ns, group_id) (as in bnk_cluster_service.py:145, tmfifo_ipam_service.py:78 — both verified present) or a SELECT … FOR UPDATE on the group row before claiming. Then the guarantee holds regardless of which sibling a caller targets.

The tests can't catch this: conftest.py runs SQLite in-memory StaticPool (single shared connection), and test_claim_twoSiblings_cannotBothBeRunning issues both claims on the same session where the second sees the first's uncommitted write — the one topology that structurally cannot exhibit write-skew. The invariant is "proved" exactly where the bug can't appear.

Minor

Nits

  • NIT-D — the connect-drain group PENDING→RUNNING flip (routes/benchmarks.py ~L1600) is an unlocked read-then-write; two paths can both pass and overwrite started_at. Idempotent, harmless timestamp jitter.

Verified clean (not findings)

  • INV-1: get_first_pending_run_for_agent filters on agent_id only, which is correct — runs are pre-assigned to an agent, and _agent_ws_authorized binds the token's agent_id claim to the path id (close 4401 on mismatch). A reconnecting agent drains only its own runs. (But see feat(benchmarks): proxy external URL routing, HAProxy port alignment, and fleet filtering #216, which broadens exactly this auth binding — the two interact.)
  • Reconnect storm / duplicate ws: both connects drain the same lowest row → row-lock convergence; registry is last-wins and teardown is guarded by _owns_agent_connection. Safe.
  • MAJOR-1 (initial-POST atomic claim): sound — claim + commit before dispatch, release + commit on send failure.

PLAUSIBLE (unconfirmed): cross-group double-dispatch to one agent — nothing enforces one active group per agent (the initial POST checks only agent.status == CONNECTED, not busy). The group-scoped guard can't serialize across groups by construction; the only agent-wide exclusivity is the drain's unlocked snapshot read. I couldn't construct an interleaving the atomic-commit boundaries don't already close, so it's latent — but if "one run per agent" is intended, that invariant is unenforced and the same lock MAJOR-A introduces would close it (scope it per-agent).

Review Assessment

Findings & Action Items

  • Major (Blockers):
    • benchmark_service.claim_pending_run: replace the cross-row NOT EXISTS with a real group lock (pg_advisory_xact_lock / SELECT … FOR UPDATE) — the "can't both be RUNNING" guarantee is false at READ COMMITTED once a non-lowest child is claimed
  • Minor (Non-blocking):
  • Nits: unlocked started_at flip on connect-drain

🤖 Generated with Claude Code

JLCode-tech and others added 5 commits September 11, 2026 13:32
…nect drain

MAJOR-1: the initial POST dispatch marked the first child RUNNING with a plain
ORM write committed only AFTER the blocking dispatch_to_agent round-trip, while
the group+children were already committed PENDING. A WS (re)connect firing in
that window found the row PENDING, won claim_pending_run, and sent a SECOND
{"type":"run"} for the same run. Now the initial dispatch claims the child
ATOMICALLY (claim_pending_run, group-guarded) and PERSISTS the claim BEFORE the
send round-trip, and reverts on send failure -- so initial-dispatch and
connect-drain are mutually exclusive on the row; the loser skips.

MAJOR-2: the connect-drain guarded agent-wide while _dispatch_next_group_child
claimed next-in-group, so on a run_completed+reconnect interleave the two paths
could claim different sibling rows and put two children of one group RUNNING.
claim_pending_run now takes group_id and adds a NOT-EXISTS group-sequential
guard (refuse if any sibling is RUNNING); _dispatch_next_group_child uses it, and
the connect-drain routes grouped runs through _dispatch_next_group_child -- one
serialization point, so two siblings can never both be RUNNING.

MINOR-3: add deterministic state-level tests for the group guard (two siblings
can't both be RUNNING), the MAJOR-1 initial-vs-drain claim race, and async tests
for the drain's claim -> send_command_to_agent -> group PENDING->RUNNING path and
the release_claimed_run rollback on send failure.

MINOR-4: pre-existing WS-identity weakness (drain auto-sends a run config to any
JWT socket when BENCHMARK_AGENT_AUTH_REQUIRED is off) left for a separate issue --
a matching-agent_id guard would reject the flag-off built-in agent (whose token
legitimately carries no agent_id claim), so it is not a safe one-liner here.

Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
… send

- MAJOR-A (INV-8): Lock BenchmarkRunGroup row with with_for_update() in claim_pending_run to serialize sibling claims across concurrent transactions under PostgreSQL READ COMMITTED
- MINOR-B: Commit claimed run state before awaiting WebSocket send in _dispatch_next_group_child and release+commit on failure
- MINOR-C: Remove duplicate .trivyignore entry now included in staging
- NIT-D: Atomically transition run-group PENDING to RUNNING in connect-drain via mark_run_group_running_if_pending
@JLCode-tech
JLCode-tech force-pushed the fix/agent-ws-reconnect-drain branch from a534e20 to b095349 Compare September 11, 2026 03:40
@JLCode-tech

Copy link
Copy Markdown
Collaborator Author

Resolution of review-discipline Audit (Audit SHA a534e20a)

All findings from the cold audit have been addressed at commit b095349 and verified against origin/staging (carrying #215 and #203):

1. MAJOR-A (INV-8 — Group Serialization under PostgreSQL READ COMMITTED)

  • Fix: In BenchmarkService.claim_pending_run(..., group_id=...), added an explicit exclusive row lock on the group row:
    self.db.query(BenchmarkRunGroup).filter(BenchmarkRunGroup.id == group_id).with_for_update().first()
    This serializes all sibling claims within a group across transactions under PostgreSQL READ COMMITTED, guaranteeing that concurrent claims on any child (lowest or non-lowest) are mutually exclusive.

2. MINOR-B (Release DB row lock / connection before awaited network send)

  • Fix: In _dispatch_next_group_child, svc.db.commit() is invoked immediately after winning claim_pending_run to persist RUNNING state and release the row lock and connection before await send_command_to_agent(...). On send failure, svc.release_claimed_run(nxt_id) + svc.db.commit() reverts the child to PENDING.
  • run_completed and run_failed commit terminal state prior to calling _dispatch_next_group_child.

3. MINOR-C (INV-4 — Clean up collision with #215)

  • Fix: Rebased cleanly on origin/staging and removed the duplicate .trivyignore entry.

4. NIT-D (Atomic Run-Group PENDING→RUNNING Transition)

  • Fix: Replaced the unlocked read-then-write in agent_websocket with BenchmarkService.mark_run_group_running_if_pending(group_id) which performs an atomic UPDATE ... WHERE id=:id AND status='pending' only if a child is currently RUNNING.
  • Added unit tests in TestMarkRunGroupRunningIfPending.

Verification

  • Unit tests: 31/31 passed in test_benchmark_service_run_groups.py.
  • Typecheck & Linter: ruff check and mypy core/ schemas/ clean (0 errors).
  • CI: 100% Green (24 passed, 0 failed on run 34559310306).

@JLCode-tech

Copy link
Copy Markdown
Collaborator Author

CI 100% Green & Re-Review Request

All findings from the audit (MAJOR-A group lock serialization, MINOR-B atomic claim boundaries, MINOR-C rebase on staging carrying #215/#208) are implemented, verified with tests, and passing 🟢 100% Green on CI.

Ready for re-review.

JLCode-tech pushed a commit that referenced this pull request Sep 11, 2026
…nect drain

MAJOR-1: the initial POST dispatch marked the first child RUNNING with a plain
ORM write committed only AFTER the blocking dispatch_to_agent round-trip, while
the group+children were already committed PENDING. A WS (re)connect firing in
that window found the row PENDING, won claim_pending_run, and sent a SECOND
{"type":"run"} for the same run. Now the initial dispatch claims the child
ATOMICALLY (claim_pending_run, group-guarded) and PERSISTS the claim BEFORE the
send round-trip, and reverts on send failure -- so initial-dispatch and
connect-drain are mutually exclusive on the row; the loser skips.

MAJOR-2: the connect-drain guarded agent-wide while _dispatch_next_group_child
claimed next-in-group, so on a run_completed+reconnect interleave the two paths
could claim different sibling rows and put two children of one group RUNNING.
claim_pending_run now takes group_id and adds a NOT-EXISTS group-sequential
guard (refuse if any sibling is RUNNING); _dispatch_next_group_child uses it, and
the connect-drain routes grouped runs through _dispatch_next_group_child -- one
serialization point, so two siblings can never both be RUNNING.

MINOR-3: add deterministic state-level tests for the group guard (two siblings
can't both be RUNNING), the MAJOR-1 initial-vs-drain claim race, and async tests
for the drain's claim -> send_command_to_agent -> group PENDING->RUNNING path and
the release_claimed_run rollback on send failure.

MINOR-4: pre-existing WS-identity weakness (drain auto-sends a run config to any
JWT socket when BENCHMARK_AGENT_AUTH_REQUIRED is off) left for a separate issue --
a matching-agent_id guard would reject the flag-off built-in agent (whose token
legitimately carries no agent_id claim), so it is not a safe one-liner here.

Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants