Data store is not saving because of the server fast shutdown

iam working on a new game
you can check the game devlogs here :-
Devlog#1 Here
Devlog#2 Here
Devlog#3 Here

and i wanted to update my datastore script to be more professional
after searching on how to make it better , i added session locking and i replaced SetAsync and GetAsync with UpdateAsync ,
the problem is with session locking , the script needs around 2 sec to save the data , but because iam the only player in the playtest the server shut down super quickly in a way that makes the script could save all the data , this is not a big issue since i also added auto saving every 1 min , the problem is at first it lock the session and after saving all the data it open it again and since the server shut down very quick it has no time to open the session again ! ,

so when the player joins the script kick the player because it thinks that the player session is locked , after it kicks the player it does not save his data

(because if the player already had data the data will be cleared because the script did not load it)

and the player stuck as session locked for half a hour , and for sure i am not gonna wait 30 min to playtest again !, i tried fixing it for 3 days now but i can not figure it out , so please if you can help me then help i will be so thankful

local PlayersService = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local DataStoreService = game:GetService("DataStoreService")
local DataBase = DataStoreService:GetDataStore("PlayersData")

local Assets = ReplicatedStorage:WaitForChild("Assets")
local PlayersGuns = Assets:WaitForChild("PlayersGuns")

local KickedPlayers = {}

local function WaitForBudget(RequestType , BudgetAmount)
	local Budget = DataStoreService:GetRequestBudgetForRequestType(RequestType)
	while Budget < BudgetAmount do
		Budget = DataStoreService:GetRequestBudgetForRequestType(RequestType)
		task.wait(5)
	end
end

local function SaveData(Player , SessionLock , BindToClose)
	if table.find(KickedPlayers , Player.UserId) then
		print("player was kicked")
		table.remove(KickedPlayers , table.find(KickedPlayers , Player.UserId))
		return
	end
	
	local UserId = Player.UserId
	
	if SessionLock then
		local IsSucsuss , Data = nil
		repeat
			IsSucsuss , Data = pcall(function()
				if not BindToClose then
					WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
				end
				DataBase:UpdateAsync(tostring(UserId).."Session" , function(OldVersion)
					return {
						["SessionLock"] = true,
						["LastSessionTime"] = os.time(),
					}
				end)
			end)
			if not IsSucsuss then
				warn(Data)
			end
		until IsSucsuss
	end
	
	local DoneRequests = 0
	
	local Screws = 0
	local MaxPositions = 6
	local PlayerDataFolder = Player:FindFirstChild("Data")
	
	if PlayerDataFolder then
		local PlayerScrews = PlayerDataFolder:FindFirstChild("Screws")
		local PositionStorage = PlayerDataFolder:FindFirstChild("MaxPotions")
		if PlayerScrews and PositionStorage then
			Screws = PlayerScrews.Value
			MaxPositions = PositionStorage.Value
		end
	end
	
	local GunParts = {}
	
	if Player:FindFirstChild("OwnedGunParts") and #Player.OwnedGunParts:GetChildren() > 0 then
		for i , GunPart in pairs(Player.OwnedGunParts:GetChildren()) do
			if not table.find(GunParts , GunPart.Name) then
				table.insert(GunParts , GunPart.Name)
			end
		end
	end
	
	local UsedGunParts = {}
	
	if Player:FindFirstChild("UsedGunPieces") then
		for i , Value in pairs(Player.UsedGunPieces:GetChildren()) do
			if Value.Name == "Front" then
				UsedGunParts["UsedFrontPiece"] = Value.Value
			elseif Value.Name == "Middle" then
				UsedGunParts["UsedMiddlePiece"] = Value.Value
			elseif Value.Name == "Back" then
				UsedGunParts["UsedBackPiece"] = Value.Value
			end
		end
	end
	
	local Gears = {}
	
	if Player:FindFirstChild("OwnedGears") and #Player.OwnedGears:GetChildren() > 0 then
		for i , Gear in pairs(Player.OwnedGears:GetChildren()) do
			if not table.find(Gears , Gear.Name) then
				table.insert(Gears , Gear.Name)
			end
		end
	end
	
	local UsedGear = nil
	if Player:FindFirstChild("UsedGear") then
		UsedGear = Player.UsedGear.Value
	end
	
	local Potions = {}

	if Player:FindFirstChild("OwnedPotions") and #Player.OwnedPotions:GetChildren() > 0 then
		for i , Potion in pairs(Player.OwnedPotions:GetChildren()) do
			for i = 1 , Potion.Value do
				table.insert(Potions , Potion.Name)
			end
		end
	end
	
	task.spawn(function()

		local Sucsess , ErrorMessage

		repeat
			if not BindToClose then
				WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
			end

			Sucsess , ErrorMessage = pcall(function()
				DataBase:UpdateAsync(tostring(UserId) , function(OldVersion)
					return {
						["Screws"] = Screws,
						["MaxPotions"] = MaxPositions,
					}
				end)
			end)
		until Sucsess
		DoneRequests += 1
	end)

	task.spawn(function()

		local Sucsess , ErrorMessage

		repeat
			if not BindToClose then
				WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
			end

			Sucsess , ErrorMessage = pcall(function()
				DataBase:UpdateAsync(tostring(UserId).."GunParts" , function(OldVersion)
					return GunParts
				end)
			end)
		until Sucsess
		DoneRequests += 1
	end)

	task.spawn(function()

		local Sucsess , ErrorMessage

		repeat
			if not BindToClose then
				WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
			end

			Sucsess , ErrorMessage = pcall(function()
				DataBase:UpdateAsync(tostring(UserId).."UsedGunParts" , function(OldVersion)
					return UsedGunParts
				end)
			end)
		until Sucsess
		DoneRequests += 1
	end)

	task.spawn(function()

		local Sucsess , ErrorMessage

		repeat
			if not BindToClose then
				WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
			end

			Sucsess , ErrorMessage = pcall(function()
				DataBase:UpdateAsync(tostring(UserId).."Gears" , function(OldVersion)
					return Gears
				end)
			end)
		until Sucsess
		DoneRequests += 1
	end)

	task.spawn(function()

		local Sucsess , ErrorMessage

		repeat
			if not BindToClose then
				WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
			end

			Sucsess , ErrorMessage = pcall(function()
				DataBase:UpdateAsync(tostring(UserId).."UsedGear" , function(OldVersion)
					return UsedGear
				end)
			end)
		until Sucsess
		DoneRequests += 1
	end)

	task.spawn(function()

		local Sucsess , ErrorMessage

		repeat
			if not BindToClose then
				WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
			end

			Sucsess , ErrorMessage = pcall(function()
				DataBase:UpdateAsync(tostring(UserId).."Potions" , function(OldVersion)
					return Potions
				end)
			end)
		until Sucsess
		DoneRequests += 1
	end)
	
	repeat
		task.wait(0.01)
	until DoneRequests == 6
	
	if SessionLock then
		local IsSucsess2 , Data2 = nil
		repeat
			if not BindToClose then
				WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
			end
			IsSucsess2 , Data2 = pcall(function()
				DataBase:UpdateAsync(tostring(UserId).."Session" , function(OldVersion)
					return {
						["SessionLock"] = false,
						["LastSessionTime"] = os.time(),
					}
				end)
			end)
		until IsSucsess2
		print("yes")
	end
end

local function LoadCertainData(Key:string , Player:Player , DefualtData)
	local Data = nil
	repeat
		WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
		local Sucsess = pcall(function()
			DataBase:UpdateAsync(Key , function(OldVersion)
				if OldVersion then
					Data = OldVersion
					return Data
				else
					return DefualtData
				end
			end)
		end)
	until (Sucsess and Data) or not PlayersService:FindFirstChild(Player.Name)
	return Data
end

local function LoadData(Player:Player)
	local Character = Player.CharacterAppearanceLoaded:Wait()
	
	for i , Object in pairs(Player.PlayerGui:GetDescendants()) do
		if Object:IsA("LocalScript") then
			Object.Enabled = false
		end
	end
	
	local SessionLock , SessionLockTime , IsSucsess , Error = nil
	
	repeat
		WaitForBudget(Enum.DataStoreRequestType.UpdateAsync , 1)
		IsSucsess , Error = pcall(function()
			DataBase:UpdateAsync(tostring(Player.UserId).."Session" , function(OldVersion)
				if OldVersion then
					SessionLock = OldVersion.SessionLock or false
					SessionLockTime = OldVersion.LastSessionTime or os.time()
					if SessionLock == true and (os.time() - SessionLockTime) < 1800 then
						table.insert(KickedPlayers , Player.UserId)
						Player:Kick("Failed to load your data , please try again later")
						return nil
					else
						SessionLock = false
						SessionLockTime = os.time()
						return {
							["SessionLock"] = true,
							["LastSessionTime"] = SessionLockTime,
						}
					end
				else
					SessionLock = false
					SessionLockTime = os.time()
					return {
						["SessionLock"] = true,
						["LastSessionTime"] = SessionLockTime,
					}
				end
			end)
		end)
		
	until (IsSucsess and SessionLock ~= nil and SessionLockTime) or not PlayersService:FindFirstChild(Player.Name)
	
	if SessionLock or not PlayersService:FindFirstChild(Player.Name) then
		return
	end
	
	local Folder = Instance.new("Folder")
	Folder.Name = "Data"
	Folder.Parent = Player
	
	local Screws = Instance.new("IntValue")
	Screws.Name = "Screws"
	Screws.Parent = Folder
	
	local PotionsStorage = Instance.new("IntValue")
	PotionsStorage.Name = "MaxPotions"
	PotionsStorage.Parent = Folder
	
	
	
	local DefaultPlayerData = {
		["Screws"] = 0,
		["MaxPotions"] = 6,
	}
	
	local PlayerData = LoadCertainData(tostring(Player.UserId) , Player , DefaultPlayerData)
	
	if not PlayersService:FindFirstChild(Player.Name) then
		return
	end
	
	Screws.Value = PlayerData.Screws
	PotionsStorage.Value = PlayerData.MaxPotions
	
	local GunPartsFolder = Instance.new("Folder")
	GunPartsFolder.Name = "OwnedGunParts"
	GunPartsFolder.Parent = Player
	
	local PlayerGunParts = LoadCertainData(tostring(Player.UserId).."GunParts" , Player , {"Default Chamber" , "Default Loading Port" , "Default Comb"})
	
	if not PlayersService:FindFirstChild(Player.Name) then
		return
	end
	
	if PlayerGunParts then
		for i , GunPart in PlayerGunParts do
			local Value = Instance.new("StringValue")
			Value.Name = GunPart
			Value.Parent = GunPartsFolder
		end
	end
	
	local PlayerUsedGunPieces = Instance.new("Folder")
	PlayerUsedGunPieces.Name = "UsedGunPieces"
	PlayerUsedGunPieces.Parent = Player
	
	local UsedPlayerGunParts = LoadCertainData(tostring(Player.UserId).."UsedGunParts" , Player , {
		["UsedFrontPiece"] = "Default Chamber",
		["UsedMiddlePiece"] = "Default Loading Port",
		["UsedBackPiece"] = "Default Comb",
	})
	
	if not PlayersService:FindFirstChild(Player.Name) then
		return
	end

	if UsedPlayerGunParts then
		local Value1 = Instance.new("StringValue")
		Value1.Name = "Front"
		Value1.Value = UsedPlayerGunParts.UsedFrontPiece
		Value1.Parent = PlayerUsedGunPieces
		local Value2 = Instance.new("StringValue")
		Value2.Name = "Middle"
		Value2.Value = UsedPlayerGunParts.UsedMiddlePiece
		Value2.Parent = PlayerUsedGunPieces
		local Value3 = Instance.new("StringValue")
		Value3.Name = "Back"
		Value3.Value = UsedPlayerGunParts.UsedBackPiece
		Value3.Parent = PlayerUsedGunPieces
	end
	
	local GearsFolder = Instance.new("Folder")
	GearsFolder.Name = "OwnedGears"
	GearsFolder.Parent = Player

	local PlayerGears = LoadCertainData(tostring(Player.UserId).."Gears" , Player , {})
	
	if not PlayersService:FindFirstChild(Player.Name) then
		return
	end

	if PlayerGears then
		for i , Gear in PlayerGears do
			local Value = Instance.new("StringValue")
			Value.Name = Gear
			Value.Parent = GearsFolder
		end
	end
	
	local PlayerUsedGear = LoadCertainData(tostring(Player.UserId).."UsedGear" , Player , "")
	
	if not PlayersService:FindFirstChild(Player.Name) then
		return
	end

	local Value = Instance.new("StringValue")
	Value.Name = "UsedGear"
	Value.Value = if GearsFolder:FindFirstChild(PlayerUsedGear) then PlayerUsedGear else ""
	Value.Parent = Player
	
	local PotionsFolder = Instance.new("Folder")
	PotionsFolder.Name = "OwnedPotions"
	PotionsFolder.Parent = Player

	local PlayerPotions = LoadCertainData(tostring(Player.UserId).."Potions" , Player , {})
	
	if not PlayersService:FindFirstChild(Player.Name) then
		return
	end

	if PlayerPotions then
		for i , Potion in PlayerPotions do
			if PotionsFolder:FindFirstChild(Potion) then
				local ExistedValue = PotionsFolder[Potion]
				ExistedValue.Value += 1
			else
				local Value = Instance.new("IntValue")
				Value.Name = Potion
				Value.Value = 1
				Value.Parent = PotionsFolder
			end
		end
	end
	
	for i , Object in pairs(Player.PlayerGui:GetDescendants()) do
		if Object:IsA("LocalScript") then
			Object.Enabled = true
		end
	end
	
end


game.Players.PlayerAdded:Connect(function(Player)
	task.spawn(LoadData , Player)
end)
game.Players.PlayerRemoving:Connect(function(Player)
	task.spawn(SaveData , Player , true , false)
end)

game:BindToClose(function()
	for i, Player in pairs(game.Players:GetChildren()) do
		task.spawn(SaveData , Player , true , true)
	end
end)

while true do
	task.wait(60)
	for i , Player in ipairs(PlayersService:GetChildren()) do
		task.spawn(SaveData , Player , false , false)
	end
end



4 Likes

guys i really need your help with that

1 Like

You’re calling task.spawn in BindToClose. BindToClose will assume the game can close once that function completes, which doesn’t take into account any spawned threads.

You need BindToClose to yield until all processes are done. An easy hack to do that is to just use coroutine.yield and then resume it once all your threads are completed. You could also just remove the task.spawns but then you’d have to do each save in serial.

Here’s how I’d rewrite your BindToClose function:

game:BindToClose(function()
	-- Get reference to the current thread:
	local thread = coroutine.running()

	local players = game.Players:GetPlayers()
	local completed = 0
	local total = #players

	for _, player in players do
		task.spawn(function()
			-- Need to pcall it so we still get to the completion step below:
			local success, err = pcall(function()
				SaveData(player, true, true)
			end)
			if not success then
				warn(err)
			end

			-- Increment completed:
			completed += 1

			-- Resume the main BindToClose thread if we've completed everything and are
			-- in a yielded state. The "suspended" check is needed bc it's possible that
			-- the SaveData function didn't yield at all for some reason, thus the BindToClose
			-- thread hasn't yielded yet either:
			if completed >= total and coroutine.status(thread) == "suspended" then
				task.spawn(thread)
			end
		end)
	end

	-- Only yield if we're waiting for tasks to complete:
	if completed < total then
		coroutine.yield()
	end
end)
3 Likes

There’s another crucial factor you need to be aware of, and it’s that data store write operations run asynchronously, hence why they end in Async.

When you call UpdateAsync, for example, your thread only yields far enough so the request is authorised and the write operation is added to the queue of requests. It doesn’t wait for it to complete.

When your game closes, that queue of requests is cleared, regardless of whether they have finished or not. This means no matter what, you need to wait until it’s gone through and the new data is written. The easiest way is too add this at the end of your code:

if game:GetService("RunService"):IsStudio() then
    task.wait(5)
else
    task.wait(30) --30 seconds is the maximum time for a callback in BindToClose()
end

You are also running multiple threads at the same time which will put a lot of strain on the data stores and many of the requests will just go off of the data store request queue.

this isn’t actually such a bad thing.






but there’s one main thing:

You don’t need to save player data on game:BindToClose().

This is because you have a Players.PlayerRemoving connection, which means that when every player gets kicked on server shutdown, or when the server closes when there are no players in it, Players.PlayerRemoving will fire for every player that gets kicked, so everyone’s data will be saved. You’re just putting additional stress on the data store by saving on BindToClose.

So, your code could literally just be this:

game:BindToClose(function()
    task.wait(if game:GetService("RunService"):IsStudio() then 5 else 30)
end)
2 Likes

Yes, it can be critical, because BindToClose will automatically stop after 30 seconds of execution.

2 Likes

The risks greatly outweigh the benefits of running data requests with multiple threads. Requests are much more likely to be dropped when saving with multiple threads due to overloading the store, but you don’t even need to save on BindToClose like I said above. 30 seconds is also enough time to save data in most cases, cases with more players are pretty much guaranteed to lose requests off the end of the data store queue.

2 Likes

is the task.wait will work on the actual clients ? , i think it only works in studio

1 Like

so saving data using multiple threads may drop the data ? i thought it is a good thing to save each key in it is own thread to make the saving process faster to make the script has enough time to save all the data

1 Like

No, saving data in separate threads will increase the risk of data loss. You don’t need to save on BindToClose, just wait.

2 Likes

but i already have a function that wait for request budget why this increase the risk of data loss

1 Like

i updated my script based on this tutorial that use multiple threads

1 Like

The request budget isn’t relevant to BindToClose, really - the request budget tells you how many requests you can make before throttling occurs. The issue here is spamming the store with too many requests at a time, which causes the queue to fill and requests after the filled queue to be dropped and therefore not saved. Running it in one thread at least allows you to yield between requests if you want to, you will need to. But, like I already said, saving on BindToClose is just unneccessary requests - PlayerRemoving will already fire for all the players leaving the game. You should instead look to implementing a custom queue to buffer requests passed to the store.

2 Likes

how i can implement a queue system to my script ?

2 Likes

You can store the player’s UserId in a request table and have it wait until the request is at the front of the queue. You can call a function like this right at the start of your saving function.

local queue = {}

local function queueRequest(player: Player): ()
    table.insert(queue, player.UserId) --add to request queue

    --wait until the request is at the front of the queue
    repeat
        task.wait(1)
    until
        table.find(queue, player.UserId) == 1

    --remove request from queue
    table.remove(queue, 1)

    --wait extra
    task.wait(1)
end
2 Likes

this should be at the start of the SaveData function ? , also how i can make the server wait until the data get saved ?

2 Likes

on BindToClose, just use this code:

game:BindToClose(function()
    task.wait(if game:GetService("RunService"):IsStudio() then 5 else 30)
end)
2 Likes

ok , thank you so much for your help , i will really appreciate if you can help me to make the wait dynamic
so it wait until the data get saved especially in studio ?

2 Likes

Yep, the data will be saved in studio. It’ll wait 5 seconds because only your data needs to be saved. It’ll wait the full 30 in Roblox servers.

2 Likes

thank you so much for your help i really appreciate it

2 Likes

Glad I could help. If you’ve got no other questions or issues, make sure to mark this topic as solved so others know and it can close. If you think of any other questions or have any other issues, feel free to ask.

2 Likes