SCUA

How-to

Share config across a cluster

Say you run a few backend nodes and want them to agree on some live settings: graphics tiers, feature flags, economy numbers, whatever you'd normally poke at without a redeploy. The usual answer is to stand up etcd or Consul or a Redis instance and wire every node to it. The cluster module is the small version of that. You open a named table, write path-shaped keys to it, and every node reading the same table converges on the same values. Last writer wins.

It's a host-granted capability, like fs and net: without the grant, import cluster is a compile error, so a script can't touch shared config unless you turn it on.

#Granting the capability

scua --allow-cluster=hunter2  script.scua   # join the cluster whose shared secret is "hunter2"
scua --allow-cluster          script.scua   # empty secret — for local dev and demos

The secret is a shared MAC key: every node that meshes the same table has to present the same one, and a mismatch is refused rather than silently trusted. A bare --allow-cluster uses the empty secret, which is fine for a single machine or a demo but not for anything real.

Without the grant, import cluster fails before a line runs:

$ scua script.scua
script.scua:1: module `cluster` needs the `cluster` capability, which this run was not granted — grant it with `--allow-cluster` on the CLI, or the host's capability API when embedding

#Open a table and read and write it

cluster.open(name) creates or attaches the named table. From there it's the shape you'd expect from a key/value store, except keys are path-shaped strings:

import cluster

let cfg = cluster.open("game_config")

cluster.set(cfg, "graphics/shadows/quality", 2)
cluster.set(cfg, "graphics/textures/res", 4096)
cluster.set(cfg, "economy/starting_gold", 100)
cluster.set(cfg, "flags/new_shop", true)

print(`shadow quality: {cluster.get(cfg, "graphics/shadows/quality")}`)   -- 2
print(`missing key:    {cluster.get(cfg, "nope") ?? "(nil)"}`)            -- (nil)
print(`has new_shop:   {cluster.has(cfg, "flags/new_shop")}`)            -- true
  • cluster.set(t, key, value) writes a value. Writes are last-writer-wins: the newest write for a key is the one that survives, and the superseded write is recorded in the host's audit trail rather than lost without trace.
  • cluster.get(t, key) returns the converged value, or nil if the key was never set or was deleted. A missing key is a plain nil, never an error, so ?? default reads well.
  • cluster.has(t, key) tells present from absent. Use it when nil is a legitimate value and you need to know whether the key actually exists.

#Deleting a key

cluster.delete writes a tombstone. The key reads back nil and has returns false, and because the tombstone carries the newest clock, an older write still in flight from another node can't resurrect it:

cluster.delete(cfg, "flags/new_shop")
print(cluster.get(cfg, "flags/new_shop") ?? "(nil)")   -- (nil)
print(cluster.has(cfg, "flags/new_shop"))              -- false

#Reading a whole subtree

Path-shaped keys are there so you can pull a section out in one call. cluster.subtree(t, prefix) gathers every live key under prefix/ into a table keyed by the relative path:

cluster.set(cfg, "graphics/shadows/quality", 2)
cluster.set(cfg, "graphics/textures/res", 4096)

let gfx = cluster.subtree(cfg, "graphics")
for k, v in gfx do
  print(`{k} = {v}`)        -- shadows/quality = 2 ,  textures/res = 4096
end

#The keyspace is prefix-exclusive

A key is either a leaf value or a namespace, never both. Once graphics/shadows/quality holds a value, graphics/shadows can't also be a value, and vice versa. Trying to do both is a fault, not a silent overwrite:

cluster.set(cfg, "graphics/shadows/quality", 2)
cluster.set(cfg, "graphics/shadows", "high")
-- fault: a key is a leaf value or a namespace, never both — this key collides with an existing one

#What you can store

Shared config is for config, not bulk data. Values must have a portable form and stay small:

  • Shareable values: int, float, bool, string, bytes, and plain tables and arrays of those.
  • Not shareable: closures, handles, partition references, paths, vectors and matrices, and Ok/Error values. Setting one is a fault, so you find out at the write, not later.
  • Size cap: 64 KiB per value. Over it is a fault. If you're bumping the cap, you want a real store, not cluster config.
  • NaN is rejected, since it breaks the "same value everywhere" promise it's supposed to keep.

#Surviving a restart

By default a shared table lives only in memory: restart every node at once and it comes back empty. That's fine when the table is always refilled by a node that stayed up, but not if the whole cluster goes down together. Pass durable to back the table with a local file:

let cfg = cluster.open("game_config", { durable = "game_config.scdb" })

Now the table is loaded from that file when you open it, and written back periodically and on a clean shutdown. Restart the process and your config is still there — deletes included, so a key you removed stays removed rather than coming back.

A few things worth knowing:

  • It's a snapshot, not a log. If a node crashes hard, it loses whatever changed in the last few seconds before the snapshot. In a cluster that doesn't matter — the node catches back up from its peers the moment it rejoins. For a single node with no peers, that few-seconds window is the one gap; a clean shutdown always writes a final snapshot, so it only applies to an actual crash.
  • The write is safe. The file is written to a temporary name, flushed to disk, then atomically renamed into place, so a crash mid-write can never leave you with a half-written or corrupt file. If the file ever is unreadable (a bad disk), the node starts empty and refills from peers rather than refusing to start.
  • Use durable for config, skip it for throwaway state. It's there so feature flags and tuning numbers survive a deploy; there's no reason to pay for it on data you'd be happy to lose.

#Reacting to a change

Polling cluster.get works, but you usually want to know when something changed — especially when the change came from another node. cluster.watch runs a handler for every change under a prefix:

cluster.watch(cfg, "economy", fn(change)
  print(`{change.key} is now {change.value ?? "(deleted)"}`)
end)

change is { key, value, deleted }. A key deleted anywhere in the cluster arrives with value = nil and deleted = true, so you can tell "removed" from "set to nothing".

Two things are worth knowing, because they're what make this safe to use:

The handler runs as its own turn — it never interrupts you. It behaves exactly like a message you sent yourself: it's queued and runs later, never in the middle of some other piece of code. So a watch handler is ordinary script, with no reentrancy to reason about — a change arriving from the network can't land halfway through a function and see your data half-updated.

Changes to the same key are coalesced. If a key changes twice before your handler gets a turn, you're told once, with the value it actually has now. You're told what the world is, not replayed every state it passed through — which for config is what you want, and avoids being handed a value that no longer exists.

A watch only fires while the partition is alive to take turns — a script that runs to the end and exits gets nothing, the same as any other message.

#Running across multiple nodes

cluster.join brings this node onto the network and starts gossiping the table to its peers:

let node_id = cluster.join({
  table     = "game_config",
  bind      = "0.0.0.0",           -- the address this node LISTENS on
  port      = 7946,
  advertise = "10.0.0.1:7946",     -- the address other nodes should DIAL to reach it
  seeds     = ["10.0.0.2:7946"],   -- one known peer is enough; the rest are discovered
})

You only need one seed. Nodes gossip their membership as well as their data, so a node that knows one member learns the whole cluster from it and dials the rest itself. Seeds are a bootstrap hint, not a configuration of the mesh — you don't have to list every node in every node's config, and a node that joins later is found without touching anyone else's setup. (Listing your own address in seeds is harmless: a node that dials itself notices and forgets the seed.)

advertise is what peers dial to reach you, and it matters because a node cannot work this out for itself: an incoming connection tells you the peer's temporary source port, not the port it listens on. So each node has to state its own address.

  • If bind is a real address (10.0.0.1), advertise defaults to it and you can leave it out.
  • If bind is the wildcard 0.0.0.0, you must pass advertise — advertising 0.0.0.0 would tell the whole cluster to dial itself. cluster.join fails at startup and says so, rather than quietly forming a mesh that half-works.

advertise and seeds must be IP literals (10.0.0.2:7946), not hostnames: resolving a name blocks, and the gossip loop is built never to block.

The returned id is this node's last-writer-wins tiebreak. It's derived from the hostname and port, so it's stable across a restart; pass an explicit node_id if you'd rather choose it.

Once joined, gossip rounds run on the host's tick and the table converges across the mesh by the same last-writer-wins rule.

#When a node dies

The cluster notices, and you can watch it happen:

for _, m in cluster.members(cfg) do
  print(`node {m.id} at {m.addr} is {m.state}`)   -- "alive", "suspect", or "dead"
end

Nodes ping each other, and a node that stops answering goes alivesuspectdead, after which the mesh stops trying to reach it. It is deliberately not a straight jump to dead:

  • A node that misses a beat is only suspected, never condemned. Before doubting it, this node asks a few other nodes to try reaching it — if any of them can, nothing happens. That's what stops one bad network link between two machines from declaring a perfectly healthy node dead.
  • A suspected node defends itself: if it's alive, it says so, and the suspicion is dropped. So a node that was merely busy for a moment rejoins rather than being evicted.
  • A node that is slow to hear back from everyone concludes that it is the unhealthy one, and gets more patient with everyone else rather than declaring the whole cluster dead.

That last point has a visible consequence worth knowing: in a two-node cluster, if one dies the survivor cannot tell "they died" from "my own network is broken" — so it waits considerably longer before calling it. Three or more nodes get a much faster and more confident answer.

cluster.members reports this node's view. Membership is eventually consistent, so two nodes can briefly disagree about a third — that's the model working, not a bug.

#One caveat about deletes and long-absent nodes

When you delete a key, the cluster keeps a marker saying "this was deleted" so that a slower node still holding the old value can't put it back. Those markers are cleaned up after a while (24 hours by default), but only once every node has confirmed it has seen the delete — if any node is unreachable, nothing is cleaned up at all, because the node you can't see is exactly the one that would bring the old value back.

The consequence worth knowing: don't rejoin a node that has been offline for longer than a day without wiping its table first. Once the delete markers are gone, that node's writes from before it went dark are refused rather than trusted, because there's no longer any way to tell a legitimate old write from a stale value that would silently undo a delete. Refusing them is the safe direction — a dropped stale write is recoverable; a resurrected deleted key is not — but it does mean an old node's writes can be ignored.

For a normal cluster where nodes are up, or restart within minutes, none of this ever comes up.

#Try it

The runnable examples/shared_config.scua walks the whole single-node surface:

scua --allow-cluster examples/shared_config.scua