Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ return Scribe({
local Data = require(game:GetService("ReplicatedStorage").Shared.Data).Server

game:GetService("Players").PlayerAdded:Connect(function(player)
local data = Data.WaitForData(player) -- yields until Ready (or ~60s timeout)
local data = Data.WaitForData(player) -- yields until Ready (default 60s timeout)
if data then
data.Coins.Increment(50)
end
Expand Down
2 changes: 1 addition & 1 deletion docgen/guides/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ end)

| API | Purpose |
| ------------------------------------------------ | ---------------------------------------------------------------------------- |
| [`WaitForData(player)`](/api/Server#WaitForData) | Yields until Ready; returns `(accessor?, reason?)`. Handle the `nil` branch. |
| [`WaitForData(player, timeout?)`](/api/Server#WaitForData) | Yields until Ready (up to `timeout` seconds, default 60); returns `(accessor?, reason?)`. Handle the `nil` branch. |
| [`GetState(player)`](/api/Server#GetState) | `"Loading" \| "Ready" \| "SessionEnded"`, without yielding. |

### Why data was unavailable
Expand Down
37 changes: 31 additions & 6 deletions src/Server/Persistence.luau
Original file line number Diff line number Diff line change
Expand Up @@ -1213,16 +1213,39 @@ function Persistence.new(ctx: any)

-- Public API ------------------------------------------------------------------

function self.WaitForData(player: Player): (any?, string?)
for _ = 1, 600 do
-- Wait for the next StateChanged fire, resuming anyway once the deadline
-- passes: under LoadFailurePolicy = "Wait" an entry can sit in Loading for
-- an entire DataStore outage without a state transition, and WaitForData
-- must still honor its timeout rather than hang.
local function waitStateChangedUntil(entry: Entry, deadline: number)
local thread = coroutine.running()
local settled = false
local function resume()
if not settled then
settled = true
task.spawn(thread)
end
end
local connection = entry.StateChanged:Connect(resume)
task.delay(math.max(deadline - os.clock(), 0), resume)
coroutine.yield()
connection:Disconnect()
end

function self.WaitForData(player: Player, timeout: number?): (any?, string?)
local deadline = os.clock() + (timeout or 60)
while true do
local entry = self.Entries[player]
if entry then
if entry.State == "Ready" then
return entry.Tree.Accessor, nil
elseif entry.State == "SessionEnded" then
return nil, entry.FailReason or "session-ended"
end
entry.StateChanged:Wait()
if os.clock() >= deadline then
return nil, "timeout"
end
waitStateChangedUntil(entry, deadline)
else
local endedReason = self.EndReasons[player]
if endedReason then
Expand All @@ -1231,10 +1254,12 @@ function Persistence.new(ctx: any)
if player.Parent ~= Players then
return nil, "player-left"
end
if os.clock() >= deadline then
return nil, "timeout"
end
task.wait(0.1)
end
end
return nil, "timeout"
end

function self.GetState(player: Player): string
Expand All @@ -1243,15 +1268,15 @@ function Persistence.new(ctx: any)
end

-- Force an immediate save. Returns true once the save is confirmed.
function self.Flush(player: Player, opts: { Force: boolean? }?): boolean
function self.Flush(player: Player, opts: { Force: boolean?, Timeout: number? }?): boolean
local entry = self.Entries[player]
if not entry or not entry.Profile or entry.State ~= "Ready" then
return false
end
if opts and opts.Force then
entry.ForceSaveOnce = true
end
return self.AwaitSave(entry, 15)
return self.AwaitSave(entry, (opts and opts.Timeout) or 15)
end

-- Trigger profile:Save() and yield until OnAfterSave/OnError/timeout.
Expand Down
18 changes: 11 additions & 7 deletions src/Server/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,12 @@ function Server.build(options: any, compiled: Template.Compiled): (any, any)
@server
@yields
@param player Player
@param timeout number?
@return (Value?, LifecycleReason?)
Yields until the player's profile is Ready, then returns their accessor
tree. On failure returns `(nil, reason)`. Always handle the `nil` branch.
This is the safe way to get data in `Players.PlayerAdded`.
tree, waiting up to `timeout` seconds (default 60). On failure returns
`(nil, reason)`. Always handle the `nil` branch. This is the safe way to
get data in `Players.PlayerAdded`.

`reason` is one of six values, exported as the `Scribe.LifecycleReason`
type and the `Scribe.Reason` table so you can branch on them without
Expand All @@ -126,8 +128,8 @@ function Server.build(options: any, compiled: Template.Compiled): (any, any)
| `session-ended` | The session ended while the player was still in game. |
| `shutdown` | The server is closing. |
]=]
function Data.WaitForData(player: Player)
return ctx.Persistence.WaitForData(player)
function Data.WaitForData(player: Player, timeout: number?)
return ctx.Persistence.WaitForData(player, timeout)
end
--[=[
@within Server
Expand Down Expand Up @@ -193,13 +195,15 @@ function Server.build(options: any, compiled: Template.Compiled): (any, any)
@server
@yields
@param player Player
@param opts { Force: boolean? }?
@param opts { Force: boolean?, Timeout: number? }?
@return boolean
Forces a save now instead of waiting for the next autosave. Call it right
after a grant or purchase. `Force = true` also pushes through a blocked
wipe-guard save.
wipe-guard save. `Timeout` bounds the wait for save confirmation in
seconds (default 15); on timeout Flush returns `false`, though the save
may still complete afterwards.
]=]
function Data.Flush(player: Player, opts: { Force: boolean? }?)
function Data.Flush(player: Player, opts: { Force: boolean?, Timeout: number? }?)
return ctx.Persistence.Flush(player, opts)
end
--[=[
Expand Down
3 changes: 2 additions & 1 deletion src/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ export type function ServerDataType(T, P)

local flushOptsProps: { [type]: type } = {}
flushOptsProps[ts.singleton("Force")] = optional(ts.boolean)
flushOptsProps[ts.singleton("Timeout")] = optional(ts.number)
local flushOpts = optional(ts.newtable(flushOptsProps, nil, nil))

-- Keep in sync with Types.LifecycleReason. Spelled out here because a type
Expand All @@ -425,7 +426,7 @@ export type function ServerDataType(T, P)
end

add("Get", fn({ P }, { node }))
add("WaitForData", fn({ P }, { optional(node), optional(reasonUnion) }))
add("WaitForData", fn({ P, optional(ts.number) }, { optional(node), optional(reasonUnion) }))
add(
"GetState",
fn({ P }, { ts.unionof(ts.singleton("Loading"), ts.singleton("Ready"), ts.singleton("SessionEnded")) })
Expand Down
44 changes: 44 additions & 0 deletions test/Specs/Lifecycle.spec.luau
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,50 @@ return function()
expect(data.Coins.Get()).to.equal(0)
end)

it("Data.WaitForData with a timeout still returns immediately when Ready", function()
local player = harness.Join()
local data, reason = harness.Data.WaitForData(player, 0.05)
expect(data).to.be.ok()
expect(reason).to.equal(nil)
end)

it("WaitForData honors its timeout for a player with no session", function()
local FakePlayer = require(Root.Test.Helpers.FakePlayer)
local player = FakePlayer.new() -- never passed to OnPlayerAdded: no entry
local startedAt = os.clock()
local data, reason = harness.Ctx.Persistence.WaitForData(player, 0.25)
expect(data).to.equal(nil)
expect(reason).to.equal("timeout")
expect(os.clock() - startedAt < 5).to.equal(true)
end)

it("WaitForData honors its timeout while an entry is stuck Loading (Wait policy)", function()
local waitHarness = TestCtx.make({ Options = { LoadFailurePolicy = "Wait" } })
waitHarness.Fake.FailNextLoad = true
local FakePlayer = require(Root.Test.Helpers.FakePlayer)
local player = FakePlayer.new()
waitHarness.Ctx.Persistence.OnPlayerAdded(player)
-- The failed first attempt parks the loader in a retry backoff: the entry
-- sits in Loading with no state transition to wake a waiter, so only the
-- timeout can end the wait.
expect(waitHarness.Ctx.Persistence.GetState(player)).to.equal("Loading")
local startedAt = os.clock()
local data, reason = waitHarness.Ctx.Persistence.WaitForData(player, 0.25)
expect(data).to.equal(nil)
expect(reason).to.equal("timeout")
expect(os.clock() - startedAt < 5).to.equal(true)
FakePlayer.leave(player) -- the retry tears the entry down when it wakes
end)

it("Flush honors a custom Timeout when the save never confirms", function()
local player, entry = harness.Join()
-- A save that neither confirms nor errors: AwaitSave can only time out.
entry.Profile.Save = function() end
local startedAt = os.clock()
expect(harness.Data.Flush(player, { Timeout = 0.2 })).to.equal(false)
expect(os.clock() - startedAt < 5).to.equal(true)
end)

it("rejects non-persistable value types at the write site", function()
local _, _, data = harness.Join()
expect(function()
Expand Down
3 changes: 2 additions & 1 deletion test/TypeCheck.luau
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,13 @@ local function serverUsage(player: Player)
end)

-- The lifecycle reason is a typed union, not a bare string.
local _tree, waitReason = Data.WaitForData(player)
local _tree, waitReason = Data.WaitForData(player, 30)
local lifecycleReason: Scribe.LifecycleReason? = waitReason
if lifecycleReason == Scribe.Reason.PlayerLeft then
lifecycleReason = nil
end

local _flushed: boolean = Data.Flush(player, { Force = true, Timeout = 5 })
local owns: boolean = Data.Owns(player, "VIP")
local ownsAsync: boolean = Data.OwnsAsync(player, "VIP")
local ok, err = Data.PromptGift(player, "GiftVIP", 12345)
Expand Down