Overview
A lock free datastore library for Roblox, where state is a fold over a log of changes.
Ledger keeps player data as a log of changes instead of a document you overwrite. You never write state. You write down the change you want, a function you own decides if it's allowed, and state is what falls out of replaying those changes.
local Store = Ledger.New({
Name = "PlayerData",
Default = { Gold = 100 },
Reducer = function(State, Op)
if Op.Kind == "Earn" then
if type(Op.Amount) ~= "number" or Op.Amount <= 0 then
return nil
end
return { Gold = State.Gold + Op.Amount }
end
if Op.Kind == "SpendGold" then
if type(Op.Amount) ~= "number" or Op.Amount > State.Gold then
return nil -- refused, on every server, forever
end
return { Gold = State.Gold - Op.Amount }
end
return nil -- an op this build has never heard of
end,
})
Store:Load(Player)
Store:Expect(Player):Apply("SpendGold", { Amount = 25 })Two servers spend the same 100 gold, both writes go through, the fold takes one and refuses the other. Every server agrees, every time.
Why there's no session lock
A session lock stops the second writer. You pay for that in three ways. A dead server leaves a lease someone has to wait out. Whoever is locked waits at the join screen. And nothing can reach a player who is offline or on another server.
A fold that validates makes the bad state unreachable, so nothing needs unlocking when a server crashes. It costs discipline instead. Changes have to be ops with names, and you have to write a reducer.
What's in it
The fold
State is a pure fold of the log, so every server works out the same answer with no lock.
Apply and Commit
Apply is instant and local. Commit is durable and tells you whether your op won.
Cross server writes
Edit, Transfer and Tx work on any key, online here, elsewhere, or offline.
Entity stores
String keys for clans, listings, world records. Shared state no server owns.
Transfers
A balance moved through an escrow, deduped by id, and it fixes itself after a crash.
Transactions
Two phase commit across two to four keys, which can sit in two different stores.
Once
Name an op after a receipt or an order and it only ever applies once.
Migrations
Shape upgrades with a version on them, safe to roll out one server at a time.
What it isn't
Ledger is server side only. It doesn't replicate to clients, it doesn't do leaderboards or ordered
stores, and it won't hide a reducer that's wrong. If your reducer reads os.time() or
math.random(), two servers will fold the same log into different state. Ledger warns about that in
Studio rather than hiding it.
Ledger is tested the way TigerBeetle tests a database. It runs against a simulated datastore that drops writes, reports success for writes it dropped, corrupts records, splits servers apart, kills them halfway through a write, and moves the clock. Everything comes off one seed, so a failure replays exactly. The transaction protocol also goes through an exhaustive interleaving search. See Testing.