Ledger
Concepts

Handling failures

When a retry is safe, and what to do about the reasons that need more than a retry.

Most code only cares about two things: did it work, and can I ask again.

local Ok, Why = Store:Edit(UserId, "GrantItem", { Item = "Sword" }):Wait()

if Ok then
	-- it went through, and a retry under the same id would answer true again
elseif Why == Ledger.Reason.Unresolved or Why == Ledger.Reason.Busy then
	-- no answer yet, ask again later with the same id
else
	-- Refused, Spent, Closed, Backlog, Full, Invalid, Behind:
	-- it didn't happen, and asking again with this id won't change that
end

Reads answer the same way with the value in front:

local State, Why = Store:Peek(UserId):Wait()
if State == nil then
	-- Why says which of the failures it was
	return
end

A key nobody has ever written folds to your Default, so a read never gives you nil on success. nil always means it failed.

Why a retry is safe

Every op carries an id, and an id applies at most once on a key. A retry that already went through answers true and moves nothing the second time. That holds for Once names, transfer ids and transaction ids.

The id has to be the same one. Build it before the call and keep it for the retry:

local OrderId = `order:{Receipt.PurchaseId}`

local Ok, Why = Store:Edit(UserId, "Grant", { ProductId = 123, Once = OrderId }):Wait()
if Why == Ledger.Reason.Unresolved then
	-- same OrderId, so the retry costs nothing if the first one applied
	Ok = Store:DidApply(UserId, OrderId):Wait() == true
end

Generate a new id on the retry and Ledger has nothing to match it against, so the retry applies a second time.

Unresolved

Ledger doesn't know whether the write applied. Unresolved does not mean no. Treat it as a refusal and you will eventually refuse something that already went through, which costs you money.

Retry with the same id. See above. Without an id you have no way to ask again safely.

Ask instead of guessing. Store:DidApply(Key, Name) says whether a Once name applied. Compare it against true, because a failed read gives nil.

In ProcessReceipt, answer NotProcessedYet. Roblox calls you again, and the next call gets a straight answer. The full pattern is in Once.

For a stuck leg, Store:Resettle(Key) settles it now instead of waiting for the sweep. true means the key has nothing unfinished on it.

Spent

Nothing was applied. Spent looks like success and it isn't.

For a transfer the money is back with the sender. Hand out the item on a Spent and you have paid for something that was refunded.

true is the answer that means "already happened". Call Transfer again with an id that delivered, or Tx again with an id that committed, and you get true with nothing moved.

So on Spent, decide what to do about a name you can't reuse. Read the keys, pick a new id, or tell the player it didn't go through. See Ids and retries and The id.

Behind

Retrying is pointless. This server is running an older build than the one that wrote the record, so nothing changes until the deploy finishes.

Never treat Behind as "this player has no data". The record is fine. Writing a fresh profile over the top would destroy it.

Pass OnLoadFailed if you deploy while people are playing. A rejoin puts them back on the same old server, so Behind wants a teleport. See Rolling deploys.

The rest

ReasonWhat to do
RefusedYour own rule said no. Tell the player.
BusyRetry in a few seconds. See Stuck legs.
ClosedThe session is gone. Write before Unload, see Shutting down.
BacklogSaves aren't going through. Stop writing and look at the datastore, see Autosave.
FullRemove something first, see Size.
InvalidA bug in the calling code. Ledger warns with the field, see What you can store.

On this page