Skip to content

Durable state

A state cell is a named value a run reads and writes directly: (scope, key) -> value, a small JSON document. It survives replay, it survives the run, and it’s the same cell whichever surface you reach it from - a shell step, a polyglot script, an SDK, or a canvas state node.

Concepts covers what a cell is and when to reach for one. This page is how to use them, including the operations that stay correct when two runs touch the same key at once.

Every cell lives in a scope, and there are two:

Scope How you address it Lifetime
Run the default - no flag Dropped when the run is collected
Entity --entity <name> (entity: on a node) Outlives every run

Run scope is a durable scratchpad for one run. Entity scope is where cross-run memory lives: two runs in the same project that pass the same entity name reach the same cell. The name is yours to choose - crawler, ledger, counters - and it’s unique within the project.

Keys starting _cryo/ are the engine’s own. You can read them; writing one is refused, on the canvas as you type it and again when the node runs. The engine reads those cells back and acts on them - a rerun --from <node> seeds every node it skips from what a run recorded - so they can’t be someone else’s scratchpad.

Terminal window
cryo state set cursor 1200 --entity crawler # write
cryo state get cursor --entity crawler # 1200
cryo state list --entity crawler # key<TAB>value per line
cryo state delete cursor --entity crawler

The same four verbs are state_get / state_set / state_delete / state_list in every SDK, and op: get|set|delete on a canvas state node.

The shape entity scope exists for. Run this on a schedule and each tick picks up where the last one left off:

Terminal window
since="$(cryo state get last-seen --entity digest)"
latest="$(cryo step "poll" -- ./fetch-since.sh "${since:-0}")"
cryo step "notify" -- ./post-digest.sh "$latest"
cryo state set last-seen "$latest" --entity digest

Three consecutive runs print 0, 10, 20. The ${since:-0} covers the first tick: cryo state get exits 0 and prints nothing for a cell nobody has written, so $since is an empty string rather than an error.

A cryo step memo can’t do this job - memos are keyed inside one run, and this value has to cross from one run to the next.

get then set has a gap in the middle. Two runs read the same absent cell, both see nothing, both decide they’re first, and the record gets processed twice. Three operations close that gap.

All three want entity scope, and two of them require it: a state node with op: add or op: cas and no entity: is refused when you save the graph. A run-scoped cell is reachable by one run, so the claim can never lose and the swap has nobody to race - the node reads as a dedup while deduping nothing, and that only surfaces the day two runs arrive together. get, set, delete and incr still take run scope, which is the right place for scratch state. In a shell the same reasoning applies to --entity: cryo state add without it claims a cell only this run can see, and reports that it won every time.

Writes the cell only if nothing holds it yet, and tells you whether you won. In a shell that’s the exit code: 0 you won, 3 someone else holds it, 1 something broke.

Terminal window
rc=0
cryo state add "seen-$order_id" --entity ledger || rc=$?
case $rc in
0) cryo step "process" -- ./process-order.sh "$order_id" ;;
3) echo "order $order_id already handled" ;;
*) exit 1 ;; # unreachable agent, refused write - not an answer
esac

The three-way branch is the point. A plain if cryo state add … sends every failure down the else branch, so an agent that couldn’t reach the server reads as “someone else has it” and the record is silently skipped. Reserve the skip for exit 3.

The || rc=$? is not decoration. Scripts run under bash -eu, so a bare cryo state add followed by case $? never reaches the case: losing the claim exits 3, errexit ends the run there, and the second run of a dedup ledger fails with no output at all. Capture the code, then branch on it.

That’s a dedup ledger, and it’s what makes a workflow safe to trigger twice with the same payload. The value is optional (it defaults to true) because usually the answer is the point and the contents don’t matter - though storing something useful is free:

Terminal window
cryo state add "seen-$order_id" "$(date -Iseconds)" --entity ledger

A ledger is for duplicates the run discovers about its own work. If what repeats is the submission - a webhook that delivers twice - the caller already knows, and cryo submit --idempotency-key <k> is cheaper: the second delivery is answered with the first run, and no second run is created, admitted, or counted.

On a canvas, the same thing is a state node with op: add, and its output is {"claimed": true|false}. Gate the work on it:

{
"version": "cryosleep-graph/v1",
"nodes": [
{ "id": "claim", "type": "state",
"config": { "op": "add", "key": { "$template": "seen-{{ input.order_id }}" },
"value": true, "entity": "ledger" } },
{ "id": "process", "type": "shell",
"if": "nodes.claim.output.claimed",
"config": { "run": { "$template": "./process-order.sh {{ input.order_id }}" } } }
],
"edges": [{ "from": "claim", "to": "process" }]
}

A duplicate trigger still starts a run; the run just does nothing, which is the honest outcome and leaves a record that it happened.

{ "id": "bump", "type": "state",
"config": { "op": "incr", "key": "processed", "by": 1, "entity": "counters" } }

From a script, same op:

Terminal window
n=$(cryo state incr processed --entity counters --as tick)

The output is {"value": <new total>, "version": <n>}, and the CLI prints the new value. by: defaults to 1 and may be negative, so a quota that hands out credits and returns them is two nodes with by: -1 and by: 1.

A read-modify-write is what this is underneath - read the cell, add, write it back only if nobody else moved it in between, retry if they did. Concurrent runs each land their own increment instead of overwriting one another. A cell that holds something other than a number fails the node loudly rather than resetting your count.

cas - a read-modify-write of your own shape

Section titled “cas - a read-modify-write of your own shape”

When the update isn’t addition. get returns the cell’s version alongside its value; cas writes only if the version hasn’t moved:

{ "id": "read", "type": "state",
"config": { "op": "get", "key": "config", "entity": "settings" } },
{ "id": "merge", "type": "code",
"config": { "script": "…produce the new document…" } },
{ "id": "write", "type": "state",
"config": { "op": "cas", "key": "config",
"expected": { "$expr": "nodes.read.output.version" },
"value": { "$expr": "nodes.merge.output.document" },
"entity": "settings" } }

The get carries the same entity: as the cas, otherwise the version you read belongs to a different cell than the one you write.

From a script the same three steps are three lines, and the exit code carries the answer:

Terminal window
v=$(cryo state get config --version --entity settings)
doc=$(./merge.sh "$(cryo state get config --entity settings)")
if cryo state cas config "$doc" --expect "$v" --entity settings --as merge; then
echo "landed"
else
echo "someone else wrote first" # exit 3, never 1
fi

The output is {"swapped": true|false}. A false means someone wrote between your read and your write, and what to do about it is yours to decide - branch to a retry, or fail the run.

--as names the attempt. A retry loop cases the same cell several times and each attempt is its own recorded answer, so they need distinct names; the default is the key, which is right when there is only one.

Read the version before the value. They are two calls, and a write landing between them lands differently depending on the order: version first leaves you with an old version and a new value, so the swap fails and you retry; value first leaves you with a stale value and a version that already moved, so the swap succeeds and overwrites what the other run wrote. The second one is silent and only shows up under contention, which is the only time cas is doing anything.

That loop - read, modify, swap, retry - is how a list grows under contention when several runs append to it at once. Batch is the worked version, including what it takes to keep such a loop replay-safe across a cryo sleep.

Omitting expected: means “only if the cell is absent”, which is what add does. The CLI has no spelling for that - use cryo state add.

The precondition is on the version rather than on the value because cells are encrypted at rest (the same key that protects secrets and step memos). The database holds ciphertext and can’t compare it. The version is the store’s own counter, in the clear, so a condition on it works.

A state read or write is a plain operation, not a suspend-and-replay boundary. In a polyglot script - which re-runs from the top on every wake - that means get, set and delete run again on each pass. Prefer last-write-wins or idempotent values.

You cannot wrap one in cryo step to fix that. A step is a checkpoint and a checkpoint is a leaf: its command runs without the agent socket, deliberately, so a cryo call inside one has nothing to talk to. Reach for the memoized ops below instead.

add, cas and incr are the exceptions, in both surfaces. Their answers belong to the run that asked - which claim won, whether the swap landed, what the count reached - and re-asking would have the run lose to its own earlier attempt, or swap twice, or count twice. They’re memoized, so a replay pass reads back the first answer rather than re-running the race. A lost swap is journaled too, because losing is a result the run acted on and replay has to reproduce it.

What each is keyed by follows from that. add is keyed by the cell: two claims of one cell in a run should share an answer. cas and incr are keyed by --as <name>, because a read-modify-write loop attempts the same cell repeatedly and each attempt is a separate question. Reusing a name is refused, the same way a repeated cryo step name is.

A script draws a ticket with cryo state incr, then suspends. Two
“pass starting” stamps show the body really ran twice, and the ticket is
1 both times.

One gap, so it isn’t a surprise: the memo is written after the store is, so an agent that dies in the window between them leaves the count raised and no memo to read - the next pass counts again. Creating a cell is safe either way, because the cell itself records who created it. Raising an existing one has that window. It is small, and if the count has to be exactly right across a crash, add a per-run marker first and increment only when the claim is yours.

A retry: is different, and the difference matters here. A retry deliberately re-runs the unit, memos and all, so a retried job’s incr counts again and its cas re-attempts with a version its own first attempt already moved - which comes back as “someone else got there first” on a swap that had in fact won. add is the one that survives a retry, because the cell itself records who claimed it. If a job carries retry: and counts, count somewhere the retry can see: add a marker first, or make the whole job idempotent.

The pattern this rules out, and what to write instead

Section titled “The pattern this rules out, and what to write instead”

“Stamp a marker, sleep, then check whether I’m still the newest” does not work in a script. The stamp is a set, so the replay pass after the sleep re-stamps it and every arrival concludes it was last.

Draw a ticket instead. incr is memoized, so the number a run drew before the sleep is the number it still holds after:

Terminal window
mine=$(cryo state incr "ticket:$key" --entity debounce --as ticket)
cryo state set "latest:$key" "$value" --entity debounce
cryo sleep 3s
# A live read - nothing of mine shadows it.
if [ "$mine" = "$(cryo state get "ticket:$key" --entity debounce)" ]; then
./handle.sh "$(cryo state get "latest:$key" --entity debounce)"
fi

Five arrivals half a second apart run handle.sh once, on the last one’s value. The same shape on a canvas uses cas on the version the stamp returned, since a graph node runs once per run and needs no ticket. Debounce is this runnable, both ways round.

The latest: cell in that recipe is a plain set, so a waking loser re-stamps it with its own older value on the way past. That is harmless when the winner wakes last, which is the normal case, and it is the reason the ticket - not the value - decides who acts. If the value has to be exactly the last arrival’s, carry it in the ticket cell itself with a cas rather than in a second cell.

On a canvas, a get reads back your own write

Section titled “On a canvas, a get reads back your own write”

The graph tier journals a cell write, so a get of a cell this run has already written answers from the journal without going to the store. That keeps a read deterministic across replay passes, and it means a graph cannot write a cell, wait, and then read it to see what another run did in the meantime - it sees its own value. Settle that with cas on the version the write returned, which does go to the store.

A polyglot script is the other way round: cryo state get is a plain store read every time it runs, so it does see other runs’ writes - and that is exactly why anything derived from it has to be pinned by a memoized op (add, cas, incr) before the script branches on it.

A claim also survives the run dying mid-claim. Writing the cell and recording that you wrote it are two steps, and a run that dies between them holds the key with nothing in its log to say so. The cell remembers who claimed it, so the run asks again on restart and is told it still holds the key rather than losing to its own earlier attempt.

A cell is held by the server, not by the agent, so reading one needs neither the run to be alive nor an agent to be free. From a terminal, name the scope you want - out here there is no run you are already in:

Terminal window
cryo state get progress --run dev:default:run-jD3sGl # that run's own cells
cryo state list --run dev:default:run-jD3sGl # all of them
cryo state get last-seen --entity digest # the project's store

A bare run-jD3sGl works too; it is qualified with your active project. The output is what the same command prints inside a job, so a value reads the same either way, and an unset cell prints nothing and exits 0.

Over HTTP the same two scopes are GET …/runs/{id}/state[/{key}] and GET …/entities/{entity}/state[/{key}]. Fetching one key also returns its version, which is what a poller compares to notice a change without re-reading the value.

This is how a run publishes something for a reader outside it: write the cell, and the reader fetches it. The run has to write it - there is no asking a workflow a question it didn’t prepare an answer for.

Three things to know before you rely on it:

  • Reading a run’s own cells needs Viewer; entity scope needs Member. A run’s cells are its working data, like its logs. The entity store spans the project and outlives every run, so it sits at the level that could already read it by running a workflow that does.
  • Cells are not masked. Secret masking applies to logs and deliberately not here, because a workflow reads a cell back authoritatively and a masked value would be wrong. A cell holding a credential hands back that credential, so write accordingly.
  • Nothing outside a run can write. A write from outside would race the workflow that owns the cell. To tell a running workflow something, send it a signal.

Run-scoped cells go when the run is collected, so reading a swept run gives back nothing - an empty listing, or a 404 on a named key. That is the same lifetime its logs have.

Cells in run scope go when the run is collected. Entity cells stay until you remove them:

Terminal window
cryo state delete last-seen --entity digest

Deleting a project removes every cell under it, both scopes.

You want Reach for
A value between steps of one run Run-scoped cell, or cryo output set
Not re-running an expensive command on replay cryo step (a memo)
A value from this run’s last execution Entity-scoped cell
“Has anyone handled this id?” add on an entity cell
A shared counter or quota incr on an entity cell
Only one run at a time in a region of the pipeline concurrency:, not a cell

The last row is worth its own note, because the two overlap more than the table suggests. Both serialize; they answer different questions.

add answers “not more than once, ever”. The claim outlives the run, so a second run doesn’t wait - it finds the key taken and skips.

A lease answers “not at the same time”. It is held until the run ends and released whatever the outcome, so the next run in line proceeds. The group is a value like any other, so it can be per-key as easily as global: concurrency: "record-{{ input.record_id }}" on a node gives one lease per record, and runs on different records never see each other. A parked run holds no agent, so a queue on one hot record costs rows rather than machines. See examples/per-record-lease for a run you can watch, and the YAML reference for the top-level and job-level forms.

So: “no two deploys at once” is a lease. “Process this record once, whoever gets here first” is add. “One writer per customer, others wait their turn” is a lease with the customer in the group.