v0.14.0 — A Daily Archive for Closed Flows, Lists by State, Continue by Key
Upcoming release: these notes may change before it is published on GitHub Releases.
Closed flows now move to an archive about a minute after they close, kept by the day for a retention you set and
dropped a day at a time, so the live tables that every step reads hold the open flows and little else. An archived
flow reads by its key as before. The lists are named by a flow’s state - ListOpen and ListClosed - and
SetFlowExpiration can end flows that would otherwise never close. This release edits migrations that earlier
releases shipped, so existing databases cannot be upgraded in place: start v0.14.0 on fresh databases (see
Upgrading). Nothing detects an old database: Startup succeeds on one, and ListOpen then fails
on every call.
Breaking Changes
- Closed flows are deleted after 30 days by default. Each closed flow moves to the archive about a minute after
it closes, and is deleted with its archive day once the retention has passed (
SetRetention(days), default 30; see New). In v0.13.0 every flow was kept untilDeleteorPurgeremoved it. To keep flows longer, raise the retention beforeStartup. - On PostgreSQL, every replica must connect as the role that owns Dwarf’s tables:
Startupfails otherwise, naming the owner. The archive creates and drops a table per day, which only the owner may do. - MySQL 8.0 or later, or MariaDB 10.6 or later, is required: archiving locks with
SKIP LOCKED, which earlier versions lack. Both are past their end of life. - Go 1.27.3 or later is required, as sequel v1.18.0 requires it.
- SQL Server is reached through
github.com/microsoft/go-mssqldb, in place of the deprecatedgithub.com/denisenkom/go-mssqldb. A program that imports the old package itself must switch to the new one: both register themssqldriver, so linking both panics at startup. - Threads are removed;
Continuetakes the key of the flow to continue.Continue(ctx, flowKey, additionalState) (nextKey string, err error)builds a new flow from the completed root flowflowKeynames, carrying its final state, graph and policy as before. Your application keeps the chain: continue the key the lastContinuereturned.- Continuing one flow twice makes two flows, each built on it. Earlier, a second
Continueof the same thread was rejected with 409 while the first turn ran. - Like
Create,Continueis not idempotent: a call retried after a lost response makes a second flow. Continueof a flow that is not completed, or is scheduled for deletion, is a 409. Earlier,Continuesearched the thread for its latest completed flow, skipping forks and flows scheduled for deletion.FlowOptions.ThreadKey,Query.ThreadKeyandFlowSummary.ThreadKeyare removed. To start the next flow with different options, callCreatewith the previous flow’s final state (fromSnapshot).
- Continuing one flow twice makes two flows, each built on it. Earlier, a second
Listis replaced byListOpenandListClosed. A flow is open while it is running or interrupted, and closed once it has completed, failed, been terminated or been cancelled.ListOpen(ctx, query)lists the open flows, the most recently updated first. A status that is not open is a 400.ListClosed(ctx, query)lists the closed flows, the most recently archived first, by the newFlowSummary.ArchivedAt. A flow appears there when it is archived, about a minute after it closes; until then it is in neither list, and is read by its key. A status that is not closed is a 400.- Ties go by shard, then the newest flow. Both require
Query.Limit(at most 65,536).ListClosedalso requires a window with both ends, the newQuery.FromTime(inclusive) andQuery.ToTime(exclusive);ListOpentakes them optionally, onUpdatedAt. Earlier,Listdefaulted to 100. ListClosed’s window is cut to the retention and to now, and may then reach at most 31 days.- Neither has pagination:
Listloses its cursor result, andQuery.Cursoris removed. The result is the firstLimitacross all shards, in one order. Earlier, on more than one shard, each shard returnedceil(Limit/shards)of its own flows, grouped by shard, so a page could hold more thanLimit. - The
dwarf_operation_secondslabelop="list"is nowlist_openandlist_closed. A newop,purge_before, timesPurgeBefore, andPurgerecords one sample per stage it runs on a shard (reading the archive, and deleting there) rather than one per call.
FlowsEndedis renamedFlowsClosed, and the metricdwarf_flows_endedis renameddwarf_flows_closed(dwarf_flows_closed_totalin Prometheus): update dashboards and alerts that read the old name.Query.OlderThanandQuery.NewerThanare removed; give the window asQuery.FromTimeandQuery.ToTimeinstead. “Older than 30 days” isToTime: time.Now().Add(-30 * 24 * time.Hour). The bounds now compare with this process’s clock rather than the database’s.DeleteandPurgeremove only closed flows. Neither terminates anything any more.Deleteof a running or interrupted flow returns 409;Terminateit first. Earlier,Deleteterminated an interrupted flow, subgraphs included, before marking it.Deletecan also return 409 when the flow was archived while it was being deleted; call it again.Purgenever touches an open flow, which it used to terminate when interrupted. APurgewhoseQuery.Statusis not closed (running,interrupted) returns 400.
Purgeremoves whatListClosedlists, returns(removed int, done bool, err error), and requiresQuery.Limitand both window bounds. It removes at mostLimitroots (up to 65,536) in a call, the oldest archived first across all shards, and deletes each at once; earlier it took at most 4,096,ceil(Limit/shards)from each shard.donesays that nothing archived and matching is left: call it untildone. A query needs no other filter: the window bounds it, and likeListClosed’s may reach at most 31 days of the archive, so a longer range is purged a month at a time. It removes only archived flows: one that closed shortly before the call is purged by a later call, once archived;Deleteremoves one by its key.- A subgraph that cannot be started comes back to the calling task from
flow.Subgraph, as a failed child does, instead of failing the step. That covers aLoadGraphthat returns an error or panics, returns no graph, or returns an invalid one. The task’s next run getserr, with no child flow created and nothing inout. Earlier the step failed at once, skipping the graph’sonErrorand giving the task no chance to retry.errkeeps its status code: your host’s own (return a 503 when the graph’s source is unreachable), 404 for no graph, 400 for an invalid one, 500 for a panic.- Returning
errtakes the task’sonErrortransition, or fails the step as before; a task that already returnsSubgraph’s error needs no change. To wait out a 503, armflow.Retry: its nextSubgraphcall starts the child again. See When the child fails, or cannot start. - As on any return from a subgraph, the task runs again from the top, so code before its
Subgraphcall runs a second time when a spawn fails.
- The
Flow’s JSON format is versioned, and its durations are whole milliseconds. This matters only to a host that carries the flow as JSON to a task in another process.- Every encoding carries
"v": 1. Decoding refuses a body withoutv(status 400) or with a newer one (status 501, naming both versions), so a host and a task server on different releases fail the step loudly instead of misreading each other. Upgrade both sides together. sleepDuration,backoffInitialDelayandbackoffMaxDelay, which were integer nanoseconds, are nowsleepMillis,backoffInitialDelayMillisandbackoffMaxDelayMillis, in whole milliseconds. A task server written in another language must use the new names.- A
Flowdecoded from JSON encodes back in the version it came in, so a task server replies in its request’s version.
- Every encoding carries
- State field names are checked. A name is an ASCII letter or underscore, followed by ASCII letters, digits,
underscores or hyphens:
line_itemandline-item-2are valid;2nd,order.itemsandnaïveare not.workflow.IsValidFieldNameapplies the rule. Earlier only the names a graph declares were checked, and those could not hold a hyphen. See Field names.Create,Run,ContinueandForkrefuse caller state holding any other name with a 400.- In a task,
Set,Overwrite, the typed setters,DelandClearpanic on such a name, which fails the step. Earlier,SetandOverwritereturned an error for a name ending with!or+and accepted every other name. - A task in another process whose result writes such a name fails its step.
- A subgraph’s
inholding such a name comes back fromflow.Subgraphas a 400, with no child started. - A
whenexpression cannot compare a field whose name holds a hyphen.
- A fairness key is at most 256 ASCII letters, digits,
-,_and., and is case-sensitive.CreateandRunrefuse any other key with a 400: one holding a space,/,@,:, a quote, an accent or any other character. The empty key is still the default bucket. Earlier any valid UTF-8 key was accepted, and on MySQL, MariaDB and SQL Server keys differing only in case, accents or trailing spaces were scheduled and matched as one. - Graph names, task names and workflow URLs must not begin or end with whitespace.
Validaterefuses such a graph or task name, soCreateandRunreturn a 400 before anything is stored, for a graph built in code or decoded from JSON.CreateandRunrefuse such a workflow URL with a 400, andflow.Subgraphreturns one without starting a child. Names and URLs are compared exactly, case included. Earlier a padded inner fan-in’s cohort was left out of the outer fan-in’s state. flow.Retry’sgiveUpAfterbounds the total of the backoff delays, not the time since the step was created.Retryreturnsfalseonce the delays it has asked for, the next one included, would add up to more thangiveUpAfter. Time spent queued, running or sleeping no longer counts, so a step held up behind other work keeps its retries; earlier, a loaded database could use them all up before the first retry ran. A task that wants its sleeps or its run time bounded keeps its own budget. A zeroinitialDelaynever reaches the bound, so bound immediate retries byf.Attempt(). See Retry.- Shard and fairness weights are at most 1,048,576 (2^20).
SetShardrefuses a larger weight, NaN or an infinity, andCreateandRunrefuse such aFairnessWeightwith a 400. A share is set by the ratio of weights, so a fraction still expresses a small one. Earlier, two very large shard weights summed to infinity and sent every new flow to one shard. - A graph with a task named
""is invalid.Validaterefuses it, soCreateandRunreturn a 400, and a subgraph of it fails its caller with a 400. Earlier the flow ran, skipped that task and completed. Flow.SetChangesis removed. Write each output field withflow.Setinstead.SetChangesmarshalled its struct, so a field taggedomitzerooromitemptyand left at zero was never written, and a task could not reset it tofalse,0or nil.- Migrations were edited in place.
dwarf_flowslosesthread_id,thread_tokenand their index, and gainsarchived_at;dwarf_stepsgainsparked_child_flow_id. On PostgreSQL every time column isTIMESTAMP(3), milliseconds as on the other databases, where it was microseconds. On MySQL and MariaDB the tablesdwarf_flowsanddwarf_stepsareutf8mb4_bin, and on SQL Server their text columns areLatin1_General_100_BIN2(see Fixes). - Times are milliseconds on every database, PostgreSQL included.
FlowSummary’s andFlowStep’s times, and theQuery.FromTime/ToTimebounds, compare and read back to the millisecond. Requires sequel v1.16.0.
Fixes
On MySQL, MariaDB and SQL Server, text compares exactly, as on PostgreSQL and SQLite. The tables took the server’s default collation, which ignores case (and on MariaDB accents), so
ListOpen,ListClosedandPurgefiltered byFairnessKey,WorkflowURL,WorkflowNameorStoppedTaskNamealso matched values differing only in case: a list of tenantacme’s flows included tenantAcme’s, and aPurgeofacmedeletedAcme’s flows too.Query.Searchstays case-insensitive.A failover that ends just as a step’s outcome write gives up no longer fails the step. When an outcome cannot be stored, the engine retries the write for about a minute, then fails the step if the database is reachable, taking the outcome itself as unstorable. A database that came back during the last retry’s wait was reachable by then, so a healthy flow failed for good and counted in
dwarf_steps_write_failed. The engine now first checks that the database answers and retries the write at once; a write that loses its connection is retried on a fresh one, up to three times. A step fails only when its outcome fails with the database answering just before and after. Requires sequel v1.18.0.A stalled worker can no longer complete a flow while its cancellation handler runs. A worker that stalled after its task finished, past its lease, could resume and mark the flow
completedafter recovery had replayed the step and routed aCancelto theonErrorhandler, with the handler still running.A subgraph’s late result can no longer settle a newer attempt. When a calling task retried with
flow.Retryand started a new child, a result from the earlier child arriving late could be delivered to it, and the task could finish on the old result while the new child still ran.Two
Resumecalls cannot both answer one interrupt. Two concurrent calls for the same interrupt both returned nil, and one answer was discarded; one now gets a 409. AResumethat selected an interrupt, then lost the race to anotherResume, could also answer the task’s next interrupt with its old data after the task retried; it now gets a 409 as well.On MySQL and MariaDB a task can no longer run twice from one claim. A worker delayed past its lease between claiming a step and reading it could read a later claim’s step and run it, so the task ran twice and its next step was created twice.
On SQL Server,
Query.Searchtreats[literally. It opened a character set, so a search for[ab]matched every value holding anaor ab, andPurgewith that search deleted those flows too.Fairness weights hold their shares at any scale. Equal weights split the work evenly whether they are
1e-6or1e20; earlier, at very large or very small weights one key took every slot.Forkkeeps the outcome of a finished subgraph it copies.Snapshotof a kept child’s key returned an empty state for a completed child and no error for a failed one.A
Retrymultiplier below 1 shrinks a delay that starts abovemaxDelay.Retry(10s, 0.1, time.Second, 0)waited 1s at every attempt rather than 100ms at the third.A
forEachtransition decoded from JSON withoutasusesitem, asAddTransitionForEachdoes. Itsitem,itemIndexanditemCountfields were left in the state after the fan-in.A step whose worker died before its next step was created is recovered on its own lease. A fan-out branch waited until every sibling had finished, which could be a sibling’s day-long retry backoff, a subgraph or an interrupt, and a step in an interrupted flow was never recovered.
flow.Subgraph("")returnsfalsewith a 400, so the step fails. Earlier the step completed with no child, skipping the code after the call.An interrupt or
Resumein a deeply nested subgraph works on SQL Server. An interrupt more than about 700 subgraphs deep, or aResumemore than about 2,100 deep, failed every time.A replica restarted under the same
SetEngineIDtakes the same place on every shard. If a peer had removed its record on some shards while it was down, it was ranked differently per shard, so the shards’ dispatching was divided unevenly between replicas, and nothing corrected it while the fleet was unchanged.A replica whose step reads are failing gives up its share of the work. Its earlier successes kept it counted as dispatching, for longer the longer it had been up, so its share of a shard’s steps waited.
Await,PollandRunno longer wait on another shard. A slow or stalled shard held up the result of every flow on the other shards until it answered.
New
- MariaDB works under its own driver name. sequel v1.16.0 reports
mariadbfor a database opened with amariadb://URL or themariadbdriver name; Dwarf treats it as it treats MySQL. A MariaDB DSN in the MySQL driver’s own format (user:pass@tcp(host:3306)/db) still works, asmysql. - Closed flows move to an archive about a minute after they close, and are kept for
SetRetentiondays (30 by default), then dropped a day at a time. An open flow is never removed, however old, unless it expires (below). A failed, terminated or cancelledDeleteOnCompletionflow is archived like any other.Deleteof an archived flow deletes it from the archive at once, with its subgraph trees; a closed flow not archived yet is marked for deletion, as before. - An archived flow reads as it did before it moved:
Snapshot,Fingerprint,Await,Poll,Run,History,StepandContinuefind it by its key,Forkclones it andListClosedlists it, until its day is dropped.Resume,TerminateandCancelanswer it as any closed flow:Resume409,TerminateandCancela no-op. SetRetention(days)sets how many days the archive keeps each day’s closed flows (default 30; 0 keeps no archive). Construction-time.SetFlowExpiration(d)terminates a flow still running or interrupteddafter its creation, with its subgraphs and a reason saying it expired; it is then archived like any other. Off by default; checked every 5 minutes. It bounds what abandoned interrupts and flows polling forever leave in the live tables.- Metrics for the archive and expiration, none costing a query:
dwarf_flows_archived,dwarf_flows_reaped{reason}(archive_failedcounts records lost to a failing archive: alert on it),dwarf_flows_removed{op,side},dwarf_archive_days_dropped,dwarf_archive_maintain_failed,dwarf_reap_pass_seconds, the gaugesdwarf_archive_lag_seconds,dwarf_archive_failing_seconds,dwarf_archive_daysanddwarf_archive_oldest_day_age_seconds, anddwarf_flows_expiredanddwarf_flows_expire_failed. workflow.RequestCarrier(f)andworkflow.ResultCarrier(f)carry a task across a process boundary one direction at a time: pass them tojson.Marshal,json.Unmarshalor an encoder in place of theFlow. The result leaves out the state and the graph’s reducers, so a large state crosses the wire once rather than twice, and reading a result onto the host’sFlowtakes only the task’s changes and control signals. The engine reads back nothing else either way, so the plainFlowencoding keeps working. See Tasks in another process.workflow.CarrierVersionis the newest version of theFlow’s JSON format this build reads and writes.PurgeBefore(t)drops the archive’s records of every day before t’s, on every shard; a t after today is a 400.- The live tables vacuum in small, unthrottled passes on PostgreSQL, and are never truncated by vacuum.
- SQLite is supported for one replica at a modest flow rate: on a file with
_pragma=journal_mode(WAL)for durable flows, or in memory, where flows last only as long as the process. Earlier, the docs ruled it out of production. See Deployment. - Retention counts from the end of the UTC day a flow was archived in, so a flow is kept between
retentionandretention + 1days after it is archived, plus up to an hour for the hourly drop. A flow archived late, after an outage, is kept its full retention from then. - A new table,
dwarf_flow_days, records the archive day of each flow archived more than a day after it was created. It stays small: only flows that ran about a day or longer get a row.
Performance
- Archiving writes each closed flow’s rows again: the move copies them into the archive and deletes them from the live tables, and on PostgreSQL every archived row is frozen once more. With little state per step this costs nothing measurable: one 32 vCPU PostgreSQL 16 shard ran ~24,100 steps/s with the archive on. With payloads, the write-ahead log sets the pace, so size the database’s write throughput in MB/s from what a step logs. See Deployment and the cloud benchmarks.
- Size PostgreSQL’s checkpoints for the write load (new guidance):
max_wal_sizeabout 2 × peak WAL MB/s × 60 s andcheckpoint_timeout15 minutes. With 8 KB payloads a 32 vCPU shard ran 42% more steps/s at 20 GB than at the 1.5 GB default many managed services use. See Deployment and Checkpoints. ListOpenandListClosedon more than one shard read up toLimitflows from each table on each shard, rather thanLimitin all.Continuemakes two fewer round trips to the database, and takes no lock.- Creating a subgraph flow writes one statement fewer on MySQL, SQL Server and SQLite.
- A
unionfan-in takes time linear in its branches. It was quadratic, inside the fan-in’s transaction: 4,000 one-element branches took 8.2s, now 16ms. - Validating a graph and routing its fan-ins take time linear in its edges, where a large graph took time proportional to its tasks times its edges.
flow.Retryon SQLite looks up the step’s earlier subgraph children by index rather than scanning every flow.