Orchestra reads a workflow's live config every time it advances a token (loadDefinition by machine name). Editing a workflow therefore changes the behavior of every instance already running on it, mid-flight, and can break them (a token on a node an edit removed fails its instance). This proposes definition versioning so a running instance always executes the definition it started on, with a deliberate migration path, designed to add no measurable cost to the start/advance hot paths.
Background (current behavior)
- orchestra_workflow is a config entity; the modeler edits it in place. There is no version history, and config entities are not revisionable in core.
- A process instance stores only the workflow machine name; the engine resolves the current config on every advance, so running instances follow the latest edits.
Goal
- An instance runs the definition that was current when it started (pinning).
- Editing a workflow under running instances is safe and produces a new version lazily; in-flight instances are untouched.
- Operators can migrate running instances between versions, mapping nodes.
- Starting hundreds of instances per second stays cheap.
Data model
- orchestra_workflow (unchanged): the editable config definition (modeler-edited, config-synced, tenant-scoped).
- orchestra_workflow_version: a new revisionable content entity, one row per workflow, holding an immutable snapshot of the executable definition (nodes, flows, start node, node-level config such as retry/timers/assignments, NOT the per-modeler layout). Each executed change is a new revision; the revision id (vid) is the version. This reuses core revisions (metadata, tooling, loadRevision) rather than a hand-rolled version field. It is content, not config, because version history is runtime lineage that travels with the database, not declarative config you sync.
- ProcessInstance.definition_version: the vid the instance is pinned to (NULL for legacy instances).
- Current-version pointer: a cached workflow_id -> vid map (key-value/State), the thing that keeps start O(1).
- WorkflowDefinitionInterface: the read contract (getNodes, getNode, getOutgoingFlows, getIncomingFlows, getStartNode, getTenant, appliesToTenant) implemented by both the config entity and the snapshot, so the engine does not care which it reads.
Editing a workflow (save)
On workflow save a hook just invalidates (deletes) the cached pointer for that workflow. No snapshot is created on save, so iterating on a draft costs nothing and produces no versions.
Starting an instance (the throughput-critical path)
- Read the cached pointer.
- Hit (steady state): stamp definition_version = vid onto the instance row already being written. No hashing, no extra query: O(1).
- Miss (first start after an edit): under a short lock, compute the executable hash, find-or-create the snapshot revision for that hash (dedupe: an identical hash reuses the existing vid), set the pointer, stamp. Subsequent starts take the hit path.
The expensive work (hash + find-or-create) runs at most once per edit, amortized across the burst, never per start.
Advancing a token
The engine resolves the pinned definition via loadRevision(definition_version) wrapped as a WorkflowDefinition reader, instead of the live config. A snapshot revision is immutable, so it is cached permanently: after the first load, advance is an entity-cache hit, no costlier than reading config today. The deserialized reader can be cached by vid too. The rest of the engine is unchanged.
Migrating running instances (deliberate)
- Pick a workflow and a target version (usually current).
- Build a node mapping from the instances' pinned version(s) to the target: same-id nodes auto-map; renamed or removed nodes are mapped by the operator or their tokens cancelled.
- Validation refuses a migration that would leave a live token (active/parked/waiting) on a node with no mapping.
- Apply: remap each instance's live tokens' node ids and set definition_version to the target vid.
- Delivered as a mapping form plus a Drush command, with an auto-migrate fast path for instances whose live nodes all still exist.
Version cleanup
Hard invariant: never prune a version a live instance is pinned to, and never the current version (deleting a pinned snapshot would make that instance unable to advance). Cleanup is liveness-driven; any age/count cap is subordinate to this.
- Retention rule: a snapshot revision is retained while it is the current version, or while at least one instance references it via definition_version. It is prunable only when neither holds. Lazy creation and hash dedupe (a revert reuses an existing snapshot) keep the count naturally low.
- Terminal-instance policy (the one choice): keep while ANY instance references it (default, full trace fidelity), or keep only while a RUNNING instance references it (leaner; terminal-instance traces still render, falling back to node ids instead of labels). Default to the safe/full option.
- When pruning runs: on instance delete (extend InstanceCleanup to prune the now-orphaned, non-current version); a chunked cron GC sweep of revisions referenced by no instance and not current; and on workflow delete (extend WorkflowDeletion to cascade-delete the workflow's snapshots, like tokens/variables/incidents).
- Mechanism: core deleteRevision(vid), which refuses the default (current) revision, matching the invariant. Optional max_versions_per_workflow / max-age backstop that only ever prunes versions the liveness rule already allows.
Throughput contract
- Start = one cached pointer read + a field on a write that already happens.
- Advance = an immutable snapshot served from cache.
- Hash + snapshot creation happen once per edit, behind a short lock, never per start or per advance.
- No draft churn: a version exists only for a definition some instance actually ran.
Phases
- Snapshot entity + WorkflowDefinitionInterface + pinning + cached pointer + advance reads the snapshot (legacy fall-back). Makes editing-under-running-instances safe on its own.
- Version UI: a per-workflow version list with running-instance counts.
- Migration tooling and version cleanup (GC sweep, retention setting).
Pre-1.0 and backward compatibility
New content entity and a new instance field. Pre-1.0 ships no update hooks; existing sites pick them up via the throwaway entity-update, and pre-existing running instances (definition_version NULL) fall back to the live config until migrated, so the change is non-breaking.
Open questions
- Whether to add an explicit "deploy/publish" step later (this design is lazy-on-first-run by default; a deploy step could layer on without changing the core).
- How the remote API (orchestra_server_api) reports and pins versions for cross-server starts.
- Default for the terminal-instance retention policy (proposed: keep while any instance references the version).
Issue fork orchestra-3604588
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedRefined the design in the summary: store snapshots as a revisionable content entity (pin by vid) rather than a hand-rolled version field; create versions lazily on first execution (deduped by executable hash) instead of on every save; added the throughput contract (cached current-version pointer keeps start O(1); immutable snapshots keep advance a cache hit) and a liveness-based version-cleanup policy.
Comment #6
mably commented