Transactions
Two to four keys that all change together, or none of them do.
Tx writes to several keys at once and guarantees all of them took it or none of them did. The keys
can be in two different stores, and none of them have to be online.
local Ok, Why = Store:Tx(`trade:{TradeId}`, {
{ UserId = Seller, Kind = "GiveItem", Fields = { Item = "Sword" } },
{ UserId = Buyer, Kind = "SpendGold", Fields = { Amount = 500 } },
}):Wait()If the buyer can't afford it, the seller doesn't lose the sword. If the seller doesn't have the sword, the buyer doesn't lose the gold. There's no window where one of those is true and the other isn't.
A transaction is the most expensive thing Ledger does, and a lot of the work it gets handed belongs somewhere cheaper. Selling limited stock, holding a place, counting a pot: those are all one key. Read Reservations and totals first.
What sorts them is whether two keys really have to change together, and whether either change could be undone afterwards. Trading a sword for a shield needs this. Selling a sword from a shop does not.
The id
The id comes first and it's required. It has to be a stable name derived from the thing you're
settling, so a trade id, an order id, a match id. Never build it from the clock, and never mint one
inside the Tx call.
A stable id is what makes a retry safe. Running the same id again doesn't do it twice. It finds every leg
already settled and answers true having moved nothing, so an Unresolved you want to chase is just
the same call again:
local Ok, Why = Store:Tx(`trade:{TradeId}`, Legs):Wait()
if Why == Ledger.Reason.Unresolved then
Ok, Why = Store:Tx(`trade:{TradeId}`, Legs):Wait()
endThe legs have to match on every attempt. Ledger records what a name meant next to the name, so the
same id run again for a different set of keys or a different amount answers
Spent rather than true. That holds however long ago the first one ran.
Pass the same legs on the retry and you get the true you are chasing.
Ids are 1 to 50 characters.
Where the id comes from
Two of the three you get handed, one you have to invent.
A purchase gives you ReceiptInfo.PurchaseId. Roblox keeps calling ProcessReceipt with the same
one until you return PurchaseGranted, so it survives a server restart as well as a retry.
A trade gives you nothing, so mint the id when the trade opens rather than when it commits:
local function OpenTrade(A: Player, B: Player)
return {
Id = HttpService:GenerateGUID(false), -- once, here
A = A,
B = B
}
end
-- later, when both sides confirm
Store:Tx(`trade:{Trade.Id}`, Legs):Wait()A GUID is fine there because it's minted once and held. What breaks is generating one inside the Tx
call, since every retry would be a new transaction and apply the money again.
Anything from outside, a webhook or your own website, should use the sender's order id. They're the ones who retry, so their id is the one that stays the same when they do.
The rule underneath all three: the id has to live at least as long as whatever might retry it. For a purchase Roblox holds it. For a trade only the hosting server would retry, so that server's memory is enough. If you can't name what would retry, you don't need a durable id at all.
The legs
Between two and four legs. Each one names a key and an op. Limits explains where those two numbers come from. Read it before you reach for a fourth leg.
{
Store = Clans, -- optional, defaults to the store you called Tx on
UserId = 12345, -- for a player store
Key = "cool-guys", -- for a string keyed store
Kind = "Donate",
Fields = { Amount = 500 },
}Use UserId when the target store uses player keys and Key when it uses string keys. Ledger
checks which one the target store wants and throws if you gave it the wrong one.
A transaction can only touch a key once, so two legs pointing at the same key is an error rather than something it tries to merge.
Don't put Once on a leg. The transaction id already makes every leg land at most one time, and
Ledger throws if it sees one.
A leg reads what is stored, not what a session is holding
Every leg folds the record on the datastore. A live session that has taken ops through
Apply has not written them yet, so a leg on that player's key
does not see them.
That bites hardest on a player who just joined. Grant them something with Apply, list it for sale a
few seconds later, and the leg reads a key that has never been written, which folds to your Default.
The reducer sees an empty inventory and a new player, refuses, and the whole transaction answers
Refused.
-- the session says the item is there, the stored record does not
Session:Apply("GrantItem", { Item = ItemId })
Store:Tx(`listing:{ListingId}`, {
{ Key = "market", Kind = "AddListing", Fields = { Item = ItemId } },
{ Store = Players, UserId = Seller, Kind = "ListItem", Fields = { Item = ItemId } },
}):Wait() --> false, RefusedTwo ways round it. Flush first if the session is on this server:
Session:Flush():Wait()Or write it durably in the first place, which is what Commit is for:
Session:Commit("GrantItem", { Item = ItemId }):Wait()Prefer Commit for anything a transaction will later depend on. A flush only works while the session
is on the server running the transaction, and the seller may be on another one or offline.
Waiting does not fix it. An autosave writes what was queued when it ran, so the newest applies are still unwritten whenever the transaction lands.
A live session does not know a leg wrote to it
The record carries the change the moment Tx answers true. A session already open on that key does
not. It holds its own copy and only picks an outside write up when it folds the record again.
A session with nothing queued and nothing parked reads every two minutes, so a player who just bought something waits that long to see it arrive. Nothing is wrong and nothing is at risk. They are reading a copy that has not caught up.
Store:Stale() carries the keys this server has changed. Subscribe
once, and flush the session on every key it names:
Profiles:Stale():Subscribe(function(Key)
local Person = Players:GetPlayerByUserId(tonumber(Key) or 0)
if Person == nil then
return
end
local Session = Profiles:Get(Person)
if Session then
Session:Flush():Wait()
end
end)That re-reads the key, folds it again, and pushes anything the session still had queued. It also
fires Observe, so a UI bound to the session updates on its own.
The stream names every key the transaction touched, so the buyer and the seller both refresh. It costs one request per key with a session here, and only on a purchase.
A cross store transaction pushes each leg onto its own store's stream, so subscribe on both stores.
This listener yields, which the stale stream allows. The ones on Session:Observe() may not. See
Observer.
A player on another server is not on this stream. Their server holds them, so their server does the flush, and the next section is how you ask it to.
Telling another server to refresh
Ledger can't reach another server. Your game can, so send the key over MessagingService and let the
server holding that player do the flush.
Drive it from the same stream. Flush a key with a session here, and publish one without:
local MessagingService = game:GetService("MessagingService")
local Players = game:GetService("Players")
local REFRESH = "LedgerRefresh"
local function Refresh(Key: string): boolean
local Person = Players:GetPlayerByUserId(tonumber(Key) or 0)
if Person == nil then
return false
end
local Session = Profiles:Get(Person)
if Session then
Session:Flush():Wait()
end
return true
end
MessagingService:SubscribeAsync(REFRESH, function(Message)
Refresh(Message.Data)
end)
Profiles:Stale():Subscribe(function(Key)
if Refresh(Key) then
return
end
pcall(function()
MessagingService:PublishAsync(REFRESH, Key)
end)
end)The buyer sees the purchase in a moment either way. Without this they wait for the ordinary two minute read.
Send the key and nothing else. The other server re-reads the key itself, so the record stays the only thing either server believes. A message carrying the new inventory would be a second copy of the truth, and a dropped or repeated one would put the two servers out of step.
Treat it as a nudge, not a guarantee. MessagingService is best effort and rate limited, and
PublishAsync throws once you hit a limit, which is why the call above is wrapped. A message that
never arrives costs that player the ordinary two minute read. Nothing is lost either way, so there is
no need to confirm delivery or retry.
Publishing only when the player isn't here keeps most purchases off the topic entirely.
What throws and what answers
The shape of a leg is your code, so getting it wrong throws where you wrote it. A key the target
store won't take, a missing or oversized id, the wrong number of legs, the same key twice, a Once
on a leg, a Store that Ledger didn't build.
What's in Fields is data, so it answers instead:
local Ok, Why = Store:Tx(`trade:{TradeId}`, {
{ UserId = A, Kind = "Give", Fields = { Amount = Price * Quantity } },
{ UserId = B, Kind = "Take", Fields = { Amount = Price * Quantity } }
}):Wait()
if Why == Ledger.Reason.Invalid then
-- a field can't be stored, so an Instance, a NaN from that multiply, a reserved name
endThat's the same split Edit uses, and the same Invalid. Nothing is
prepared on any key when it happens, so there's nothing to clean up.
How it decides
Each leg gets prepared on its key first. A prepared op is written into the log but carries a stamp that makes the fold skip it, so it's sitting there doing nothing.
Once every leg is prepared, Ledger writes the outcome to a marker key. That single write is the moment the transaction commits, and there's exactly one of them, so two servers racing the same transaction can't disagree about what happened.
Committing takes the stamps off, which is what makes the ops start counting. Aborting removes them.
If any leg's reducer refuses during prepare, the whole thing aborts and every other leg gets its op pulled back out.
Stuck legs
If the server dies between preparing a leg and writing the outcome, that leg sits there stamped. Anything that reads the key sees a pending leg, so it can't just pretend it isn't there.
A few things clear it. Any read or write on that key tries to settle it first. The background sweeper picks up keys it knows have pending legs. And a transaction that's older than a minute is considered dead, so another server will abort it on its behalf.
While it's stuck, writes to that key answer Busy, and Edit or Reset
can answer Unresolved when the stuck leg would change the verdict. Neither means it failed. Both
mean ask again in a moment.
Ask again yourself. Tx does not retry a Busy internally, because a key under contention is the
last place to send more traffic:
local Ok, Why
for _ = 1, 6 do
Ok, Why = Store:Tx(`trade:{TradeId}`, Legs):Wait()
if Ok or Why ~= Ledger.Reason.Busy then
break
end
task.wait(0.2 + math.random() * 0.3)
endA key every player writes to is a throughput limit rather than a correctness one. See One key at a time.
You can force a pass with Ledger.Sweep().
Marker cleanup
Committed markers get tidied up in the background once they're old enough that nothing could still
be asking about them. That happens on the store named <YourStore>_Tx, which Ledger creates
alongside yours.
That's also why store names cap at 47 characters instead of the datastore's 50. Ledger needs the
three for _Tx.
Mixing stores
local Ok, Why = Players:Tx(`donate:{OrderId}`, {
{ UserId = Player.UserId, Kind = "SpendGold", Fields = { Amount = 500 } },
{ Store = Clans, Key = "cool-guys", Kind = "Donate", Fields = { Amount = 500 } },
}):Wait()Both stores have to have been built by Ledger.New in this server. A leg naming something else
throws.
When not to reach for it
If you're moving one balance one direction, use a transfer. It's one
protocol instead of two phases, it self heals, and it doesn't leave a key Busy while it runs.
Tx is for when two different kinds of change have to happen together.