Session locking explained (Datastore)

Small Update (8/13/22)
  • Updated to fix confusing code example and added note for metadata update time data.
  • Added hyperlink for a solution for a community user’s methods on combatting item duping.
  • Added ending note which explains slightly about when session locking is unnecessary.

Note: This tutorial was made with the help of someone that thoroughly understands ProfileService and session locking, and was kind enough to explain session locking in detail. The session code below was a modified Lua version of his pseudocode. Please know that I have not yet tested my own session locking code based off this tutorial and will try to update once I do so.

I have also dissected ProfileStore to make sure my understanding of session locking is accurate.

Intro

Session locking is a solution to dealing with the race condition between loading and saving data with DataStore API calls, when for instance, loading data is faster than the data being saved in a time frame despite saving data being first.

ProfileService’s main selling point over DataStore2 is a session locking feature that it incorporates using only UpdateAsync calls. This will be explained why below.

If you just wish to use session locking, you don’t have to make your own system. ProfileService does the job just fine.

Why is session locking useful?

The benefit of session locking on Roblox is preventing data regression (old data is loaded so the player is forced to save that old data).

Let’s take a game with a trading system for example. Item duplication can be achieved by giving a different player the item they plan to duplicate in the trading system then rejoin ASAP before the data saves so that they load the old data which still has the item.

If you prefer a video to explain item duplication, go here.

In addition, you can prevent item duplication in other methods such as written in this solution:
Preventing item duplication via trading - #3 by goro7

  • Note: Datastore v2 is out so you can alternatively use the metadata version number instead of manually saving version numbers with regular datastore.

Session locking is a more definitive answer to item duplication because it is irrelevant to timestamping, it keeps the save/load process sequential (load → save, never load → load) via sessions.


To explain how to implement session locking in Roblox, it’s important to understand what the DataStore API functions for saving/loading data does. There are three methods, SetAsync, UpdateAsync, and GetAsync. (Ignore IncrementAsync as it is meant for integers, we will deal with data that requires a different type of data)

SetAsync just overwrites data on a key. It won’t be used.

GetAsync fetches a key’s data or nil if it does not exist. However, it caches data for 4 seconds and may be unreliable to retrieve the utmost current value.

UpdateAsync solves the caching headache associated with GetAsync and unlike SetAsync, lets you also fetch the utmost current value possible performing a both read and overwrite if necessary. This is the function that we’ll utilize for session locking only.

This feature of UpdateAsync (shown in the image) will be the primary reason we use it for session locking, keep it in mind.

Quote explaining the cons of GetAsync & SetAsync vs UpdateAsync only

How session locking works

A session is placed into the data during loading and when saving data, it will be removed. This way, if the player rejoins and the data save was not complete, it will show a session is still in.

What is this session exactly? It is a unique ID for the server, JobId, obtained from game.JobId. (Optional: you can include PlaceID with the JobId for session locking. JobID is never the same for two servers but it does not say the same for servers outside the place which hosts servers itself. This could imply that a JobID can equal a JobID from a different place in the same game by rare chance)

Let’s say we have two servers, respectively called Server A & Server B utilizing the same code below:

Example code
local function defaultData()
    return {
        Inventory = {} -- example
    } -- return default data
end

local loadedData
local JobId = game.JobId
local success, message = pcall(function(
    datastore:UpdateAsync(key, function(data)
        if data == nil then
            return defaultData() -- return your default data
        end
        print("Current SessionJobId: " .. tostring(data.SessionJobId))
        -- Check if the save doesn't have a session lock OR if it's locked by this server below
        if data.SessionJobId == nil then
            data.SessionJobId = JobId -- set the session ID with current session ID
            -- Handle fetching or overwriting here
            loadedData = data -- take the data out
            return data
        end
        -- otherwise, session is locked, so return nil to indicate that
    end)
end)
if success and data then
    -- handle loaded data
end

Both servers will call the same UpdateAsync query at a virtually simultaneous moment, but Server A will manage to have its query first received.

Server A will manage to have its query completed as normal and set the session ID to its JobId and Server B will be expected to not be able to change the session ID back.

This is because Server B will have its UpdateAsync ran twice, the first one being cancelled because of Server A’s query going in first to ensure the data is not overwritten. The second time, it will see the change Server A has made and thus cancel the query (by returning nil) since there is a session from a different server placed.

However, this code is for loading data and shows the implementation of session locking. When saving data, you’ll have to set the SessionJobId to nil to remove the session. That should be simple to do on your own.

Cases

If a session was still active (player still loading) and they leave before the load completed. Don’t let the save overwrite any data except the session. This way, there won’t be any problems like a session still existing when the player loads again. ProfileService achieves this with :Release() which calls the save function but prevents overwriting any data except the session data.

It is also possible for a player to rejoin the same server super fast and cause a load before their initial loading was supposed to be done. With the current example loading code, the new UpdateAsync would set a session as the older one returns nil and won’t set the session. However, if your code may be affected by this edge case, adjust accordingly (use the new call’s result instead of the old call though when reading). ProfileService has done so itself.

So what do we do when we find a session?

NOTE: With Datastore v2 out, there is Datastore Metadata Info that can be retrieved by UpdateAsync, which can record the last updated time of the key. You can use this instead of saving os.time() into the datastore session data. When comparing time, ALWAYS use os.time(), not time() or os.clock() or tick() (os.clock is better for benchmarks).

If a session is found and you can’t set in your server’s session, you should make sure you wait until the save has processed. This may be arbitrary since there is no real way to determine an API call finishing. In the case of ProfileService, they set the time of the session with os.time() and when you see that session and compare your current os.time() to that session timestamp:

currentTime - sessionTime <= secondsToDetermineDeadSession

That difference should be the amount of seconds you think will be necessary to replace the session and consider it dead. ProfileService considers this 30 minutes.

If it’s not dead but there is a session, you could then repeat successive API calls until the save has completed like a loop. ProfileService uses 15 seconds per successive call through its custom waiting function using Heartbeat:Wait(). (task.wait() is good enough)


What about MessagingService?

You could use MessagingService but one issue is that you’ll have to know if the server you found even still exists (it may have shut down for example) thus inducing a yield anyways and that MessagingService could be prone to delivery failure;

Loading/saving data is critical, don’t take the risk with MessagingService.


What about data corruption?

Data corruption is hardly significant to worry about, this is usually Roblox’s fault. The way you could find corrupted data is to utilize how ProfileService does it, by seeing if the data’s type (with type() function) is unequal to nil or table (you should be using session locking with tables for data so that’s why the data should be a table)

if type(data) ~= "table" then -- assumes all data will be tables
    print("data corrupted")
end

Now that DataStore v2 is out, you can control data corruption by reverting the key’s data to a past version if you wish.


Can't UpdateAsync just save the new intended data after the loading process on rejoining?

While this is true, when you save the player’s data again, you don’t have any proof or indicator of duplication (at least with traditional datastore). If you save the player’s data based on what is currently cached in the game (current inventory, items, weapons for example), you are prone again to the item duplication problem.

You are free from the problem that session locking solves if you save data unaffected by players, or save data affected by players to only their owned key (e.g. no trading).


End

If you notice something off, have questions, or want to suggest improvements, please let me know.

49 Likes

What’s the point of checking if data.SessionJobId == JobId then ? On saving the data, you set the session Id to nil.

1 Like

Do you really need to compare server IDs…? I just have the auto saving with os.time(), also while looking for the data that ProfileService saved, I noticed that there wasn’t really anything about serverIds.

Im sorry for bumping this post but I have a few questions

As you said when fetching data and it’s session locked, you return nil or wait until the old data has been saved. But you said on the quote that I have to set SessionJobId to nil to remove the session, when there’s clearly no data loaded because the current data is session locked. Am I missing or misunderstanding something?

Sorry for confusing you, you’re supposed to extract the data out of the function. It’s been adjusted to be more than pseudocode. You also check the success value from the pcall and data to make sure data was loaded properly.

The example code was adjusted to make it more clear.

1 Like