Skip to content

Transactions

Changes two DataDriver profiles as one durable decision, preparing both records, committing or aborting through a single journal write, and leaving any interrupted trade for recovery on the next load.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Commerce.Transactions
Since
v0.1.0
Source
Commerce/Transactions.luau

Transactions.atomic changes two DataDriver profiles as one unit. It is the escrow primitive under trades, gifts and shared banks. Duplication lives in the gap between “A changed” and “B changed”, and the defining decision is to replace that gap with one durable decision: the mutator runs on copies, both records are saved with a pointer to a journal record, a single atomic write to the journal commits or aborts the trade, and both records are settled afterwards. If the server dies at any point, the next load of either participant reads the journal and applies its decision (transaction recovery). Before 0.8.0 a failed second save was undone with a compensating write, which a crash between the two writes could not survive.

One call moves through these steps. Nothing is written before step 5.

  1. The guards run. Both profiles must be active and not blocked, must be different profiles with different keys, must not be in another trade or in the middle of a save, and must belong to the same DataDriver instance. With a Policy, every owner must be allowed to trade paid items. A trade Id must be a string of 1 to 256 characters of valid UTF-8, and neither participant may have seen it.
  2. Both profiles’ TransactionOwner is set to the calling thread. While it is set, other saves of these profiles return profile is in a transaction, the NetDriver rejects client intents from either player (TransactionLocked), Receipts waits, and a release requested meanwhile runs when the trade ends.
  3. Both Data tables are deep-copied and the mutator runs on the copies in its own coroutine. Returning false aborts, raising aborts with the error, and yielding aborts with MutatorMustNotYield. Any other return commits.
  4. Both results are checked and encoded the way a save checks data: serializable values, Validate, and a codec read-back. A draft that fails ends the trade with TransactionError and nothing written.
  5. A journal record CKTxn_<GUID> is created in the same backend with State = "Preparing", both encoded drafts and both participants’ replay ids, tagged with both owners’ UserIds for GDPR erasure.
  6. Profile A is saved with Meta.PendingTransaction = { Key, Token }, then profile B, only if A’s save landed. These are the prepare saves: both records still hold their pre-trade data, now with a pointer to the journal.
  7. One atomic journal update decides. It moves Preparing to Committed when both prepares landed, neither profile’s data changed during them, and both profiles are still active, and to Aborted otherwise. Recovery on another server contends for the same update, and exactly one of them decides.
  8. On a commit, the trade’s changes are merged into the live data. A server write that landed during the decision write (a quest counter, a reward) is kept when it touched values the trade did not change. The tables are updated in place, so references game code holds into Profile.Data stay live, and the replay ids move into Meta.TransactionIds.
  9. Both pointers are cleared and both profiles saved again. These are the settle saves. When both land, the journal is deleted.
  10. Ownership is cleared, deferred releases run, and Commerce.TransactionEnded(a, b, ok) is published.

A successful trade costs six backend updates (journal, two prepares, decision, two settles) plus the journal delete, before retries and backup copies. The journal holds both participants’ encoded data, so the two profiles together must fit the store’s 4 MB value limit.

Failure What happens
A guard fails Nothing ran; the result names the guard.
The mutator aborts, raises or yields Nothing was written.
The journal cannot be created Nothing was written (JournalFailed).
A prepare save fails The decision is Aborted; the settle saves clear any pointer that landed.
A profile’s data changed during the prepare saves The decision is Aborted (ConcurrentMutation).
The decision write fails The outcome is unknown until recovery. Both participants are blocked (PendingRecovery, Committed = nil).
A write during the decision changed a value the trade also changed Both are blocked (ConcurrentMutationNeedsRecovery). Recovery applies the journal decision: the trade wins over the unsaved write.
A settle save fails Only that participant is blocked, with its pointer kept (PendingRecovery). The other one’s record is final and it keeps playing. Before 0.9.0 both were blocked.

A blocked profile (TransactionBlocked = true) refuses ordinary saves and further trades, and its release only removes the lock, so the uncertain live data never overwrites the record that points at the journal. An attached DataDriver kicks a blocked participant when Commerce.TransactionEnded fires (“Your trade is being finished. Please rejoin.”), and their next load settles the trade from the journal. Before 0.9.0 a blocked participant kept playing on a profile that could no longer save.

A server crash at any step leaves the trade to recovery on each participant’s next load. Recovery uses the load’s own lock rule, so once lock handoff sees the crashed server’s presence stop, the joining server takes the lock and settles the trade in the same load, about 15 s after the crash. Before 0.9.1 recovery went by lock age alone, and the participant was kicked until the crashed server’s lock was 3 minutes old.

Validate inside the mutator and abort freely:

local function sell(seller: any, buyer: any, itemId: string, price: number, offerId: string): (boolean, string?)
local Result = Transactions.atomic(seller.Profile, buyer.Profile, function(sellerData, buyerData)
local Index = table.find(sellerData.Inventory, itemId)
if not Index or buyerData.Coins < price then
return false -- abort: neither profile changes
end
table.remove(sellerData.Inventory, Index)
table.insert(buyerData.Inventory, itemId)
buyerData.Coins -= price
sellerData.Coins += price
return true
end, { Id = offerId })
if Result.Ok or Result.Reason == "DuplicateTransaction" then
return true, nil -- done, or an earlier accept of this offer already committed
elseif Result.Committed == true then
return true, nil -- done; a participant still settling finishes on their next load
elseif Result.Reason == "PendingRecovery" then
return false, "pending" -- unknown until recovery; never retry under a new id
end
return false, Result.Reason
end

Give every player-initiated trade an id that stays the same across retries, such as the offer id. A double-clicked accept or a retried request then returns DuplicateTransaction instead of trading twice.

With Policy attached (kernel.Policy through the profiles’ DataDriver, or options.Policy), a trade returns PolicyRestricted before touching either profile unless every owner in both profiles’ Meta.UserIds is allowed to trade paid items (IsPaidItemTradingAllowed). An owner whose policy has not loaded on this server counts as not allowed. Pass Paid = false for trades that move nothing bought with Robux or Robux-bought currency. A game that lets players trade paid items must attach Policy; without it, trades are not policy-checked.

atomic returns { Ok, Reason?, Id?, Committed? }. Guard refusals return only Ok and Reason (plus Id for DuplicateTransaction). After the guards, Id is the trade id and Committed is true when the journal decision committed, false when the trade was aborted or never reached a decision, and nil when the decision itself is unknown.

Reason Ok Meaning
nil true Committed and settled on both sides.
ProfileInactive false A profile is released, lost its lock, or is blocked for recovery. Nothing ran.
SameProfile false Both arguments are the same profile or the same key. Nothing ran.
Busy false A profile is in another trade or a save is running on it. Nothing ran; try again shortly.
DifferentDataDrivers false The profiles come from different DataDriver instances. Nothing ran.
PolicyRestricted false An owner may not trade paid items. Nothing ran.
InvalidId false options.Id is not a string of 1 to 256 characters of valid UTF-8. Nothing ran.
DuplicateTransaction false A participant already committed this id. Nothing ran; the original trade stands.
MutatorMustNotYield false The mutator yielded. Nothing was written.
Aborted false The mutator returned false, or the decision aborted because a profile deactivated or recovery decided first. Neither balance changed.
MutatorError: <err> false The mutator raised. Nothing was written.
JournalFailed: <err> false The journal record could not be created. Nothing was written.
SaveAFailed, SaveBFailed false A prepare save failed. Aborted; neither balance changed.
ConcurrentMutation false A profile’s data changed during the prepare saves. Aborted.
ConcurrentMutationNeedsRecovery false Committed, but a concurrent write changed a value the trade changed. Both blocked; recovery applies the trade.
PendingRecovery false The decision write failed (both blocked, Committed = nil) or a settle save failed (that participant blocked).
TransactionError: <err> false An unexpected error. Before the journal existed nothing was written; after it, both participants are blocked for recovery.
Dupe class What stops it
Concurrent trades over one inventory Both profiles are checked and claimed before the first yield, so two trades sharing a profile cannot interleave. The second gets Busy before any copy is made.
Replayed intents Committed ids are stored in Meta.TransactionIds, the newest 64 per participant, outside the codec-encoded data. They are recorded only when the decision commits (or by recovery from the journal), so a failed trade never burns its id. Ids that older builds stored in Data.CKSeenTransactions are still read.
A crash between the two writes There is one decision, in the journal. Each participant’s record points at it until settled, and a load never exposes a record with a pending pointer.
Server writes racing the trade A change during the prepare saves aborts the trade. A change during the decision write is merged when it touched other values and sent to recovery when it touched the same value.
Cross-server trades Refused by design. Both profiles must be loaded on this server by one DataDriver, and session locks keep each profile on one server. Put both players in one server before trading.
Member Description
Transactions.atomic(profileA, profileB, mutate, options?) -> Result mutate(dataA, dataB) -> boolean? runs on copies; false aborts. Yields while it writes.
options.Id Trade id for replay protection. Default: a fresh GUID, which protects nothing across calls.
options.Policy A Policy instance. Default: kernel.Policy of the profiles’ DataDriver.
options.Paid false skips the policy check for trades of free items. Default true.
profile.TransactionOwner The thread running a trade on this profile, or nil.
profile.TransactionBlocked true after a trade that needs recovery. The profile no longer saves.
Topic Payload Direction
Commerce.TransactionEnded profileA, profileB, ok Published when a trade that passed the guards ends, on every path. An attached DataDriver kicks a blocked participant; Receipts wakes a waiting receipt.
  • The mutator gets copies. Change dataA and dataB, the arguments; writing to Profile.Data from inside the mutator bypasses the escrow.
  • Keep mutators synchronous. A yield is refused (MutatorMustNotYield), and anything the mutator does outside the two tables cannot be rolled back.
  • Busy is a normal outcome, and an autosave running on either profile returns it too. Retry after a moment, and rate-limit trade intents at the networking layer like anything else a client can spam.
  • Never compensate by hand. Do not restore a participant from a snapshot after PendingRecovery, and never retry an uncertain trade under a new id. Recovery owns that decision.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.