Skip to content

Releases: microbus-io/dwarf

The creation hour in every flow and step key

Choose a tag to compare

@bw19 bw19 released this 31 Oct 23:56

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, Continue and Fork return;
    • FlowSummary.FlowKey and FlowSummary.ThreadKey;
    • FlowStep.SubgraphKey;
    • FlowOptions.ThreadKey and Query.ThreadKey;
    • Flow.FlowKey().

    A three-part key from an earlier release is rejected with 400.

  • Step keys are {shard}-{hour}-{flowID}-{stepID}-{token}: a step key names its flow. This applies to
    Flow.StepKey(), to FlowStep.StepKey, PredecessorKey, SuccessorKey, PrevKey and NextKey, and to the step
    keys Step and Fork accept. 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 from StepKey(), 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 an Await parked 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_flows gains created_hour, set by the database clock, and
    dwarf_steps gains flow_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, Purge and the recovery sweeps no longer fail on a flow deleted concurrently. If the
    flow's row was removed by the reaper or a racing Delete, 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_stolen and dwarf_steps_claim_lost when 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. Purge still marks
    up to 4,096 flows per call.
  • Query.Search matches keys in the new spelling, so List and Purge by 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

Choose a tag to compare

@bw19 bw19 released this 23 Oct 04:12

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; Overwrite and
    Del replace. (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 (SetMaxPriority widens 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.MaxConnections is 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 every Create fails.
  • dwarf_flows.graph_hash, NOT NULL with no default. A hand-applied ALTER TABLE ... ADD on a populated table
    needs DEFAULT '', which the engine reads as "no hash" and falls back to reading the graph.
  • dwarf_steps.advancing and the advance_* columns, state_reduced and payload_deferrals: without
    payload_deferrals every claim fails.
  • dwarf_peers.draining and dwarf_peers.joined_at: without draining every 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_saturation
  • DROP INDEX idx_dwarf_steps_status (... ON dwarf_steps on 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}) under append adds m. 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-item forEach gives the same result.
  • Flow.Overwrite replaces a reduced field instead of contributing to it, and Del is Overwrite(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 resumed Interrupt or a Subgraph re-entry, the task sees its earlier
    runs' writes and adds to them. Write after Interrupt or Subgraph, 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 Fork at an inner fan-in hands on its cohort's original part, whatever the overrides.
  • Folds keep numbers exact, including integers past 2^53, and union compares numbers by value, so 1, 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...
Read more

Graceful Cancel, Budget-Bound Shard Connections, Measured Pool Sizing

Choose a tag to compare

@bw19 bw19 released this 28 Sep 02:21

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 its onError transition, where
    workflow.IsCancelled(err) reports true. The flow resolves cancelled when every unrecovered loss was a
    cancellation. (Breaking — see below.)
  • Terminate and the terminated status. 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 new dwarf_shard_dispatching gauge 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, as and SetReducer field 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
    onError route.
  • A step that has not started is not run. It goes straight to its onError route.
  • An error the task returns itself wins over the cancellation and takes the ordinary error path.
  • A step with no onError transition fails its branch. The flow ends cancelled when every unrecovered
    loss in it was a cancellation, and failed when a real error is mixed in. The outcome is decided per loss,
    never from whether Cancel was called, so a flow whose handlers all recover ends completed.
  • 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 one workflow.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 Cancel does 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,
    Terminate it.

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.
  • SetMaxOpenConns opts 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.
  • when expressions compare integers exactly rather than through a float64, and their literals are exactly
    true, false and 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

  • Cancel is renamed Terminate, and Cancel is now the graceful operation. Code that called Cancel for
    an immediate stop still compiles, but it now gets graceful cancellation: steps are not abandoned, and onError
    handlers run. Change those call sites to Terminate.
  • New terminal status terminated. workflow.StatusTerminated joins completed, failed and cancelled,
    and cancelled now means a graceful cancellation. Code that switches on a status string must handle the new
    value; workflow.IsTerminalStatus and workflow.TerminalStatuses include it.
  • FlowOutcome and FlowSummary gain TerminateReason, and CancelReason now carries only a Cancel
    reason.
  • Validate rejects a forEach, as or SetReducer field name that is not an ASCII identifier (a letter or
    underscore, then letters, digits or underscores). A dotted name such as order.items was a nested path in
    when but a flat key in forEach, 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 two UPDATE statements relabel
    flows and steps that v0.10.x stopped with its Cancel, which v0.11.0 calls terminated; without them those
    rows read as graceful cancellations.

MySQL and MariaDB:

ALTER TABLE dwarf_flows ADD COL...
Read more

Update dependencies

Choose a tag to compare

@bw19 bw19 released this 03 Aug 04:19

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

Choose a tag to compare

@bw19 bw19 released this 03 Aug 03:43

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

Choose a tag to compare

@bw19 bw19 released this 03 Aug 01:14

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).
    Pass t.Name() from a Go test.
  • Engine.SetTestName is removed. The constructor argument is the isolation key,
    so there is nothing left to override.
  • Startup no longer registers a t.Cleanup shutdown. Teardown is yours:
    defer eng.Shutdown(ctx).
  • The logger default no longer varies by testing.TB type. Every test engine logs
    to stderr at Error (DWARF_TEST_LOG_LEVEL overrides as before); a benchmark or
    fuzz target silences it with SetLogger(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

Choose a tag to compare

@bw19 bw19 released this 02 Aug 03:56

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

  • Host loses SignalPeers. The engine now sends nothing to its peers at all; hosts implement two methods, LoadGraph and ExecuteTask. 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}) replaces SetDSN / SetNumShards / SetWorkersPerConn. You declare facts about each shard's server; the engine derives the tuning. New SetEngineID pins a replica identity so restarts don't inflate the observed fleet count.
  • workflow.State is a type, not a map[string]any — on FlowOutcome.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.HasFanIn and the Duration() 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) / SetTestName replace RunInTest / SetInTest.
  • Schema: a new dwarf_peers registry, new step columns (state_refs, engine_id), fixed-width CHAR tokens and statuses widened to VARCHAR, and SQL Server JSON payloads stored as VARBINARY — 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

Choose a tag to compare

@bw19 bw19 released this 07 Jul 03:44

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 concurrent Create calls, two
    validations (or a validation racing a MarshalJSON) 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 seamster module. The fault-injection and checkpoint instrumentation the engine
    uses in white-box tests now lives in github.com/microbus-io/seamster v0.1.0 (a new, standard-library-only
    dependency). The engine embeds one seamster.Seamster as e.seams and consults it directly
    (IsFault, Checkpoint, Inject, Wait, Break, Resume). The seam is inert in production by construction.
    engine/debug.go is renamed engine/seams.go and reduced to the engine's fault and checkpoint names plus
    deliverFailureLost, the one consult that needs a database read.

API changes

  • Removed workflow.Graph.FanInFor and the fanOutToFanIn JSON field. Graph now exposes only its definition
    and validation. The fan-in lookup that FanInFor provided is an engine concern, served by internal/faninmap.
    A pre-existing graph JSON that still carries a fanOutToFanIn field 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 tagging v0.10.0 instead of v0.9.5.

Docs

  • Updated engine/CLAUDE.md and docs/graphs.md to 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.5 pulls in seamster v0.1.0 automatically.

Maintenance release. No API or behavior changes to the engine.

Choose a tag to compare

@bw19 bw19 released this 06 Jul 15:41

Fixes

  • Test stability: Fixed a MySQL-timing flake in TestFault_PollSizingErr. The faultPollSizingErr
    fault is now armed sticky rather than one-shot, so the background timerLoop'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 full maxPollInterval.

Dependencies

  • Bumped github.com/microbus-io/sequel v1.10.6 → v1.10.7.
  • Bumped golang.org/x/text v0.38.0 → v0.39.0 (indirect).

Docs

  • Removed the stale Go Report Card badge from README.md.

Poll operation

Choose a tag to compare

@bw19 bw19 released this 05 Jul 22:31

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.