All docs
Docs · Data & state
KV store
persist values across requests
txco://kv/* is the one place an op can both read AND write durable state. The envelope lives for a single request; the KV store outlives it, so rules can keep counters, flags, locks, cached lookups — any small JSON — between requests.
Pick a backend with --kvstore:
boltdb(default) — an embedded on-disk store, local to that one chassis. Zero setup; right for a single chassis or dev.redis— a shared redis that several chassis point at (--kvstore-addrs), so they all read and write the same keys. Use it when more than one chassis serves the same tenants and they need to see each other’s writes.
The ops are identical either way.
The ops
| Op | WITH | Does |
|---|---|---|
txco://kv/get | key, into?, fallback? | Read a value into the envelope. |
txco://kv/set | key, value? / from?, ttl? | Write a value. |
txco://kv/delete | key | Remove a value. |
txco://kv/incr | key, by?, ttl?, into? | Atomically add to an integer. |
txco://kv/cas | key, value? / from?, expected?, ttl?, into? | Check-and-set. |
txco://kv/mget | items, into? | Read many keys — each in its own namespace, if you like — in one dispatch. |
txco://kv/mset | items, ttl? | Write many keys in one atomic step: all or none. |
txco://kv/mdelete | items | Delete many keys in one atomic step. |
txco://kv/list | after?, limit?, values?, into? | List a namespace’s keys (and values), one sorted page at a time. |
Every op also takes an optional namespace (see below).
Keys are scoped per tenant + namespace
Each key is stored under <tenant>/<namespace>/<key>:
- tenant — the request’s resolved tenant. You can’t reach another tenant’s keys.
- namespace — defaults to the stack serving the request, so one stack’s
keys never collide with another’s. A
_-nested inlet sub-stack (<stack>/_mail,<stack>/_websocket) defaults to its app stack’s namespace (<stack>): the app owns its state across inlets. Passnamespace = "shared"(any name) to share keys across a tenant’s stacks. Names starting with_txcare reserved for the chassis’ own indexes (the blob name index lives in_txc.blob, the WebSocket session directory in_txc.websocket) and are refused by everykv/*op and byKV/seed packs. - key — yours; no
/(use a namespace to group).
So kv/incr key="hits" from stack web of tenant acme touches acme/web/hits.
Values are JSON; results land at into
Values are arbitrary JSON. kv/get / kv/incr / kv/cas write their result into
the envelope at into (default _kv). _kv is _-prefixed, so it’s dropped
from the default web response — a scratch slot the client never sees. Point into at a non-private path to surface a value, e.g. into = ".count".
# read a counter into the response, defaulting to 0 when unset
WITH key = "hits", into = ".hits", fallback = 0
EXEC "txco://kv/get" The fallback param is
fallback, notdefault—defaultis a reserved txcl keyword.
set / delete
# a literal value
WITH key = "greeting", value = "hello"
EXEC "txco://kv/set"
# a value pulled from an envelope path
WITH key = "ua", from = "@web.req.headers.user-agent.0"
EXEC "txco://kv/set"
WITH key = "greeting"
EXEC "txco://kv/delete" TTL — values can expire (opt-in)
kv/set and kv/incr take an optional ttl in seconds. Omit it (the
default) and the key is persistent — it lives until you overwrite or delete
it. With a ttl, the key vanishes once it lapses.
# a 10-minute cache entry
WITH key = "rates", from = ".fetched", ttl = 600
EXEC "txco://kv/set" An operator can cap the maximum with --kv-max-ttl (a larger requested ttl clamps down to it).
Atomic counters — kv/incr
kv/incr adds by (default 1) to an integer key and writes the new value to into. It’s atomic — concurrent requests never lose an update, even across
chassis sharing one redis. by is signed, so a negative by decrements.
WITH key = "page:hits", by = 1, into = ".count"
EXEC "txco://kv/incr"
WITH key = "inventory", by = -1, into = ".left"
EXEC "txco://kv/incr" Check-and-set — kv/cas
kv/cas writes a new value only if the current value equals expected —
or, with expected omitted, only if the key is absent. It reports {swapped, current} at into: swapped is whether it wrote, and current is
the value now in the store. On a failed check current is the real current,
so you can recompute and retry.
# optimistic update — write only if nobody changed it since you read it
WITH key = "config", expected = .prev, value = .next
EXEC "txco://kv/cas"
# ._kv.swapped == false → ._kv.current holds the latest; retry against it With expected omitted it’s a lock — only the first caller wins:
# scope 100 — try to take the lock
WITH key = "job:42:lock", value = "me"
EXEC "txco://kv/cas"
# scope 200 — proceed only if we got it (later scope: same-scope ops run in parallel)
WHEN ._kv.swapped == true
EMIT .status = "running" That’s also how you build a safe state machine: kv/get to read, decide in a
rule, then kv/cas with the value you read as expected — the write only lands
if the world hasn’t moved under you.
Read many keys at once — kv/mget
kv/mget reads a list of keys in one dispatch. items is an array; each
item is a bare key string or an object {key, namespace?}. An item without its
own namespace uses the call’s (WITH namespace, else the stack’s default),
so one call can read across namespaces:
# ._cells was built by an earlier op:
# [{"key": "triggers", "namespace": "pony-ada"},
# {"key": "triggers", "namespace": "pony-bo"}]
WITH items = ._cells, into = "_trig"
EXEC "txco://kv/mget" It writes {items, count, found} at into (default _kv):
{
"items": [
{
"namespace": "pony-ada",
"key": "triggers",
"found": true,
"value": {
"at": "09:00"
}
},
{
"namespace": "pony-bo",
"key": "triggers",
"found": false
}
],
"count": 2,
"found": 1
} - In order.
items[i]answers the i-th item you asked for. A key listed twice is read, and reported, twice. - A miss is
found: falsewith novalue, and so is an expired key. - At most 200 items; above that the whole call is refused. It never returns a partial answer, because a dropped item would look exactly like a missing key. Split larger reads across calls.
- One bad item refuses the whole call, before anything is read: an empty
key, a
/in a key or namespace, or a reserved_txcnamespace. items = []is not an error; it writes{items: [], count: 0, found: 0}.
Compared with N kv/gets, this saves N−1 dispatches (25 fuel each), and the
store reads the batch together: one MGET per 500 keys on redis, one read
transaction on boltdb.
Write or delete many keys at once — kv/mset / kv/mdelete
kv/mset writes a batch atomically: every key lands or none does, and no
other request sees the batch half-written. items is an array of {key, value} objects. Each item may also carry a namespace, a ttl in
seconds, or from (an envelope path) in place of value, exactly as for kv/set. A namespace or ttl on the call is the default for items without
one.
# ._batch was built by an earlier op:
# [{"key": "state", "namespace": "pony-ada", "value": {"step": 3}},
# {"key": "state", "namespace": "pony-bo", "value": {"step": 1}, "ttl": 3600}]
WITH items = ._batch
EXEC "txco://kv/mset" kv/mdelete removes a batch atomically. Its items are kv/mget’s: bare keys,
or {key, namespace?} objects. Deleting a key that isn’t there is not an error.
WITH namespace = "drips", items = ._purge.keys
EXEC "txco://kv/mdelete" Both follow the same batch rules as kv/mget:
- At most 200 items; above that the whole call is refused.
- One bad item refuses the whole call, before anything is written: a
missing key or value, a
/in a key or namespace, a reserved_txcnamespace, invalid JSON, or a value over the size cap. kv/msetrefuses a key listed twice, since the batch would have no single answer for it.kv/mdeletedoesn’t mind a repeat.items = []does nothing and is not an error.
On boltdb a batch is one transaction. On redis, kv/mset runs as one script
and kv/mdelete as one DEL, and redis runs each without interleaving any
other command; only the redis server itself failing partway through a script
could leave part of a batch written.
A batch is atomic, not conditional — it overwrites whatever is there. To write
only if nothing has changed since you read it, use kv/cas on the keys that
matter. Like kv/set and kv/delete, neither op writes anything into the
envelope.
List a namespace — kv/list
kv/list returns one page of a namespace’s keys, sorted: up to limit (default and maximum 200) that sort after the after cursor.
WITH namespace = "subscribers", limit = 100, into = "_subs"
EXEC "txco://kv/list"
# ._subs = {"keys": ["a@x", "b@x", …], "next": "b@x", "count": 100} next is the cursor for the following call — pass it back as after. It is "" once the namespace is exhausted.
Add values = true to get each key’s value alongside it, as rows, instead of
following up with a kv/get per key. keys, next and count are still
written:
WITH namespace = "subscribers", values = true, into = "_subs"
EXEC "txco://kv/list"
# ._subs.rows = [{"key": "a@x", "value": {…}}, …] To read a whole namespace, drain it with a LOOP. Arrays append across passes,
so rows collects every page:
WITH namespace = "subscribers", values = true, after = ._kv.next
EXEC "txco://kv/list"
LOOP EVERY "2ms" UNTIL ._kv.next == "" MAX 50 There is no per-key prefix filter: the namespace is the prefix. Give a set you’ll want to list — subscribers, a queue — a namespace of its own.
Listing is not cheap, and paging doesn’t make it cheaper. The store has no
cursor, so every page reads the whole namespace, sorts it, and returns a
window. Listing 10,000 keys 200 at a time reads the namespace 50 times. On redis it is worse again: each page scans the entire keyspace, not just your
namespace. kv/list suits small namespaces and occasional sweeps; don’t list a
large namespace on every request. values = true adds no store work — the
values are read either way.
Notes
- KV ops pay normal fuel and appear in traces.
kv/mget, andkv/listwithvalues = true, also pay 100 fuel per MiB of values returned, andkv/mset100 per MiB written — rounded up, so any such call that moves a value pays at least 100. - Values over
--kv-max-value-bytes(default 64 KiB) are rejected. - With
boltdbeach chassis keeps its own store; switch torediswhen several chassis must share state.