fix(scheduler): audit a tick suppressed by the distributed lock (#1969) - #1975
Conversation
The lock-denial branch of `_execute_schedule()` logged at INFO and
returned bare. No `schedule_executions` row was written, so a suppressed
tick was indistinguishable from a tick that never fired — in the
execution history, the UI, and monitoring alike — while APScheduler
still reported the job successful and advanced to the next occurrence.
Suppression itself is correct: two concurrent runs of one schedule is
exactly what the lock exists to prevent. What was missing is the
evidence that it happened.
Trinity already audited the *other* suppression path, so this is a
missing call rather than a design gap. The two do not overlap:
* APScheduler refuses the job (`max_instances=1`) → the job never
starts, so `_execute_schedule` is never entered and no lock is
attempted;
* the Redis lock denies the run → reaching that branch means
APScheduler already let the job start, so no max-instances event
fires.
Exactly one of the two per tick, which is why calling the same helper
from both cannot reintroduce the duplicate `skipped` + `success` pairing
of #91.
The gap bit hardest on the common case. A manual trigger bypasses
APScheduler entirely (`_trigger_handler` dispatches via
`asyncio.create_task`), so its instance counter stays at zero, the cron
job starts normally, and the collision lands one layer down — in
precisely the branch that recorded nothing. "Manual trigger shortly
before the scheduled time" was the one case producing no audit record at
all.
`_record_skipped_agent_schedule()` was already parameterised in #1808 for
exactly this kind of reuse, so no new machinery: the branch now yields
the same `status='skipped'` row and `schedule_execution_skipped`
WebSocket event the max_instances path produces. The reason names the
lock rather than reusing the max_instances default wording, so the two
causes stay tellable apart by anyone reading the row.
tests/unit/test_1969_lock_denied_tick_audit.py — 12 checks, 5 of which
fail against the pre-fix tree. The other 7 pin behaviour the fix must
preserve: the audited tick still does not run, the granted-lock path
stays silent, and the lock is still released on both the success and
raise paths. Ends with an end-to-end pass against a real SQLite file
proving a `skipped` row actually lands — the wiring assertions alone
would still pass with a broken write path.
Related to #1969
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Resolve by running |
/review — self-review (PR #1975, #1969)Branch: [I1] Row cardinality on a chronically-overrunning schedule (Confidence: 7/10)A schedule whose run outlasts its own cron interval now writes one I checked this against the sibling before shipping: the [I2]
|
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr — textbook narrow fix: exactly the issue's suggested one-call change, 5/12 regression tests fail pre-fix, lock-release + granted-path silence + #91 single-row cardinality all pinned, e2e SQLite row proof. I resolved the tests/registry.json tail conflict (kept #1969 + #1971 entries) and full CI re-ran green. Follow-up worth filing: the identical unaudited lock-denial branch in _execute_process_schedule (out of #1969's scope).
Problem
When a cron tick is suppressed by the Redis distributed lock,
_execute_schedule()logged at INFO and returned:No
schedule_executionsrow, so the suppressed tick is invisible in execution history, the UI, and monitoring — indistinguishable from a tick that never fired. APScheduler meanwhile marks the job successful and advancesnext_run_at.Suppression itself is correct: two concurrent runs of one schedule is exactly what the lock exists to prevent. What was missing is the evidence that it happened.
Why this is a missing call, not a design gap
Two paths suppress a run, and only one was audited:
max_instances=1→EVENT_JOB_MAX_INSTANCES→_on_job_max_instances()→_record_skipped_agent_schedule()status='skipped'row +schedule_execution_skippedevent_execute_schedule()lock-denial branchreturnThey cannot both fire for one tick, which is what makes calling the same helper from both safe:
max_instancesrefusal means the job never started, so_execute_scheduleis never entered and no lock is attempted;The gap bit hardest on the common case. A manual trigger bypasses APScheduler entirely (
_trigger_handlerdispatches viaasyncio.create_task), so its per-job instance counter stays at zero, the cron job starts normally, and the collision is caught one layer down — in precisely the unaudited branch. Manual trigger shortly before the scheduled time was the one case producing no audit record at all.Fix
The branch calls the existing helper, exactly as the issue suggests.
_record_skipped_agent_schedule()was already parameterised in #1808 for this kind of reuse, so no new machinery is involved.The wording names the lock, not the max_instances default — reusing the default would file a lock collision as a max_instances refusal and send anyone debugging it to the wrong mechanism. Both now produce the same
status='skipped'row shape and the sameschedule_execution_skippedWebSocket event.The #91 regression shape
The issue asks to confirm a manual run colliding with its own cron tick produces exactly one
skippedrow, not a duplicate. Three tests cover it:_on_job_max_instancesstill never routes through_execute_schedule. That mutual exclusion is the property the whole fix rests on, and it is the kind of thing a future refactor breaks silently — if the listener ever reached_execute_schedule, a single tick would be audited twice, which is the Scheduler creates duplicate execution records (skipped + success) for single trigger #91 shape.Verification
tests/unit/test_1969_lock_denied_tick_audit.py— 12 checks, 5 of which fail against the pre-fix tree:The 7 that pass either way are deliberate — they pin behaviour the fix must preserve, and are the reason to trust the change is narrow:
skippedrow on every successful tick would invert the meaning of the status for every consumer of the history);.release()is attempted when no lock was handed out. The new branch sits directly above thatfinally; a lock leaked there would wedge the schedule until the TTL.The file ends with an end-to-end pass against a real temp SQLite DB, asserting a
status='skipped'row with the lock reason actually lands. The wiring assertions alone would still pass with a broken write path.Also green:
scheduler_tests/test_skipped_executions.py+test_service.py(34 tests, the existing coverage of the DB write path and the max_instances listener), and the adjacent unit suitestest_1808,test_1945,test_1557,test_schedule_status_observability(44 tests).Deliberately not included
The DB's
next_run_atprojection is not advanced on a denied tick. That is the #1472 "receding Next: Nd ago" class, and the existingmax_instancespath does not advance it either — adding it to only one of the two would reintroduce exactly the inconsistency this PR removes. Worth its own issue if it bites; flagging rather than silently expanding scope here.Acceptance criteria
status='skipped'row and publishesschedule_execution_skippedmax_instancesskipped+successpairing (Scheduler creates duplicate execution records (skipped + success) for single trigger #91)Sibling issues
Independent of #1974 (#1970, execution-origin columns) — different hunks in
service.py, mergeable in either order. #1968 (execution_id: undefined) is untouched.Related to #1969
🤖 Generated with Claude Code