Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Key point

Convert to DynamicFsmHandle at the boundary where control flow prevents one typestate return type, not earlier.

Check yourself

When should a function return a DynamicFsmHandle?

What happens when a dynamic-state handle attempts an invalid transition?

Full code