Resources

Lifecycle-managed, typed dependency injection for Cano workflows.

Resources<TResourceKey> is the typed dictionary every workflow carries. Database pools, HTTP clients, config structs, MemoryStore, and per-run parameters are all resources — registered once, injected everywhere. Each lookup returns an Arc<R>, cheap to clone and safe to share across concurrent split tasks.

The engine calls setup() on each registered resource in insertion order before the FSM runs, and teardown() in reverse order after — even on failure. Tasks never receive resources directly; they retrieve typed handles from the injected &Resources<TResourceKey> via res.get::<R, _>(key)?.


Quick Start

Define a resource, register it, retrieve it by typed key in any task. The engine wires setup / teardown automatically.

End-to-end example
use cano::prelude::*;

#[derive(Debug, Hash, Eq, PartialEq)]
enum Key { Store, Config }

// Stateless resource — derive Resource for a no-op setup/teardown impl
#[derive(Resource)]
struct AppConfig { multiplier: u32 }

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Step { Init, Done }

struct InitTask;

#[task(state = Step, key = Key)]
impl InitTask {
    async fn run(&self, res: &Resources<Key>) -> Result<TaskResult<Step>, CanoError> {
        let store  = res.get::<MemoryStore, _>(&Key::Store)?;
        let config = res.get::<AppConfig, _>(&Key::Config)?;

        store.put("value", 10u32 * config.multiplier)?;
        Ok(TaskResult::Single(Step::Done))
    }
}

#[tokio::main]
async fn main() -> Result<(), CanoError> {
    let resources = Resources::<Key>::new()
        .insert(Key::Store,  MemoryStore::new())
        .insert(Key::Config, AppConfig { multiplier: 3 });

    let workflow = Workflow::new(resources)
        .register(Step::Init, InitTask)
        .add_exit_state(Step::Done);

    workflow.orchestrate(Step::Init).await?;
    Ok(())
}

Runnable example: cargo run --example workflow_resources — a stateless config resource, a stateful counter resource (interior mutability), and a resource with real setup / teardown lifecycle work, all retrieved by type inside tasks.


Defining a Resource

The Resource trait gives every dependency three hooks — two lifecycle, one observability. All default to no-ops (and health() defaults to Healthy), so most resources need no manual impl. health() is opt-in observability — it is never called during normal workflow execution; see Observers & Health Probes.

Resource trait
pub trait Resource: Send + Sync + 'static {
    async fn setup(&self) -> Result<(), CanoError> { Ok(()) }
    async fn teardown(&self) -> Result<(), CanoError> { Ok(()) }
    async fn health(&self) -> HealthStatus { HealthStatus::Healthy }
}

Stateless — #[derive(Resource)]

Generates an empty impl Resource for T {}. Use it for config, parameter bags, read-only data — anything without lifecycle work.

Stateless resource via derive
#[derive(Resource)]
struct WorkflowParams {
    batch_size: usize,
    timeout_ms: u64,
}

Stateful — #[resource] on the impl block

Override setup / teardown for resources that open connections, allocate buffers, or flush state. Both hooks take &self: resources are shared via Arc, so mutation requires interior mutability — tokio::sync::Mutex, RwLock, atomics, etc.

Resource with custom setup/teardown
use cano::prelude::*;

use std::sync::{Arc, Mutex};

struct CounterResource {
    setup_count: Arc<Mutex<u32>>,
}

#[resource]
impl Resource for CounterResource {
    async fn setup(&self) -> Result<(), CanoError> {
        *self.setup_count.lock().unwrap() += 1;
        Ok(())
    }

    async fn teardown(&self) -> Result<(), CanoError> {
        // flush, close handles, etc.
        Ok(())
    }
}


Building a Resources Map

Resources is a typed builder. insert consumes self and panics on duplicate keys (programmer error). try_insert returns Result<Self, CanoError> so dynamic input can handle collisions explicitly. The two compose in a single chain.

insert + try_insert
// Static keys — duplicates indicate buggy wiring; let it panic
let resources = Resources::new()
    .insert("store",  MemoryStore::new())
    .insert("config", AppConfig::default())
    // Dynamic key — handle collision as data
    .try_insert(user_supplied_key, plugin)?;


Retrieving Resources in Tasks

get::<R, Q>(key) returns Result<Arc<R>, CanoError>. R names the resource type; Q is the key query type — use _ to let the compiler infer it from the key argument.

Recommended form — Q inferred
// String keys
let store  = res.get::<MemoryStore, _>("store")?;
let params = res.get::<WorkflowParams, _>("params")?;

// Enum keys
let store  = res.get::<MemoryStore, _>(&Key::Store)?;
let params = res.get::<WorkflowParams, _>(&Key::Params)?;

Error variantMeaning
ResourceNotFoundNo entry for the key. The resource was not inserted.
ResourceTypeMismatchKey present, but stored under a different type than R.
ResourceDuplicateKeyReturned by try_insert when the key is already present.

Declarative Loading with #[derive(FromResources)]

A struct of Arc dependencies can declare its lookups instead of calling res.get field-by-field. The derive generates fn from_resources(res: &Resources<K>) -> CanoResult<Self>.

Field-level #[res(...)] declares the lookup key — string literal for string-keyed maps, enum path for enum-keyed maps. Container-level #[from_resources(key = MyKey)] sets the key type when fields use enum keys.

Deps struct with enum keys
use cano::prelude::*;
use std::sync::Arc;

#[derive(Hash, Eq, PartialEq)]
enum Key { Store, Config }

#[derive(Resource)]
struct AppConfig { multiplier: u32 }

#[derive(FromResources)]
#[from_resources(key = Key)]
struct InitDeps {
    #[res(Key::Store)]
    store: Arc<MemoryStore>,
    #[res(Key::Config)]
    config: Arc<AppConfig>,
}

fn lookup(res: &Resources<Key>) -> Result<(), CanoError> {
    // In a task:
    let InitDeps { store, config } = InitDeps::from_resources(res)?;
    let _ = (store, config);
    Ok(())
}

Key Types: String vs Enum

TResourceKey defaults to Cow<'static, str>: &'static str literals stay borrowed (no allocation), owned Strings are accepted, lookups go through &str. Any type satisfying Hash + Eq + Send + Sync + 'static works — an enum is recommended for non-trivial workflows.

String keys (default)Enum keys
ConstructionResources::new()Resources::<Key>::new()
Typo detectionRuntime — ResourceNotFoundCompile time
Allocation on lookupNone for &str literalsNone
Best forPrototypes, small workflowsProduction, larger workflows

Runnable example: cargo run --example resources_advanced — enum-typed keys, try_insert returning CanoError::ResourceDuplicateKey on a repeated key, and the partial-LIFO rollback when a later resource's setup fails.


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.


Workflows Without Resources

When every task is self-contained, skip the resources map. Implement run_bare() instead of run(), and build the workflow with Workflow::bare() (equivalent to Workflow::new(Resources::empty())).

Resource-free workflow
use cano::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Step { Compute, Done }

#[derive(Clone)]
struct PureTask;

#[task(state = Step)]
impl PureTask {
    async fn run_bare(&self) -> Result<TaskResult<Step>, CanoError> {
        let answer = 40 + 2;
        println!("Computed: {answer}");
        Ok(TaskResult::Single(Step::Done))
    }
}

fn build() -> Workflow<Step> {
    Workflow::bare()
        .register(Step::Compute, PureTask)
        .add_exit_state(Step::Done)
}

Runnable example: cargo run --example workflow_bare — a workflow with no resources at all.


API Reference

MethodSignatureNotes
new()Resources::new() -> SelfEmpty map.
empty()Resources::empty() -> SelfAlias for new().
with_capacity(n)Resources::with_capacity(n) -> SelfPre-allocates the underlying HashMap.
insertinsert<R: Resource>(self, key, resource) -> SelfBuilder; panics on duplicate.
try_inserttry_insert<R>(self, key, resource) -> Result<Self, CanoError>Returns ResourceDuplicateKey on duplicate.
getget<R, Q>(&self, key: &Q) -> Result<Arc<R>, CanoError>ResourceNotFound / ResourceTypeMismatch.
setup_all()async fn setup_all(&self) -> Result<(), CanoError>FIFO setup with partial LIFO rollback. Engine calls this.
teardown_all()async fn teardown_all(&self)LIFO teardown. Errors logged, never propagated.