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

OpWITHDoes
txco://kv/getkey, into?, fallback?Read a value into the envelope.
txco://kv/setkey, value? / from?, ttl?Write a value.
txco://kv/deletekeyRemove a value.
txco://kv/incrkey, by?, ttl?, into?Atomically add to an integer.
txco://kv/caskey, value? / from?, expected?, ttl?, into?Check-and-set.
txco://kv/mgetitems, into?Read many keys — each in its own namespace, if you like — in one dispatch.
txco://kv/msetitems, ttl?Write many keys in one atomic step: all or none.
txco://kv/mdeleteitemsDelete many keys in one atomic step.
txco://kv/listafter?, 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. Pass namespace = "shared" (any name) to share keys across a tenant’s stacks. Names starting with _txc are 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 every kv/* op and by KV/ 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, not default — default is 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: false with no value, 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 _txc namespace.
  • 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 _txc namespace, invalid JSON, or a value over the size cap.
  • kv/mset refuses a key listed twice, since the batch would have no single answer for it. kv/mdelete doesn’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, and kv/list with values = true, also pay 100 fuel per MiB of values returned, and kv/mset 100 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 boltdb each chassis keeps its own store; switch to redis when several chassis must share state.

Edit this page · View as markdown