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

  1. Snapshot entity + WorkflowDefinitionInterface + pinning + cached pointer + advance reads the snapshot (legacy fall-back). Makes editing-under-running-instances safe on its own.
  2. Version UI: a per-workflow version list with running-instance counts.
  3. 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

Command icon 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

mably created an issue. See original summary.

mably’s picture

Issue summary: View changes

Refined 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.

  • mably committed 69bccdd0 on 1.x
    Workflow versioning phases 2-3: version UI, publish and migration (#...

  • mably committed 7fe55953 on 1.x
    Workflow definition versioning: pin running instances to a version (#...
mably’s picture

Status: Active » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.