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.
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.
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.
#[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.
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.
// 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.
// 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 variant | Meaning |
|---|---|
ResourceNotFound | No entry for the key. The resource was not inserted. |
ResourceTypeMismatch | Key present, but stored under a different type than R. |
ResourceDuplicateKey | Returned 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.
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 | |
|---|---|---|
| Construction | Resources::new() | Resources::<Key>::new() |
| Typo detection | Runtime — ResourceNotFound | Compile time |
| Allocation on lookup | None for &str literals | None |
| Best for | Prototypes, small workflows | Production, 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
| Guarantee | Detail |
|---|---|
| Setup order | FIFO — insert dependencies before dependents. |
| Teardown order | LIFO — each teardown can rely on its dependencies still being live. |
| Sequentiality | Setup and teardown calls are sequential, never concurrent. |
| Partial rollback | If setup fails at position N, teardown runs LIFO from N−1 to 0. Resources at positions ≥ N never set up, never torn down. |
| Teardown errors | Logged, never aborts the sequence — every remaining resource still gets 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.
| Primitive | When 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. |
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.
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())).
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
| Method | Signature | Notes |
|---|---|---|
new() | Resources::new() -> Self | Empty map. |
empty() | Resources::empty() -> Self | Alias for new(). |
with_capacity(n) | Resources::with_capacity(n) -> Self | Pre-allocates the underlying HashMap. |
insert | insert<R: Resource>(self, key, resource) -> Self | Builder; panics on duplicate. |
try_insert | try_insert<R>(self, key, resource) -> Result<Self, CanoError> | Returns ResourceDuplicateKey on duplicate. |
get | get<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. |