sandl
A Rust framework for building parallel execution engines with dependency management, type-safe method dispatch, and event observation.
Installation
Run the following Cargo command in your project directory:
cargo add sandl
Or add the following line to your Cargo.toml:
sandl = "0.1.0"
Quick Start
use *;
Overview
sandl enables you to define reusable computational Layers with typed methods, compose them into execution Slices, and run them in parallel with automatic dependency resolution. It's designed for scenarios where you need to:
- Execute the same operations across many similar workloads in parallel
- Manage complex dependencies between computation stages
- Maintain type safety across dynamic execution boundaries
- Observe and monitor execution progress
Some use cases:
- Data pipeline processing
- Parallel API request handling
- Batch image processing
- Monte-Carlo simulations
- Generic ETL operations
Key Concepts
Layers
A Layer is a collection of independent methods that perform related operations. Each method has:
- A name
- Type-safe arguments (via the
FromValue/ToValuetraits) - Optional default arguments
- An implementation function
let process_layer = builder
.method
// The type is optional
. // Defaults are optional, just call args()
.bind
.build;
Slices have access to a thread-safe get/set context by default. For methods that don't need it, use bind_pure:
let process_layer = builder
.method
// The type is optional
.
.bind_pure
.build;
Slices
A Slice specifies which layer methods to execute and with what arguments. Slices are the units of work that get executed in parallel:
let slice = builder
.layer // If you have more than one layer and/or method,
// slices will only interact with the ones you configure them to.
.build;
Default arguments can be overridden at the slice level with automatic merging for object types:
// Layer defines defaults
.args_with_default
// Slice overrides one field
.call
// Merged: { "timeout": 30, "retries": 5 }
Engine
The Engine orchestrates execution:
- Registers layers and slices
- Manages layer dependencies
- Executes slices in parallel using rayon
- Performs topological sorting for proper execution order
- Provides event observation hooks
let engine = builder
.add_layer
.add_slice
.add_slice
.build?;
let results = engine.run;
sandl relies on rayon for its parallelism, whose parameters can be configured with EngineConfig:
let config = new
// Rayon configuration
.num_threads
.stack_size // Expands to 2 * 1024 * 1024
.chunk_size
// sandl specific. Set this to enable batching
.batch_size;
let engine = builder
.config
.build?;
let results = engine.run;
You can also pass run flags to the engine:
let results = engine.run; // Default. Tracks runtime data and prints to stdout.
let results = engine.run; // Prints nothing (still tracks timing data).
let results = engine.run; // Minimal overhead - Nothing but runtime.
// run() still returns RunResults in all cases, so you can do your own processing *after* it's done.
Overhead from stdout writes and from observer hooks is minimal, but it exists.
Context
A Context provides thread-safe, per-slice shared state during execution. Methods can read from and write to the context:
.bind
Beware the shared state. Methods within a slice run in parallel, so all behavior is undefined by default. You can set dependencies amongst layers in the engine builder:
// Use the builder...
let engine = builder
.add_layer
.add_layer
.dependency // show depends on process
.add_slice
.add_slice
.build?;
// ...or macros
let engine = dependencies!
.add_slice
.build?;
You can also set an initialization layer - All layers will depend on it:
// Here, we're using the quick_layer! macro to create a layer with a single method and some arg type.
let init = quick_layer!;
let verify = quick_layer!;
let slice = builder
.layer
.layer
.build;
let engine = builder
.add_layer
.add_layer
.init_layer // Every other layer now depends on "init"
.add_slice
.build?;
Observer
You can inspect the runtime by creating an observer:
// Create an observer with various event handlers
let mut observer = new;
// Track when slices start and complete
observer.on_slice_start;
observer.on_slice_complete;
// Track when methods are called
observer.on_method_start;
observer.on_method_complete;
// Hook into failures
observer.on_method_failed;
// Define a simple computational layer
let compute = builder
.method
. // {x: i32, y: i32}, derives Args
.bind
.build;
let mut slices = vec!;
let engine = builder
.add_layer
.add_slices
.observer // Set the observer
.build?;
// Inspect errors with one of the many helper methods
let failures = results.get_all_method_errors;
Helper Macros
quick_layer!
Quickly define a single-method layer:
let init = quick_layer!;
value!
Construct Value instances easily:
let v = value!;
sandl Value is fully compatible with serde_json::Value.
add_slices! / add_layers!
Fluently add multiple items:
let builder = add_slices!;
let builder = add_layers!;
dependencies!
Define multiple dependencies concisely:
let builder = dependencies!;
json_wrapper!
When using serde, wrap types with a sandl compatibility layer:
json_wrapper!; // pub is optional!
execution_error!
Inside a method, quickly return an error:
.
.bind
This error is automatically wrapped with runtime context:
MethodExecutionFailed ,
KiB! / MiB! / GiB!
Useful when configuring stack_size for rayon:
let two_kilobytes = KiB!;
let two_megabytes = MiB!;
let two_gigabytes = GiB!;
Result Analysis
The RunResults type provides rich analysis:
let results = engine.run;
println!;
println!;
println!;
// fn total_slices(&self) -> usize;
// fn successful_slices(&self) -> usize;
// fn failed_slices(&self) -> usize;
// fn total_methods(&self) -> usize;
// fn successful_methods(&self) -> usize;
// fn failed_methods(&self) -> usize;
// fn is_all_success(&self) -> bool;
// fn has_failures(&self) -> bool;
// fn summary(&self) -> String;
// fn get_slice_errors(&self) -> Vec<(&String, &Error)>;
// fn get_all_method_errors(&self) -> Vec<(&String, &String, &String, &Error)>;
// fn get_execution_errors(&self) -> Vec<(&String, &String, &String, &Error)>;
// fn from_slice(&self, slice_name: &str) -> Option<&Result<SliceResults>>;
// fn slice_names(&self) -> Vec<&String>;
// fn average_slice_duration(&self) -> Option<Duration>;
// fn min_slice_duration(&self) -> Option<Duration>;
// fn max_slice_duration(&self) -> Option<Duration>;
// fn timing_summary(&self) -> String;
if results.has_failures
// Get results for a specific slice
if let Some = results.from_slice
Slice results contain whatever was returned from each method that was run, as well as how long it took to run the whole slice:
Performance
sandl adds minimal overhead over rayon. For maximum performance:
- Use
RunFlags::SILENT_NO_OBSERVER - Consider larger batch sizes and smaller chunks for 10ms~ workloads
- Limit stack size per worker thread
Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
Please make sure to update tests as appropriate.
License
MIT OR Apache-2.0