Repository navigation
Conversation
A cron run could be recorded as a failure after both its work and its delivery succeeded: the execution row carried `Interrupted by shutdown before terminal completion.` while its deliveries row was `delivered` and the run's output markdown existed. `_fire_job_lock` fails closed after `_JOBS_LOCK_TIMEOUT_SECONDS = 30.0`, and the fence is held across network side effects by design — the run's own delivery blocks ~50-57s client-side on the Matrix lane (homeserver answers in 2-6ms), so 49 of 71 recent delivery rows exceed the cap. Two refusal sites then read "the fence was not acquired" as an ownership verdict, although a lock timeout samples nothing from the store: * `fire_claim_fence` -> `_FireClaimLostDuringSideEffect` -> `_record_fire_ownership_lost`, which re-confirms the claim is still ours and then writes `_OWNERSHIP_LOST_INTERRUPTED`; * `_finish_completed_run` -> `mark_job_run` False (fence, not claim) -> "Fire claim ownership lost before terminal completion." after the delivery had left the process. Reproduced on the previous head with a sibling side effect holding the fence while the claim was unchanged: side-effect refusal recorded `failed | Interrupted by shutdown before terminal completion.` with the claim still ours, and a terminal-write refusal after a completed delivery recorded `failed | Fire claim ownership lost before terminal completion.` Distinguish the refusal reason (`fence_busy`) so a contended/unavailable fence is recorded as itself while the run still fails closed — no delivery, no terminal status write without the fence, nothing recorded as success — and treat a fire-claim miss whose compare-and-refresh ran without the cross-process jobs lock (`jobs_lock_degraded()`) as uncertain: re-sample and keep renewing until `_FIRE_CLAIM_HEARTBEAT_GRACE_SECONDS`, instead of latching a loss that cancels a live, still legitimately owned run. Tests: new tests/cron/test_fire_fence_busy_is_not_ownership_loss.py (fence refusal vs stale owner, busy side-effect fence, terminal-write fence timeout after delivery, degraded-lock heartbeat re-sampling); test_run_one_job's exact-kwargs assertion picks up the new argument; cron/AGENTS.md records the invariant.
Reviewed at
It is thread-local state that
The sample that reader is meant to qualify came from AST-extracting the real
So a fire-claim miss sampled while a sibling holds the lock past the timeout still latches The new tests can't catch it: To fix: latch the flag for the caller instead of clearing it on exit, or put the sampling and the Not reporting: leaving |
|
Closing this. The automated review is right on the substantive point: the second repair is a dead branch. Rather than pursue the correction upstream now, I'm closing it and keeping the work local — the defect is not present in my runtime tree ( |
What does this PR do?
Closes the remaining places where a fire fence that was never acquired is still recorded as a
verdict about the fire claim. A cron run could be reported
failedafter both its work and itsdelivery succeeded — the execution row carrying an ownership-lost / interrupted error while the
deliveriesrow saysdeliveredand the run's output markdown exists.The heartbeat half of this class is already fixed on
main(heartbeat_fire_claimdeliberatelydoes not take the fence, pinned by
tests/cron/test_claim_job_for_fire.py::test_heartbeat_does_not_wait_on_the_fence_its_own_run_holds;see closed #108341 / #108862 / #100401 / #106737). What is left is the fence boundary itself:
_fire_job_lockfails closed after_JOBS_LOCK_TIMEOUT_SECONDS = 30.0, and the fence is heldacross network side effects by design — the run's own delivery. That call blocks ~50–57 s
client-side on the Matrix lane while the homeserver answers in 2–6 ms; 49 of 71 recent delivery
rows across the profiles measured here exceed the 30 s the fence allows. Two refusal sites then
turned "the fence did not get acquired" into an ownership verdict, although a lock timeout samples
nothing from the store:
fire_claim_fence(save output / deliver) →_FireClaimLostDuringSideEffect→_record_fire_ownership_lost, which re-confirms the claim is still ours and then writes_OWNERSHIP_LOST_INTERRUPTED(Interrupted by shutdown before terminal completion.) to the jobrecord and the execution row.
_finish_completed_run→mark_job_runreturningFalse→Fire claim ownership lost before terminal completion., after the delivery had already left the process.Falsefrom the fence is ambiguous (genuine revocation or a contended/unavailable lock) and onlythe first is evidence about the claim; #116164 / #105861 covered the delivered-run overwrite, this
covers why the refusal happened and what gets written for a refusal that carries no evidence.
Related Issue
Fixes no open issue: the tracking issues for this class are closed (#108341, #108862, #100401,
#106737, #105861, #116164) and the heartbeat-side fix they landed is in
main. This is theresidual half, reproduced on the current head.
Related open PRs, complementary rather than duplicated: #110152, #116301, #108912 and #116463
change what the ledger/text says after a refused or presumed-lost claim. This PR makes the
refusal reason explicit at the fence boundary (a fence that was never acquired is never treated as
ownership evidence at all, at both the side-effect and terminal writes) and adds the same rule to
the heartbeat's store sample. Happy to fold any of it into those if maintainers prefer one PR.
Type of Change
Changes Made
cron/jobs.py—_under_fire_fence,fire_claim_fenceandmark_job_runaccept afence_busyout-list that is appended to when the FENCE (not the store) refused, so callers cantell a contended/unavailable fence from a stale owner; new
jobs_lock_degraded()reports whetherthe calling thread's last
_jobs_locksection ran without the cross-process flock (timeout or nobackend).
cron/scheduler.py—_FireFenceBusy(new) separates "the fence was not acquired" from_FireClaimLostDuringSideEffect;_run_one_job_body/_finish_completed_runrecord_FIRE_FENCE_BUSYfor that case instead of an ownership-lost string, still failing closed (nodelivery, no terminal status write without the fence, nothing recorded as a success, and the
job record is deliberately left untouched — a refusal must not be laundered into
last_status);the fire-claim heartbeat re-samples a miss whose compare-and-refresh ran without the
cross-process jobs lock and keeps renewing until
_FIRE_CLAIM_HEARTBEAT_GRACE_SECONDSinsteadof latching a loss that cancels a live, still-owned run.
cron/AGENTS.md— the hardening-invariant entry for the above.tests/cron/test_fire_fence_busy_is_not_ownership_loss.py(new) andtests/cron/test_run_one_job.py(exact-kwargs assertion picks up the new argument).How to Test
run's claim is unchanged, with
_JOBS_LOCK_TIMEOUT_SECONDSlowered for speed. Before:failed | Interrupted by shutdown before terminal completion.with the claim still ours; after:failed | Fire fence busy before terminal completion; the run's result was not recorded.failed | Fire claim ownership lost before terminal completion.; after: the fence-busy cause,never an ownership verdict.
pytest tests/cron/test_fire_fence_busy_is_not_ownership_loss.py tests/cron/test_run_one_job.py tests/cron/test_claim_job_for_fire.py tests/cron/test_fire_claim_lost_after_delivery.py -qChecklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/cron -q: 1396 passed, 3 skipped, 13 failed — the same 13 failures occur on the unpatched tree on this host (environment/interpreter-guard fixtures:test_estop×4,test_mirror_origin_fallback×5,test_cron_non_push_origin×2,test_cron_script[pythonw],test_atomic_paused_creation); the patch introduces noneDocumentation & Housekeeping
docs/, docstrings) —cron/AGENTS.mdinvariant + docstringscli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows —cron/AGENTS.mdjobs_lock_degraded()reads the existing lock state and reports degraded locking on any platform without a flock backendScreenshots / Logs
Reproduction on the previous head (
bc7f58f3b0), fence held by a sibling owner, claim unchanged:With the patch, both rows read
failed | Fire fence busy before terminal completion; the run's result was not recorded.and thejob record is left untouched.
Production log pairing this recording (profile
bob, the run whose ledger row is the falsefailure):