Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ Each worker checks instance metadata for a Spot interruption notice (`spot/insta
On a notice, the worker interrupts the running trial with SIGINT, which cancels the trial's reported queries, uploads the attempt, releases its claim as `interrupted` and its slots, and exits without terminating the host.
A separate thread watches for the notice, so slow S3 requests do not delay it.
The group replaces the reclaimed instance, and the new host's worker takes over the interrupted claim as the next attempt: it downloads the earlier attempts, quiesces their trials as after a failed job, and runs its own warmups and then only the measured repetitions that they did not record as successful, through `run --resume`.
If Athena still throttles a request after its retries while the worker quiesces an earlier attempt, the worker releases the claim without running or recording the job and continues with the next pending job; a later attempt on any host quiesces the earlier attempts again, and the worker keeps its host while the job is pending.
If Athena still throttles a request after its retries while the worker quiesces an earlier attempt, the worker releases the claim without running or recording the job and continues with the next pending job, or exits after an interruption notice, including one observed while it quiesced; a later attempt on any host quiesces the earlier attempts again, and the worker keeps its host while the job is pending.
The job's summary merges the attempts and leaves a failed or incomplete trial whose repetition succeeded in a later attempt out of `failed_trials`, noting its reason as `Superseded: ...`, so it reports the trials of an uninterrupted run without hiding failures.
`report results/recovered/fleet/<name>/results/<job>` produces the same merged summary from the recovered attempts.
Rebalance recommendations are not acted on; an interruption does not always follow them, and the notice leaves enough time to stop a trial.
Expand Down
14 changes: 9 additions & 5 deletions benchmarks/pyathena_bench/fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -980,8 +980,9 @@ class Outcome:
worker took over the job's claim or a slot, and ``deferred`` when Athena
throttled the quiescing of an earlier attempt, so the job was released
without running. ``errors`` lists queries that could not be confirmed as
stopped. ``noticed`` tells whether an interruption notice was observed
after the run, while its queries were quiesced.
stopped. ``noticed`` tells whether the watch observed an interruption
notice after the attempt checked for one, for example while queries were
quiesced.
"""

state: str
Expand Down Expand Up @@ -1103,10 +1104,12 @@ def settle_error(path: Path, exc: Exception) -> str:
break
upload_attempt()

# Each outcome reads the watch's notice last: after a lost claim, the watch

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent review (relayed): Codex CLI 0.162.1, model gpt-6-astra, codex exec -s read-only, session 01a13b00-f337-7962-a3e8-4929041c85d0. Static review of d47bd22..1b13ccf on an unchanged detached snapshot; no PR number, description, commit messages or prior findings were given. Result: FINDINGS (1).

P2, introduced: "notice snapshot can become stale. When the renewal thread detects claim loss, JobWatch.__exit__ joins each thread for only 10 seconds. An in-flight notice callback can therefore remain running when noticed=False is captured. If it subsequently reports an interruption while abandon() performs its S3 lease release, the returned lost outcome still carries False. ... Preserve the event reads at each outcome return, after lease release."

Verified: the single read added in e8b042e (a /simplify suggestion) happens before abandon() releases the lease. The default notice check needs at most two 2 s metadata requests, which makes the window narrow, but a custom notice has no such bound. Repaired in 34e02ac: each outcome reads watch.interrupted at its return again, after releasing the lease, as at 100175f, with a comment stating why. The reviewer also said the new test "deterministically holds notice completion until after the attempt's interruption check" and exercises the deferred regression; it does not cover the bounded-join case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent follow-up (relayed): Codex gpt-6-astra, codex exec -s read-only, session 01a13b05-afc5-79f1-bc02-5363eddb17cd. Static review of the repair 1b13ccf..34e02ac on an unchanged detached snapshot. Result: CLEAN. "The reported read-before-release race is fixed. ... abandon() now reads watch.interrupted after end_lease() ... Since the event is never cleared, these later reads preserve previously observed notices. Existing work() error precedence remains unchanged." Limit stated by the reviewer: the tests do not deterministically exercise the timed-join and lease-release interleaving.

Validation at 34e02ac (local): just benchmark test 248 passed, 1 skipped.

# is joined for only 10 seconds, and a notice check may still finish.
if deferred:
# Nothing ran, so the next attempt resumes from the same earlier attempts.
queue.end_lease(claim, "released")
return Outcome("deferred")
return Outcome("deferred", noticed=watch.interrupted.is_set())

def abandon() -> Outcome:
# A claim lost only by its local expiry, for example while a renewal that
Expand Down Expand Up @@ -1213,7 +1216,8 @@ def work(
records are uploaded and its claim is released as interrupted, or
once the attempt of a failed job ends, if the notice arrived while
its queries were quiesced or is found after they could not be
confirmed as stopped.
confirmed as stopped, or once a deferred takeover released its
claim.
RuntimeError: If an attempt stopped by a lost claim could not be quiesced;
the worker's host is then kept.
"""
Expand Down Expand Up @@ -1287,7 +1291,7 @@ def settle(output: Path) -> list[str]:
_logger.warning(
f"Deferred {job['id']}: Athena throttled quiescing its earlier attempts"
)
if check_notice(notice):
if outcome.noticed or check_notice(notice):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Self-review round two (claims, callers, operators). Base d47bd22, head 1b13ccf. CLEAN for this scope.

Claims checked:

  • "the fresh metadata check ... failed or timed out, it returned false": check_notice() returns False on any exception (fleet.py:1356-1358).
  • "the deferred outcome is returned after the watch has stopped": noticed is read after the with JobWatch block, whose __exit__ joins both threads (without a timeout unless stop_event is set). It also covers a notice request in flight at the watch.interrupted check.
  • "Close the remaining gaps in the benchmark fleet's quiescing聽#1175 already carries the observation for done and lost": both returns use the same noticed.
  • README (... or exits after an interruption notice, including one observed while it quiesced) and the Outcome/work() docstrings match work()'s deferred branch.

Callers and operators: Outcome gets no new field, and its only consumer is work(). No new AWS or metadata requests; when noticed is set, the fresh check short-circuits. The worker CLI already handles SpotInterruptionError with exit 0 and no retirement.

Validation at 1b13ccf (local): just benchmark test 248 passed, 1 skipped; UV_PYTHON=3.13 just lint passed; just docs lint 0 errors. No fleet run on AWS.

raise SpotInterruptionError(completed)
# A later pass, or another worker, retries the job; try the next one.
continue
Expand Down
44 changes: 44 additions & 0 deletions benchmarks/tests/test_fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,50 @@ def settle(output):
assert queue.read_lease(queue.key("claims", "a")).takeable(time.time())
assert "b" not in queue.names("claims")

def test_a_notice_observed_after_a_throttled_takeover_outlasts_a_failed_check(self, tmp_path):
_, queue = queue_with([job("a"), job("b")], tmp_path)
queue.acquire(queue.key("claims", "a"), {"host": "dead", "job": "a"}, -1)
queue.put(queue.key("results", "a", "attempt-1", "events.jsonl"), {})
armed = threading.Event()
polling = threading.Event()
uploading = threading.Event()
upload_tree = queue.upload_tree

def upload(*args, **kwargs):
uploading.set()
return upload_tree(*args, **kwargs)

queue.upload_tree = upload

def notice():
# Only one request of the watch thread sees the notice; the worker's
# own check fails.
if (
not armed.is_set()
or polling.is_set()
or threading.current_thread() is threading.main_thread()
):
return False
# The request is still in flight when the attempt checks for a notice.
polling.set()
return uploading.wait(5)

def settle(output):
if armed.is_set():
return []
armed.set()
assert polling.wait(5)
raise throttling("ListQueryExecutions")

# Without the observed notice, the worker would run "b" and then "a".
with pytest.raises(SpotInterruptionError) as raised:
work(queue, tmp_path / "w", api_slots=1, poll_seconds=0,
runner=recording_runner([], trials={"a": [ok_trial(0)], "b": [ok_trial(0)]}),
settle=settle, lease_seconds=2, notice=notice) # fmt: skip
assert raised.value.ran == []
assert queue.read_lease(queue.key("claims", "a")).takeable(time.time())
assert "b" not in queue.names("claims")


class TestWorkerCommand:
def test_worker_uses_the_results_location_without_the_stack(self, tmp_path, monkeypatch):
Expand Down
Loading