Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 55 additions & 33 deletions codecarbon/external/scheduler.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,74 @@
from threading import Lock, Timer
import time
from threading import Event, Thread, current_thread

from codecarbon.external.logger import logger


class PeriodicScheduler:
"""
A periodic task running in threading.Timers
From https://stackoverflow.com/a/18906292/14541668
Run ``function`` every ``interval`` seconds on a single daemon thread.

The deadline is absolute, so the cadence does not drift with the time the
function itself takes. A tick that overruns its slot is skipped rather than
queued, so the function is never re-entered.
"""

def __init__(self, interval, function, *args, **kwargs):
"""
Init the scheduler. You have to call start() after initialization.
::interval:: interval in seconds to run the function.
::function:: function to run.
::interval:: in seconds, the delay between two calls to function.
::function:: the function to call.
::args:: args to pass to the function.
::kwargs:: kwargs to pass to the function.
"""
self._lock = Lock()
self._timer = None
self.function = function
self.interval = interval
self.function = function
self.args = args
self.kwargs = kwargs
self._stopped = True
self._thread = None
self._stop_event = None

def start(self, from_run=False):
@property
def _stopped(self):
return self._thread is None

def start(self):
"""
Start the scheduler.
::from_run:: For internal purposes to allow re-scheduling
Please do not use from_run=True until you know what you do !
Start the scheduler. Calling it on a running scheduler is a no-op.
"""
self._lock.acquire()
if from_run or self._stopped:
self._stopped = False
self._timer = Timer(self.interval, self._run)
self._timer.daemon = True
self._timer.start()
self._lock.release()

def _run(self):
self.start(from_run=True)
self.function(*self.args, **self.kwargs)

def stop(self):
if self._thread is not None:
return
# One event per run: a thread left behind by a timed-out stop() keeps
# its own, still-set event and exits as soon as its callback returns.
self._stop_event = Event()
self._thread = Thread(
target=self._loop,
args=(self._stop_event,),
daemon=True,
name=f"codecarbon-{getattr(self.function, '__name__', 'scheduler')}",
)
self._thread.start()

def _loop(self, stop_event):
next_call = time.monotonic() + self.interval
while not stop_event.wait(max(0.0, next_call - time.monotonic())):
try:
self.function(*self.args, **self.kwargs)
except Exception: # noqa: BLE001 - must not kill the only thread
logger.error("Scheduled measurement failed", exc_info=True)
# Absolute deadline, but never a burst of catch-up ticks.
next_call = max(next_call + self.interval, time.monotonic())

def stop(self, timeout=None):
"""
Stop the scheduler.
Stop the scheduler and wait for the in-flight call to return.
::timeout:: seconds to wait for the running function, bounded by
default so a wedged measurement cannot hang the caller for long.
"""
if not self._stopped:
self._lock.acquire()
self._stopped = True
self._timer.cancel()
self._lock.release()
if self._stop_event is not None:
self._stop_event.set()
thread, self._thread = self._thread, None
if thread is not None and thread is not current_thread():
# ponytail: 5s cap is a guess at "a measurement should never take
# longer than this"; make it configurable if a slow hardware
# backend ever needs more.
thread.join(min(self.interval, 5.0) if timeout is None else timeout)
125 changes: 125 additions & 0 deletions tests/test_scheduler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import threading
import time
import unittest

from codecarbon.external.scheduler import PeriodicScheduler

INTERVAL = 0.05


class TestPeriodicScheduler(unittest.TestCase):
def test_ticks_run_on_a_single_thread(self):
"""Regression guard: one long-lived thread, not one thread per tick."""
names = set()
scheduler = PeriodicScheduler(
INTERVAL, lambda: names.add(threading.current_thread().name)
)
scheduler.start()
time.sleep(INTERVAL * 10)
scheduler.stop()
# A one-element set also proves at least one tick happened.
self.assertEqual(len(names), 1, f"expected one worker thread, got {names}")

def test_slow_function_is_never_re_entered(self):
"""A function slower than the interval must not overlap with itself."""
state = {"in_flight": 0, "overlaps": 0, "calls": 0}

def slow():
state["calls"] += 1
state["in_flight"] += 1
if state["in_flight"] > 1:
state["overlaps"] += 1
time.sleep(INTERVAL * 2.4)
state["in_flight"] -= 1

scheduler = PeriodicScheduler(INTERVAL, slow)
scheduler.start()
time.sleep(INTERVAL * 20)
scheduler.stop()
self.assertGreater(state["calls"], 1)
self.assertEqual(state["overlaps"], 0)

def test_stop_is_prompt_and_final(self):
calls = []
scheduler = PeriodicScheduler(10.0, lambda: calls.append(1))
scheduler.start()
before = time.monotonic()
scheduler.stop()
self.assertLess(time.monotonic() - before, 1.0)
self.assertTrue(scheduler._stopped)
time.sleep(0.1)
self.assertEqual(calls, [])

def test_stop_before_start_and_double_stop_do_not_raise(self):
scheduler = PeriodicScheduler(INTERVAL, lambda: None)
scheduler.stop()
scheduler.start()
scheduler.stop()
scheduler.stop()
self.assertTrue(scheduler._stopped)

def test_start_is_idempotent_and_restartable(self):
calls = []
scheduler = PeriodicScheduler(INTERVAL, lambda: calls.append(1))
scheduler.start()
first = scheduler._thread
scheduler.start()
self.assertIs(scheduler._thread, first, "start() armed a second thread")
scheduler.stop()
self.assertFalse(first.is_alive())
stopped_at = len(calls)

scheduler.start()
second = scheduler._thread
time.sleep(INTERVAL * 4)
scheduler.stop()
self.assertGreater(len(calls), stopped_at, "restart did not resume ticking")
self.assertFalse(second.is_alive())

def test_start_after_a_timed_out_stop_leaves_only_the_new_loop(self):
"""A wedged callback exits when released instead of ticking again."""
release = threading.Event()
idents = []
blocked_once = threading.Event()

def wedged():
idents.append(threading.get_ident())
if not blocked_once.is_set():
blocked_once.set()
release.wait(5)

scheduler = PeriodicScheduler(INTERVAL, wedged)
scheduler.start()
first = scheduler._thread
self.assertTrue(blocked_once.wait(2), "callback never ran")

scheduler.stop() # join times out, callback still blocked
self.assertTrue(first.is_alive())

scheduler.start() # a new run, immediately, on its own event
second = scheduler._thread
self.assertIsNot(second, first)

release.set()
first.join(2)
self.assertFalse(first.is_alive(), "the wedged thread never exited")

time.sleep(INTERVAL * 6)
scheduler.stop()
self.assertEqual(
set(idents[1:]), {second.ident}, f"the old loop kept ticking: {idents}"
)

def test_exception_does_not_kill_the_loop(self):
calls = []

def flaky():
calls.append(1)
if len(calls) == 1:
raise ValueError("boom")

scheduler = PeriodicScheduler(INTERVAL, flaky)
scheduler.start()
time.sleep(INTERVAL * 6)
scheduler.stop()
self.assertGreater(len(calls), 1)
Loading