Repository navigation
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 pipelined batch on PostgreSQL, a
single-successor transition is one batch, a flow is created in one batch, and the claim returns the flow row.
Successors are inserted with multi-row inserts, and a flow-row lock no longer writes a row version on PostgreSQL
and MySQL. - Less allocation per step, and a graph cache that parses each graph once per replica.
- Two dispatchers per shard. The dispatchers are a window over the replicas in the order they joined, so the
role spreads evenly across shards. A replica losing its dispatcher slot keeps its share of the pool until the
steps it already claimed finish, and a draining replica hands its work to its peers. - Connection budget. A shard's budget is a constant of 250-500 connections, ramped by the round trip to the
database and clamped by the server'smax_connections, shared by its dispatchers.ShardSpec.MaxConnectionssets
it;ShardSpec.Weight(new) weights flow placement across shards. - Placement. A new flow goes to a shard the creating replica dispatches, when there is one. Any
ShardSpec.Weightother than 1 switches the fleet to weighted placement over every non-cordoned shard. - Scheduling at scale. The scheduling queries read only what each band needs, and the background sweeps no
longer read the whole step table at once.
Reliability
- A lost transition is replayed. A replica that dies between a step's completion and its transition used to
leave the flowrunningwith nothing to run. The completion now records what the transition needs, and recovery
commits it without running the task again. - Expired leases are recovered every minute (was 5), so a crashed replica's claimed steps run again within the
step'sTimeBudget+ 30 s + 1 min. A dead worker's completed step is recovered on the same bound. - Contention on the completion write is retried, never the task.
Shutdownno longer waits for background maintenance in flight; an abandoned pass is redone by a peer or the
next start.- Payload format. A flow records the format its state is stored in. A replica that meets a newer format answers
501 on the reads that cannot wait (Snapshot,Step,Fork) and defers the step to a newer replica. - Unreadable rows fail loudly. A step whose stored state, changes or references cannot be read fails, and
SnapshotandStepreturn the error, instead of carrying on with an empty value.
Limits
- Every value over 256 MB is refused: a 413 from
Create,Run,Continue,ResumeandFork. A step whose
output is too large fails at once, and a final state over the limit fails the flow with an empty final state.
MySQL and MariaDB needmax_allowed_packetof at least 512M, since a step row holds its state and its changes,
each up to 256 MB. - A
TerminateorCancelreason over 4 KB, or holding a NUL or invalid UTF-8, is a 400. - Stored error messages are capped at 16 KB, with invalid UTF-8 replaced.
Createreturns a 400 for aFlowOptions.Prioritybeyond 32 bits or aTimeBudgetover just under 24.8
days, andSetTimeBudgetreturns a 400 over that ceiling.
API changes
Reducers and the carrier:
workflow.Changes(contributions and overwrites) is a step's writes.State.ApplyandApplyAlltake it, and
Changes.Composecombines two.Changes.AdditionsandAdditionSuffixare new.- Removed:
State.Merge,MergeReduce,MergeReduceAll,DelNils. RawFlow.RawChangesandSetRawChangestakeChanges;RawFlow.SetReducers,RetryDelayandSetInBranch
are new.- A field name ending in
!or+cannot be written. Flow.SetChanges(source)drops its snapshot parameter and sets every field the source marshals, skipping empty
omitempty/omitzerofields. A host's generated wrappers drop theSnapshot()call.Flow.Getreturns(ok bool, err error)(waserror).Flow.ParseStateisParse.GotoRequested,RetryRequested,SleepRequested,InterruptRequestedandSubgraphRequestedmoved from
FlowtoRawFlow.
Renames:
Graph.SetEndpointisSetURL.workflow.NodeandGraph.Nodes()areTaskandTasks();workflow.ENDisEnd.Transition'sWithGoto,OnErrorandSwitchbooleans are oneKind("",TransitionSwitch,
TransitionGoto,TransitionError; JSONkind). Stored graphs with the booleans still decode.FlowStep.SubWorkflowURLandSubWorkflowNameareSubgraphURLandSubgraphName.FlowSummary.TaskNameandQuery.TaskNameareStoppedTaskName, and the meaning changes: the task the flow
stopped at, empty for a running flow, so the filter matches no running flow.ShardSummary.ShardisIndex, andLatencyMs intisLatency time.Duration.engine.TestProxyandNewTestProxyarehosts.Muxandhosts.NewMux, in a new package:
HandleGraph(workflowURL, graph),HandleTask(taskURL, func).Engine.FlowsTerminatedisFlowsEnded.
Added:
Engine.SetStateCacheSize,Engine.SetMaxPriority.FlowStep.SubgraphKey:Historynames a subgraph caller's child flow.ShardSpec.MaxConnectionsandShardSpec.Weight.
Removed:
Engine.SetDefaultPriority.Engine.HistoryMermaid: renderHistorywithworkflow.NewFlowRenderer(steps).WithLinks("step").Render().FlowRenderer.WithTitleandGraphRenderer.WithTitleLabel: no renderer draws a title.engine.TaskHandler:HandleTasktakes a plain func.Engine.SetMaxOpenConns: the engine derives each shard's connection budget, andShardSpec.MaxConnections
sets it instead, for example lower on a database shared with other workloads.Engine.DB,Engine.Seamsand the exported fault, checkpoint and variable seam constants.Engine.SetRefillInterval.workflow.TerminalStatuses: useIsTerminalStatus.ShardSpec.VirtualCPUs: pools no longer follow vCPUs.ShardSummary.StepsandFlows:ShardInforeports health only.
Wire and serialized formats
- The flow's JSON, which a host exchanges with a remote task, gains
reducers(the graph's) andinBranch.
changesnames an overwritten field with a!suffix:{"log": [...], "summary!": "x", "gone!": null}. On a
re-run it may also hold an addition recorded by an earlier run, under a+suffix:{"history+": [m]}. FlowStep's JSON dropsstepID,predecessorIDandsuccessorID; the edges arepredecessorKeyand
successorKey, as step keys.subgraphURL,subgraphNameandstoppedTaskNamefollow the renames above.ShardSummary's JSON hasindexandlatency(nanoseconds).
Metrics and operations
- Renamed:
dwarf_flows_terminatedisdwarf_flows_ended. Dashboards and alerts must change. - New labels:
dwarf_flows_startedanddwarf_flows_endedhave aprioritylabel, so more series; a View can
drop it. - New:
dwarf_operation_seconds(to see a slow shard),dwarf_state_cache_bytes,
dwarf_steps_dispatch_lag_seconds,dwarf_shard_dispatching. dwarf_steps_pendinganddwarf_steps_oldest_pending_age_secondsare reported every 30 s by one leading
replica per shard. They can be absent for up to 30 s at a handoff; aggregate them withmaxper shard, never sum.- No gauge queries the database at collection.
- Memory: the state cache defaults to a tenth of the memory limit (
GOMEMLIMIT, else the cgroup, else 1 GiB),
and withoutGOMEMLIMITthe heap can reach about twice that, so an upgraded replica's resident memory grows.
SetStateCacheSizeoverrides it. - Tracing: with no OpenTelemetry SDK installed by
Startup, the engine traces through a no-op and never picks
up a provider installed later. Install the tracer beforeStartup. - PostgreSQL sessions are pinned to UTC, so stored times are right whatever the server's or role's time zone.
New operating guidance in the docs:
- Set
autovacuum_work_mem = 1GBon PostgreSQL. - Leave
statement_timeout,transaction_timeout,idle_in_transaction_session_timeoutand MySQL's
max_execution_timeunset for the engine's database, and keep long-running queries off it. - After a failover to an asynchronous standby, or a restore, restart every replica.
Fixes
Terminateof an ended flow returns nil (was 409). Construction-time configuration errors are 400 or 409 (were
500).Resumewith data that is not JSON-marshalable is a 400 (was silently dropped).AwaitandRuntimeouts are 408s that satisfyerrors.Is(err, context.DeadlineExceeded), also when the
caller's context had no deadline. AnAwaitracing aResumestays parked instead of returning a 408 while the
flow is still running.ListandPurgereject a negativeOlderThanorNewerThanwith a 400, andPurgeaccepts everyList
filter.- SQL Server: an interval past ~24.8 days no longer overflows. Before, a
ListorPurgeage filter of 30 days
failed, and aflow.SleeporRetrybackoff that long failed its flow. - SQL Server: an operation over a few thousand flows or steps no longer exceeds the 2,100-parameter limit.
- Long tasks no longer slow
Createand the step hand-off. Retryreturns false for a negativeinitialDelayormaxIntervalDelay, or a multiplier that is negative, NaN
or infinite. A backoff with no maximum saturates rather than overflowing, and the engine fails a step whose retry
delay comes out negative.- A flow or step key parses only in the spelling the engine issues:
01-5-tokor1-+5-tokis a 404. SnapshotandResumefollow the interrupt the flow row records, so they agree withStoppedTaskNamewhen
several siblings interrupt.- During a deletion window,
Historyof a subgraph child's key returns 404 with its root. Forkof a flow that aDeleteorPurgestamped mid-call is a 409, and of one already removed a 404 (was a
500).Purgeno longer marks a root that aResumerevived after it was selected and that has since completed.Terminate,DeleteandPurgestop a flow whose rows hold an unresolvable state reference or an unparseable
graph, with an empty final state. They used to retry forever.- A step covered by
Cancelwhose changes, with the error added, exceed the payload limit fails on the size. - A failed child's error reaches its
flow.Subgraphcaller whole, status code and properties included. dwarf_steps_executed{interrupted}no longer counts an interrupt fenced away by a lost lease, and
dwarf_flows_endedcounts only the flows aTerminateitself ended.