Skip to content

test(temporal): stage the starvation the queue split removed - #303

Open
mashkovd wants to merge 1 commit into
mainfrom
feat/agents-152-demonstrate-queue-isolation
Open

mashkovd wants to merge 1 commit into
mainfrom
feat/agents-152-demonstrate-queue-isolation

Conversation

@mashkovd

@mashkovd mashkovd commented Sep 3, 2026

Copy link
Copy Markdown
Member

Closes the two open acceptance criteria of #152. Refs, not Closes — the
issue is closed by comment once the gitops replica change has soaked.

Criterion 1 — "a slow or failing agent workload cannot starve approval, scheduling or reconciliation"

Structurally in place since 1.39.0, never demonstrated. Production could
not demonstrate it: over the 44 h baseline to 2026-09-03 the control pool never
fell below 96 free slots of 100, so the isolation was never exercised in
either direction. Waiting longer would not have helped — the criterion waits on
load, and two more quiet days add no information.

So the load is staged here, deterministically and without spending model quota:
CONTROL_MAX_CONCURRENT_ACTIVITIES + 20 slow activities holding execution-queue
slots the way a two-hour submit_and_wait poll does, with short control
activities racing beside them.

Two tests, because the first alone proves nothing:

probes completed
saturated exec queue, control free (today) 10 / 10
identical load on one queue (pre-#282) 0 / 10

The second test is the mutation, kept as a permanent control rather than run
once by hand. Without it the first is satisfied by any topology whatsoever —
ten short activities can always run somewhere.

Two things the test does on purpose:

  • Asserts on probes completing, not on wall-clock latency. Latency in a
    time-skipping environment is meaningless.
  • Waits for saturation before probing, and asserts it. Otherwise a run
    that staged nothing would pass — the exact "check that skips when its data
    is missing" shape.

Criterion 3 — "scaling worker replicas does not create duplicate schedules or duplicate runs"

The schedule half was covered, but only ever on one process: _FakeClient
models a single replica, so two of them would each see existing=None and each
"create" into their own list. TestConcurrentReplicas adds a shared fake
cluster with a yield between the check and the write, so the race is real:

  • the loser converges instead of crashing — a ScheduleAlreadyRunningError
    escaping would crash-loop every replica after the first, i.e. a scale-out
    that scales to one;
  • every schedule comes out of the race with overlap=SKIP, which is what
    actually prevents two replicas' ticks running side by side.

What is NOT in this PR

replicaCount: 1 → 2 on mctl-agents-worker-exec — a mctl-gitops change,
landing separately so the code that describes the behaviour is merged and
reviewed before the cluster does it.

ADR-008

## Non-goals listed HPA next to quotas and stopped, which left Scope's
"provide an HPA/replica strategy" undelivered rather than declined. Replaced
with a real strategy: fixed replicas raised by hand; why a CPU-target HPA
measures the one signal guaranteed not to move on two I/O-bound waiters; the
two signals that together justify a replica (a queue's
temporal_worker_task_slots_available at zero and p95
schedule_to_start rising — either alone is not it); what N>1 is safe against
and which test knows it; execution-worker-first ordering.

Verification

mutation result
route slow activities to the control queue probes 10 → 0, second test is exactly this
drop the ScheduleAlreadyRunningError branch test_the_loser_of_the_race_converges_instead_of_failing fails
overlap=SKIP → ALLOW_ALL test_every_schedule_survives_a_concurrent_boot_with_skip fails

793 passed, 2 skipped; ruff check clean.

One test written and deleted

An earlier test_two_replicas_booting_together_create_one_schedule asserted
one schedule per id. That is guaranteed by the id being a dict key and passes
on any implementation — it survived the ALLOW_ALL mutation untouched. Three
tests where one cannot fail are worse than two that can, so it is gone and its
one real assertion (both replicas actually raced) moved into the test that can.

Honest remainder

Control still registers submit_and_wait, because patched() memoizes per
execution: pre-1.39.0 executions stay on the control queue for the rest of
their lives, bounded by the 14-day merge watch. Isolation in production is
complete only once those drain. That is a correct partial, not a gap in this
PR — the topology is what is under test here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AMpSrP38wdw9eskEwUhurQ

#152's first criterion — "a slow or failing agent workload cannot starve
approval, scheduling or reconciliation" — was structurally in place and never
demonstrated. Production could not demonstrate it: across the 44 h baseline to
2026-09-03 the control pool never fell below 96 free slots of 100, so the
isolation was never exercised in either direction. A criterion that has never
been put under the load it describes is untested, not met.

So the load is staged deterministically instead, with no model quota spent:
CONTROL_MAX_CONCURRENT_ACTIVITIES + 20 slow activities holding execution-queue
slots the way a two-hour submit_and_wait poll does, and short control
activities racing beside them.

Two tests, because the first alone proves nothing:

- a saturated execution queue lets all ten control probes through;
- the identical load on ONE queue (the pre-#282 world) lets ZERO through.

The second is the mutation, kept as a permanent control rather than run once
by hand — without it the first test is satisfied by any topology whatsoever,
since ten short activities can always run somewhere. Measured: 10/10 with the
split, 0/10 without.

The assertion is on probe activities COMPLETING, not on wall-clock latency:
latency in a time-skipping environment means nothing, and saturation itself is
awaited and asserted before the probe starts, so a run that staged nothing
fails instead of passing.

Criterion 3, "scaling worker replicas does not create duplicate schedules or
duplicate runs", was covered only on a single process. TestConcurrentReplicas
adds a shared fake cluster so two replicas can actually race in
_ensure_schedule: the loser converges rather than crashing (drop the
ScheduleAlreadyRunningError branch and it fails), and every schedule comes out
of the race with overlap=SKIP, which is what stops two replicas' ticks running
side by side (flip it to ALLOW_ALL and it fails).

A third test asserting "one schedule per id" was written and deleted: that is
guaranteed by the id being a dict key and passes on any implementation, and
three tests where one cannot fail are worse than two that can.

ADR-008's Non-goals listed HPA alongside quotas and stopped there, leaving
Scope's "provide an HPA/replica strategy" undelivered. Replaced with an actual
replica strategy: fixed replicas raised by hand, why a CPU-target HPA measures
the one signal guaranteed not to move on two I/O-bound waiters, the two
signals that together justify a replica, what N>1 is safe against and which
test knows it, and execution-worker-first ordering.

Refs #152
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMpSrP38wdw9eskEwUhurQ
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@claude

claude Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 27s —— View job


Review complete — changes requested

  • Gather context (diff, changed files, CLAUDE.md)
  • Analyze tests/test_queue_isolation.py
  • Analyze tests/test_worker_schedules.py changes
  • Analyze ADR-008 changes
  • Post inline findings
  • Submit verdict

Findings: 1 P2, 1 P3

  • P2 (tests/test_queue_isolation.py:173): the saturation wait (await asyncio.wait_for(gate.saturated.wait(), timeout=60)) sits outside the try/finally (lines 175-192) that releases gate.released. If saturation is never reached in time, this raises a bare TimeoutError while the slow activities are still blocked, and gate.released is never set before async with control, execution: tears the workers down — turning what should be a clear assertion failure into an opaque timeout/cancellation. Suggest folding the saturation wait into the same try/finally.
  • P3 (tests/test_queue_isolation.py:79): the saturation threshold is hardcoded to EXECUTION_MAX_CONCURRENT_ACTIVITIES regardless of which queue is under load. test_the_same_load_on_one_queue_does_starve_control only reaches it because CONTROL_MAX_CONCURRENT_ACTIVITIES (100) currently exceeds it (40) — if that ordering is ever flipped (plausible per ADR-008's new replica-sizing guidance), the same P2 path triggers.

Everything else checked out:

  • TestConcurrentReplicas in tests/test_worker_schedules.py correctly exercises the create/update race in _ensure_schedule (orchestrator/temporal/worker.py:96-210) against a shared fake cluster with a real yield point, and the assertions match the described mutations (dropping the ScheduleAlreadyRunningError catch, or SKIP → ALLOW_ALL).
  • ADR-008's new "Replica strategy" section is doc-only prose, consistent with the code it describes.

Note: I was unable to execute pytest/python3 in this sandbox (commands required interactive approval that wasn't available), so this review is based on static reading of the diff and the referenced source, not an actual test run.

Verdict posted: CHANGES_REQUESTED (bound to commit 0cfbca6).

# Wait for the queue to be genuinely backed up before probing.
# Without this the probe could run before a single slow activity
# had taken a slot, and the test would pass having staged nothing.
await asyncio.wait_for(gate.saturated.wait(), timeout=60)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: the saturation wait (await asyncio.wait_for(gate.saturated.wait(), timeout=60)) sits outside the try/finally at lines 175-192 that releases gate.released. If saturation never happens in time (CI slowness, or a genuine regression where the execution worker never picks up activities), this raises a bare TimeoutError while the SaturateExecutionQueue workflow's slow activities are still blocked on gate.released.wait(). The gate is never released before async with control, execution: tears down, so the failure surfaces as an opaque timeout/cancellation rather than the clear, purpose-built assertion this file is designed to give. Consider wrapping the saturation wait in the same try/finally (or moving the whole body under one try/finally that always calls gate.released.set()) so any failure path releases the blocked activities before worker teardown.

async def _slow_activity(gate_key: str) -> str:
gate = _GATES[gate_key]
gate.started += 1
if gate.started >= EXECUTION_MAX_CONCURRENT_ACTIVITIES:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the saturation signal is hardcoded to EXECUTION_MAX_CONCURRENT_ACTIVITIES (40) regardless of which queue actually runs the slow activities. test_the_same_load_on_one_queue_does_starve_control only reaches this threshold because CONTROL_MAX_CONCURRENT_ACTIVITIES (100) happens to exceed it today. If the two constants' relative ordering is ever flipped (e.g. control capacity tuned down, or execution capacity tuned up — plausible given ADR-008's new replica-sizing guidance), that test's saturation wait would time out instead of demonstrating starvation, hitting the P2 issue noted at line 173. Worth a comment noting the dependency, or computing the threshold from the worker actually receiving the load.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P2 (missing try/finally around the saturation wait in test_queue_isolation.py can turn a saturation-timeout failure into an opaque TimeoutError instead of the intended assertion, without releasing blocked activities before worker teardown), 1 P3 (implicit ordering assumption between EXECUTION_MAX_CONCURRENT_ACTIVITIES and CONTROL_MAX_CONCURRENT_ACTIVITIES that the same P2 path would hit if it ever flips). Everything else — the concurrent-replica schedule race test, the ADR-008 replica-strategy writeup — reads correctly and matches the described behaviour in orchestrator/temporal/worker.py.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Antigravity (agy) review

Model: gemini-3.1-pro-high.

Here is my review of the provided diff.

Finding 1

Severity: P2
File: tests/test_queue_isolation.py (approx. line 79)
Scenario: The saturation signal in _slow_activity is unconditionally triggered by checking if gate.started >= EXECUTION_MAX_CONCURRENT_ACTIVITIES:. However, the tests only spawn SLOW_ACTIVITY_COUNT activities (defined as CONTROL_MAX_CONCURRENT_ACTIVITIES + 20). If EXECUTION_MAX_CONCURRENT_ACTIVITIES is greater than CONTROL_MAX_CONCURRENT_ACTIVITIES + 20 (which is highly likely, given an execution worker's long-polling role vs. a control worker's role), gate.started will never reach the required execution limit, even when the queue is completely saturated. This will cause await asyncio.wait_for(gate.saturated.wait(), timeout=60) to time out and crash the test suite unconditionally with an unhandled TimeoutError instead of gracefully validating the expected queue starvation.
Proposed Fix: Remove the hardcoded execution limit from the condition. Pass the target queue's actual concurrency limit into _SaturateWorkflow and down to _slow_activity as an argument (e.g., limit), and use if gate.started >= limit: so the saturation event triggers correctly based on the specific queue being saturated.

Finding 2

Severity: P3
File: tests/test_queue_isolation.py (approx. lines 180-184)
Scenario: In _run_isolation_probe, if client.execute_workflow for _ProbeWorkflow times out (as expected during the starvation control test), the TimeoutError is caught and the function returns early. However, execute_workflow only cancels the client-side wait; the workflow remains pending on the Temporal server. Once the finally block calls gate.released.set() and frees up slots, the Temporal server will abruptly dispatch the queued record_execution activities in the background while the worker is shutting down. While harmless in short-lived time-skipping tests, this leaks a dangling workflow and causes unintended background activity noise.
Proposed Fix: Obtain a workflow handle and use await handle.cancel() inside the except TimeoutError: block to explicitly terminate the remote workflow before returning.

Finding 3

Severity: P3
File: tests/test_worker_schedules.py (approx. line 398)
Scenario: The test test_the_loser_of_the_race_converges_instead_of_failing aims to simulate two replicas booting concurrently, where one wins the race to create the schedule and the loser catches ScheduleAlreadyRunningError. However, by initializing _FakeCluster with an existing schedule, both replicas instantly lose the race against a phantom previous deployment and both hit ScheduleAlreadyRunningError. While this still tests that the error is caught, it fails to simulate the actual concurrent creation race condition described in the comments.
Proposed Fix: To properly test the concurrent creation race, initialize _FakeCluster(existing=None) so one replica succeeds in creating the schedule while the other fails. The logic for updating a pre-existing paused schedule should be separated into a different test case.

This branch has not been deployed

No deployments
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.

1 participant