Task: Finish the workflow fixture

Table of Contents

This page documents a task in the Exercise the workflow engine end to end story. It captures the goal, current status, acceptance, and any notes or results.

1. Goal

Every path the identity fixture can reach is trustworthy: a step that completes always advances its instance, each configuration is a named script that asserts its own terminal state, and a start the engine refuses is an error at the shell rather than a plausible instance id.

2. Status

Field Value
State DONE
Parent story Exercise the workflow engine end to end
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-09-27

3. Acceptance

  • Two engine handlers can no longer lose each other's write on a step row, so a step that reports completion always advances its instance whatever the timing.
  • The start path reports whether the engine accepted the run, so an unregistered type or a request with no steps fails at the shell instead of printing an instance id to follow.
  • Each configuration has a name and a script that asserts its terminal state, and each script fails when the path it covers does nothing.
  • The repeat-instance-id behaviour survives the race fix: supplying an id twice still leaves exactly one run.

4. Plan

Three workstreams, in this order, because each is what makes the next one trustworthy.

4.1. The step-row race

on_start_workflow calls stamp_command_published after it publishes, and on_step_completed calls set_step_state when the answer arrives. Both are a read-modify-write of the same row under optimistic locking, and the two subscriptions are not serialised against each other, whatever the engine's own header claims. The fixture answers in about two milliseconds, so the two overlap; the three-millisecond window is the whole bug.

Reproduce it against a live fleet before changing anything. What it looks like when it lands, from ores.workflow.service's log on the rebased #2184:

Publishing step command:  step_index=0 step_name=one step_id=c3f1d1ce-...
Step-completed event received: step=c3f1d1ce-... outcome=completed
Workflow STARTED: step_count=2 first_step=one
ERROR Executing INSERT failed: Version conflict: expected version 1, but current version is 2
ERROR Unhandled exception in NATS message handler for subject '...events.step-completed'

and workflow wait then reports [1/1] one: in_progress until it times out, with nothing the shell can report as an error.

The fix is not a mutex bolted on. If a publish can re-enter a handler on the same thread, a non-recursive std::mutex deadlocks and a recursive one hides the re-entrancy rather than removing it. Establish which of the two it is first: the timestamps above say the completion is processed inside the start, which is what a synchronous delivery on one thread would also look like, and the two want opposite fixes.

4.2. A start that reports acceptance

workflow start prints "Dispatched", an instance id and a follow-up command whatever happens. It did so for an unregistered type, for a request with no steps, and while every workflow in the product was failing to insert. Three of the four defects found on #2184 were invisible at the shell for exactly that reason, and were read out of a log instead.

The reply must report acceptance and not completion. The start is a durable JetStream publish and callers still poll for the terminal state, so this must not quietly turn a crash-safe publish into a request/response transaction. Record that where the reply is defined.

4.3. The configurations

Named, one script each, asserting terminal state rather than printing it. The set designed on the identity workflow task: happy_path, fan_out, with_warnings, fail_fast, saga, long_running, recovery, empty and unknown_type.

--instance-id is what makes this one invocation per configuration rather than two, so a script can start a run under a name it chose and then wait on it.

Each script must fail when the path it covers does nothing. That is the point of the task, and it is why the race comes first: a configuration written on top of it would pass whenever it happened to win.

5. Notes

5.1. The per-test watchdog cost a second per case

Found while measuring the suite. test_timeout_listener, registered by twenty test mains, slept in one-second ticks and testCaseEnded joined it, so every case that finished inside a tick waited out the rest of it. ores.shell.workflow.tests reported 34 cases at almost exactly 1.000s each – 34.17s for tests whose work is milliseconds – and ores.shell.application.tests took 22.23s the same way.

It reached this branch by rebase: the shell workflow tests used to register database_lifecycle_listener, and another environment's commit swapped it for this one, which is why the same target went from 1.3s to 34s at the rebase. The watchdog now waits on a condition variable, so teardown is immediate: 34.17s to 0.11s and 22.23s to 0.69s.

Worth knowing: changing that listener's members is an ABI break for every test binary that instantiates it. Building only the target under test leaves the others linking the new libores.testing.so with the old layout, which aborts with "terminate called without an active exception". All twenty were rebuilt.

5.2. Running a configuration

The fixture needs no other component's data, so every configuration runs as super_admin in the system tenant. Drive it from ores.shell:

compass shell -l <script>.ores

--load runs a script non-interactively and exits. A script cannot read a command's output back, which is why a run that has to be followed is named by its caller:

workflow start identity_workflow '{"steps":[{"name":"one"}]}' --instance-id <uuid>
workflow wait <uuid> --expect-steps 1

6. Test Scenarios

Manual QA scenarios (scaffolded via compass add test_scenario) that verify this task. Link new ones here as they're created; the scenario doc itself links back via its "Verifies task" field.

Scenario State Notes
     

7. PRs

PR Title
#2246 [workflow] Cover the engine paths a shell script cannot drive
#2225 [shell] Add the identity workflow configurations
#2207 [workflow] Serialise the engine handlers; wake the test watchdog

8. Review

Comment summary File Decision Notes
       

9. Result

9.1. The step-row race – done

Measured first, because the two candidate causes want opposite fixes. Both engine handlers were given a log line carrying std::this_thread::get_id(), the service was restarted and a two-step identity run was started. They are on different threads:

on_start_workflow   thread=140192874710720
on_step_completed   thread=140192883103424

and the interleaving reproduced on the same run:

23:13:26.802333 Publishing step command: step_index=0 step_id=7f7da171-...
23:13:26.804214 Step-completed event received: step=7f7da171-... outcome=completed
23:13:26.816318 Workflow STARTED: step_count=2 first_step=one
23:13:26.820405 ERROR Executing INSERT failed: Version conflict: expected version 1, but current version is 2

So it is true concurrency, not re-entrancy, and the engine's own header claim that the callbacks are "protected by the NATS library's serialisation guarantees" is simply false. That is what made the fix safe to pick: a plain std::mutex cannot deadlock, because a publish is asynchronous and the handler it provokes never re-enters this one on the same thread.

The engine now holds a std::mutex for the whole of on_start_workflow, on_step_completed and recover_in_progress, and the header says what is actually true, with the measurement in it. Held for the whole handler rather than around the write, because the read-modify-write is the thing that has to be atomic.

Verified on the same probe, a fresh id, with the fix in:

Start-workflow request received: instance_id=eeeeeeee-1111-2222-3333-444444444444
Publishing step command: step_index=0 step_name=one
Workflow STARTED: step_count=2 first_step=one
Step-completed event received: step=0 outcome=completed
Publishing step command: step_index=1 step_name=two
Dispatched step 1/1 (two)
Step-completed event received: step=1 outcome=completed
Workflow COMPLETED: steps=2

Zero version conflicts in the whole log, and the shell agrees:

workflow wait eeeeeeee-... --timeout 60 --expect-steps 2
  [1/2] one: completed
  [2/2] two: completed
✓ All 2 step(s) completed.

All four workflow test targets pass.

9.2. A start that reports acceptance – done

workflow start now matches the type against the registered definitions before it dispatches anything, and then waits for the instance row the engine creates. What it reports is acceptance, not completion: the publish stays a durable JetStream publish and callers still follow the run with workflow wait.

Verified against the live fleet:

workflow start no_such_workflow '{"steps":[{"name":"one"}]}'
✗ No workflow type named 'no_such_workflow' is registered.
✗ Registered types: bundle_publish_workflow identity_workflow ore_import_workflow
  provision_parties_workflow report_execution_workflow
✗ Script aborted at line 1

workflow start identity_workflow '{"steps":[{"name":"one"}]}' --instance-id ffffffff-...
Started identity_workflow.
workflow_instance_id: ffffffff-1111-2222-3333-444444444444

9.3. The configurations – done

Six recipes under doc/recipes/shell/workflow/, each tangling into a script that asserts its own terminal state: happy_path, fan_out, with_warnings, fail_fast, saga and long_running. All six verified in one pass against the live fleet, twelve commands and no aborts.

What they needed was workflow wait --expect-state, because a wait that only knows "all steps completed" cannot assert a run whose correct end is failed or compensated, and an aborted script cannot be told from a broken one.

Three paths have no script, and section 4 covers them instead. empty and unknown_type start nothing, so there is no terminal state for a script to assert; recovery needs a service restart mid-run, which a script cannot orchestrate; and a duplicate completion cannot be produced from outside the engine at all.

The instance ids are fixed, so a second run of any script reports the existing run rather than re-exercising the engine: each path is proven on first run only.

9.4. The paths a script cannot reach – done

Four cases in ores.workflow.core.tests, in workflow_engine_tests.cpp. A --load script can only start a run and watch it, so it cannot ask the engine to refuse a start, cannot deliver a completion twice, and cannot run a recovery pass. The handlers are called directly here, with the real store and the real bus either side of them.

  • starts nothing for a type it does not know and starts nothing for a definition with no steps assert no instance row and no step command. Each then makes the same call with a type that does resolve, and asserts that it does create one: that control is the only thing separating "the engine refused" from "the harness could not have worked either way".
  • advances once when a step completes twice. Two steps are declared, so a second advance would end the run rather than repeat a step. The instance is still in_progress at step 1, with two step rows and two dispatched commands.
  • recovery re-dispatches the step that was in progress. The re-dispatch carries the same step id, which is the idempotency key a service deduplicates on.

The harness runs the fixture in the test tenant, so the rows a case writes are its own, and reads the state ids from the system tenant, where dq seeds them. A state id carries no foreign key to a machine, so the two halves need not agree on a tenant.

9.5. The duplicate guard, shown to discriminate

A suite is only a regression test if it fails when the engine stops doing what it claims. Disabling the duplicate guard in on_step_completed fails exactly one case, on the instance state it asserts, and no other; the guard was then restored. The two refusal cases carry their control inside the case, so they cannot pass against a harness that starts nothing at all.

9.6. A flake the recovery case found

recover_in_progress is tenant-wide: it re-dispatches every instance the tenant has in progress, including the ones the earlier cases leave behind. The recovery case first counted the commands arriving on its subject, so the count depended on how many recoverable instances the other cases had left and on the order they ran in – green in one run, four commands and a foreign step id in the next. Every wait is now scoped to the case's own instance id, read from the X-Workflow-Instance-Id header. Five consecutive runs are green.

Emacs 29.3 (Org mode 9.6.15)