From fdb8e7e9fec00d4d4f4af8f813d161b1c8b6c60b Mon Sep 17 00:00:00 2001 From: ryancundiff <90420703+ryancundiff@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:43:48 -0500 Subject: [PATCH] feat: Add timeout arguments to WaitForData and Flush --- README.md | 2 +- docgen/guides/lifecycle.md | 2 +- src/Server/Persistence.luau | 37 +++++++++++++++++++++++----- src/Server/init.luau | 18 ++++++++------ src/init.luau | 3 ++- test/Specs/Lifecycle.spec.luau | 44 ++++++++++++++++++++++++++++++++++ test/TypeCheck.luau | 3 ++- 7 files changed, 92 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 67e1d64..9ab4a81 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docgen/guides/lifecycle.md b/docgen/guides/lifecycle.md index 3a95987..7cda20c 100644 --- a/docgen/guides/lifecycle.md +++ b/docgen/guides/lifecycle.md @@ -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 diff --git a/src/Server/Persistence.luau b/src/Server/Persistence.luau index d080745..9d2551d 100644 --- a/src/Server/Persistence.luau +++ b/src/Server/Persistence.luau @@ -1213,8 +1213,28 @@ 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 @@ -1222,7 +1242,10 @@ function Persistence.new(ctx: any) 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 @@ -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 @@ -1243,7 +1268,7 @@ 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 @@ -1251,7 +1276,7 @@ function Persistence.new(ctx: any) 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. diff --git a/src/Server/init.luau b/src/Server/init.luau index 1346cab..98e1f55 100644 --- a/src/Server/init.luau +++ b/src/Server/init.luau @@ -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 @@ -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 @@ -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 --[=[ diff --git a/src/init.luau b/src/init.luau index 8b3067a..0ca127f 100644 --- a/src/init.luau +++ b/src/init.luau @@ -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 @@ -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")) }) diff --git a/test/Specs/Lifecycle.spec.luau b/test/Specs/Lifecycle.spec.luau index 11aca9c..2a9d1d8 100644 --- a/test/Specs/Lifecycle.spec.luau +++ b/test/Specs/Lifecycle.spec.luau @@ -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() diff --git a/test/TypeCheck.luau b/test/TypeCheck.luau index 8e644b4..8bf3f47 100644 --- a/test/TypeCheck.luau +++ b/test/TypeCheck.luau @@ -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)