Skip to content

[Feature Request] Dynamic parallel regions — fan-out over runtime-sized lists of sub-machines #616

@brunolnetto

Description

@brunolnetto

Summary

The current State.Parallel construct requires all regions to be statically declared as uniquely named inner classes at class-definition time. This makes it impossible to express a common real-world pattern:

Fan-out over a runtime-sized list of identical items, each driven by its own sub-machine instance, with a fan-in barrier that advances the parent only once all items are done.

This is a feature discussion, not a bug. Multiple implementation paths exist; their tradeoffs are documented below. The goal of this issue is to settle on the right abstraction before any code is written. This is first mentioned on issue #614.


Motivation

Consider a War statechart that must track several simultaneous Battle sub-machines. The number of battles is not known until the orchestrating machine is instantiated. The desired pseudo-code would be:

# Desired (not valid today)
class Battle(State.Compound):
    fighting = State(initial=True)
    won      = State(final=True)
    victory  = fighting.to(won)
 
class War(StateChart):
    class at_war(State.Parallel):
        battles = [Battle, Battle, Battle]   # ← 3 identical regions
 
    triumphant = State(final=True)
    declare_victory = at_war.to(triumphant)  # fires when ALL battles are won

Today this must be hand-wired through an orchestration wrapper, pushing coordination logic out of the statechart and into imperative Python. The result is boilerplate that the library should be able to absorb.

Current behavior and workaround

The closest existing hook is create_machine_class_from_definition, which can build a StateChart subclass from a dict at runtime. A dynamic parallel state with N regions can be assembled via:

from statemachine.io import create_machine_class_from_definition
 
def make_war_class(n: int):
    region = {
        "initial": True,
        "states": {
            "fighting": {"initial": True},
            "won":      {"final": True},
        },
        "on": {"victory": [{"target": "won"}]},
    }
    return create_machine_class_from_definition(
        "War",
        states={
            "at_war": {
                "initial":  True,
                "parallel": True,
                "states":   {f"battle_{i}": region for i in range(n)},
            },
            "triumphant": {"final": True},
        },
    )

This works but has serious ergonomic costs:

  • The declarative Python API is bypassed entirely.
  • Region names must be generated manually.
  • Type-checking is lost.
  • The class cannot carry methods defined in a normal StateChart subclass body.

Proposed alternatives

Four designs are outlined below in ascending order of implementation complexity. They are not mutually exclusive — proposals A and B could ship as near-term conveniences while C and D are discussed separately.

A — State.Parallel.replicate(n, template) class-level factory

Complexity: 🟢 Low lift · Spec-conformant

A class method on State.Parallel that returns a new State.Parallel subclass with n uniquely-named copies of template baked in at metaclass time. n must be a literal integer — resolved at class definition.

class War(StateChart):
    class at_war(State.Parallel.replicate(3, Battle)):
        pass
 
    triumphant = State(final=True)
    declare_victory = at_war.to(triumphant)

Tradeoff: Hardcodes n at class definition. Does not solve the runtime-count use case. Lowest risk.


B — count= and region= parameters on State.Parallel

Complexity: 🟢 Low lift · Spec-conformant

Same semantics as A, but expressed via __init_subclass__.

class War(StateChart):
    NUM_FRONTS = 3
 
    class at_war(State.Parallel, count=NUM_FRONTS, region=Battle):
        pass

Tradeoff: More readable than A, still static. Mostly syntax sugar over dict-based construction.


C — State.DynamicParallel with runtime region count

Complexity: 🔴 High lift · Beyond SCXML

A new state type that defers region instantiation to __init__.

class War(StateChart):
    class at_war(State.DynamicParallel):
        region = Battle
        count  = "num_battles"
 
    triumphant = State(final=True)
    declare_victory = at_war.to(triumphant)
 
war = War(num_battles=5)

Tradeoff: This actually solves the problem. But:

  • Requires cloning state trees at runtime.
  • Dynamic active configuration management.
  • Extended done.state semantics.
  • Breaks strict SCXML compliance.
  • Impacts diagram generation and serialization.

D — invoke= accepting a list of sub-machine instances

Complexity: 🟡 Medium lift · Spec-aligned

Generalize invoke to accept a runtime list of StateChart instances.

class War(StateChart):
    at_war = State(initial=True, invoke=lambda ctx: ctx.battles)
    triumphant = State(final=True)
    declare_victory = at_war.to(triumphant)
 
    def __init__(self, num_battles):
        self.battles = [Battle() for _ in range(num_battles)]
        super().__init__()

Tradeoff: Most spec-aligned approach. However:

  • Semantics of completion must be defined precisely.
  • No cross-region guards (In(state)).
  • Shifts responsibility into invoke machinery.

Prior art and references

System Construct Reference
BPMN 2.0 Multi-instance subprocess (parallel) OMG BPMN 2.0 §10.3.5
SCXML (W3C) No dynamic-region construct W3C SCXML spec
XState v5 Actor model (spawnChild) XState actors
AWS Step Functions Map state Amazon States Language — Map state
Airflow / Prefect Dynamic task mapping Astronomer docs

Questions for @fgmacedo

  1. Is the library strictly bound to W3C SCXML semantics?
    • → If yes, only A, B, and D are viable.
  2. Would extending create_machine_class_from_definition with a template + count shorthand be the preferred minimal solution?
  3. For proposal D, should invoke support lists, or is one-to-one intentional?
  4. Should proposal C live in a contrib module until stabilized?

Environment

  • python-statemachine version: 3.0.x
  • Python version: 3.11+

This is a feature request, not a regression.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions