scheduler
June 29, 2026 · View on GitHub
A small C++20 timer-wheel scheduler and a per-key debouncer built on it, extracted from Xapiand.
What it is
scheduler is a 24-hour, multi-resolution timer wheel with a background thread
that runs tasks when they come due. You schedule a cancellable task at a future
wakeup time; the thread sleeps until the earliest task is due (or until adding a
task wakes it sooner), runs everything due, and goes back to sleep. Two flavors:
Schedulerruns each due task inline on the scheduler thread. This is what Xapiand's logger thread uses.ThreadedSchedulerdispatches each due task to a worker pool, so a slow task can't stall the wheel.
On top of ThreadedScheduler sits the Debouncer: a per-key throttle/debounce.
Rapid touches of the same key collapse into a single eventual call, with a
randomized "force window" that guarantees a forever-touched key still fires while
spreading a thundering herd of due keys across time. Xapiand drives every
debounced fsync, commit, and replication trigger through it.
It is three layers, each in one header, stacked cleanly:
stash.h a lock-free hierarchical slot store (the timer-wheel primitive; a separate library)
▲
scheduler.h a 24-hour multi-resolution timer wheel + a thread that runs due tasks
▲
debouncer.h per-key throttle / debounce on top of the scheduler
How it works
SchedulerQueue nests four StashSlots levels (from the
stash library) over a StashValues leaf
into a single 24-hour wheel keyed on nanoseconds since the steady-clock epoch:
$ 4800 \times 18 \text{s} → 36 \times 500 \text{ms} → 10 \times 50 \text{ms} → 50 \times 1 \text{ms} → \text{task} \text{list} └── 4800 \times 18 \text{s} = 86 400 \text{s} = \text{exactly} 24 \text{h} \text{of} \text{horizon}, \text{at} 1 \text{ms} \text{granularity} $
A task scheduled more than ~24 h out overflows the wheel (caught and logged). The
queue exposes peep (earliest upcoming, no mutation), walk (drain everything
due now), clean (GC slots older than a minute), and add.
BaseScheduler is a Thread (from the
threadpool library) whose loop proposes
a wakeup (30 s idle, 100 ms when tasks pend), peeps for anything sooner, then
sleeps on a condition variable until that time or an add() notify wakes it,
then walks the due tasks and cleans. One mutex, one condvar, a lock-free
queue. Scheduler and ThreadedScheduler derive from it and differ only in how
they run a due task (inline vs. dispatched to the pool).
ScheduledTask is a CRTP, enable_shared_from_this task with a wakeup_time
and atomic created/cleared timestamps. clear() cancels it with a single CAS, so
a scheduled callback is cancellable — the debouncer leans on this heavily.
Install
CMake with FetchContent:
include(FetchContent)
FetchContent_Declare(
scheduler
GIT_REPOSITORY https://github.com/Kronuz/scheduler.git
GIT_TAG main
)
FetchContent_MakeAvailable(scheduler)
target_link_libraries(your_target PRIVATE scheduler::scheduler)
scheduler itself FetchContents its two dependencies —
stash (the slot store) and
threadpool (the thread + pool) — at the
exact SHAs it was extracted against, so you get the whole stack from one
FetchContent_Declare. The scheduler target is a small STATIC library (it
compiles one .cc, random.cc, for the debouncer's RNG helpers), requests
cxx_std_20, and puts the source dirs of all three libraries on your include
path. Then:
#include "scheduler.h" // Scheduler, ThreadedScheduler, ScheduledTask
#include "debouncer.h" // Debouncer, make_debouncer, ...
Requires C++20. On macOS it builds with AppleClang/libc++, the same toolchain Xapiand uses.
Usage
A scheduled, cancellable task
ScheduledTask is CRTP: derive a task Impl with an operator(), and use it as
both the scheduler's and the task's template argument.
#include "scheduler.h"
#include <memory>
struct MyTask;
using Sched = Scheduler<MyTask, ThreadPolicyType::regular>;
using Base = ScheduledTask<Sched, MyTask, ThreadPolicyType::regular>;
struct MyTask : Base {
void operator()() { /* runs on the scheduler thread when due */ }
};
Sched sched("SCHED");
auto t = std::make_shared<MyTask>();
sched.add(t, std::chrono::steady_clock::now() + std::chrono::milliseconds(200));
// Cancel before it fires (a single CAS); if it hasn't run yet, it never will:
t->clear();
sched.finish(); // stop the thread and join
ThreadedScheduler<MyTask, policy> is identical to construct except it also
takes a worker-name format and a thread count
(ThreadedScheduler sched("TSCHED", "TW{:02}", 4)), and runs due tasks on the
pool instead of inline.
A debouncer
make_debouncer<Key>(...) deduces the callback's argument tuple from the
callback itself (via callable_traits), so you just pass a lambda:
#include "debouncer.h"
auto deb = make_debouncer<int>(
"DEB", "DW{:02}", /*threads*/ 2,
[](int key, int value) { /* called once per settled key */ },
/*throttle_time*/ 0ms,
/*debounce_timeout*/ 50ms, // a fresh key fires after this
/*debounce_busy_timeout*/50ms, // subsequent touches push it out by this
/*min_force*/ 150ms, // but never past now + random(min, max)
/*max_force*/ 250ms);
for (int v = 0; v < 100; ++v) deb.debounce(1, /*key*/1, /*value*/v);
// -> the callback runs ~once for key 1, with the last value, not 100 times.
make_unique_debouncer / make_shared_debouncer return the same thing wrapped in
a unique_ptr / shared_ptr. delayed_debounce(delay, key, args...) adds a
fixed delay on top.
API reference
ScheduledTask<SchedulerImpl, ScheduledTaskImpl, ThreadPolicyType policy = regular>
CRTP base for a cancellable scheduled task.
operator bool()— true while not yet cleared.clear() -> bool— cancel; the first call wins a CAS and returns true, later calls return false. A cleared task is skipped when it comes due.- derive
Implwithvoid operator()()— your task body.
Scheduler<ScheduledTaskImpl, ThreadPolicyType policy = regular>
Scheduler(std::string name)— starts the scheduler thread immediately.add(task)/add(task, wakeup_time)— schedule (wakeup_timeclamped to not be in the past; the one-arg form expectstask->wakeup_timealready set).finish(wait = 10) -> bool/join(timeout = 60s) -> bool— stop and wait.- runs each due task inline on the scheduler thread.
ThreadedScheduler<ScheduledTaskImpl, ThreadPolicyType policy = regular>
Same as Scheduler plus a worker pool.
ThreadedScheduler(std::string name, const char* format, size_t num_threads)—formatis astd::formatworker-name string (e.g."TW{:02}").size(),running_size(),threadpool_size(),threadpool_capacity()— pool state queries.- runs each due task by dispatching it to the pool.
Debouncer<Key, Func, Tuple, policy> and factories
Construct via the factories, which deduce Func/Tuple for you:
make_debouncer<Key, policy = regular>(name, format, num_threads, func, throttle_time, debounce_timeout, debounce_busy_timeout, min_force, max_force)— returns aDebouncerby value.make_unique_debouncer<...>(...)— same, asstd::unique_ptr.make_shared_debouncer<...>(...)— same, asstd::shared_ptr.
On a Debouncer:
debounce(key, args...)— touch a key; collapses rapid touches into one eventual call tofunc(args...)carrying the last arguments.delayed_debounce(delay, key, args...)— same with a fixed extra delay.
The timing model: a fresh key fires after debounce_timeout; each subsequent
touch pushes the call out by debounce_busy_timeout, but never past a randomized
force window now + random(min_force, max_force), so a forever-touched key still
fires. After firing, an optional throttle_time floor (when > debounce_timeout)
keeps the key from firing again too soon.
Tracing and coloring
scheduler.h and debouncer.h instrument themselves through four macros that
are no-ops by default, so the library builds with zero dependency on any logging
or color header, and tracing has no runtime cost:
L_SCHEDULER(...)— the wheel/scheduler's own trace channel (set toL_NOTHINGunless already defined).L_DEBUG_HOOK(label, ...)— a labelled debug hook.L_EXC(...)— logs an exception swallowed in a destructor.L_CALL(...)— the debouncer's call-trace channel.
The bundled scheduler_trace.h supplies all four as #ifndef-guarded no-ops.
Two ways to plug in real implementations:
-
Point
SCHEDULER_TRACE_HEADERat a header that defines them;scheduler.hincludes it instead ofscheduler_trace.h:c++ -std=c++20 -DSCHEDULER_TRACE_HEADER='"my_trace.h"' ... -
Define the macros before including the library headers.
About the color identifiers. scheduler.h references nine color constants
(BROWN, CLEAR_COLOR, LIGHT_SKY_BLUE, DIM_GREY, PURPLE, STEEL_BLUE,
DODGER_BLUE, LIGHT_GREEN, FOREST_GREEN) only inside the arguments of
L_SCHEDULER(...) / L_DEBUG_HOOK(...). In the default build those macros
expand to nothing, so the preprocessor discards their arguments and the color
identifiers are never compiled — no color stubs are needed. A trace header that
turns the macros into real, string-building calls must also define those nine
identifiers (as std::strings that concatenate with +). See
examples/colored_trace/ for a complete, runnable
override.
To recover Xapiand's exact behavior, the trace header maps the four macros back
to Xapiand's log.h (L_SCHEDULER/L_CALL/L_DEBUG_HOOK/L_EXC) and the nine
color names to colors.h.
Build & test
cmake -B build && cmake --build build && ctest --test-dir build
The test schedules tasks at future wakeup times and verifies they fire in wakeup
order on both a Scheduler and a ThreadedScheduler, that clear() cancels a
task, and that a Debouncer collapses a burst of rapid same-key calls into a
single call carrying the last arguments. It prints all scheduler tests passed
and exits 0. The scheduler is timing-based; the test uses generous windows, not
tight races.
Examples
examples/demo.cc is a runnable tour of the core engine. A
top-level CMake build produces it next to the test:
cmake -B build && cmake --build build && ./build/scheduler_demo
It schedules three tasks out of insertion order and watches them fire in time
order on an inline Scheduler; cancels a task with clear() before it fires and
shows it skipped; dispatches a batch of due tasks to a ThreadedScheduler's
worker pool; and hammers a Debouncer key with a 30-touch burst to watch it
coalesce into roughly one delayed call carrying the last value (plus a second key
that fires once and a third via delayed_debounce). Each section prints labelled
output, including when each task fired relative to when it was scheduled.
examples/colored_trace/ is a separate, specialized
example: it injects a SCHEDULER_TRACE_HEADER that turns the library's no-op
trace hooks into colored, std::format-rendered output, demonstrating the
tracing seam described above. See its own README.md for the build flags.
Provenance
Extracted from Xapiand, where this engine
underpins the logger's dedicated background thread and every debounced fsync,
commit, and replication trigger. The standalone delta is pure decoupling:
Xapiand's log.h / colors.h macros were replaced by the injectable trace
header, and the three random helpers the debouncer needs were vendored into
random.cc/random.hh. The scheduler logic is otherwise identical. See
ARCHITECTURE.md for the design and AGENTS.md for
the repo map and invariants.
License
MIT, Copyright (c) 2015-2019 Dubalu LLC. See LICENSE.