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

MethodDescriptionDefault
.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:

MethodClassDescription
.rate(freq) / .budget(dur) / .deadline(dur)Real-TimeAny of these promotes an otherwise unclassified node to RT. Runs on a dedicated RT thread. Use for control, safety, and sensors.
.compute()ComputeOffloaded to a worker thread pool. Use for planning, SLAM, or ML inference.
.on(topic)Event-DrivenWakes only when the named topic receives new data.
.async_io()Async I/ORuns 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.
⚠️`.on(topic)` only wakes from a publisher in the same process

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

MethodDescription
.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

MethodDescription
.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 secondshorus.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.

ℹ️Quick Answer

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.

MetricValue
DeterministicYes
Multi-coreNo (single thread)
Best ForSafety-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.

MetricValue
DeterministicNo
Multi-coreYes
Best ForMulti-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

FeatureSequentialParallel
DeterministicYesNo
Multi-coreNoYes
Certification ReadyYesNo

See Also