fix(scheduler): audit a lock-denied process-schedule tick (#1994) - #2039
Conversation
The sibling of #1969, on the path that fix did not cover. `_execute_process_schedule` logged at INFO and returned bare when the distributed lock denied a tick, so no process_schedule_executions row was written and the 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. Suppression itself is correct; the evidence was missing. Safe as a single added call because the two suppression paths do not overlap: APScheduler's max_instances=1 refusal fires EVENT_JOB_MAX_INSTANCES and the job never starts, so this function is never entered; reaching the lock-denial branch means APScheduler already let the job start. Exactly one of the two per tick, so this cannot re-create #91's duplicate skipped+success pairing. _record_skipped_process_schedule gains skip_reason/event_reason for the same reason its agent-schedule sibling did in #1808 — without them a lock collision would be filed under the max_instances wording and send anyone debugging it to the wrong mechanism. Defaults preserve the original wording for the existing EVENT_JOB_MAX_INSTANCES caller, which is unchanged and pinned by a test. tests/unit/test_1994_process_schedule_lock_audit.py: 14 tests mirroring test_1969 so the two paths stay reviewable side by side — the denial records exactly one row, names the lock not max_instances, does not run the schedule, leaves the happy path silent, releases the lock on both success and raise, never calls .release() on None, keeps the process_-namespaced lock key, and lands a real row in a SQLite DB built from the shipped ensure_process_schedules_table(). Related to #1994 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified against the real instanceReal scheduler image built from this branch ( Setup, and two deliberate choicesThe branch was reached by calling it directly, not by arming APScheduler. The lock is really contended. A second I did not start the instance's own Redis. A/B on the real databaseSame script, same Redis, same live Postgres — only the image differs.
The row, straight out of Postgres: The untouched caller keeps its wordingInvoked exactly as So the two suppression causes are distinguishable in the real execution history, and parameterising the shared helper did not change the caller that did not change. FootprintBoth Created and then removed: one |
vybe
left a comment
There was a problem hiding this comment.
Scoped to one branch on the process-schedule path, mirroring #1969's fix on the agent-schedule path.
The load-bearing argument — that the max_instances refusal and the lock denial are mutually exclusive per tick, so calling the shared helper from both cannot re-create #91's duplicate skipped+success pairing — is correct and, better, pinned by a source-level test rather than asserted in prose.
Parameterising _record_skipped_process_schedule is the right call over hard-coding a second wording, and the defaults preserve the untouched EVENT_JOB_MAX_INSTANCES caller's behaviour with a test asserting exactly that. The end-to-end SQLite case builds its schema from the shipped ensure_process_schedules_table(), so it cannot drift from production.
Body patched to Fixes #1994 so the issue auto-promotes to status-in-dev on merge.
Fixes #1994
What
_execute_process_schedulelogged at INFO and returned bare when the distributed lock denied a tick, so noprocess_schedule_executionsrow was written. 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.The exact branch #1975 fixed on the agent-schedule path, surviving on the process-schedule one. Suppression itself is correct: two concurrent runs of one schedule is what the lock exists to prevent. What was missing is the evidence it happened.
Why one added call is enough
The two suppression paths do not overlap, which is also what keeps this from re-creating #91's duplicate
skipped+successpairing:max_instances=1) →EVENT_JOB_MAX_INSTANCES→_on_job_max_instances→ askippedrow. The job never starts, so_execute_process_scheduleis never entered and no lock is attempted.Exactly one of the two per tick. Pinned by a source-level test, since the two entry points are wired to APScheduler rather than to each other.
The reason string
_record_skipped_process_schedulegainsskip_reason/event_reason, for the same reason its agent-schedule sibling did in #1808: the helper hard-coded the max_instances wording, so reusing it as-is would file a lock collision as a max_instances refusal and send anyone debugging it to the wrong mechanism.The defaults preserve the original wording, so the existing
EVENT_JOB_MAX_INSTANCEScaller — which is otherwise untouched — behaves identically. There is a test asserting exactly that, because parameterising a shared helper is only safe if the caller that did not change keeps its old behaviour.Tests
tests/unit/test_1994_process_schedule_lock_audit.py— 14 tests, structured to mirrortest_1969_lock_denied_tick_audit.pyso the two paths stay reviewable side by side.The defect, and the halves that must not move:
skippedrow on every successful tick would invert the meaning of the status for every consumer of the history.release()is never called onNoneprocess_{id}, deliberately distinct from the agent path's bare{id}— the new call sits directly under it, and a namespace slip would make an agent schedule and a process schedule with the same id contend for one lockPlus one end-to-end case against a real SQLite file: a denied tick lands a
status='skipped'row carrying the right schedule/process ids and a lock-shapederror. Every other assertion proves the branch calls the helper; this proves a row an operator can actually see turns up. Schema comes from the shippedensure_process_schedules_table()rather than hand-written DDL, so it cannot drift from production.src/scheduleris a standalone package that cannot import the backend, so the service is driven directly with an injected fake DB and lock manager —SchedulerService.__init__takes both by injection, so nothing reaches Redis.Mutation check. Reverting only
src/scheduler/service.pyfails 6 of 14, including the end-to-end row test.Scope
One branch, one helper signature, one new test file. No schema change, no migration, no new endpoint, no config.
🤖 Generated with Claude Code