Join the RoClicks Discord
RoClicks

SessionStore.luau

Luau, 233 lines from Engineering overview

Download SessionStore.luau
--!nocheck
-- SessionStore: one-key-per-player DataStore saves with a session lock, retry/backoff, a kick on failed
-- load, idempotent receipts and a BindToClose that waits for in-flight saves. Game-agnostic.
-- See ../data-and-saves.md for the reasons behind every rule. Plain-Luau test: test_sessionstore.luau.
--
--   local S = SessionStore.new({
--       store    = DataStoreService:GetDataStore("Saves_v1"),
--       defaults = function() return { version = 1, cash = 0 } end,
--       migrate  = function(data) return data end,        -- optional: fill/rename fields
--       isStudio = RunService:IsStudio(),
--       sessionId = HttpService:GenerateGUID(false),       -- game.JobId is "" in Studio
--   })
--   local data = S:load(player)            -- nil = player was kicked (never a silent fresh game)
--   S:save(player, data)                   -- autosave; refreshes the lock; ok, why
--   S:saveFinal(player, data)              -- leave save; clears the lock
--   S:grantReceipt(player, data, purchaseId, grantFn) -- "granted" | "duplicate" | "failed"
--   S:bind(Players, getData, setData)      -- PlayerAdded/Removing, autosave loop, BindToClose
-- Injected for tests (default to the real globals): opts.clock, opts.time, opts.wait, opts.spawn, opts.warn.
local SessionStore = {}
SessionStore.__index = SessionStore

SessionStore.LEASE = 180 -- a lock refreshed less than this many seconds ago belongs to a live server
SessionStore.LOCK_WAIT = 30 -- how long a join waits for another server's lock before taking it over
SessionStore.LOAD_TRIES = 5
SessionStore.WRITE_TRIES = 3
SessionStore.AUTOSAVE = 60
SessionStore.CLOSE_BUDGET = 28 -- BindToClose has 30 s in total

local RUNTIME_KEYS = { lock = true, noSave = true }

local function deepCopy(t)
	if type(t) ~= "table" then return t end
	local o = {}
	for k, v in pairs(t) do o[k] = deepCopy(v) end
	return o
end

-- replace NaN / inf with 0 (one NaN soft-locks every comparison) and report where it was
local function sanitise(t, path, warnFn)
	for k, v in pairs(t) do
		if type(v) == "number" and (v ~= v or v == math.huge or v == -math.huge) then
			t[k] = 0
			warnFn("[SessionStore] non-finite number at " .. path .. "." .. tostring(k))
		elseif type(v) == "table" then
			sanitise(v, path .. "." .. tostring(k), warnFn)
		end
	end
end

function SessionStore.new(opts)
	local self = setmetatable({}, SessionStore)
	self.store = opts.store
	self.defaults = opts.defaults
	self.migrate = opts.migrate or function(d) return d end
	self.isStudio = opts.isStudio
	self.session = opts.sessionId or tostring(math.random(1, 2 ^ 30))
	self.keyFor = opts.keyFor or function(p) return "p_" .. tostring(p.UserId) end
	self.clock = opts.clock or os.clock
	self.time = opts.time or os.time
	self.wait = opts.wait or task.wait
	self.spawn = opts.spawn or task.spawn
	self.warn = opts.warn or warn
	self.memoryOnly = opts.memoryOnly or false
	self.memory = {}
	self.saving = {} -- [player] = true while a write runs
	self.nextSave = {} -- [player] = clock time of the next autosave
	self.fails = {} -- [player] = consecutive failed saves
	return self
end

-- Only Studio with API access off is "memory-only". A live "502: API Services rejected request" is transient.
function SessionStore:isAccessOff(msg)
	msg = tostring(msg)
	return self.isStudio and (string.find(msg, "403") ~= nil or string.find(msg, "StudioAccessToApisNotAllowed") ~= nil)
end

function SessionStore:lockedByOther(old)
	local l = type(old) == "table" and old.lock
	return type(l) == "table" and l.id ~= self.session and self.time() - (tonumber(l.t) or 0) < SessionStore.LEASE
end

function SessionStore:serialise(data)
	local copy = deepCopy(data)
	for k in pairs(RUNTIME_KEYS) do copy[k] = nil end
	sanitise(copy, "save", self.warn)
	return copy
end

-- Returns data, or nil after kicking the player. Never returns a fresh game for a failed load.
function SessionStore:load(player)
	local key = self.keyFor(player)
	if self.memoryOnly then
		return self.migrate(deepCopy(self.memory[key]) or self.defaults())
	end
	local t0, tries = self.clock(), 0
	while true do
		local state, result
		local ok, err = pcall(function()
			self.store:UpdateAsync(key, function(old)
				if old == nil then state = "new" return nil end -- no lock-only table for a new player
				if self:lockedByOther(old) and self.clock() - t0 < SessionStore.LOCK_WAIT then
					state = "locked" return nil
				end
				if self:lockedByOther(old) then self.warn("[SessionStore] taking over a stale lock for " .. key) end
				old.lock = { id = self.session, t = self.time() }
				state, result = "ok", old
				return old
			end)
		end)
		if ok and state == "new" then return self.migrate(self.defaults()) end
		if ok and state == "ok" then
			result.lock = nil
			return self.migrate(result)
		end
		if ok and state == "locked" then
			self.wait(3) -- the other server is finishing its leave save
		elseif not ok then
			if self:isAccessOff(err) then
				self.warn("[SessionStore] Studio API access is off: saves are memory-only this session")
				self.memoryOnly = true
				return self:load(player)
			end
			tries += 1
			self.warn("[SessionStore] load failed (" .. tries .. "): " .. tostring(err))
			if tries >= SessionStore.LOAD_TRIES then
				player:Kick("We couldn't load your save. Your progress is safe - please rejoin in a minute!")
				return nil
			end
			self.wait(2 ^ (tries - 1))
		end
	end
end

-- One guarded write. release = true clears the lock (leave save). Returns ok, why ("locked" | "error").
function SessionStore:write(player, data, release)
	local key = self.keyFor(player)
	if data.noSave then return false, "noSave" end
	if self.memoryOnly then self.memory[key] = self:serialise(data) return true end
	local payload = self:serialise(data)
	local why = "error"
	for attempt = 1, SessionStore.WRITE_TRIES do
		local refused = false
		local ok, err = pcall(function()
			self.store:UpdateAsync(key, function(old)
				if self:lockedByOther(old) then refused = true return nil end
				payload.lock = (not release) and { id = self.session, t = self.time() } or nil
				return payload
			end)
		end)
		if ok and not refused then return true end
		if refused then why = "locked" break end
		if self:isAccessOff(err) then self.memoryOnly = true return self:write(player, data, release) end
		self.warn("[SessionStore] save attempt " .. attempt .. " failed: " .. tostring(err))
		if attempt < SessionStore.WRITE_TRIES then self.wait(attempt) end
	end
	if why == "locked" then
		-- another server owns this profile now: stop saving this session and send the player back
		data.noSave = true
		player:Kick("Your progress was updated on another server. Please rejoin to keep playing!")
	end
	return false, why
end

-- Autosave entry point: serialised per player, next time set BEFORE the attempt, backoff on failure.
function SessionStore:save(player, data, release)
	while self.saving[player] do self.wait(0.1) end
	self.saving[player] = true
	self.nextSave[player] = self.clock() + SessionStore.AUTOSAVE
	local ok, why = self:write(player, data, release)
	self.saving[player] = nil
	if ok then
		self.fails[player] = 0
	else
		local n = (self.fails[player] or 0) + 1
		self.fails[player] = n
		self.nextSave[player] = self.clock() + math.min(15 * 2 ^ (n - 1), 300)
	end
	return ok, why
end

function SessionStore:saveFinal(player, data)
	return self:save(player, data, true)
end

-- Idempotent receipt: grant once per PurchaseId, and report granted only after the save landed.
function SessionStore:grantReceipt(player, data, purchaseId, grantFn)
	data.receipts = data.receipts or {}
	if data.receipts[purchaseId] then return "duplicate" end
	local undo = grantFn(data) -- returns an undo function (or nil)
	data.receipts[purchaseId] = self.time()
	if self:save(player, data) then return "granted" end
	data.receipts[purchaseId] = nil
	if type(undo) == "function" then undo(data) end
	return "failed"
end

-- Wire it up. getData(player) / setData(player, data|nil) connect it to your game state.
function SessionStore:bind(Players, getData, setData)
	local leaving = 0
	Players.PlayerAdded:Connect(function(p)
		local d = self:load(p)
		if d and p.Parent then setData(p, d) end
	end)
	Players.PlayerRemoving:Connect(function(p)
		leaving += 1
		local d = getData(p)
		if d then pcall(self.saveFinal, self, p, d) end
		setData(p, nil)
		leaving -= 1
	end)
	game:BindToClose(function()
		for _, p in Players:GetPlayers() do
			local d = getData(p)
			if d then self.spawn(pcall, self.saveFinal, self, p, d) end
		end
		local t0 = self.clock()
		while (leaving > 0 or next(self.saving)) and self.clock() - t0 < SessionStore.CLOSE_BUDGET do self.wait(0.1) end
	end)
	self.spawn(function()
		while true do
			self.wait(5)
			for _, p in Players:GetPlayers() do
				local d = getData(p)
				if d and not d.noSave and not self.saving[p] and self.clock() >= (self.nextSave[p] or 0) then
					self.spawn(self.save, self, p, d)
				end
			end
		end
	end)
end

return SessionStore