odin_action
The odin_action
crate provides several variants of action types together with macros to define and instantiate ad
hoc actions. The generic action construct represents application specific objects that encapsulate async
computations, to be executed by an action owner that can invoke such computations with its own data (e.g. sending
messages in actor systems that are built from its data).
The primary purpose of actions is to build re-usable action owners that do not have to be aware of in which application context they are used. All the owner has to know is when to execute an action and what of its own data it should provide as an argument.
In a synchronous world this is often described as a "callback".
The basis for this are "Action" traits with a single async fn execute(&self,data..)->Result<()>
method. Instances of
these traits are normally created where we assemble an application (e.g. in main()
), i.e. where we know all the
relevant interaction types. They are then passed either as generic type constructor arguments or later-on (at runtime)
as trait objects to their owners, to be invoked either on-demand or when the owner state changes.
Technically, actions represent a special case of async closures in which capture is done by either Copy
or Clone
. Reference capture is not useful here since actions are executed within another task, without any
lifetime relationship to the context in which the actions were created.
We support the following variants:
- [
DataAction<T>
] trait and ['data_action`] macro - [
DataRefAction<T>
] trait and ['dataref_action`] macro - [
BiDataAction<T,A>
] trait and [bi_data_action
] macro - [
BiDataRefAction<T,A>
] trait and [bi_dataref_action
] macro - [
DynDataAction<T>
] type and ['dyn_data_action`] macro - [
DynDataRefAction<T>
] type and ['dyn_dataref_action`] macro
The difference between ..DataAction
and ..DataRefAction
is how the owner data is passed into the trait's
execute(..)
function: as a moved value (execute(&self,data:T)
) or as a reference (execute(&self,data:&T)
).
The Bi..Action<T,B>
traits have execute(..)
functions that take two arguments (of potentially different types). This is
helpful in a context where the action body requires both owner state (T
) and information that was passed to the
owner (B
) in the request that triggers the action execution and can avoid the runtime overhead of async action trait
objects (requiring Pin<Box<dyn Future ..>>
execute return values). The limitation of bi-actions is that both
action owner and requester have to know the bi_data type (B
), which therefore tends to be unspecific (e.g. String
).
This in turn makes bi-actions more susceptible to mis-interpretation and therefore the action owner should only use
B
as a pass-through argument and not generating it (which would require the owner knows what potential requesters
expect semantically).
Dyn..Action
types (which represent trait objects) are used in two different contexts:
- to execute actions that were received as function arguments (e.g. through async messages)
- to store such actions in homogenous
Dyn..ActionList
containers for later execution
The Dyn..ActionList
containers use an additional ignore_err: bool
argument in their execute(..)
methods
that specifies if the execution should shortcut upon encountering error results when executing its stored actions
or if return values of stored actions should be ignored.
#![allow(unused)] fn main() { struct MyActor { ... data: MyData, actions: DynDataActionList<MyData> } ... impl MyActor { async fn exec (&self, da: DynDataAction<MyData>) { da.execute(&self.data).await; } fn store (&mut self, da: DynDataAction<MyData> ) { .. self actions.push( da) .. } ... self.actions.execute(&self.data, ignore_err).await ... } }
Note that Dyn..Action
instances do have runtime overhead (allocation) per execute(..)
call.
Since actions are typically one-of-a-kind types we provide macros for all the above variants that both define the type and return an instance of this type. Those macros all follow the same pattern:
#![allow(unused)] fn main() { //--- system construction site: let v1: String = ... let v2: u64 = ... let action = data_action!{ let v1: String = v1.clone(), let v2: u64 = v2 => |data: Foo| { println!("action executed with arg {:?} and captures v1={}, v2={}", data, v1, v2); Ok(()) } }; let actor = MyActor::new(..action..); ... //--- generic MyActor implementation: struct MyActor<A> where A: DataAction<Foo> { ... action: A ... } impl<A> MyActor<A> where A: DataAction<Foo> { ... let data = Foo{..} ... self.action.execute(data).await ... } }
the example above expands into a block with three different parts: capture struct definition, action trait impl and capture struct instantiation
#![allow(unused)] fn main() { { struct SomeDataAction { v1: String, v2: u64 } impl DataAction<Foo> for SomeDataAction { async fn execute (&self, data: Foo)->std::result::Result<(),OdinActionError> { let v1 = &self.v1; let v2 = &self.v2; println!(...); Ok(()) } } SomeDataAction{ v1: v1.clone(), v2 } } }
The action bodies are expressions that have to return a Result<(),OdinActionError>
so that we can coerce errors in crates using
odin_action
. This means that we can use the ?
operator to shortcut within action bodies, but we have to map respective results
by means of our map_action_err()
function and make sure to use action_ok()
instead of explicit Ok(())
(to tell the compiler
what Result<T,E>
it refers to):
#![allow(unused)] fn main() { fn compute_some_result(...)->Result<(),SomeError> {...} ... data_action!( ... => |data: MyData| { ... map_action_err( compute_some_result(...) )? ... action_ok() }) }
For actions that end in a result no mapping is required (map_action_err(..)
is automatically added by the macro expansion):
#![allow(unused)] fn main() { data_action!( ... => |data: MyData| { ... compute_some_result(...) }) }
[OdinActionError
] instances can be created from anything that implements [ToString
]`