This mini project implements a small background scheduler for Python commands.
It can run a command once, run a command repeatedly, cancel a repeated command by
key, and store execution errors in a shared context.
The code lives under scheduler_pkg and is split into small modules:
File
Responsibility
commands.py
Defines the command interface and the wrapper used for repeated commands.
scheduler_queue.py
Stores future commands in time order.
cancellation.py
Tracks which keyed commands are still active.
worker.py
Runs the scheduler loop in the background thread.
scheduler.py
Provides the public CommandScheduler API.
usage_example.py
Shows how to create commands and run the scheduler.
exception_handling_example.py
Shows how scheduler errors can be captured or reported.
Basic Idea
The scheduler keeps pending commands in a priority queue built with Python's
heapq module. When new work is submitted, heapq.heappush inserts a
QueuedCommand into the heap. Because QueuedCommand is ordered by run_at
first and sequence second, the command with the earliest scheduled time stays
at the front of the queue. If two commands have the same run_at value,
sequence preserves the order they were added.
The worker can then look at the first heap item to know which command has the
highest priority to run next. It does not need to sort the whole queue each time
a command is added.
The scheduler is based on the command pattern. A task is represented by a class
that inherits from Command and implements execute.
When its execute method runs, it simply delegates to the original command. This
keeps the repeat logic outside the command itself.
TimedCommandQueue
TimedCommandQueue stores commands in a heap. A heap is useful here because the
worker always needs the command with the earliest run_at time.
Each queued item contains:
run_at: when the command should run
sequence: a tie breaker for commands scheduled at the same time
token: the cancellation token
command: the command object
pop_ready only returns commands whose run_at time has passed and whose token
is still active.
CancellationRegistry
The cancellation system uses tokens instead of removing old items from the heap.
When a keyed command is submitted, the registry creates a new object token and
stores it under that key. When remove(key) is called, the registry replaces the
stored token with a new object.
Old queued commands still exist in the heap, but they no longer match the active
token. The worker skips them when they reach the front of the queue.
This keeps cancellation simple because the queue does not need to search through
all pending items.
SchedulerWorker
SchedulerWorker owns the run loop:
Check whether a command is ready.
If nothing is ready, wait until the next command time or until a wake event.
Execute the command.
Store or report errors.
Requeue repeating commands that are still active.
The worker uses:
a Lock to protect shared queue and cancellation data
a wake_event so new commands or removals can interrupt waiting
a stop_event so stop() can end the loop
CommandScheduler
CommandScheduler is the public API that connects all the lower-level objects.
The first command runs once. The second command runs every half second until
remove("printer") invalidates its cancellation token. Finally, stop() shuts
down the worker thread.
Why The Design Works
The scheduler separates responsibilities:
commands define what to do
the queue defines when to run
the cancellation registry defines whether a keyed command is still valid
the worker defines the background execution loop
the scheduler class gives users a small public API
This makes the project easy to extend. For example, new command classes can be
added without changing the scheduler, and different error handling can be added
by passing an on_error callback to CommandScheduler.
from__future__importannotationsimportheapqimportitertoolsfromcollections.abcimportCallablefromdataclassesimportdataclass,fieldtry:from.commandsimportCommandexceptImportError:fromcommandsimportCommand@dataclass(order=True)classQueuedCommand:"""Command scheduled for execution by ``TimedCommandQueue``. Instances are ordered by ``run_at`` and then ``sequence`` so the heap returns the earliest command first while preserving insertion order for ties. Attributes: run_at: Monotonic timestamp when the command becomes ready to run. sequence: Insertion-order tiebreaker used only for deterministic heap ordering when multiple commands share the same ``run_at`` value. token: Optional identity value used by callers to decide whether this queued entry is still active when it reaches the front of the heap. command: Command object to execute once the entry is ready and active. """run_at:floatsequence:inttoken:object|None=field(compare=False)command:Command=field(compare=False)classTimedCommandQueue:def__init__(self)->None:self._items:list[QueuedCommand]=[]self._sequence=itertools.count()defpush(self,run_at:float,token:object|None,command:Command)->None:item=QueuedCommand(run_at=run_at,sequence=next(self._sequence),token=token,command=command,)heapq.heappush(self._items,item)defpop_ready(self,now:float,is_active:Callable[[Command,object|None],bool],)->QueuedCommand|None:whileself._itemsandself._items[0].run_at<=now:item=heapq.heappop(self._items)ifis_active(item.command,item.token):returnitemreturnNonedefnext_delay(self,now:float,default_s:float=0.1)->float:ifnotself._items:returndefault_sreturnmax(0.0,self._items[0].run_at-now)
from__future__importannotationsimportthreadingimporttimefromcollections.abcimportCallabletry:from.cancellationimportCancellationRegistryfrom.commandsimportCommand,SchedulerContextfrom.scheduler_queueimportQueuedCommand,TimedCommandQueueexceptImportError:fromcancellationimportCancellationRegistryfromcommandsimportCommand,SchedulerContextfromscheduler_queueimportQueuedCommand,TimedCommandQueueErrorCallback=Callable[[BaseException,Command],None]classSchedulerWorker:def__init__(self,queue:TimedCommandQueue,cancellation:CancellationRegistry,lock:threading.Lock,wake_event:threading.Event,stop_event:threading.Event,context:SchedulerContext,set_last_error:Callable[[BaseException],None],on_error:ErrorCallback|None=None,)->None:self._queue=queueself._cancellation=cancellationself._lock=lockself._wake_event=wake_eventself._stop_event=stop_eventself._context=contextself._set_last_error=set_last_errorself._on_error=on_errordefrun(self)->None:""" Run the scheduler worker loop, executing commands as they become ready. """whilenotself._stop_event.is_set():item=self._pop_ready_command()ifitemisNone:self._wait_until_next_command()continuetry:item.command.execute(self._context)exceptBaseExceptionasexc:self._handle_error(exc,item.command)ifitem.command.repeat_interval_sisnotNone:ifself._cancellation.is_active(item.command,item.token):self._requeue(item.command,item.token)def_pop_ready_command(self)->QueuedCommand|None:now=time.monotonic()withself._lock:returnself._queue.pop_ready(now,self._cancellation.is_active)def_wait_until_next_command(self)->None:withself._lock:delay_s=self._queue.next_delay(time.monotonic())self._wake_event.wait(delay_s)self._wake_event.clear()def_requeue(self,command:Command,token:object|None)->None:ifcommand.repeat_interval_sisNone:returnrun_at=time.monotonic()+command.repeat_interval_swithself._lock:self._queue.push(run_at,token,command)self._wake_event.set()def_handle_error(self,exc:BaseException,command:Command)->None:ifself._on_errorisnotNone:self._on_error(exc,command)returnself._set_last_error(exc)
from__future__importannotationsimportmathimportthreadingimporttimefromloguruimportloggertry:from.cancellationimportCancellationRegistryfrom.commandsimportCommand,ScheduledCommand,SchedulerContextfrom.scheduler_queueimportTimedCommandQueuefrom.workerimportErrorCallback,SchedulerWorkerexceptImportError:fromcancellationimportCancellationRegistryfromcommandsimportCommand,ScheduledCommand,SchedulerContextfromscheduler_queueimportTimedCommandQueuefromworkerimportErrorCallback,SchedulerWorkerdef_validate_delay(delay_s:float)->None:ifnotmath.isfinite(delay_s)ordelay_s<0:raiseValueError("delay_s must be a finite number >= 0")def_validate_interval(interval_s:float)->None:ifnotmath.isfinite(interval_s)orinterval_s<=0:raiseValueError("interval_s must be a finite number > 0")classCommandScheduler:def__init__(self,context:SchedulerContext|None=None,on_error:ErrorCallback|None=None,name:str="scheduler",)->None:self.name=nameself.context=contextself._queue=TimedCommandQueue()self._cancellation=CancellationRegistry()self._lock=threading.Lock()self._wake_event=threading.Event()self._stop_event=threading.Event()self._worker=SchedulerWorker(queue=self._queue,cancellation=self._cancellation,lock=self._lock,wake_event=self._wake_event,stop_event=self._stop_event,context=self.context,set_last_error=self._set_last_error,on_error=on_error,)self._thread:threading.Thread|None=None# region Public APIdefstart(self)->None:ifself._threadisnotNoneandself._thread.is_alive():logger.info("scheduler already started name={}",self.name)returnself._stop_event.clear()self._thread=threading.Thread(target=self._worker.run,daemon=True,name=f"{self.name}_t",)self._thread.start()logger.info("scheduler started name={}",self.name)defstop(self,timeout:float|None=2.0)->None:logger.info("scheduler stopping name={}",self.name)self._stop_event.set()self._wake_event.set()ifself._threadisnotNone:self._thread.join(timeout=timeout)self._thread=Nonelogger.info("scheduler stopped name={}",self.name)defsubmit(self,command:Command,delay_s:float=0.0)->None:""" submit a command to be executed after a delay or immediately. """_validate_delay(delay_s)token=self._cancellation.new_token(command)self._submit(command,delay_s=delay_s,token=token,replace=True)defschedule(self,command:Command,interval_s:float,delay_s:float=0.0,key:str|None=None,)->None:""" schedule a command to be executed repeatedly at a given interval. """_validate_interval(interval_s)_validate_delay(delay_s)self.submit(ScheduledCommand(command=command,repeat_interval_s=interval_s,key_override=key,),delay_s=delay_s,)defremove(self,key:str)->None:""" remove a scheduled command by its key. """withself._lock:self._cancellation.remove(key)logger.debug("scheduler canceled command name={} key={}",self.name,key)self._wake_event.set()# endregiondef_submit(self,command:Command,delay_s:float,token:object|None,replace:bool,)->None:""" submit command to schduler queue """run_at=time.monotonic()+delay_swithself._lock:ifreplace:self._cancellation.activate(command,token)self._queue.push(run_at,token,command)logger.debug("scheduler added command name={} command={} delay_s={} run_at={} repeat_interval_s={}",self.name,command.__class__.__name__,delay_s,run_at,command.repeat_interval_s,)self._wake_event.set()def_set_last_error(self,exc:BaseException)->None:self.context.last_error=exc
from__future__importannotationsimportsysimporttimefromdataclassesimportdataclass,fieldfromtypingimportcastfromloguruimportloggertry:from.commandsimportCommand,SchedulerContextfrom.schedulerimportCommandSchedulerexceptImportError:fromcommandsimportCommand,SchedulerContextfromschedulerimportCommandSchedulerlogger.remove()logger.add(sys.stderr,level="DEBUG")@dataclassclassAppContext:last_error:BaseException|None=Noneerrors:list[str]=field(default_factory=list)runs:int=0@dataclassclassFailsEverySecondCallCommand(Command):job_name:strdefexecute(self,context:SchedulerContext)->None:app_context=cast(AppContext,context)app_context.runs+=1logger.info("{} run #{}",self.job_name,app_context.runs)ifapp_context.runs%2==0:app_context.errors.append(f"{self.job_name} failed on run #{app_context.runs}")raiseRuntimeError(f"{self.job_name} failed on run #{app_context.runs}")defhandle_scheduler_error(exc:BaseException,command:Command)->None:logger.error("command {} failed: {}",command.__class__.__name__,exc)defmain()->None:command_key="nightly-report"context=AppContext()scheduler=CommandScheduler(context,on_error=handle_scheduler_error)scheduler.start()try:scheduler.schedule(FailsEverySecondCallCommand(job_name="nightly-report"),interval_s=1.0,key=command_key,)time.sleep(5.0)scheduler.remove(command_key)finally:scheduler.stop()logger.info("scheduler stopped")logger.info("total command runs: {}",context.runs)logger.info("errors recorded in context: {}",len(context.errors))if__name__=="__main__":main()