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

sequenceDiagram participant E as Engine participant R0 as Resource A participant R1 as Resource B participant R2 as Resource C Note over E: setup_all() — FIFO E->>R0: setup() E->>R1: setup() E->>R2: setup() Note over E: execute_workflow() Note over E: teardown_range(all) — LIFO E->>R2: teardown() E->>R1: teardown() E->>R0: teardown()
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.