Scheduling Intelligence

Why Execution Classes Exist

Different nodes need different scheduling:

Node TypeExampleWhat it needsBuilder method
Control loopPID controllerFixed rate, low jitter.rate(freq)
Data-triggeredCamera detectorWake on new data.on("topic")
AlgorithmsPath plannerThroughput, isolation from RT.compute()
I/OTelemetry uploaderBlocking calls off the hot path.async_io()
Everything elseIMU reader, glue logicSimple 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 methodExecution classUse ForWhere it runs
.rate(freq)RtFixed-rate control loopsDedicated high-priority thread, spin-wait timing
.compute()ComputeCPU-bound work: planning, ML inference, SLAMParallel thread pool
.on("topic")EventData-triggered processingWakes 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