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.