Dynamic state
Typestate handles are the preferred FSM API because they make invalid transitions impossible to express at compile-time. Sometimes, however, a function can finish in one of several states selected at runtime.
Consider a function that prepares the Job from the
basic lifecycle. It either loads input or restores a
checkpoint:
queued ─┬─> loading_input ───────────┐
└─> restoring_checkpoint ────┴─> running ─> completed
YAML model
quent: alpha
model: fsm_dynamic_state
fsms:
Job:
states:
queued:
initial: true
to: [loading_input, restoring_checkpoint]
loading_input:
to: [running]
restoring_checkpoint:
to: [running]
running:
to: [completed]
completed: {}
The two branches return different Rust types:
fn prepare_job(
job: FsmHandle<Job, job_state::Queued>,
restore: bool,
) -> /* no single typestate handle works here */ {
if restore {
job.restoring_checkpoint() // FsmHandle<Job, RestoringCheckpoint>
} else {
job.loading_input() // FsmHandle<Job, LoadingInput>
}
}
An enum containing both handle types could represent this result, but it would be specific to this function’s two possible outcomes. Quent knows the FSM topology, but it cannot know in advance which subsets and combinations of states application control flow will need to return. Generating enums for every possible combination would cause the API surface to grow rapidly, while asking applications to define them creates a different wrapper type for each such boundary. Consumers would also have to match each enum before doing common work.
Instrumentation API
Calling into_dynamic() consumes any typestate handle and returns the common
DynamicFsmHandle<Job> type. The handle stores its current state and exposes
all transitions declared by the FSM. Each example below returns the dynamic
handle from the same runtime branch:
use instrumentation::{
Context, DynamicFsmHandle, FsmDynamicState, FsmHandle, Job, Noop, job_state,
};
fn prepare_job(
job: FsmHandle<Job, job_state::Queued>,
restore_from_checkpoint: bool,
) -> DynamicFsmHandle<Job> {
if restore_from_checkpoint {
job.restoring_checkpoint().into_dynamic()
} else {
job.loading_input().into_dynamic()
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let restore_from_checkpoint = std::env::args().any(|arg| arg == "--restore");
let context = Context::<FsmDynamicState>::try_new(Noop)?;
let queued = context.observer::<Job>().handle().queued();
let mut job = prepare_job(queued, restore_from_checkpoint);
job.running()?;
let job = job.try_into::<job_state::Running>()?;
job.completed();
Ok(())
}
#include "quent-tutorial-fsm-dynamic-state-cpp-bridge/gen/quent.hpp"
#include <iostream>
quent::DynamicFsmHandle<quent::Job> prepare_job(
quent::FsmHandle<quent::Job, quent::job_state::Queued> job,
bool restore_from_checkpoint) {
if (restore_from_checkpoint) {
return std::move(job).restoring_checkpoint().into_dynamic();
}
return std::move(job).loading_input().into_dynamic();
}
int main(int argc, char**) {
const bool restore_from_checkpoint = argc > 1;
auto context = quent::Context::none();
auto queued = context.job_observer()->handle().queued();
auto job = prepare_job(std::move(queued), restore_from_checkpoint);
job.running();
auto running = std::move(job).try_into<quent::job_state::Running>();
if (!running) {
std::cerr << "job did not reach the running state\n";
return 1;
}
std::move(*running).completed();
return 0;
}
import sys
import quent_tutorial_fsm_dynamic_state as quent
def prepare_job(
job: quent.JobQueuedHandle, restore_from_checkpoint: bool
) -> quent.JobDynamicFsmHandle:
if restore_from_checkpoint:
return job.restoring_checkpoint().into_dynamic()
return job.loading_input().into_dynamic()
def main() -> None:
restore_from_checkpoint = "--restore" in sys.argv
with quent.Context() as context:
queued = context.job_observer().handle().queued()
job = prepare_job(queued, restore_from_checkpoint)
job.running()
running = job.try_into_running()
running.completed()
if __name__ == "__main__":
main()
The tradeoff is when transition correctness is checked. FsmHandle lets the
compiler (if any) check it through the Rust or C++ type system, or through
typechecks for Python type stubs. DynamicFsmHandle checks it when the
transition method is called. An invalid transition returns FsmTransitionError
in Rust, throws from the C++ method, or raises InvalidFsmTransitionError in
Python. A failed transition does not emit an event or change the stored state.
Converting a typestate handle preserves its current state and entity ID. In Python, the generated type stubs still distinguish each typestate handle, while the dynamic handle gives type checkers a common return type for both branches.
When the current state becomes known again, convert back to a typestate handle.
Rust uses try_into::<job_state::Running>(), C++ uses
try_into<quent::job_state::Running>(), and Python exposes the state-specific
try_into_running() method. A successful conversion consumes the dynamic
handle. A mismatch does not: Rust returns it in FsmStateMismatch, C++ returns
an empty std::optional without moving from the handle, and Python raises
InvalidFsmStateError without consuming it.
Instrumentation supports up to 255 declared states in one FSM. Dynamic handles use a compact state index, with one additional value for a handle that has not entered its initial state. Code generation rejects an FSM with 256 or more states.
When to use it
Use DynamicFsmHandle when an API boundary must represent multiple possible
current states, such as a function that resumes persisted work, performs an
optional preparation phase, or reconstructs state from runtime input.
Keep FsmHandle when the current state is known to the caller. It provides the
stronger guarantee, offers only valid transitions in editor completion, and
does not require handling runtime transition errors.
Convert to DynamicFsmHandle at the boundary where control flow prevents one typestate return type, not earlier.