Resource Lifecycle and Concurrency

When setup/teardown fire, and how resources behave under concurrency.

See Resources for defining and retrieving resources. This page covers their lifecycle and concurrency guarantees.


Lifecycle Guarantees

setup_all() in FIFO order; teardown in LIFO order

The engine calls setup() on resources in insertion order (FIFO) and teardown() in exactly the reverse order (LIFO), so each resource's live span nests inside the spans of the resources set up before it. Engine Resource A Resource B Resource C setup_all() FIFO order setup() 1 setup() 2 setup() 3 execute_workflow() teardown_range(all) LIFO order teardown() 4 teardown() 5 teardown() 6 Bars mark each resource's live span — they nest, so teardown mirrors setup exactly.
GuaranteeDetail
Setup orderFIFO — insert dependencies before dependents.
Teardown orderLIFO — each teardown can rely on its dependencies still being live.
SequentialitySetup and teardown calls are sequential, never concurrent.
Partial rollbackIf setup fails at position N, teardown runs LIFO from N−1 to 0. Resources at positions ≥ N never set up, never torn down.
Teardown errorsLogged, never aborts the sequence — every remaining resource still gets torn down.
Partial rollback — only A and B are torn down
use cano::prelude::*;

#[derive(Resource)] struct ServiceA;
#[derive(Resource)] struct ServiceB;
#[derive(Resource)] struct ServiceD;

struct ServiceC;

impl ServiceA { fn new() -> Self { Self } }
impl ServiceB { fn new() -> Self { Self } }
impl ServiceC { fn new() -> Self { Self } }
impl ServiceD { fn new() -> Self { Self } }

#[resource]
impl Resource for ServiceC {
    async fn setup(&self) -> Result<(), CanoError> {
        Err(CanoError::Configuration("ServiceC failed".into()))
    }
}

#[tokio::main]
async fn main() {
    let resources: Resources = Resources::new()
        .insert("a", ServiceA::new())  // position 0 — setup OK
        .insert("b", ServiceB::new())  // position 1 — setup OK
        .insert("c", ServiceC::new())  // position 2 — setup FAILS
        .insert("d", ServiceD::new()); // position 3 — never reached

    // setup_all() returns Err from C; teardown runs for B then A (LIFO)
    let result = resources.setup_all().await;
    assert!(result.is_err());
}

Concurrency and Interior Mutability

get() returns an Arc<R>. Split tasks each get their own clone — a refcount bump, no data copy. Read-only resources need no synchronization. Resources with mutable state must use interior mutability.

PrimitiveWhen to use
tokio::sync::RwLock<T>Many readers, rare writers. Best default.
tokio::sync::Mutex<T>Exclusive access. Serializes split tasks — watch throughput.
std::sync::atomic::*Counters and flags. Lock-free.
DashMap<K, V>Concurrent map writes. Sharded locking.
Coarse locks eliminate parallelism

A single Mutex<T> guarding an entire resource serializes split tasks that write to it. If your splits all hit the same resource, parallelism gives no throughput gain — partition by task index or use a concurrent structure.


Standalone vs Scheduler Lifecycle

Standalone orchestrate() runs the full lifecycle on every call: setup_all() before the FSM, teardown_range(all) after, even on error. Resources are scoped to one workflow run. This is the per-request model — HTTP handlers, request-bound jobs.

Scheduler runs setup_all() exactly once on scheduler.start() and teardown_range(all) once on scheduler.stop(). Each scheduled firing calls execute_workflow() directly and reuses the same resource instances — open a DB pool once, reuse across runs.

Per-run state under the Scheduler

Resources persist across runs. Anything accumulated in a resource (counters, cached state, the contents of a MemoryStore) carries forward. For per-run reset, clear it at the start of the workflow — for example, store.clear()? in the initial task.

to navigate to open esc to close