Datastore transactions

This is an alternative solution to the problem proposed in this post Dependent UpdateAsyncs aka Datastore failure atomicity
It is a more general solution based on the modern programming practice of promises.

As a Roblox developer, it is currently impossible to guarantee the safety of performing dependent data store interactions. The issue presented is visible in the following swap routine

function SwapKeys (DataStore,A,B)
    local a, b = DataStore:GetAsync(A), DataStore:GetAsync(B)
    DataStore:SetAsync(A,b)
    DataStore:SetAsync(B,a)
end

The issue here is that the third SetAsync may fail. We could push back the problem by checking for failure and then reverting our change like so

function SwapKeys (DataStore,A,B)
    local a, b = DataStore:GetAsync(A), DataStore:GetAsync(B)
    --it's ok if this fails
    DataStore:SetAsync(A,b)
    --it's not ok if this fails so let's revert our changes on failure
    if not pcall(DataStore.SetAsync, DataStore, B,a) then
        --what happens if this fails??
        DataStore:SetAsync(A,a)
        --we should probably propagate the error from the second setasync
        error "Second set failed." 
    end
end

My alternate proposed solution is to bundle multiple datastore actions into a transaction. A transaction is a way of bundling related operations together into a single operation. A transaction would have the following properties:

  • The transaction succeeds entirely or fails entirely
  • If a transaction fails no change is made to the underlying datastore
  • Transactions methods are referentially transparent: if you get a key inside a transaction it will have the same value as it did when the transaction began

An example of a library function for this is:

Transaction GlobalDataStore:Transaction()

Transaction would have the following methods

Based loosely on Google's Firestore
Transaction:Get(
    Keys : string...
) : Promise<any[string]>

This can only be called before Transaction:CommitAsync () is called. Attempting to resolve a Get is the same as running Transaction:CommitAsync ()
Returns immediately.

Transaction:Set(
    Key : string,
    Value : any
) : void

Adds a set operation to a transaction. Returns immediately as it only updates Transaction’s state until Transaction is committed. Calling Set multiple times is an error.

Transaction:Resolve () : ResolvedPromise<any[string]>

First resolves all of its Get requests, then runs their “then” callbacks, then computes all the Set requests, batches them together, and sends them to the DataStore. On the DataStore Set attempts to aquire a mutex for all the keys being set. If any of the things being Set have changed since the transaction began then none of the Sets will go through. Otherwise all the sets will occur. On failure and with a lack of “Catch” clause then an error will be thrown.

If a transaction takes too long then it’s mutexes will be freed and the transaction will fail.

Transaction:CommitAsync () : any[string]

Yielding syntactic sugar for Resolve. Akin to
await transaction.

Let’s see some usage

function Swap(datastore, key1, key2)
    local transaction = datastore:Transaction()
    transaction:Get(key1, key2):Then(
        function(data)
            transaction:Set(key1, data[key2])
            transaction:Set(key2, data[key1])
        end
    )
    return transaction:CommitAsync()
end
function GiftItem(from, to, itemid)
    local transaction = InventoryDataStore:Transaction()
    transaction:Get(from, to):Then(
        function(data)
            local item = from.items[itemid]
            table.remove(data[from].items, itemid)
            table.insert(data[to].items, item)
            transaction:Set(from, data[from])
            transaction:Set(to, data[to]
        end
    ):Resolve():Then (
        function(changes)
            InventoryCache[from] = changes[from]
            InventoryCache[to] = changes [to]
        end
    )
end
Original Proposal (with several issues) Returns an opaque promise representing the value of the given key from before the transaction began. It is referentially transparent i.e. will return the same value every time you call it on the same transaction with the same arguments. ``` Transaction:Get( key : string ) : OpaquePromise ``` Adds a set operation to the transaction. Resolves opaque promises before finishing the transaction. Due to the referential transparency of Get, it is only reasonable to have one set per key. ``` Transaction:Set( key : string, value : OpaquePromise ) : void ``` Yields and returns a dictionary of the post-transaction values of any keys that were touched or throws an error with a relevant message. ``` Transaction:Resolve() : Table ``` OpaquePromise is an intermediate value used to represent a dependency in a transaction. It cannot be resolved from the Lua side of things. These promises can be then'd however. Because these are promises and not values this function may return immediately.

The swap routine using this would look something like

function SwapKeys (DataStore, A, B)
    local transaction = DataStore:Transaction()
    transaction:Set(A, transaction:Get(B))
    transaction:Set(B, transaction:Get(A))
    return transaction:Resolve()
end

Complex mutations may be done with Then:

function GiftItem(Player1, Player2, WeaponId)
    local t = InventoryDataStore:Transaction()
    local p1key, p2key = PlayerKey(Player1), PlayerKey(Player2)
    local gift = t:Get(p1key):Then(
        function(inventory)
            return inventory.items[WeaponId]
        end
    )
    t:Set(p1key, t:Get(p1Key):Then(
        function(inventory)
            table.remove(inventory.items, WeaponId)
            return inventory
        end
    ))
    t:Set(p2key, t:Get(p2Key):Then(
        function (inventory)
            table.insert(inventory.items, gift:Resolve())
            return inventory
        end
    ))
    t:Resolve()
end

transaction:Set(key, transaction:Get(key):Then(func))
appears pretty often so it may be worth replacing it with
transaction:Update(key, func). This would only need to be syntactic sugar however due to transactions already being atomic (i.e. the reason UpdateAsync is special)

What if we want to add some kind of automatically retry? Well this is trivially easy now:

for try=1, RETRY_COUNT do
    if pcall(t.Resolve, t) then
        return
    end
    wait(10)
end
error "Transaction failed" 
If Roblox is able to address this issue, it would improve my game because it would allow for safe complex DataStore operations such as swapping data between players.
12 Likes

As a side note this covers the current API as you can implement the existing functions off of this

function DataStore:GetAsync(key)
    local t = self:Transaction()
    return Await(t:Get(key))[key]
end

function DataStore:SetAsync(key, value)
    local t = self:Transaction()
    t:Set(key, value)
    return Await(t)[key]
end

function DataStore:UpdateAsync(key, func)
    return Await(self:Transaction():Get(key):Then(
        function(data)
            t:Set(key, func(data[key]))
        end
   ))[key]
end
3 Likes