DataStore with SessionLocking

Hi! I scripted a datastore with session locking.
Is there anything I should change to make it more secure?

local DataStoreService = game:GetService("DataStoreService")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local CashDataStore = DataStoreService:GetDataStore("CashDataStore")

local failedToGet = {}

local function waitForRequestBudget(requestType)
    local currentBudget = DataStoreService:GetRequestBudgetForRequestType(requestType)
    while currentBudget < 1 do
        currentBudget = DataStoreService:GetRequestBudgetForRequestType(requestType)
        wait(5)
    end
end

local function playerAdded(plr)
    local userId = plr.UserId
    local leaderstats = Instance.new("Folder")
    leaderstats.Name = "leaderstats"
    leaderstats.Parent = plr 

    local cash = Instance.new("IntValue")
    cash.Value = 0 --default
    cash.Name = "Cash"
    cash.Parent = leaderstats

    local data 
    repeat
        waitForRequestBudget(Enum.DataStoreRequestType.UpdateAsync)
        local success, errormessage = pcall(function()
            CashDataStore:UpdateAsync(userId, function(oldValue)
                if oldValue then --he might be new
                    if oldValue.SessionLock == false then
                        data = oldValue.Cash --getAsync kinda
                        return {Cash = oldValue.Cash, SessionLock = true} --make SessionLock true
                    else
                        failedToGet[plr.Name] = true
                        plr:Kick("The server you were in before is still trying to save your data, please rejoin or wait...") --kick him
                    end
                end
            end)
        end)
    until success
    --maybe he left by now?
    if plr:IsDescendantOf(Players) and data then 
        cash.Value = data --he has data saved
    end
end

local function playerRemoving(plr, dontWait)
    if failedToGet[plr.Name] == nil then --only save if it could get his data
        local cash = plr.leaderstats.Cash.Value
        local userId = plr.UserId

        repeat
            if dontWait == nil then --else dont wait, there is no time for that, the server is shutting down
                waitForRequestBudget(Enum.DataStoreRequestType.SetIncrementAsync)
            end
            local success, errormessage = pcall(function()
                CashDataStore:SetAsync(userId, {Cash = cash, SessionLock = false}) --updateAsync not needed, i dont care about oldValue, he might've bought something
            end)
        until success
    else
        failedToGet[plr.Name] = nil
    end
end

--maybe someone already joined while making these functions
for i,v in ipairs(Players:GetPlayers()) do
    playerAdded(v)
end

Players.PlayerAdded:Connect(playerAdded)
Players.PlayerRemoving:Connect(playerRemoving)

game:BindToClose(function()
    if RunService:IsStudio() == false then
        for i,v in ipairs(Players:GetPlayers()) do
            playerRemoving(v, true)
        end
    else
        wait(3) --wait so that playerRemoving can fire, often it shuts down too fast in studio
    end
end)

--now autosave
while true do
    wait(30) --wait can return false, it's very rare as far as I know but it can occur
    for i,v in ipairs(Players:GetPlayers()) do
        playerRemoving(v) --can wait
    end
end

Thanks.

1 Like

Would there be an advantage to using this over profile service?

1 Like

Nope. That’s not the right way. I actually did the same in the past for testing; but that doesn’t work. (I kind of knew that, I’m not that dumb)

If I were you, I would make a module which handles that for you, it’s easier too.

So let me explain a bit of session locking.

Session locking will lock data to a server (until it’s released).
To KEEP a session lock, you save a os.time() value, every 30, 60 seconds with your data.

Along with this os.time() value, you keep a game.JobId as well, and even maybe the place Id.

When loading data, you check if the os.time() value is older than 30 minutes or so. If it is less, then don’t load the data. (You can keep retrying until it’s not session locked or until it tried enough times)

If the os.time() value is OLDER than 30 minutes, then load their data, and lock it to the current server.

Every 30, 60 seconds, do a auto save loop, (for each player’s data), when you’re saving and relocking it to a new os.time() value, when doing so, you should check if the data that is inside the datastore for that game.JobId, and check if it’s the same than the current server, if it isn’t, release the data from the current server and stop everything.

There’s more to it, I’m not gonna explain everything, I recommend just using ProfileService.

2 Likes

When session locking, you check if the value of os.time saved in the data loaded is >= approximately 90 seconds, not 30 minutes. os.time is reliable and I don’t believe that it can be “unreliable”.

1 Like

@loleris disagrees, and it’s just best practice as if anything happens with os.time() randomly, it could cause some problems.

Additionally if you use ForceLoad on ProfileService it will steal the profile if it tried enough times with the profile still locked.

We don’t actually know that. Contacted a big dev recently that had code which demanded on it being in sync between servers and he said that he doesn’t know and he didn’t test anything, and it seems like other people also can’t prove it’s right now, but can’t prove it’s wrong…

Anyways, going for 30 minutes is fine. It makes sure everything works properly without os.time() dismatches. I wouldn’t risk not going for 30 minutes for the most part.

Also if a auto save fails, 90 seconds might not be enough to keep the data locked. (depending on how often you’re saving)

A session lock should only here and there happen anyways for the most part, so problems with data being wrongly locked for too long shouldn’t happen.

1 Like

Just because loleris disagrees doesn’t mean he is right and we aren’t talking about ProfileService.

We don’t actually know that. Contacted a big dev recently that had code which demanded on it being in sync between servers and he said that he doesn’t know and he didn’t test anything, and it seems like other people also can’t prove it’s right now, but can’t prove it’s wrong…

Yes, your point doesn’t stand here.

Anyways, going for 30 minutes is fine. It makes sure everything works properly without os.time() dismatches. I wouldn’t risk not going for 30 minutes for the most part.

Definitely not, that is way too much time.

Also if a auto save fails, 90 seconds might not be enough to keep the data locked. (depending on how often you’re saving)

Please make sure you know about how session locking works before making such bold false statements. That is not how session locking works, 90 seconds is more than enough, that is an extremely rare case of what you’re talking about.

1 Like

:face_with_raised_eyebrow: Yes I know how session locking works, thank you.

1 Like

I’ve found a report on os.time being unstable as recent as 9 months ago:

Last official statement about os.time being stable was also 9 months ago, but 10 days earlier than the former post:

5 Likes

ProfileService is definitely 100x safer than this.
I just attempted to make my own Session Locking datastore, this could have a huge issue I think,
basically when someone is leaving and it somehow fails to set the sessionlock value to false, that player is basically banned.

Another issue I just noticed is that this turns the sessionlock to false when autosaving. Oops.

Other good practices other than session locking is throttling and retrying your datastore requests.

local DataStoreService = game:GetService("DataStoreService")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local CashDataStore = DataStoreService:GetDataStore("CashDataStore")

local function retry(callback, ...) 
    local attempts = 0

    while (attempts < 5) do 
        attempts += 1

        local ok, value = pcall(callback, ...)

        if (ok) then 
            return value
        end

        if (attempts < 5) then 
            print("retrying")
        else
            error("Errored after 5 attempts")
        end
    end
end

local requestTypes = {
    GetAsync = Enum.DataStoreRequestType.GetAsync,
    UpdateAsync = Enum.DataStoreRequestType.UpdateAsync,
    SetAsync = Enum.DataStoreRequestType.SetIncrementAsync,
    RemoveAsync = Enum.DataStoreRequestType.SetIncrementAsync
}
local _queue = {}
local function throttle(datastore, method, key, transformFunction) 
    local request = requestTypes[method]
    if (DataStoreService.GetRequestBudgetForRequestType(request) > 1) then 
        return retry(datastore[method], key, transformFunction)
    end

    if (_queue[request] == nil) then 
        local connection
        connection = RunService.Heartbeat:Connect(function()
            for _, thread in pairs(_queue) do 
                throttle(unpack(thread))
            end

            if (#_queue == 0) then 
                connection:Disconnect()
                _queue[request] = nil
            end
        end)
    end

    _queue[request] = {method, key, transformFunction}
end

local function playerAdded(player) 
    local userId = player.UserId
    local leaderstats = Instance.new("Folder")
    leaderstats.Name = "leaderstats"
    leaderstats.Parent = player

    local cash = Instance.new("IntValue")
    cash.Value = 0 --default
    cash.Name = "Cash"
    cash.Parent = leaderstats
    throttle(CashDataStore, "UpdateAsync", userId, function(oldValue) 
        --handle session locks via write/read-Interval checking and expiration
    end)
end 

local function playerRemoving(player) 
    local cash = player.leaderstats.Cash.Value
    local userId = player.UserId
    throttle(CashDataStore, "SetAsync", userId, {
        Cash = cash})
end

Players.PlayerAdded:Connect(playerAdded)
Players.PlayerRemoving:Connect(playerRemoving)
for _, player in pairs(Players:GetPlayers()) do 
    playerAdded(player)
end

To go over session locks, it is a pretty simple method to get atomicity. Although it is lengthy, so I can’t write the code for you :frowning: sorry.

But to go over the basics, you want to treat a sessionlock like an object, not a boolean. Generally you want each sesssionlock to have an unique id and two methods. Lock and unlock;

  1. Lock - What we are doing here is firstly checking when was the last time the retrieved oldValue (from UpdateAsync) was locked at (in os.time) and compare it to the current time. If it is under 5(?) minutes, then you can’t acquire the lock. Else we change the lockId.

  2. Unlock - Here, we do pretty simple stuff. Do a dataStoreRequest to update the value’s data and remove its lockId. We generally want to do this when we want to close a player’s dataStore.

Final note: When writing to data, you want to compare the old value’s lockId with the current sessoinlock’s id. If they are not the same, you can assume that the lockId has changed.

ProfileService by @loleris goes over session-locking, I am not sure how it is implemented, but it should be of similar fashion.

5 Likes

That’s the reason why there’s threshold of whether to steal the profile after certain duration (mine does it after 10 minutes of no updates made from the server that owns the session). That way, even if the original server crashes (rarely happens, as loleris says) the player will be able to enter the game by overwriting the session that is non-existant.

1 Like

For the lock method, why do you choose 5 minutes?