Skip to content

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.

One call moves through five phases:

  1. Guards. Both profiles must be Active, must be different profiles, and (with an Id) must not have seen this transaction before. Then both are locked: a profile already inside another atomic returns Busy immediately.
  2. Escrow. Data from both profiles is deep-copied and the mutator runs on the copies under a pcall. Return false to abort (insufficient funds, missing item); a crash is caught the same way. Either way the real profiles were never touched.
  3. Commit in place. The mutated copies are written back into the existing Profile.Data tables — keys removed, added, and recursed rather than the tables being replaced. Table identity is preserved deliberately: any references game code already holds into Profile.Data or its subtables stay live. Backups of the pre-commit state are taken first.
  4. 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.
  5. Replay id. Only after both saves land is the Id recorded 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 true
end)
if not Result.Ok then
warn(`trade failed: {Result.Reason}`)
end

Give 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 failed
end

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.

  • Concurrent trades over the same inventory (same server). Two atomic calls sharing a profile cannot interleave: per-profile locks are taken in deterministic order (sorted by Profile.Key, so two trades over the same pair cannot deadlock), and the loser gets Busy before 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, so DuplicateTransaction protection 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. atomic takes two same-server Profile objects, 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.
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.

  • The mutator gets copies. Mutate dataA/dataB, the arguments — not captured Profile.Data references. 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.
  • Busy is 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.