Scheduler Configuration
HORUS uses a single scheduler entry point — Scheduler::new() — with a fluent node builder API that gives you full control over execution class, timing, ordering, and failure handling on a per-node basis.
Creating a Scheduler
Every scheduler starts with Scheduler::new(). From there you can optionally set global parameters with builder methods before adding nodes:
use horus::prelude::*;
fn main() -> Result<()> {
let mut scheduler = Scheduler::new()
.tick_rate(1000_u64.hz()) // Global tick rate (default: 100 Hz)
.name("arm_control"); // Name for logging/profiling
// ... add nodes ...
scheduler.run()?;
Ok(())
}
Builder Methods
| Method | Description | Default |
|---|---|---|
.tick_rate(freq) | Global scheduler tick rate (Frequency) | 100.0 Hz |
.name(name) | Human-readable scheduler name | "Scheduler" |
These are the two knobs used most often. The remaining global flags — .prefer_rt(), .require_rt(), and .blackbox(mb) — are covered under Global Configuration with Builder Methods below.
Adding Nodes
Add nodes with scheduler.add(n), then chain configuration calls, and finalize with .done():
use horus::prelude::*;
fn main() -> Result<()> {
let mut scheduler = Scheduler::new()
.tick_rate(1000_u64.hz());
// Real-time motor control — runs first every tick
// (an explicit budget/deadline promotes the node to the RT class)
scheduler.add(MotorController::new("arm"))
.order(0)
.budget(200_u64.us())
.deadline(1_u64.ms())
.done()?;
// Sensor node — high priority, custom rate
// (.rate() also promotes the node to the RT class)
scheduler.add(LidarDriver::new("/dev/lidar0"))
.order(10)
.rate(500_u64.hz())
.done()?;
// Compute-heavy planning — runs on a worker thread
scheduler.add(PathPlanner::new())
.order(50)
.compute()
.done()?;
// Event-driven node — wakes only when the topic has new data
scheduler.add(CollisionChecker::new())
.on("lidar/points")
.done()?;
// Async I/O — network or disk, never blocks the real-time loop
scheduler.add(TelemetryUploader::new())
.order(200)
.async_io()
.rate(10_u64.hz())
.done()?;
scheduler.run()?;
Ok(())
}
Execution Classes
Every node belongs to exactly one execution class. Set it in the builder chain:
| Method | Class | Description |
|---|---|---|
.rate(freq) / .budget(dur) / .deadline(dur) | Real-Time | Any of these promotes an otherwise unclassified node to RT. Runs on a dedicated RT thread. Use for control, safety, and sensors. |
.compute() | Compute | Offloaded to a worker thread pool. Use for planning, SLAM, or ML inference. |
.on(topic) | Event-Driven | Wakes only when the named topic receives new data. |
.async_io() | Async I/O | Runs on an async executor. Use for network, disk, or cloud calls. |
There is no .rt() method — the RT class is entered implicitly. If no execution class is specified, the node defaults to the BestEffort class and is ticked on the main loop by the ready-dispatch executor — concurrently with other BestEffort nodes it has no dependency on, unless .deterministic(true) is set. Calling .rate(), .budget(), or .deadline() on a BestEffort node auto-promotes it to the Rt class.
On a node that already has an explicit class, .rate() does not promote it — it only rate-limits. .rate(10_u64.hz()).async_io() stays an Async I/O node, and .rate(n).compute() stays a Compute node.
When to Use Each Class
.rate(freq)— Motor controllers, safety monitors, sensor fusion, anything that must run every tick with bounded latency..compute()— Path planning, point cloud processing, ML inference. These can take longer than a single tick without blocking RT nodes..on(topic)— Collision detection, event handlers, reactive behaviors. Only runs when there is new data, saving CPU when idle.
The wake-up comes from a process-local notifier registry that Topic::send() bumps in
the publisher's own process. Nothing consults shared memory, so an event node whose
publisher lives in a different process is never woken: it never ticks, reports no
error, and looks idle. Measured with a publisher in a second process sending 90 messages,
the subscriber logged CROSS-PROCESS event ticks: 0; the same code in one process ticked
30 times a second.
Use .on(...) for a pipeline stage inside one scheduler. For a node driven by another
process's data, give it .rate(..) (or leave it BestEffort) and poll recv().
.async_io()— Telemetry upload, log shipping, cloud API calls. Never blocks any real-time or compute work.
Per-Node Configuration
Ordering and Timing
| Method | Description |
|---|---|
.order(n) | Execution priority within a tick (lower = runs first) |
.rate(freq) | Node tick rate (Frequency), independent of the global rate; auto-derives budget (80% of period) and deadline (95% of period) |
.budget(dur) | Per-tick execution budget (Duration); defaults to 80% of the period from .rate() |
.deadline(dur) | Deadline from tick start (Duration); defaults to 95% of the period from .rate() |
.budget() and .deadline() are RT-only. Combining either with .compute(), .on(topic), or .async_io() is rejected at .done() as a conflicting configuration.
Under .deterministic(true) the per-node rate limiter is disabled: every node ticks once per scheduler tick at .tick_rate(), whatever its .rate() says, while horus::time::dt() still reports 1/rate. A 10 Hz logger in a 1 kHz deterministic scheduler therefore runs at 1 kHz. Set .tick_rate() to the cadence you want, or divide down inside the node.
Failure Policy and Finalization
| Method | Description |
|---|---|
.failure_policy(policy) | Per-node failure handling (see Circuit Breaker) |
.done() | Finalize and register the node (alias for .build()) |
Order Guidelines
- 0-9: Critical real-time (motor control, safety)
- 10-49: High priority (sensors, fast control loops)
- 50-99: Normal priority (processing, planning)
- 100-199: Low priority (logging, diagnostics)
- 200+: Background (telemetry, non-essential)
Global Configuration with Builder Methods
For complex setups, chain builder methods on Scheduler::new():
use horus::prelude::*;
let mut scheduler = Scheduler::new()
.tick_rate(1000_u64.hz())
.prefer_rt() // memory locking + RT scheduling class, warn if unavailable
.blackbox(64); // 64 MB flight recorder
This is useful when you need to toggle features like memory locking or black-box recording without changing node code. Use .require_rt() instead of .prefer_rt() when the run must abort on a kernel without RT support. Runtime profiling is always on — there is no opt-in flag. Fault handling is configured per node with .failure_policy(...), not globally.
OS Priority
Set real-time OS scheduling priority:
let scheduler = Scheduler::new();
scheduler.set_os_priority(50)?; // Set SCHED_FIFO priority 1-99
Note: Requires root or CAP_SYS_NICE capability on Linux.
Python API
The Python API is not a fluent builder. Scheduler.add() takes a fully configured horus.Node and returns the scheduler itself, so all per-node settings are constructor keyword arguments. Durations (budget, deadline, watchdog) are in seconds — horus.us and horus.ms are the conversion constants.
from horus import Scheduler, Node, us, ms
# Create scheduler with global settings
scheduler = Scheduler(tick_rate=1000.0, name="arm_control")
# Real-time motor controller
scheduler.add(Node(name="motor", tick=motor_ctrl, order=0, rate=1000,
budget=200 * us, deadline=1 * ms))
# Sensor with custom rate
scheduler.add(Node(name="lidar", tick=lidar, order=10, rate=500))
# Compute node
scheduler.add(Node(name="planner", tick=planner, order=50, compute=True))
# Event-driven node
scheduler.add(Node(name="collision", tick=collision, on="lidar/points"))
# Async I/O node — declare `telemetry` as `async def`; async ticks are
# auto-detected and run on the async I/O thread pool
scheduler.add(Node(name="telemetry", tick=telemetry, order=200, rate=10))
scheduler.run()
Global settings are plain keyword arguments — there is no config dict:
scheduler = Scheduler(tick_rate=1000.0, rt=True, blackbox_mb=64)
rt=True turns on memory locking and the RT scheduling class; blackbox_mb sizes the black-box buffer.
Execution Modes
HORUS supports sequential and parallel execution. There is no mode argument to Scheduler::new() — the mode follows from the execution classes you give individual nodes.
Scheduler::new() runs your nodes in parallel, driven by the dependency graph it builds from the topics they publish and subscribe. That is the right starting point for most robots. Add .deterministic(true) when you need a reproducible execution order — for replay, regression testing or certification.
Sequential Mode (.deterministic(true))
Nodes execute one-by-one in order sequence — the same execution order every tick.
Deterministic and certification-ready. This is not the default; you opt into it.
| Metric | Value |
|---|---|
| Deterministic | Yes |
| Multi-core | No (single thread) |
| Best For | Safety-critical, certification, replay |
use horus::prelude::*;
// Safety-critical robot controller — 1 kHz tick, everything on the main loop
let mut scheduler = Scheduler::new()
.tick_rate(1000_u64.hz())
.deterministic(true); // without this the main-loop nodes run in parallel
scheduler.add(safety_monitor).order(0).done()?;
scheduler.add(controller).order(1).done()?;
scheduler.run()?;
.deterministic(true) keeps every node on the main thread — no executor threads are spawned at all, even for nodes that declare an RT or compute class — and runs them in order sequence.
Parallel Mode
Concurrency is opt-in per node, through the execution class — not through order. A node leaves the main tick loop as soon as it has an execution class: RT (.rate() / .budget() / .deadline()) moves to a dedicated RT thread, .compute() to the parallel thread pool, .on(topic) to a per-node watcher thread, and .async_io() to the tokio blocking pool. .order(n) sequences the main tick loop, and by default BestEffort nodes sharing an order value form one parallel step — they run concurrently, and different order values run one after another. Only .deterministic(true) makes the main loop strictly sequential.
| Metric | Value |
|---|---|
| Deterministic | No |
| Multi-core | Yes |
| Best For | Multi-sensor fusion, compute-heavy pipelines |
use horus::prelude::*;
let mut scheduler = Scheduler::new();
// `.compute()` moves these sensor nodes onto the parallel thread pool
scheduler.add(lidar_node).order(0).compute().done()?;
scheduler.add(camera_node).order(0).compute().done()?;
scheduler.add(imu_node).order(0).compute().done()?;
// Without an execution class, fusion stays sequential on the main tick loop
scheduler.add(fusion_node).order(1).done()?;
scheduler.run()?;
Mode Comparison
| Feature | Sequential | Parallel |
|---|---|---|
| Deterministic | Yes | No |
| Multi-core | No | Yes |
| Certification Ready | Yes | No |
See Also
- Scheduling - Scheduler overview and node ordering
- Safety Monitor - Safety monitoring and emergency stop
- Circuit Breaker - Fault tolerance patterns
- Record & Replay - Recording and playback
- Deterministic Execution - Reproducible runs