Repository navigation
Releases: microbus-io/dwarf
Release list
The creation hour in every flow and step key
Flow and step keys carry the flow's creation hour, and a step key names its flow. This is groundwork for retaining
ended flows cheaply. This release edits migrations that earlier releases shipped, so existing databases cannot be
upgraded in place: start v0.13.0 on fresh databases (see Upgrading).
Breaking changes
-
Flow keys are
{shard}-{hour}-{flowID}-{token}.{hour}is the flow's creation hour, in whole hours since
2000-01-01 00:00 UTC, taken from the database clock. This applies everywhere a flow key appears:- the keys
Create,Run,ContinueandForkreturn; FlowSummary.FlowKeyandFlowSummary.ThreadKey;FlowStep.SubgraphKey;FlowOptions.ThreadKeyandQuery.ThreadKey;Flow.FlowKey().
A three-part key from an earlier release is rejected with 400.
- the keys
-
Step keys are
{shard}-{hour}-{flowID}-{stepID}-{token}: a step key names its flow. This applies to
Flow.StepKey(), toFlowStep.StepKey,PredecessorKey,SuccessorKey,PrevKeyandNextKey, and to the step
keysStepandForkaccept. A step key is still stable across every re-execution of one step. A key stored
under an earlier release, including an idempotency key built fromStepKey(), does not carry over. -
Keys are accepted only as dwarf spells them. A key is rejected with 400 if any of these hold:
- the token is not exactly 16 lowercase hex digits;
- a number in the key has a
+sign or a leading zero; - the hour is above 2^31-1.
Earlier, a database collation that ignores case or trailing spaces (MySQL's and SQL Server's defaults) could match
another spelling of a key to its flow, and anAwaitparked on that spelling was never woken. An out-of-range hour
now returns 400 instead of 500. -
A key with the right token but the wrong hour is not found (404), the same as a wrong token. So is a step key
that names a flow the step is not in. -
Migrations were edited in place.
dwarf_flowsgainscreated_hour, set by the database clock, and
dwarf_stepsgainsflow_created_hour. Existing databases cannot be upgraded in place, and flows in them do not
carry over.
Behavior changes and fixes
- Keys now reveal when their flow was created, to the hour (see Data handling). The
correlation id on traces, logs and metrics is unchanged:{shard}-{flowID}, with no hour or token. Terminate,Delete,Purgeand the recovery sweeps no longer fail on a flow deleted concurrently. If the
flow's row was removed by the reaper or a racingDelete, the operation finishes with an empty final state
instead of returning an error.- Fewer spurious steals on a healthy fleet. Under saturation, two dispatchers sharing a shard no longer report
dwarf_steps_stolenanddwarf_steps_claim_lostwhen neither is slow. - Deleted flows are removed in smaller batches: 512 root flows per transaction, not 4,096. On PostgreSQL, a
deletion of large-state flows then holds back vacuum for seconds rather than tens of seconds.Purgestill marks
up to 4,096 flows per call. Query.Searchmatches keys in the new spelling, soListandPurgeby a full key work as before.
Performance
- MySQL and MariaDB: creating a flow costs one more statement, which reads back the creation hour.
Fork: one more read per flow it clones, on every database.- Otherwise no change. The engine's internals were restructured with no change in behaviour, and the local
benchmarks show no change in throughput.
Reducers That Fold Everywhere, History Stored Once, Priority Bands That Spill
v0.12.0 makes a reduced field mean the same thing wherever a task writes it. Every write to a field with a
reducer is a contribution, folded through the reducer at every hand-off: the next step, the fan-in, the final
state and Continue. A task writes only what it adds, and the outcome no longer depends on the graph's shape. An
agent loop's history is stored once: a field under append, or a list a task keeps rewriting but only extends,
is kept as references to the rows that hold its elements rather than as the whole history at every step.
Priorities are now five bands that spill, so a better band no longer starves a worse one while workers sit
idle. Beneath that, the engine does fewer round trips per step, a shard is dispatched by two replicas at a
budgeted pool, and a step whose transition was lost to a crash is replayed rather than stranded.
This release breaks the schema, the API and the wire format, so upgrading from v0.11.0 needs a maintenance
window and a fresh database or hand-applied schema changes. Read "Upgrading from v0.11.0" first.
Highlights
- Reducers fold at every hand-off. A write to a reduced field is a contribution everywhere;
Overwriteand
Delreplace. (Behavior change - see below.) - An agent loop stores each message once. A 100-step loop of 1 KB messages stored ~37x less under
append, and
~33x less when a task rewrites a growing list each step, on PostgreSQL. - Priority bands that spill. Priorities run 1 to 5 by default (
SetMaxPrioritywidens it up to 100), the
default is 3, and capacity a better band leaves unused goes to the next one. (Breaking - see below.) - Fewer round trips per step. The transition after a task runs as one pipelined batch on PostgreSQL, a flow
is created in one batch, and the claim returns what the dispatch needs. - Two dispatchers per shard, on a budgeted pool. Each shard's connections are a budget of 250-500 by distance
to the database, clamped by the server, and split between the two replicas that dispatch it.
ShardSpec.MaxConnectionsis the lever. - A lost transition is replayed. A replica that dies between a step's completion and its transition no longer
leaves the flow stranded; the step's recorded outcome is committed without running the task again. - A 256 MB ceiling on every value, refused with a 413 instead of failing deep inside a write.
- Many API renames and removals ahead of v1.0. (Breaking - see "API changes".)
Upgrading from v0.11.0
Plan a maintenance window. Three things change at once: the schema, how stored writes to reduced fields are
read, and how replicas share a shard. None of them can be rolled through one replica at a time.
1. Drain in-flight flows
Let every flow finish, or Terminate it, before the upgrade. In v0.11.0 a step's write to a reduced field outside a
fan-out branch was the field's whole value; v0.12.0 reads every stored write as a contribution and folds it in.
Anywhere a v0.11.0 step's stored output is folded again, it would count twice: an interrupted step resumed, a
pending Retry, a Subgraph re-entry, a replay, a cohort still converging, a tail merged into the final state.
Terminal flows are unaffected; a Continue from one folds its final state the new way, which is correct.
2. Start from a fresh database, or apply the schema changes by hand
The migration scripts were edited in place, so a v0.11.0 database does not receive these changes on startup. A
fresh database gets all of them. On an existing one, apply them during the drained window:
Columns added. Without them the engine cannot run:
dwarf_flows.payload_ver: without it everyCreatefails.dwarf_flows.graph_hash,NOT NULLwith no default. A hand-appliedALTER TABLE ... ADDon a populated table
needsDEFAULT '', which the engine reads as "no hash" and falls back to reading the graph.dwarf_steps.advancingand theadvance_*columns,state_reducedandpayload_deferrals: without
payload_deferralsevery claim fails.dwarf_peers.draininganddwarf_peers.joined_at: withoutdrainingevery registry read fails, and each
replica dispatches every shard at the full budget.
Use the per-dialect definitions in the migration scripts for the ALTER TABLE ... ADD statements.
Column renamed: dwarf_flows.step_id is stopped_step_id. On an older database every List, Create and
claim fails with "no such column". Rename it after the drain, since a running flow's old value is not a stopped
step: ALTER TABLE dwarf_flows RENAME COLUMN step_id TO stopped_step_id; on SQL Server
EXEC sp_rename 'dwarf_flows.step_id', 'stopped_step_id', 'COLUMN'.
Column type changed: dwarf_steps.subgraph_error is a JSON column (JSON, JSONB, VARBINARY(MAX) or TEXT by
dialect, like subgraph_result), holding {} for no error. Alter it to the type the migration script declares.
PostgreSQL time columns are TIMESTAMP, not TIMESTAMPTZ. An older database keeps TIMESTAMPTZ, which on a
server or role whose time zone is not UTC stores every time shifted by the offset (a flow.Retry give-up horizon
then never expires). After the drain, alter each time column with
ALTER TABLE ... ALTER COLUMN <col> TYPE TIMESTAMP USING <col> AT TIME ZONE 'UTC', or keep the server in UTC.
Rebuild idx_dwarf_steps_due on PostgreSQL, SQL Server and SQLite: drop it and recreate it from the migration
script. Its key and its predicate changed. An older database keeps the old index, which the new scheduling
statements no longer match, so they silently fall back to reading every pending row. MySQL's is unchanged.
Indexes removed. An older database keeps them; they are harmless but written on every step:
DROP INDEX idx_dwarf_steps_saturationDROP INDEX idx_dwarf_steps_status(... ON dwarf_stepson MySQL and SQL Server)
PostgreSQL storage setting: dwarf_flows no longer sets a fillfactor. A table created during this release
cycle may carry fillfactor = 80, which is harmless; ALTER TABLE dwarf_flows RESET (fillfactor) reverts it for
new pages.
3. Priorities run 1 to 5
The default priority is 3, the midpoint, and SetDefaultPriority is gone. SetMaxPriority(n), at most 100 and set
before Startup, widens the range; Create and Run reject a priority above it. Lower numbers still run first.
Work stored above the range is served after every level in it. That includes every flow created at the old default
of 100: in flight if it was not drained, and anything a Continue, Fork or subgraph inherits from a finished one,
since inheritance keeps the stored priority. No migration remaps it. To bring old threads back into range, run
UPDATE dwarf_flows SET priority=3 WHERE priority>5, and the same on dwarf_steps, during the window.
4. Upgrade every replica, and the host, together
Mixed versions disagree on which replicas dispatch a shard and how its pool is sized, on how stored state
references are read, and on the flow's JSON form. In particular, a v0.11.0 replica reads v0.12.0's list and
forEach element references as no references at all: silent data loss. From this release on, a replica fails a
step it cannot read rather than dropping data, and a replica that meets a flow in a newer payload format defers it
to a newer replica.
Reducers fold at every hand-off
A write to a field with a reducer is a contribution, folded through the reducer at the next step, the fan-in,
the final state and Continue. A read returns the combined value.
- A task writes only its contribution.
f.Set("messages", []any{m})underappendaddsm. A read-modify-write
(Set(k, append(Get(k), m))) adds the history again. - The result does not depend on graph shape. A step that changes nothing changes nothing, and wrapping a step in
a one-itemforEachgives the same result. Flow.Overwritereplaces a reduced field instead of contributing to it, andDelisOverwrite(k, nil).
Both are refused on a reduced field inside a fan-out branch, where a reset has no defined meaning for the other
branches.- A step's runs compose. On
Retry, a resumedInterruptor aSubgraphre-entry, the task sees its earlier
runs' writes and adds to them. Write afterInterruptorSubgraph, not before. - A bare nil contributes nothing to a reduced field.
- Nested fan-outs: an inner cohort's work reaches the outer fan-in and the final state. Before, it could be lost.
- A
Forkat an inner fan-in hands on its cohort's original part, whatever the overrides. - Folds keep numbers exact, including integers past 2^53, and
unioncompares numbers by value, so1,1.0
and a database's respelling of the same number are one element.
An agent loop stores each message once
A field under append, and a list with no reducer that a task rewrites each step but only extends, are stored as
references to the rows holding their elements, instead of the whole history at every step. A 100-step loop of 1 KB
messages measured ~37x less storage under append and ~33x less for the rewrite, on PostgreSQL. A forEach branch
references its element inside the array rather than storing a second copy.
dwarf_state_write_bytes gained column=state_refs, where those references are written; such a workload's state
series falls to near zero.
Priority bands that spill
The scheduler fills the best due band first, then gives whatever capacity remains to the next, and so on. A sparse
but continuous better band therefore no longer starves every worse band while workers sit idle. Priority stays
strict over capacity: worse work gets only what better work leaves unused.
Throughput and dispatch
- Fewer round trips per step. The transition after a task is one pipel...
Graceful Cancel, Budget-Bound Shard Connections, Measured Pool Sizing
v0.11.0 gives a flow a way to stop gracefully. The immediate, unconditional stop is renamed Terminate,
and Cancel now delivers the cancellation to every step in progress as an error on its node's onError
transition, so a workflow can compensate, clean up, or carry on. The flow ends cancelled, failed, or
completed, depending on what its handlers do. Beneath that, a shard's connection budget no longer grows with the
fleet. A few replicas per shard dispatch it and split its budget, and every other replica holds a
two-connection reader pool there, so a host scaled wide for its own traffic can no longer over-connect the
database. Pool sizes come from measured sweeps on six database sizes instead of a model, sleeping steps get an
index of their own so they stop slowing every scheduling pass, and graph validation rejects field names that a
when expression could never reference.
Highlights
- Graceful
Cancel. Every step in progress receives the cancellation on itsonErrortransition, where
workflow.IsCancelled(err)reports true. The flow resolvescancelledwhen every unrecovered loss was a
cancellation. (Breaking — see below.) Terminateand theterminatedstatus. The old immediate stop keeps its behavior under a new name and a
terminal status of its own. (Breaking — see below.)- Cancellation crosses subgraphs. A cancelled child reaches its caller as an error
workflow.IsCancelled
recognizes, and a real child error now keeps its status code and properties across the boundary. - Each shard's connections are bounded by its budget, not by the fleet. Dispatcher replicas split the budget;
every other replica holds two connections. A newdwarf_shard_dispatchinggauge shows which is which. - Measured pool sizing. The connections-per-vCPU table is measured on 1–64 vCPU databases across 0.25–2 ms of
round-trip time and interpolated between tiers. Short-RTT pools are markedly smaller than before. - Sleeping steps stop costing dispatch. A step waiting on
flow.Sleep, a retry backoff or a long poll no
longer adds to every scheduling pass. - Stricter graph validation.
forEach,asandSetReducerfield names must be identifiers.
(Breaking — see below.)
Graceful Cancel
Engine.Cancel(ctx, rootFlowKey, reason) asks a flow to stop and lets the flow decide how. Every step in
progress when it is called is covered, and each receives the cancellation as an error on its node's onError
transition:
- A step already running finishes first. Its changes are kept, but its next transition is replaced by the
onErrorroute. - A step that has not started is not run. It goes straight to its
onErrorroute. - An error the task returns itself wins over the cancellation and takes the ordinary error path.
- A step with no
onErrortransition fails its branch. The flow endscancelledwhen every unrecovered
loss in it was a cancellation, andfailedwhen a real error is mixed in. The outcome is decided per loss,
never from whetherCancelwas called, so a flow whose handlers all recover endscompleted. - Subgraphs. Cancellation reaches the whole tree under the root key. A subgraph caller runs once more when its
child returns, so its code sees the child's result (a cancelled child's error is oneworkflow.IsCancelled
recognizes) before its own cancellation is delivered. - Interrupts. An interrupted step is cancelled when it is next resumed, and its resume data is not acted on.
- Once per flow. A second
Canceldoes nothing, so a call whose response was lost is safe to retry, even
after a handler caught the first cancellation and the flow carried on. To stop a flow that recovered,
Terminateit.
Cancel returns once the request is recorded, not when the flow stops; use Await for that. A Fork of a
cancelled flow is not under cancellation.
g.AddTransition("Charge", "Ship")
g.AddTransitionOnError("Charge", "Refund") // runs on a cancellation, with workflow.IsCancelled(onErr) true
err := engine.Cancel(ctx, flowKey, "customer withdrew the order")Terminate(ctx, flowKey, reason) remains the unconditional alternative: in-flight work is abandoned, no handler
runs, and the flow reads terminated as soon as the call returns. FlowOutcome and FlowSummary report the two
reasons separately, as TerminateReason and CancelReason.
Budget-Bound Shard Connections
In v0.10.x every replica took at least two connections on every shard, so past a certain fleet size the shard's
connection budget stopped dividing and every added replica pushed the database further past it. Over-connection
is the direction in which a database collapses rather than merely slowing down, and a host scaled wide for its own
traffic could reach it without doing much workflow work at all.
Each shard now has a small number of dispatcher replicas that split its connection budget and run its steps.
Every other replica holds a reader pool of two connections there, enough to create, await and read flows, and
runs none of that shard's steps. The shard's total stays within its budget at any fleet size.
- Selection is automatic. Each shard's dispatchers are chosen by a rank every replica computes the same way
from the shared peer registry, and it re-balances as replicas join and leave. A replica that shuts down gives up
its slot at the start of its drain, not the end, so a small shard is never left waiting on the slowest
in-flight task. - A crashed dispatcher holds its slot for about 40 s, until its registry row ages out. The shard runs on its
other dispatchers meanwhile; it stops only if all of them crash at once. SetMaxOpenConnsopts out. With an explicit pool size, every replica dispatches every shard at that size.- Observability.
dwarf_shard_dispatching{shard}is 1 where this replica dispatches and 0 where it only
reads. Sum it across the fleet: a shard summing to zero runs nothing. The engine also reads the database's
connection limit at startup and logs a warning when the fleet approaches it.
Measured Pool Sizing
The connections-per-vCPU table that sizes each dispatcher's pool now comes from pool sweeps on Cloud SQL at 1, 2,
4, 8, 16, 32 and 64 vCPU across round-trip times of 0.25–2 ms. It replaces a model that over-provisioned at short
round-trip times on every tier measured, and worst on the smallest: a 1-vCPU shard derived 15 connections where 3
sustain its load. Each cell is the smallest pool that sustained that distance's achievable throughput, with a
safety margin applied on top. A shard between two measured sizes interpolates between them, and one declared
larger than 64 vCPU uses the 64-vCPU row.
Sleeping steps get an index of their own. Measured at 400k pending steps in one priority band, 396k of them
asleep: a scheduling pass goes from 47 to 5 ms on PostgreSQL, 38 to 5 ms on SQL Server, 295 to 12 ms on SQLite,
and 192 to 93 ms on MySQL. MariaDB's optimizer does not use it and is unchanged.
The scheduler's scan for work and its supply of work to the workers now run as two independently paced loops,
so a slow scan over a deep backlog no longer throttles how fast workers receive steps.
Other Changes
- Many fairness keys work on SQL Server. More than about 2,000 active fairness keys exceeded SQL Server's
per-statement parameter limit, and that shard quietly stopped supplying work. On PostgreSQL and SQL Server the
fetch also stops early per key instead of ranking every due row. whenexpressions compare integers exactly rather than through a float64, and their literals are exactly
true,falseand finite numbers.- A shard on a slow database kept its readers from being promoted, so it ran nothing after its dispatchers
left. - A rolling restart could briefly exceed a shard's connection budget.
- A cancel reason containing
%was garbled. - Dependencies. boolexp v1.3.0.
Breaking Changes
Cancelis renamedTerminate, andCancelis now the graceful operation. Code that calledCancelfor
an immediate stop still compiles, but it now gets graceful cancellation: steps are not abandoned, andonError
handlers run. Change those call sites toTerminate.- New terminal status
terminated.workflow.StatusTerminatedjoinscompleted,failedandcancelled,
andcancellednow means a graceful cancellation. Code that switches on a status string must handle the new
value;workflow.IsTerminalStatusandworkflow.TerminalStatusesinclude it. FlowOutcomeandFlowSummarygainTerminateReason, andCancelReasonnow carries only aCancel
reason.Validaterejects aforEach,asorSetReducerfield name that is not an ASCII identifier (a letter or
underscore, then letters, digits or underscores). A dotted name such asorder.itemswas a nested path in
whenbut a flat key inforEach, so a fan-out over it found no field and ran zero branches without an error.
State itself may still carry fields under any name.
Operational Prerequisites
- Stop the whole fleet to upgrade, as for every dwarf release: no replica of v0.10.x may run against a
v0.11.0 database. - Apply the schema changes by hand, on every shard, before starting v0.11.0. The engine does not migrate a
database created by v0.10.x forward: without the statements below, the new columns and index are never added,
and the first query that uses them fails. Flows in progress carry over. The twoUPDATEstatements relabel
flows and steps that v0.10.x stopped with itsCancel, which v0.11.0 callsterminated; without them those
rows read as graceful cancellations.
MySQL and MariaDB:
ALTER TABLE dwarf_flows ADD COL...Update dependencies
Update dependencies to latest, including Sequel v1.11.1 which changes the sequel_pool_wait_count and sequel_pool_wait_duration_seconds gauges to counters and renames them appropriately.
A fan-out node can route to END instead of fanning out
Fixes a non-terminating loop. A node that both fans out over an array and carries a
withGoto/onError edge to END — the shape a task-driven loop takes, "fan out over
what is pending, or end the flow when there is nothing left to do" — now ends the flow
when the task steers to END.
Previously it converged on its fan-in instead: an explicit route to END and a
genuinely empty forEach array both leave the cohort empty, and the engine could not
tell them apart. Where the fan-in leads back to the source, which is the point of the
shape, the flow never terminated and wrote a step row per lap — thousands within
seconds — until something outside it gave up.
No schema migrations. No change to dispatch, scheduling, recovery, or pool sizing.
Newly expressible
g.AddTransitionForEach("Decide", "Work", "pending", "current")
g.AddTransitionGoto("Decide", workflow.END) // the task picks, per round
g.AddTransition("Work", "Join")
g.AddTransition("Join", "Decide")
g.SetFanIn("Join")Validate() used to reject this outright ("unpopped fan-out frames"), because the
goto was treated as a branch of the fan-out. It is a runtime override the task body
chooses — taking it means no branches are spawned at all — so it now carries no
fan-out frame, and reaching END through it is legal.
Behavior change: two graph shapes are now rejected at Create
Same rule, applied in the other direction. An override edge out of a fan-out node
bypasses the fan-out, so it must not rejoin it:
| Override edge out of the fan-out node | |
|---|---|
→ END |
allowed |
| → that node's own fan-in, directly | allowed (converges on the source's own state, as an empty array does) |
| → a path that never reaches the fan-in | allowed |
| → a node that later leads back into the fan-in | rejected — no fan-out frame to pop |
| → one of the fan-out's own branches | rejected — two different lineage stacks |
The two rejected shapes passed Validate() in earlier releases and then failed at
dispatch — the fan-in was waiting on branches that were never created, so the arrival
had no cohort to arrive at. The failure just moves to Create, where it is
diagnosable.
Nothing in flight is affected. A graph frozen onto a running flow is never
re-validated, so existing flows continue as they are; only new Create calls see the
check. If one of your graphs has an override edge out of a fan-out node, validate it
before upgrading.
Note that this restricts the fan-out node's own outgoing edges only. A branch
whose onError handler rejoins the fan-in is unchanged and still valid — that is the
documented "an errored branch continues down its handler path and rejoins the cohort"
behavior.
NewEngineUnderTest takes a test name, not a testing.TB
Test-support change only. engine.NewEngineUnderTest is now keyed by a plain test
name instead of a testing.TB, so a host can stand up an isolated engine under its
own harness — one that has no *testing.T to hand over. A name was all the
constructor ever needed from one.
No schema migrations. No change to dispatch, scheduling, recovery, pool sizing, or
anything else on the production path.
Breaking (test code only)
NewEngineUnderTest(t testing.TB)→NewEngineUnderTest(testName string).
Passt.Name()from a Go test.Engine.SetTestNameis removed. The constructor argument is the isolation key,
so there is nothing left to override.Startupno longer registers at.Cleanupshutdown. Teardown is yours:
defer eng.Shutdown(ctx).- The logger default no longer varies by
testing.TBtype. Every test engine logs
to stderr at Error (DWARF_TEST_LOG_LEVELoverrides as before); a benchmark or
fuzz target silences it withSetLogger(slog.New(slog.DiscardHandler)).
Migration
// before
eng := engine.NewEngineUnderTest(t)
eng.SetHost(proxy)
eng.Startup(ctx)
// after
eng := engine.NewEngineUnderTest(t.Name())
defer eng.Shutdown(ctx)
eng.SetHost(proxy)
eng.Startup(ctx)Engines that must share one isolated database — a multi-replica test — take the
same name. Engines meant to be separate deployments take different names
(t.Name()+"#a", t.Name()+"#b"), which is what SetTestName was for.
Two things to check in your own tests
Where the shutdown defer sits. Registered right after the constructor, it
unwinds last. A test that holds a worker on a channel must release it from a
defer declared below the shutdown one, or the drain waits forever on a worker
that can never return. A t.Cleanup release is worse — it runs after every defer,
including the shutdown. Six fixtures in this repo needed that fix.
An engine you start is now yours to stop. Anything that relied on the old
auto-cleanup leaks its dwarf_peers row and its connections. That is invisible in a
single run and fails the next run in the same process, where the stale rows still
count toward the replica divisor.
Upgrading is still governed by docs/upgrading.md.
Replicas coordinate through the database, and size themselves
This release rebuilds how a dwarf fleet finds work, divides resources, and carries state. Both the API and the database schema change. Dwarf is v0.x, so upgrading is a maintenance window, not a rolling deploy — see docs/upgrading.md.
Breaking changes
HostlosesSignalPeers. The engine now sends nothing to its peers at all; hosts implement two methods,LoadGraphandExecuteTask. Three signal kinds existed here and all three were deleted after measurement — a per-step work doorbell, a per-flow stop broadcast, and a fleet-membership nudge — because in each case the poll the engine already runs converges faster than the message.SetShard(ShardSpec{Index, DSN, VirtualCPUs})replacesSetDSN/SetNumShards/SetWorkersPerConn. You declare facts about each shard's server; the engine derives the tuning. NewSetEngineIDpins a replica identity so restarts don't inflate the observed fleet count.workflow.Stateis a type, not amap[string]any— onFlowOutcome.State,Flow.Snapshot(), baggage, and interrupt/subgraph payloads. It holds each field as raw JSON and decodes on read.Flow.Delete→Flow.Del;Flow.SetState,Flow.Transform,MergeState,Graph.HasFanInand theDuration()helpers are gone;Render()no longer returns an error; typed getters panic on a mistyped read instead of quietly returning a zero value.NewEngineUnderTest(t)/SetTestNamereplaceRunInTest/SetInTest.- Schema: a new
dwarf_peersregistry, new step columns (state_refs,engine_id), fixed-widthCHARtokens and statuses widened toVARCHAR, and SQL Server JSON payloads stored asVARBINARY— which ends a silent corruption of non-ASCII payloads on that dialect. The baseline migrations are edited in place, so a database created by v0.9.x cannot be migrated forward — recreate it.
Coordination is the database, and nothing else
Replicas discover each other through a dwarf_peers registry read per shard, not through the host's transport. From one reading a replica derives two different numbers with deliberately opposite risk postures: how many ways to divide a shard's connection budget (over-count and you collapse a database) and which replicas demonstrably dispatch work (over-count and you strand it). A replica that goes blind holds its last good reading rather than acting on silence.
Sizing derives itself
Connection pools, the worker crew, the candidate cache and the scan cadence are all derived from declared vCPUs and the observed fleet, and every path that moves a pool re-derives what depends on it. Pools sit at 6x vCPUs, or 12x on servers of 32 vCPUs or more. The worker crew grows on demand for long tasks and retires surplus goroutines on their own idleness, bounded by a ceiling derived from the crash-recovery lease margin and a startup RTT probe. Database access is ordered by band and job age through a per-shard turnstile.
Dispatch is per-shard
The single merged refiller is replaced by a fleet of per-shard pistons with no barrier between them (the straggler wait measured 2.02x at six shards). Selection is three-phase and capped, so scan cost tracks fairness-key cardinality rather than backlog depth — a 3M-row single-key flood fell from 15.3s to 2.2s per pass on Postgres. Candidates are partitioned across replicas by residue class (claim misses 41.3% → 11.6% at R=2), and a replica that is alive but not keeping up has its share stolen rather than stranded (137–153 → 458–501 steps/s with one crippled peer).
State is stored once, not once per step
A large carried field is written to one anchor row and referenced from every step that carries it — ~29x fewer stored state bytes on a document-extraction shape. Flow-boundary flattening, Fork remapping and fan-in reduction are handled by the engine; the encoding never reaches task code.
Durability and correctness
A step's outcome is persisted by retrying the write, never re-running the task; when the retries are exhausted the engine asks the database which failed rather than reading driver error codes. Lease generations (lease_seq) fence every post-execution write, so a slow worker can no longer corrupt or terminalize a flow a peer is healthily re-running. Plus fixes to fan-in cohort accounting, Fork's nested-cohort recompute, subgraph failure propagation and orphan recovery.
Observability, docs and benchmarks
33 dwarf_* OTEL instruments, including per-column state byte counters and gauges for state held across a host call. Seven new operator guides — data handling, flows, deployment, runbook, backup and restore, resharding, production checklist, upgrading — and a cloud benchmark campaign with sizing formulas and charts, measured from 1 to 64 database vCPUs.
Testing
Tests rendezvous with engine checkpoints instead of sleeping, the suites run in parallel under a connection budget, and CI sweeps all four dialects — SQLite, PostgreSQL, MySQL, SQL Server.
Seamster extraction and Graph.Validate race fix
This release fixes a concurrency bug in graph validation and moves the engine's test-only instrumentation into a
standalone module. Both live in one commit (0c113c3).
Fixed
- Data race in
workflow.Graph.Validate.Validate()built the fan-out to fan-in convergence map as a side
effect stored on the graph. Because a graph is loaded once and shared across concurrentCreatecalls, two
validations (or a validation racing aMarshalJSON) wrote and read that field at the same time, which the race
detector flagged and which could corrupt the map.Validate()is now pure over the graph's immutable definition
and stores nothing on the graph.
Changed
-
Fan-in routing is now derived at dispatch, not persisted. The fan-out to fan-in map is a pure function of the
graph structure, so the engine computes it per flow from the frozen graph definition (new internal package
internal/faninmap) and caches it beside the parsed graph. It is no longer written into the stored graph JSON, so
a workflow graph carries only its author-defined shape over the wire. -
Test seams extracted to the
seamstermodule. The fault-injection and checkpoint instrumentation the engine
uses in white-box tests now lives ingithub.com/microbus-io/seamsterv0.1.0 (a new, standard-library-only
dependency). The engine embeds oneseamster.Seamsterase.seamsand consults it directly
(IsFault,Checkpoint,Inject,Wait,Break,Resume). The seam is inert in production by construction.
engine/debug.gois renamedengine/seams.goand reduced to the engine's fault and checkpoint names plus
deliverFailureLost, the one consult that needs a database read.
API changes
-
Removed
workflow.Graph.FanInForand thefanOutToFanInJSON field.Graphnow exposes only its definition
and validation. The fan-in lookup thatFanInForprovided is an engine concern, served byinternal/faninmap.
A pre-existing graph JSON that still carries afanOutToFanInfield decodes cleanly (the field is ignored), and
the routing is recomputed from the structure, so persisted flows keep working.This removes an exported method, so downstream code calling
graph.FanInFor(...)will not compile. If that
matters for the version line, consider taggingv0.10.0instead ofv0.9.5.
Docs
- Updated
engine/CLAUDE.mdanddocs/graphs.mdto describe pure validation and the dispatch-time fan-in map.
Upgrade notes
- No schema migration. No behavior change for existing flows.
go get github.com/microbus-io/dwarf@v0.9.5pulls inseamsterv0.1.0 automatically.
Maintenance release. No API or behavior changes to the engine.
Fixes
- Test stability: Fixed a MySQL-timing flake in
TestFault_PollSizingErr. ThefaultPollSizingErr
fault is now armed sticky rather than one-shot, so the backgroundtimerLoop's own poll can no longer
consume the fault before the test's explicit poll — the poll reliably clamps to the near-term
error-retry interval instead of occasionally sleeping the fullmaxPollInterval.
Dependencies
- Bumped
github.com/microbus-io/sequelv1.10.6→v1.10.7. - Bumped
golang.org/x/textv0.38.0→v0.39.0(indirect).
Docs
- Removed the stale Go Report Card badge from
README.md.
Poll operation
The engine now exposes Poll(ctx, flowKey) alongside Await. Like Await, it blocks up to the context deadline for a flow to stop, but where Await treats a deadline expiring before the flow stops as a timeout error, Poll instead returns the flow's current outcome — non-terminal, with Stopped() reporting false — and no error. This makes it the natural fit for bridging an open-ended durable flow to a bounded request (for example, answering an HTTP long-poll within its time budget and re-polling on the next request) without conflating "not done yet" with a real failure. The returned FlowOutcome gains a Stopped() helper to distinguish a terminal/interrupted outcome from one still running. As always, the underlying flow keeps running on the engine independent of the caller; a ctx timeout never tears it down.