Transactions
Transactions.atomic mutates two DataDriver profiles as one unit — the escrow primitive under trades, gifting, and shared banks. The defining decision: your mutator never touches the real profiles. It runs on deep copies; the originals change only after the mutator succeeds, and they persist only if both profiles save. Every failure path — abort, mutator crash, either save failing — leaves both players exactly as they were. Item dupes are born in the gaps between “A changed” and “B changed”; this module’s job is to have no gaps.
Mental model
Section titled “Mental model”One call moves through five phases:
- Guards. Both profiles must be
Active, must be different profiles, and (with anId) must not have seen this transaction before. Then both are locked: a profile already inside anotheratomicreturnsBusyimmediately. - Escrow.
Datafrom both profiles is deep-copied and the mutator runs on the copies under apcall. Returnfalseto abort (insufficient funds, missing item); a crash is caught the same way. Either way the real profiles were never touched. - Commit in place. The mutated copies are written back into the existing
Profile.Datatables — keys removed, added, and recursed rather than the tables being replaced. Table identity is preserved deliberately: any references game code already holds intoProfile.Dataor its subtables stay live. Backups of the pre-commit state are taken first. - Save both, compensate on failure. Profile A saves, then profile B. If A fails, both roll back in memory — nothing persisted, nothing to repair. If B fails after A saved, both roll back and A saves again — a compensation write that persists A’s rolled-back state. If even the compensation save fails, in-memory state is still rolled back and the next autosave persists it; a warn records the window.
- Replay id. Only after both saves land is the
Idrecorded into both profiles and re-saved. An id is never burned by a failed transaction — the retry must be allowed through.
The locks release on every path out, success or failure. A crashing mutator cannot wedge a pair of profiles.
The escrow shape — validate inside the mutator, abort freely:
local Result = Transactions.atomic(SellerProfile, BuyerProfile, function(seller, buyer) local Index = table.find(seller.Inventory, ItemId) if not Index or buyer.Coins < Price then return false -- abort: both untouched end table.remove(seller.Inventory, Index) table.insert(buyer.Inventory, ItemId) buyer.Coins -= Price seller.Coins += Price return trueend)
if not Result.Ok then warn(`trade failed: {Result.Reason}`)endGive every player-initiated trade an id, and identical replays — a double-clicked accept, a retried remote — are rejected instead of applied twice:
local Result = Transactions.atomic(SellerProfile, BuyerProfile, Mutator, { Id = TradeOfferId })if not Result.Ok and Result.Reason == "DuplicateTransaction" then -- the first accept already went through; tell the client it's done, not failedendResult and reasons
Section titled “Result and reasons”atomic returns { Ok: boolean, Reason: string? }. Every Reason the source can produce:
| Reason | Ok |
Meaning |
|---|---|---|
ProfileInactive |
false |
One profile is released or lost its session lock. Nothing ran. |
SameProfile |
false |
Both arguments are the same profile. Nothing ran. |
DuplicateTransaction |
false |
options.Id was already seen by either profile. Nothing ran — the original transaction stands. |
Busy |
false |
Either profile is inside another atomic right now. Nothing ran; retry after it finishes. |
Aborted |
false |
The mutator returned false. Both profiles untouched. |
MutatorError: {err} |
false |
The mutator crashed. Both profiles untouched. |
SaveAFailed |
false |
The first save failed. Both rolled back in memory; nothing persisted. |
SaveBFailed |
false |
The second save failed after the first landed. Both rolled back; A’s rollback was persisted by a compensation save (warned if that also failed). |
ReplayIdSaveFailed |
true |
The trade committed and persisted, but the replay id failed to save on one or both profiles. Same-server replays remain blocked by the in-memory id until shutdown; a cross-session replay of this exact id is the residual risk. |
Note the last row: it is the one reason attached to a successful result. Treat Ok as the commit signal and Reason as diagnostics.
The dupe classes, and which ones die here
Section titled “The dupe classes, and which ones die here”- Concurrent trades over the same inventory (same server). Two
atomiccalls sharing a profile cannot interleave: per-profile locks are taken in deterministic order (sorted byProfile.Key, so two trades over the same pair cannot deadlock), and the loser getsBusybefore any copy is made. The spec proves the classic version — a trade against A started inside another trade against A — moves exactly one sword. - Replayed intent. The id ledger lives at
Data.CKSeenTransactions—{ Ids, Order }, capped FIFO at 64 — inside the profile, soDuplicateTransactionprotection survives a rejoin or server hop. Ids are recorded only after both saves land, so a failed trade never burns its id. - Cross-server trades: refused by design.
atomictakes two same-serverProfileobjects, and session locks guarantee a profile is loaded on at most one server — so the API cannot even express a cross-server trade. This is deliberate, not a missing feature: two servers saving two halves of a trade with no coordinator is how dupes are born. Put both players in the same server (or build a coordinator with its own escrow record) before trading.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Transactions.atomic(profileA, profileB, mutate, options?) → Result |
mutate(dataA, dataB) -> boolean? runs on deep copies; return false to abort. options.Id: string? enables replay rejection. Yields (it saves both profiles). |
Result = { Ok: boolean, Reason: string? } — see the reasons table.
Gotchas
Section titled “Gotchas”- The mutator gets copies. Mutate
dataA/dataB, the arguments — not capturedProfile.Datareferences. Writing to the real profile from inside the mutator bypasses the escrow entirely. - Keep mutators synchronous and pure. A yield inside the mutator holds both locks across frames and lets unrelated writes race the commit; a pcall-caught crash is safe, a yield is not.
Busyis a normal outcome, not an error. Queue or retry the intent; do not spam-loop it.- Both saves mean up to two (or four, with an
Id) backend writes per trade. Trades are not free — rate-limit trade intents at the networking layer like anything else a client can spam. - Inactive means no. A profile mid-release or with a stolen lock refuses immediately (
ProfileInactive); never hold profile references across yields when the player might be leaving.