refactor(scheduler): run ticks on one daemon thread instead of chained Timers - #1339
davidberenstein1957 wants to merge 2 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1339 +/- ##
==========================================
+ Coverage 91.43% 91.73% +0.29%
==========================================
Files 49 49
Lines 5057 5176 +119
==========================================
+ Hits 4624 4748 +124
+ Misses 433 428 -5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Note for whoever reviews this: #1324 fixes the same scheduler defect by a different route. It is a narrow fix to the existing chained-Timer design, while this PR replaces the design with a single daemon thread. Worth picking one before reviewing either, otherwise they conflict on merge. |
…d Timers Every tick spawned a fresh threading.Timer and the next one was armed before the callback ran, so a callback slower than the interval overlapped with itself and the thread count grew with the run. Run the loop on a single daemon thread waiting on an Event, with an absolute deadline so the cadence does not drift and an overrun skips ahead instead of firing catch-up ticks. Each run owns its Event, so a thread left behind by a timed-out stop() keeps its own set event and exits after its callback returns, while start() takes effect immediately on a new thread. Note: stop() now blocks up to min(interval, 5.0) waiting for the in-flight call. start_task() calls _scheduler.stop() (emissions_tracker.py:755), so start_task() becomes potentially multi-second where it used to be instant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
611879b to
1e90504
Compare
Verdict: ✅ Approve with nitsReplacing the chained
The 7 new scheduler tests pass, and the PR merges cleanly with master. Issues:
Nits:
|
…unning stop() keeps the thread reference and logs a warning when the join times out; start() waits for it and refuses to start a second loop if it is still busy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Made the changes in 61bc10d: timed-out stop() now warns and blocks a second loop. Not done: making the final flush wait on the in-flight tick (needs a lock shared between scheduler and tracker, bigger change); the pre-existing start_task restart and the real-sleep tests are left as is. |
Description
PeriodicSchedulerchained a freshthreading.Timerper tick and armed the successor before running the payload. This replaces it with a single daemon thread waiting on anEventagainst an absolutetime.monotonic()deadline, with a catch-up guard, in-loop exception logging, and astop()that sets the event and joins the worker. A wedged (unresponsive) scheduler thread now refuses to letstart()double-start a second loop rather than silently creating two concurrent loops.Related Issue
Part of #1338
Motivation and Context
With chained Timers, a measurement slower than the tick interval re-entered the payload, letting two concurrent invocations mutate
_total_energy/_last_measured_timeat once and corrupt reported numbers. This is routine for_scheduler_monitor_power(1s, hard-coded) whenpowermetricsor RAPL is slow. There was also astop()race where measurements and API pushes could continue after the tracker had written its final row.Behaviour changes worth flagging:
tracker.stop()(andstart_task(), which calls it internally) can now block for up tomin(measure_power_secs, 5.0)seconds instead of returning immediately, though the normal case is ~0.1 ms — this is a real behaviour change, not a refactor-only tweak, since a wedged in-flight tick can holdstop()for that whole window; a slow callback now yields missing measurements instead of overlapping ones, so the existing "Background scheduler didn't run for a long period" warning will fire where master previously produced corrupted concurrent measurements; andfrom_runwas removed as a parameter since nothing outside the removed_runpassed it.Known limit: the final flush on
stop()can still overlap a wedged in-flight tick, since the 5 s join timeout doesn't guarantee the tick has released its locks beforestop()proceeds.How Has This Been Tested?
Measured against master: overlapping entries under a payload 2.4x the interval went from 17 to 0; extra calls after
stop()in a gated race repro went from 8 (and never stopping) to 0; drift per tick at 1s interval went from +4.3ms (0.4%) to +0.1ms. Newtests/test_scheduler.py, 7 tests, all passing; 4 of them fail against master's scheduler, includingtest_ticks_run_on_a_single_thread,test_slow_function_is_never_re_entered, andtest_start_is_idempotent_and_restartable.test_start_after_a_timed_out_stop_never_runs_two_loopsguards the wedged-thread case and fails if the one-linestop()fix is reverted. Full suite: 633 passed, 21 skipped (excludingtests/test_viz_data.py, which fails to import on master too sincedashis not installed).uv run pre-commit run --all-filespasses.Screenshots (if appropriate):
N/A
Types of changes
Refactor with a behaviour change on
stop()latency (see Motivation and Context) plus tests.AI Usage Disclosure
Checklist:
This should merge before #1340, which builds on this scheduler.
Not addressed here: the final flush on
stop()can still overlap a wedged in-flight tick (see Motivation and Context).