Session locking explained (Datastore)

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.


We ignore all DataStore API function calls except UpdateAsync.

Why?

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 is this session
        if data.SessionJobId == nil or data.SessionJobId == game.JobId 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 assume Server A will manage to have its query first received.

Server A will 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. (Server B updated after Server A did while in a short span of time, so Server B reads off what Server A did since A was first)

In the example code above, data loads even if the session’s JobId is the same as the server’s JobId. Remember to block a load if the load already exists in the server via wherever you loaded data.

Queue system

You can implement a queue so that each operation you complete in your session locking system is sequential. That way, if someone joins the same server so fast that they could load before their save completes, the save will be completed in the queue first before the load is allowed to run.

It forces the server to act sequentially with yielding API calls instead of in parallel. However, testing is required to see if there is any real problem of not having a queue system in this scenario.

Saving

The above code only shows the loading procedure but when you save, you must first check if the session is equal to the current server’s session. (session.JobId == JobId)

When that is true, you save by releasing the session, set the session’s JobId to nil so that it is unclaimed the next time it loads.

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 from outside the server?

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. Dead sessions are the result of crashed servers. Currently, ProfileService considers a session dead at 30 minutes.

(Note: Servers that crash can cause data loss. To minimize this, you can use auto-saving on some interval.)

(for autosaving systems) force load mechanic

When a session is taken by another server, ProfileStore attaches a tag to the data that identifies the current server (JobId) so that the other server as it completes its autosave process, will release the session. Then, it will steal (aka take the session by force) if it takes too long. You can implement something similar if you have autosaving implemented into your game. Without autosaving however, the tag system is useless.

This system nonetheless, ensures that any saving will finish but if a session is ongoing in another server, it will give them time to close the session. If they do not close the session, you can immediately steal the session.

Alternative to force load mechanic (no autosave needed)

When there is a live session, you could then repeat successive API calls until the save has completed like a loop. (API calls aren’t guaranteed to yield, be careful to prevent an infinite loop by requiring a minimum wait)

In ProfileStore, this is known as the “Repeat” handler.

However, be aware that recently dead sessions can make this repeated loading process a nightmare for the player if they must wait a long amount of time. Be prepared to end the repeat and force the session if you are suspicious of the status of the session.


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.


Should I make my own session locking system or use ProfileStore?

ProfileStore is reliable enough to use. I’d recommend otherwise if you have some unique needs or want to make it more customized.


End

This is a bare minimums guide to session locking.

I have found that in pursuit of a session locking system, you will inevitably make something that functions similar to ProfileStore, maybe even a carbon copy. But, that should not stop you from learning how it works!

67 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

Let’s say a player was in Server A, then leaves and joins a new server (Server B), the data from Server A is still saving and sessionlocked.

What can I do in Server B to make sure the player’s data will be eventually loaded? I don’t want to return nil and not load their data

Hmm you can wait till the data is loaded, you can do this by keep fetching the data with updateasync every lets say 15 seconds till the lock is released from the previous server.

1 Like

The code you posted

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 is this session
        if data.SessionJobId == nil or data.SessionJobId == game.JobId 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

If i return nil in UpdateAsync because the data is sessionlocked does that wipe the data and make it nil?

Returning nil will not set data to nil but It will do nothing (not 100% sure but it does that with memory store)

1 Like

should a server hold a lock for as long as the player is in the server?

Yes, because the fact you are in the server defines an existing session.

I am having trouble understanding the purpose. isnt it impossible for someone on roblox to be in 2 different servers at the same time?

Watch this tutorial to understand.

1 Like