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, ornilif the key was never set or was deleted. A missing key is a plainnil, never an error, so?? defaultreads well.cluster.has(t, key)tells present from absent. Use it whennilis 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/Errorvalues. 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.
NaNis 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
durablefor 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
bindis a real address (10.0.0.1),advertisedefaults to it and you can leave it out. - If
bindis the wildcard0.0.0.0, you must passadvertise— advertising0.0.0.0would tell the whole cluster to dial itself.cluster.joinfails 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
alive → suspect → dead, 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