Scheduling Intelligence
Why Execution Classes Exist
Different nodes need different scheduling:
| Node Type | Example | What it needs | Builder method |
|---|---|---|---|
| Control loop | PID controller | Fixed rate, low jitter | .rate(freq) |
| Data-triggered | Camera detector | Wake on new data | .on("topic") |
| Algorithms | Path planner | Throughput, isolation from RT | .compute() |
| I/O | Telemetry uploader | Blocking calls off the hot path | .async_io() |
| Everything else | IMU reader, glue logic | Simple sequential ticking | (none) |
Declaring a node's execution class tells the scheduler which execution group it belongs to: at startup the scheduler splits the registered nodes into RT, compute, event, async-I/O, and main-loop groups, and each group gets its own execution strategy.
Declaring an Execution Class
The node builder assigns the execution class — there is no separate enum for you to import or construct:
use horus::prelude::*;
let mut scheduler = Scheduler::new();
// RT control loop, main-loop sensor, and CPU-bound planner
scheduler.add(pid_node).order(0).rate(1000_u64.hz()).build()?;
scheduler.add(sensor_node).order(1).build()?;
scheduler.add(planner_node).order(2).compute().build()?;
scheduler.run()?;
Examples elsewhere in these docs end the chain with .done() instead — it is a compatibility alias for .build(). Both validate the node and return HorusResult<&mut Scheduler>, so both need ?.
Available Execution Classes
| Builder method | Execution class | Use For | Where it runs |
|---|---|---|---|
.rate(freq) | Rt | Fixed-rate control loops | Dedicated high-priority thread, spin-wait timing |
.compute() | Compute | CPU-bound work: planning, ML inference, SLAM | Parallel thread pool |
.on("topic") | Event | Data-triggered processing | Wakes when the topic is updated |
.on(...) is woken only by a Topic::send() in the same process — a node whose publisher runs elsewhere never ticks and reports nothing. See Execution Classes.
| .async_io() | AsyncIo | Blocking I/O: network, disk, database | Tokio blocking pool |
| (none) | BestEffort | Everything else — the default | Main tick loop, dispatched by topic dependencies and .order() |
.rate() auto-selects Rt only for a node that has not already been given another class; on a .compute(), .on(), or .async_io() node it just rate-limits.
Failure Policies
Failure handling is independent of the execution class. A node has no failure policy unless you set one. Without one, a panicking tick() is logged and the scheduler continues. Set one explicitly:
use horus::prelude::*;
// Control-loop node: any failure must stop the system
scheduler.add(critical_pid)
.failure_policy(FailurePolicy::Fatal)
.build()?;
// Recoverable node: re-init with backoff, escalate after 3 restarts
scheduler.add(planner)
.compute()
.failure_policy(FailurePolicy::restart(3, 50_u64.ms()))
.build()?;
The four policies are FailurePolicy::Fatal, FailurePolicy::restart(max_restarts, initial_backoff), FailurePolicy::skip(max_failures, cooldown), and FailurePolicy::Ignore. restart and skip take their parameters explicitly — there are no built-in values to fall back on.
Best Practices
1. Start Simple
Most nodes need no execution-class annotation — they default to best-effort scheduling in the main tick loop. Add .rate(), .compute(), .on(), or .async_io() only when the node needs a dedicated execution group.
// Good: let HORUS tick it in the main loop
scheduler.add(my_node).build()?;
// Only annotate when the node needs its own execution group
scheduler.add(critical_pid).rate(1000_u64.hz()).build()?;
2. Match the Class to the Workload
Choose the builder method that matches what the node actually does:
// Fixed-rate control loop: dedicated RT thread, spin-wait timing
scheduler.add(pid_node).rate(1000_u64.hz()).build()?;
// Data-triggered processing: wakes when the topic is updated
scheduler.add(detector).on("camera.rgb").build()?;
// CPU-bound work — planning, ML inference, SLAM: parallel thread pool
scheduler.add(planner_node).compute().build()?;
// Blocking I/O — network, disk, database: tokio blocking pool
scheduler.add(telemetry).async_io().rate(1_u64.hz()).build()?;
3. Reproducible Runs Need .deterministic(true)
Execution class and reproducibility are separate concerns. By default the main-loop group is driven by the ready-dispatch executor: it honours topic dependencies and .order() tiers, but nodes in the same tier with no dependency between them run in parallel — and .rate(), .compute(), .on(), or .async_io() hands the node to its own executor thread on top of that. Scheduler::new().deterministic(true) spawns no executor threads at all: every node, whatever its execution class, ticks sequentially on the main thread. See deterministic execution.
See Also
- Execution Modes - Sequential vs parallel execution
- Circuit Breaker - Fault tolerance and failure policies
- Deterministic Execution - Reproducible runs