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.
How it works
Section titled “How it works”One call moves through these steps. Nothing is written before step 5.
- 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
Idmust be a string of 1 to 256 characters of valid UTF-8, and neither participant may have seen it. - Both profiles’
TransactionOwneris set to the calling thread. While it is set, other saves of these profiles returnprofile 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. - Both
Datatables are deep-copied and the mutator runs on the copies in its own coroutine. Returningfalseaborts, raising aborts with the error, and yielding aborts withMutatorMustNotYield. Any other return commits. - 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 withTransactionErrorand nothing written. - A journal record
CKTxn_<GUID>is created in the same backend withState = "Preparing", both encoded drafts and both participants’ replay ids, tagged with both owners’ UserIds for GDPR erasure. - 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. - One atomic journal update decides. It moves
PreparingtoCommittedwhen both prepares landed, neither profile’s data changed during them, and both profiles are still active, and toAbortedotherwise. Recovery on another server contends for the same update, and exactly one of them decides. - 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.Datastay live, and the replay ids move intoMeta.TransactionIds. - Both pointers are cleared and both profiles saved again. These are the settle saves. When both land, the journal is deleted.
- 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.
When a step fails
Section titled “When a step fails”| 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.ReasonendGive 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.
Policy
Section titled “Policy”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.
Result and reasons
Section titled “Result and reasons”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. |
The dupe classes, and which ones die here
Section titled “The dupe classes, and which ones die here”| 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. |
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”| 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. |
Gotchas
Section titled “Gotchas”- The mutator gets copies. Change
dataAanddataB, the arguments; writing toProfile.Datafrom 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. Busyis 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.