Repository navigation
refactor(scheduler): run ticks on one daemon thread instead of chained Timers - #1339
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1339 +/- ##
==========================================
+ Coverage 93.62% 93.66% +0.03%
==========================================
Files 50 50
Lines 5556 5591 +35
==========================================
+ Hits 5202 5237 +35
Misses 354 354 ☔ 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. |
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:
|
|
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. |
61bc10d to
fd98e5f
Compare
About the clock: |
…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>
…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>
…start After a stop() whose join timed out, start() blocked the caller for up to min(interval, 5s) and then, if the old callback was still running, only logged a warning and returned. Nothing retried it, so a start_task() / stop_task() pair during a slow measurement left the periodic scheduler off for the rest of the run. start() now always arms the new loop and hands it the previous thread; the new loop joins it before its first tick, so the function still never runs concurrently, start() never blocks, and the restart is not lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the callback stop() called from inside the scheduled function cleared _thread although that function was still running. A following start() then saw no previous thread and launched a loop that ran concurrently with it, the overlap this refactor is meant to rule out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a call outlasted the interval, next_call = max(next_call + interval, now) set the deadline to now, so the next call started at once. A function slower than its interval, e.g. a 1.2 s GPU read in the 1 s _monitor_power scheduler, then ran back-to-back at 100 % duty cycle instead of skipping the missed ticks as the class docstring says. The deadline now advances by whole intervals past the current time, which keeps the grid and leaves a pause before the next call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Timer-based scheduler serialised start()/stop() with a Lock; the refactor dropped it. Both now check then replace _thread and _stop_event, so two threads calling start() together could each arm a loop. The first loop's Event was overwritten and could never be set, leaving a loop that stop() cannot end. The lock covers the state changes only; stop() still joins outside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…led one PeriodicScheduler.stop() gives up after min(measure_power_secs, 5 s), but a scheduled _measure_power_and_energy can take longer: an Electricity Maps lookup has a 30 s timeout and live_out() may push to the API. tracker.stop() then ran its final measurement and _persist_data on the main thread while the tick was still updating _total_energy, _last_measured_time and _measure_occurrence, corrupting the last row. A re-entrant lock now serialises _measure_power_and_energy and the stale check in _measure_power_and_energy_if_stale, so the final measurement runs after the tick has finished. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le constant No caller passed stop(timeout=...), and _join_timeout(None) only computed min(interval, 5.0). A named module constant says what the cap is and lets a test lower it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scheduler tests slept on real time with a 50 ms interval and asserted how many ticks had happened, so a loaded CI runner could make them fail. PeriodicScheduler now reads time and waits through a clock attribute, MonotonicClock by default, that a test replaces before start(). The FakeClock in tests/testutils.py only moves when the test advances it and returns once the loop is waiting again, so the tests assert exact tick times (e.g. [1, 4, 7, 10] for an overrun). A new test checks that the cadence does not drift. The only remaining real-time waits are upper bounds on things that must not happen, which a correct scheduler passes however slow the machine is. The tracker overlap test synchronises on the final measurement instead of a 1.5 s Timer. The scheduler and tracker tests now take 1.6 s instead of about 8 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aaad59d to
b2254b6
Compare
Description
PeriodicSchedulerused to chain a freshthreading.Timerfor each tick and armed the next timer before running the payload. This PR replaces that with a single daemon thread that waits on anEventuntil an absolutetime.monotonic()deadline.stop(): sets the event, then joins the worker for up tomin(interval, 5 s). If the join times out, it keeps the thread reference and logs a warning.start(): a no-op while the scheduler is running. After a stop it starts a new loop right away and passes it the previous thread. If that thread is still finishing a call, the new loop waits for it before its first tick. The function therefore never runs concurrently, and the restart is never lost. This also holds whenstop()is called from inside the callback.start()andstop()are serialised by a lock.clockattribute (MonotonicClock). Tests replace it with a fake clock to drive the loop without sleeping._measure_power_and_energyis serialised by anRLock. The final measurement intracker.stop()therefore waits for an in-flight scheduled one, even when that tick takes longer than the join timeout.Related Issue
Part of #1338
Motivation and Context
With chained Timers, a measurement slower than the tick interval re-entered the payload. Two concurrent calls then mutated
_total_energy/_last_measured_timeat the same time and corrupted the reported numbers. This happens routinely for_scheduler_monitor_power(1 s, hard-coded) whenpowermetricsor RAPL is slow. There was also astop()race in which measurements and API pushes could continue after the tracker had written its final row.Behaviour changes worth flagging:
stop()can block.PeriodicScheduler.stop()waits up tomin(measure_power_secs, 5 s)for the call in progress; in the normal case it takes about 0.1 ms.tracker.stop()can wait longer: its final measurement now waits for an in-flight tick to finish, for example a slow Electricity Maps lookup.start_task()calls_scheduler.stop(), so it can block too.from_runremoved. Thefrom_runparameter ofstart()was removed; only the removed_runused it.How Has This Been Tested?
tests/test_scheduler.py: 11 tests, driven by a fake clock (tests/testutils.py::FakeClock), so they assert exact tick times instead of sleeping. They cover:stop()start()/ restartstop()from inside the callbackstart()callstests/test_emissions_tracker_constant.py::TestFinalMeasurementWaitsForInFlightTick:tracker.stop()does not overlap a scheduled measurement that outlasts the join timeout.HOME, as in CI (uv run task test-coverage): 818 passed, 3 skipped, 0 failed.codecarbon/(100 %).pre-commitpasses.Screenshots (if appropriate):
N/A
Types of changes
AI Usage Disclosure
Checklist:
This should merge before #1340, which builds on this scheduler. #1324 fixes the same defect with a narrower change; close it if this PR is merged.