Need help with datastores fails to save/load :((

Players experience data loss, storing data sometimes fails so I put it in a loop until it saves (I don’t know if it’s the correct way) Tried to recover old data → to the new datastore. The code is very messy because of time stress… but I can’t figure out the errors. It works for me but others it keep loading their old data probably from the “Recovery” module that recovers the old data and puts it in the new data.

Player Data (Server Script)

--[[
	Date :  2025-04-14
]]

-- Services
local DataStoreService = game:GetService("DataStoreService")
local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

-- Modules
local SessionManager = require(script.SessionManager)
local Commands = require(script.Commands)
local Recovery = require(script.Recovery)

-- All player data is stored here...
local UserData = DataStoreService:GetDataStore("User Data")

-- Table of user data
local UserTable = {}
local UserConnections = {}

-- Retrieve data from the specific user using their user id
local function RetrieveData(userId: number, ignoreLockedSessions: boolean?): string
	
	if ignoreLockedSessions == nil then
		ignoreLockedSessions = false
	end
	
	if ignoreLockedSessions == true then
		SessionManager.Claim(userId)
	end
	

	local success: boolean, result: string = pcall(function()
		return UserData:GetAsync(userId)
	end)

	if success then
		print(string.format("Successfully retrieved data from user: %s 🟩", tostring(userId)))
		return result
	end

	warn(string.format("Failed to retrieve data from user: %s 🟥", tostring(userId)))
	return "Error"

end

-- Stores data to the specific user using their user id
local function StoreData(userId: number, data): boolean
	
	if not SessionManager.IsOwner(userId) then
		warn(string.format("Skipping save: session lock expired or stolen for user {%s}", tostring(userId)))
		return false
	end

	local success: boolean, result: string = pcall(function()
		UserData:SetAsync(userId, data)
	end)

	if success then
		print(string.format("Successfully stored data to user: %s 🟩", tostring(userId)))
		return true
	end

	warn(string.format("Failed to store data to user: %s 🟥", tostring(userId)))
	return false

end

-- When client is joining the experience
local function OnJoin(client: Player)

	-- User data
	--[[
		Points
		Medals
	]]
	local connections: { RBXScriptConnection } = {}
	local data = {
		["Points"] = 0,
		["Medals"] = {
			["Gold"] = 0,
			["Silver"] = 0,
			["Bronze"] = 0
		}
	}

	-- Function to retrieve user data from the server!
	local retrievedData: string = RetrieveData(client.UserId)

	-- Checks if you get an error...
	-- If an error occurs it will repeat every 60 seconds until it isn't an error!
	while true do
		if retrievedData ~= "Error" then
			if retrievedData == nil then

				-- Recovers old data if its possible
				local oldData = Recovery:Retrieve(client.UserId)
				if oldData.Points ~= nil then
					data["Points"] = oldData.Points
				end
				if oldData.Medals.Gold ~= nil and oldData.Medals.Silver ~= nil and oldData.Medals.Bronze ~= nil then
					data["Medals"]["Gold"] = oldData.Medals.Gold
					data["Medals"]["Silver"] = oldData.Medals.Silver
					data["Medals"]["Bronze"] = oldData.Medals.Bronze
				end

				break
			end
			data = HttpService:JSONDecode(retrievedData)
			break
		end
		task.wait(60)
		retrievedData = RetrieveData(client.UserId)
	end

	UserTable[tostring(client.UserId)] = data
	print(UserTable)

	-- Leaderboard
	local leaderstats: Configuration = Instance.new("Configuration")
	leaderstats.Name = "leaderstats"

	local medals: Configuration = Instance.new("Configuration")
	medals.Name = "Medals"


	-- Stats inside of the leaderstats
	local statistics: { Rank: StringValue, Points: IntValue } = {
		Rank = Instance.new("StringValue"),
		Points = Instance.new("IntValue")
	}

	-- Hidden stats inside of the hidden stats
	local medalsStats: { Gold: IntValue, Silver: IntValue, Bronze: IntValue  } = {
		Gold = Instance.new("IntValue"),
		Silver = Instance.new("IntValue"),
		Bronze = Instance.new("IntValue")
	}

	do -- Initializing statistics

		do -- Rank
			statistics.Rank.Name = "Rank"
			statistics.Rank.Value = client:GetRoleInGroup(9344569) or "Guest"
			statistics.Rank.Parent = leaderstats
		end

		do -- Points
			statistics.Points.Name = "Point(s)"
			statistics.Points.Value = UserTable[tostring(client.UserId)]["Points"]
			table.insert(connections, statistics.Points:GetPropertyChangedSignal("Value"):Connect(function()
				UserTable[tostring(client.UserId)]["Points"] = statistics.Points.Value
			end))
			statistics.Points.Parent = leaderstats
		end

	end

	do -- Initializing hidden statistics

		do -- Gold
			medalsStats.Gold.Name = "Gold"
			medalsStats.Gold.Value = UserTable[tostring(client.UserId)]["Medals"]["Gold"]
			table.insert(connections, medalsStats.Gold:GetPropertyChangedSignal("Value"):Connect(function()
				UserTable[tostring(client.UserId)]["Medals"]["Gold"] = medalsStats.Gold.Value
			end))
			medalsStats.Gold.Parent = medals
		end

		do -- Silver
			medalsStats.Silver.Name = "Silver"
			medalsStats.Silver.Value = UserTable[tostring(client.UserId)]["Medals"]["Silver"]
			table.insert(connections, medalsStats.Silver:GetPropertyChangedSignal("Value"):Connect(function()
				UserTable[tostring(client.UserId)]["Medals"]["Silver"] = medalsStats.Silver.Value
			end))
			medalsStats.Silver.Parent = medals
		end

		do -- Bronze
			medalsStats.Bronze.Name = "Bronze"
			medalsStats.Bronze.Value = UserTable[tostring(client.UserId)]["Medals"]["Bronze"]
			table.insert(connections, medalsStats.Bronze:GetPropertyChangedSignal("Value"):Connect(function()
				UserTable[tostring(client.UserId)]["Medals"]["Bronze"] = medalsStats.Bronze.Value
			end))
			medalsStats.Bronze.Parent = medals
		end

	end


	-- Set leaderstats and data parent to the client so it will be fully loaded when it's attached to the client!
	leaderstats.Parent = client
	medals.Parent = client

	UserConnections[client.UserId] = connections

	repeat
		task.wait(30)
		statistics.Points.Value += 1
	until client == nil

end


-- When client is leaving the game
local function OnLeave(client: Player)
	
	local userId: number = client.UserId

	-- Disables all user connections to specific user!
	if UserConnections[userId] ~= nil then
		for index: number, value: RBXScriptConnection in UserConnections[userId] do
			value:Disconnect()
		end
		UserConnections[userId] = nil
	end

	local userTable = UserTable[tostring(userId)]
	if not userTable then
		warn(string.format("Can't store data to user: %s\nBecause of no retrieved data! 🟥⚠️", tostring(userId)))
		return
	end

	while true do
		local result: boolean = StoreData(userId, HttpService:JSONEncode(userTable))
		if result == true then
			UserTable[tostring(userId)] = nil
			print(UserTable)
			break
		end
		task.wait(60)
	end
	
	SessionManager.Release(userId)

end

-- Connecting events to functions
Players.PlayerAdded:Connect(OnJoin)
Players.PlayerRemoving:Connect(OnLeave)


-- Whenever the game crashes or closes
game:BindToClose(function()
	if RunService:IsStudio() then warn("Ignoring crash storing while in studio! ⚠️") return end
	for index: number, client: Player in Players:GetPlayers() do
		OnLeave(client)
	end
end)

script.Retrieve.OnInvoke = RetrieveData
script.Store.OnInvoke = StoreData
Commands.Initialize()

Recovery (Module Script)

local DataStoreService = game:GetService("DataStoreService")

local points = DataStoreService:GetDataStore("myDataStore")
local medals = DataStoreService:GetDataStore("MedalsData")

local Recovery = {}

-- Retrieves the old data from the user and makes it into a table instead
function Recovery:Retrieve(userId: number): { Points: number, Medals: { Gold: number, Silver: number, Bronze: number }}
	
	local data = {
		["Points"] = nil,
		["Medals"] = {
			["Gold"] = nil,
			["Silver"] = nil,
			["Bronze"] = nil
		}
	}
	
	do -- Points
		
		local function RetrievePoints()
			local success: boolean, result: string = pcall(function()
				return points:GetAsync("Player" .. userId)
			end)

			if success and result ~= nil then
				print(string.format("Successfully recovered old point data from user: %s 🟩", tostring(userId)))
				data["Points"] = result
				return
			elseif not success then
				return "Error"
			end
		end
		
		
		-- Checks if you get an error...
		-- If an error occurs it will repeat every 60 seconds until it isn't an error!
		while true do
			local message = RetrievePoints()
			if message ~= "Error" then
				break
			end
			task.wait(60)
		end
	end
	
	do -- Medals
		local function RetrieveMedals()
			local success: boolean, result: string = pcall(function()
				return medals:GetAsync(userId)
			end)

			if success and result ~= nil then
				print(string.format("Successfully recovered old medal data from user: %s 🟩", tostring(userId)))
				local medalValues = string.split(result, ':')
				if #medalValues == 3 then
					data["Medals"]["Gold"] = medalValues[1]
					data["Medals"]["Silver"] = medalValues[2]
					data["Medals"]["Bronze"] = medalValues[3]
					return
				end
			end
			if not success then
				return "Error"
			end
		end
		
		while true do
			local message = RetrieveMedals()
			if message ~= "Error" then
				break
			end
			task.wait(60)
		end
		
	end
	
	return data
end

return Recovery

SessionManager (Module Script)

local MemoryStoreService = game:GetService("MemoryStoreService")

local SessionLocks = MemoryStoreService:GetSortedMap("SessionLocks")
local SessionManager = {}

-- Duration before session is considered expired (in seconds)
local SESSION_TIMEOUT = 60*5

-- Claim a session for a user and wait for any existing one to expire
function SessionManager.Claim(userId: number): boolean
	
	local sessionKey: string = tostring(userId)
	local now: number = os.time()
	local attempts: number = 10

	while attempts > 0 do
		local success, timestamp = pcall(function()
			return SessionLocks:GetAsync(sessionKey)
		end)

		if success and timestamp ~= nil then
			local age = now - timestamp
			if age < SESSION_TIMEOUT then
				task.wait(5)
				attempts -= 1
			else
				break -- expired session, we can proceed
			end
		else
			break -- no session or failure, proceed
		end
	end

	local success, err = pcall(function()
		SessionLocks:SetAsync(sessionKey, now, SESSION_TIMEOUT)
	end)

	if success then
		return true
	else
		warn(`[SessionManager] Failed to claim session for {userId}: {err}`)
		return false
	end
end

-- Check if we still hold the session before saving
function SessionManager.IsOwner(userId: number): boolean
	local sessionKey: string = tostring(userId)
	local now: number = os.time()

	local success, timestamp = pcall(function()
		return SessionLocks:GetAsync(sessionKey)
	end)

	if success and timestamp ~= nil then
		local age = now - timestamp
		return age <= SESSION_TIMEOUT
	end

	return false
end

-- Release the session after saving
function SessionManager.Release(userId: number)
	local sessionKey = tostring(userId)
	pcall(function()
		SessionLocks:RemoveAsync(sessionKey)
	end)
end

return SessionManager

I can give 10k robux from buying anyones gamepass. Its all I have left but I will be so glad if someone can correct the script and fix it I promise! But it has to work on a server with a lot of users.

I haven’t read trough the entire script, but why do you have a table labeled UserTable? even if it did serve a purpose, it only saves in that same script. now, it would make sense to use it if you inserted something into it, but in no part of the script are you inserting anything into UserTable unless I am blind

sorry if it’s hard to understand, but what I am saying is that you aren’t inserting anything into UserTable.

also, why are you bribing people with money to help you with a script?

1 Like

UserTable. Yeah it should only save temporary data in the same script…
Its used like this

UserTable[tostring(theirUserId)]
which returns a table with points and their medals.

UserTable is just their backup data if they leave and their data hasnt been saved. Tbh could have done it better but it worked for a while until I changed something.

Not bribing with money, just robux, I mean thats better than nothing and could be a fun challenge to fix my script haha. It gets more fun if you earn a reward like robux! I mean if the user doesn’t want robux for the job he/she has done then I am fine with it.

Sorry if my english is bad but I am trying my best :slight_smile:

okay I understand, but I have one major question. I have never seen a DataStore script this big unless it was for saving objects. from what I see, all you’re saving is points, ranks, and medals. I can assure you that you do not need 3 module scripts for this unless it’s for actually assigning and giving the leaderstats.

you could just probably use a template DataStore and expand on that. I made this script for you to expand upon:
I did use an older script that I made before, just modified it

-- Services
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")


-- Variables
local PlayerData = DataStoreService:GetDataStore("PlayerData")


-- Code
Players.PlayerAdded:Connect(function(plr)

	local UserID = plr.UserId

	local leaderstats = Instance.new("Folder")
	leaderstats.Parent = plr
	leaderstats.Name = "leaderstats"

	local medals = Instance.new("Folder")
	medals.Parent = plr
	medals.Name = "Medals"


	-- leaderstats
	local rank = Instance.new("StringValue")
	rank.Parent = leaderstats
	rank.Name = "Rank"
	rank.Value = "?" -- change to the default value

	local points = Instance.new("IntValue")
	points.Parent = leaderstats
	points.Name = "Points"
	points.Value = 0 -- change to the default value

	-- medals
	local gold = Instance.new("IntValue")
	gold.Parent = medals
	gold.Name = "Gold"
	gold.Value = 0 -- change to the default value
	
	local silver = Instance.new("IntValue")
	silver.Parent = medals
	silver.Name = "Silver"
	silver.Value = 0 -- change to the default value
	
	local bronze = Instance.new("IntValue")
	bronze.Parent = medals
	bronze.Name = "Bronze"
	bronze.Value = 0 -- change to the default value


	-- Data Store
	local data
	local success, errormsg = pcall(function()
		data = PlayerData:GetAsync(UserID)
	end)

	if success then
		rank.Value = data.Rank
		points.Value = data.Points
		
		gold.Value = data.Gold
		silver.Value = data.Silver
		bronze.Value = data.Bronze
	end
end)


Players.PlayerRemoving:Connect(function(plr)

	local UserID = plr.UserId

	local data = {
		Rank = plr.leaderstats.Rank.Value;
		Points = plr.leaderstats.Points.Value;
		
		Gold = plr.Medals.Gold.Value;
		Silver = plr.Medals.Silver.Value;
		Bronze = plr.Medals.Bronze.Value;
	}

	local success, errormsg = pcall(function()
		PlayerData:SetAsync(UserID, data)
	end)
end)

keep in mind that I wouldn’t recommend you simply copy the script, but just use it as reference. if you need any more help, I would be glad to help you. if I am forgetting some details about the original script, just let me know

by the way, your english is not bad at all.

oh and by the way, the error message you get upon testing out the script for the first time is a one time thing for every player. it will only happen once per player.

the reason the error happens is because the variable “data” in the PlayerAdded function is empty. it won’t be empty once you rejoin because 2 lines after the variable "data, it sets the variable to the table that was saved when you left the game.

listen, it might be very hard to understand what I just said so if you have questions, do not hesitate to ask

I highly recommend ProfileStore, which is a data saving and loading module. It is very easy and user friendly, and I use this data module for all of my games. Hope this helps.

How frequent do your players experience data loss? Specifically, do you notice it happen when shut downs occur?

I haven’t looked through your code super thoroughly, but one big problem I can see how is how long the retry delays are.

For starters, waiting 60 seconds to retry data load isn’t a good practice because it means a player will be sitting in a server for over a minute without data if an error occurs. For loading data, is best to use some sort of exponential back off, where each successive retry attempt waits longer than the previous. This allows you to get data pretty quickly in the case of a 1-off error, but also not eat through your API budget when and if DataStores have larger outages.

This is the formula that I typically use for loading retry behavior, which is based off of how ProfileService handles loading data:

task.wait(math.min(2 ^ (retry - 1), 64)

Now for the larger problem, having a 60 second retry cooldown on saving data can and will cause major problems, especially when BindToClose to called. From the documentation, the server will shutdown after 30 seconds, even if the bound function is still running. Since you’re running all closing saves one after another, if one save fails, then that save and all saves behind it will essentially be dropped as they server cannot get around to them in time.

I would suggest using a flat 5 second cooldown for save attempts (and have a maximum number of retries before dropping the request if the save is non-critical, aka the player is not leaving the server), as well as running the saves asynchronously, which would look something like this:

game:BindToClose(function()
	if RunService:IsStudio() then warn("Ignoring crash storing while in studio! ⚠️") return end
	
	local players = Players:GetPlayers()
	local savesRemaining = #players
	
	for index: number, client: Player in players do
		task.spawn(function()
			OnLeave(client)
			savesRemaining -= 1
		end)
	end
	
	-- keep the thread alive until all saves are finished
	repeat task.wait() until savesRemaining <= 0
end)