SessionStore.luau
Luau, 233 lines from Engineering overview
--!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