DataDriver
Persists player data through atomic writes under a session lock, and fails the load or the save instead of substituting defaults or writing data it cannot read back.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Data - Since
- v0.1.0
- Source
- Data/init.luau
DataDriver is the server’s persistence layer: one driver per store, one Profile per loaded key, and every write an atomic read-modify-write under a session lock. Its defining rule is to fail closed. A record it cannot read is never replaced with defaults, data it cannot store exactly is never written, and a key that another live server holds is never touched. Defaults overwriting a real record is how data wipes happen, and a load without a lock is how items get duplicated. The parts big enough for their own pages are lock handoff (fast rejoins across servers), version history (restoring corrupt records), backups, profile snapshots and the storage backends. Receipts and Transactions are built on its profiles.
Mental model
Section titled “Mental model”Every stored record is an envelope, { Data, Meta }. Data is the game’s table, or a codec-packed blob of it. Meta is the driver’s bookkeeping and is never codec-encoded:
| Meta field | Meaning |
|---|---|
SchemaVersion |
The schema the record was last written under. Versioning lives here; a codec carries no version byte. |
CreatedAt, UpdatedAt |
First and latest write, from the driver’s Clock (default os.time). |
Lock |
{ JobId, Token, Timestamp, Presence? }, the session lock. Token is a fresh GUID for every acquisition. Presence = true means the holder beats presence for lock handoff. Absent after a release. |
ReleasedToken |
The token of the release that cleared Lock. A release whose response was lost recognizes its own commit by it. |
UserIds |
The record’s owners for Roblox’s GDPR erasure. The DataStore backend passes them to UpdateAsync. |
DefaultPaths |
The reconcile ledger: every Defaults path this record has already received. |
TransactionIds |
Replay ids of committed trades, the newest 64. See Transactions. |
PendingTransaction |
{ Key, Token }, a pointer to a trade journal while a trade is in flight or waiting for recovery. |
BackupAt, BackupCounter |
The backup schedule. See Backups. |
RestoredFromBackupAt, RestoredFromSlot |
Stamped when a load restores a backup slot or a version. |
With a codec, Data is stored as { F = "Buf", P = buffer } on backends that accept buffers (DataStores) and { F = "B64", P = string } elsewhere. Codec = "Auto" records also carry A = 2 and S, the layout that wrote them (see Codecs).
Four rules hold on every path:
- Every write is an atomic read-modify-write. The driver only calls
backend:update(key, transform), which isUpdateAsyncon DataStores. The transform sees the stored envelope and returns a new one, ornilto abort with nothing written. A load is a write too, because it takes the lock. - Ownership is checked inside the transform. A save needs the exact lock its profile acquired: the same
JobIdand the sameToken. Only a load can take another server’s lock, and only when that lock is stale by age or its holder has stopped beating presence. The token works as a fencing token: a stale owner cannot overwrite its successor even when both run under the sameJobId. - A server runs one operation per key at a time. A load reserves its key until it finishes, and each profile runs one save at a time. Two overlapping retry ladders could otherwise land an older snapshot after a newer one, or re-lock a key that
release()just unlocked. - Data is checked before any I/O: serializability when there is no codec (UTF-8, finite numbers, cycles, key shapes), then
Validate, then a codec read-back, then the 4 MB value limit. A save that fails any check is refused, and the stored data is left as it was.
The attached lifecycle
Section titled “The attached lifecycle”attach(kernel) wires the whole per-player lifecycle. Games get this from python tools/scaffold.py data, which creates a DataService like this one:
--!strict-- DataService: owns player persistence.
local ServerScriptService = game:GetService("ServerScriptService")
local DataDriver = require(ServerScriptService.ChloeKernelServer.Data)
local DataService = { Name = "DataService",}
function DataService.init(self: any, kernel: any) self.Driver = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Level = 1, PlaySeconds = 0, Inventory = {}, Settings = { Music = true } }, SchemaVersion = 1, -- Types and shape only. A game rule (Coins >= 0) that a bug can break would refuse every save. Validate = function(data: any): boolean return type(data.Coins) == "number" and type(data.Level) == "number" and type(data.Inventory) == "table" end, }) self.Driver:attach(kernel)end
function DataService.start(_self: any) end
function DataService.stop(_self: any) end
return DataServiceOther services wait for the profile through Milestones, which replays profiles that loaded before the subscription. A cleanup bound after the load still runs before the final save:
--!strict-- PlaytimeService: adds each visit's length to the profile.
local ServerScriptService = game:GetService("ServerScriptService")
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local PlaytimeService = { Name = "PlaytimeService", Dependencies = { "DataService" },}
function PlaytimeService.init(self: any, kernel: any) self.Kernel = kernelend
function PlaytimeService.start(self: any) self.Loaded = Milestones.on(self.Kernel, "Kernel.ProfileLoaded", function(session, profile) local JoinedAt = os.clock() session:bind(function() profile.Data.PlaySeconds += math.floor(os.clock() - JoinedAt) end) end)end
function PlaytimeService.stop(self: any) if self.Loaded then self.Loaded:disconnect() endend
return PlaytimeServiceWhat attach(kernel) does:
| Moment | What the driver does |
|---|---|
| Join | Sets session.ProfilePending = true and loads Player_<UserId>, tagging the player’s UserId inside the load’s own write. If another server holds the lock, it waits up to LockWaitSeconds (default 15) while lock handoff asks that server to let go. On success it sets session.Profile, clears ProfilePending and publishes Kernel.ProfileLoaded(session, profile) as a milestone, plus Kernel.ProfileRestored(session, profile) when the load restored an older copy. On failure it warns and kicks the player with a message saying nothing was lost, unless KickOnLoadFailure = false. Defaults are never substituted. |
| Left during the load | The profile is released at once, so it cannot stay locked with nobody saving it. |
| Leave | The release save is bound with session:bindLast, so it runs after every session:bind cleanup and OnEnd listener, including cleanups bound in Kernel.ProfileLoaded handlers. Leave-time writes reach it. |
| Autosave | Every AutosaveSeconds (default 60) a Background-priority Scheduler job starts a pass off the frame. The pass spreads its saves over a quarter of the interval and runs each profile’s save on its own thread, skipping any profile whose previous save is still running. The autosave is also every profile’s lock heartbeat, so one stuck request must not delay the rest. For a profile staged by restoreAt, the autosave is only that heartbeat and writes no data. |
| Shutdown | Waits for the service stop phase, then drains every profile. See Leave and shutdown. |
| Lock lost | A save, or the lock refresh of a staged restoreAt profile, that finds its lock taken ends the player’s session and publishes Data.LockLost. |
| Blocked trade | A trade participant left blocked for recovery is kicked when Commerce.TransactionEnded fires, so the next load settles the trade. |
While session.ProfilePending is true, write nothing that must persist. The kits already handle it: InventoryKit merges items granted during the load into the profile when it arrives, AchievementKit and QuestKit hold unlocks and completions until then, and CraftingKit refuses timed crafts with Loading.
The manual lifecycle
Section titled “The manual lifecycle”Records that are not player-keyed, such as clan banks, use the same primitives directly:
local ClanData = DataDriver.new({ Name = "ClanData", Defaults = { Vault = 0, Members = {} },})
local function deposit(clanId: string, amount: number, depositorId: number): (boolean, string?) local Bank, LoadErr = ClanData:load(`Clan_{clanId}`, { UserIds = { depositorId } }) if not Bank then return false, LoadErr end Bank.Data.Vault += amount -- release() is a final save that also removes the lock return Bank:release()endload returns nil, err on every failure. A manually loaded profile is yours to release(); until you do, no other server can load the key. Manual profiles are not autosaved unless the driver is attached, in which case every active profile is included in the passes.
Configuration
Section titled “Configuration”Everything DataDriver.new(config) reads:
| Field | Default | Description |
|---|---|---|
Name |
required | Store name. Also the base of the default backup store ({Name}_Backups) and the name lock handoff routes requests by. |
Defaults |
required | Template for new keys and the reconcile source for every load. |
Backend |
"DataStore" |
"DataStore", "Memory", "Http" or a backend instance. See Backends. |
BackendConfig |
nil |
{ Scope?, Store?, Service? } for DataStore, { BaseUrl, Headers?, Request? } for Http. |
Versions |
{ Fallback = true, MaxVersions = 10 } |
Restore from native DataStore version history. See Version history. |
Backup |
nil |
Rotated copies in separate keys. See Backups. |
Codec |
nil |
A Serde schema codec, or "Auto". nil stores plain tables. |
SchemaVersion |
1 |
The schema this build writes. |
Migrations |
{} |
{ [n] = function(data) -> data } upgrades a vN record to vN+1. Runs inside the transform and must not yield. |
Validate |
nil |
(data) -> boolean. Checks every save and every load. Must not yield. |
AutosaveSeconds |
60 |
Autosave interval when attached. Every save refreshes the lock. |
LockTtlSeconds |
180 |
Age after which a lock counts as stale. Asserted to be at least twice AutosaveSeconds, because a TTL without headroom over the heartbeat makes live locks stealable mid-session. |
LockSkewSeconds |
60 |
Extra age allowed for clock differences between servers before a stale lock is taken. |
LockWaitSeconds |
15 |
How long a joining player waits for another server’s lock before the load fails. |
LocalHandoffSeconds |
nil |
How long load() waits for a load or release of the same key on this server. Player sessions use LockWaitSeconds when unset; manual loads use 0. |
Handoff |
on for live servers | Cross-server lock handoff. On by default for live servers on a shared backend; false turns it off. |
BackoffSeconds |
{ 1, 2, 4, 8 } |
Retry ladder for every backend call. Attempts are #BackoffSeconds + 1; a 0 entry retries without yielding a frame. |
KickOnLoadFailure |
true |
Attached lifecycle only: kick the player when the load fails. |
JobId |
game.JobId |
Lock owner identity ("Studio" when game.JobId is empty). Setting it turns the default handoff off; tests use it to simulate servers. |
Clock |
os.time |
Time source for locks and backup schedules. Injectable for tests. |
Loading
Section titled “Loading”driver:load(key, options?) reserves the key on this server, then runs one atomic update, retried up the ladder. Inside the transform, in order:
- A lock held by another server aborts the load. A lock counts as held until it is older than
LockTtlSeconds + LockSkewSeconds(4 minutes by default). A stale lock whose holder beats presence is checked before it is taken, and a holder that still beats keeps it. See Lock handoff. - A record with
Meta.PendingTransactionaborts without writing. The driver settles the trade from its journal outside the transform (transaction recovery) and loads again. - A missing record becomes a deep copy of
DefaultsatSchemaVersion. - A malformed envelope, or a
SchemaVersionthat is not a positive integer, aborts. So does a record at a newerSchemaVersionthan this build, checked before decoding because a newer layout may not decode with this codec. - A codec record is decoded with the layout that wrote it. Data that will not decode, or is not a table, aborts.
- Migrations run in order from the stored version to
SchemaVersion. - Missing
Defaultsfields are filled in, following the ledger (see Reconciling defaults). - Without a codec, the data must be serializable.
Validatemust accept it. - UserIds from
options.UserIdsare merged intoMeta.UserIds, the lock{ JobId, Token, Timestamp, Presence }is written, and the envelope goes back in the same operation, re-encoded. A codec that cannot encode the migrated data fails at once, since retrying a deterministic failure cannot help.
Every failure is typed:
| Failure | Error | Falls back to an older copy |
|---|---|---|
| Backend down through the whole ladder | the backend’s error | No: loading without a lock is a duplication path |
| Another server holds the lock | profile is locked by another server |
No: the lock wins, even over a corrupt record |
| A trade journal that cannot be settled yet | transaction recovery failed: ... |
No |
Record written by a newer SchemaVersion |
stored record is schema vN, newer than this server's vM (rolled-back deploy?) |
No: old code must not run over newer data |
A migration or Validate yielded |
migration vN yielded; ..., Validate yielded; ... |
No: a code defect fails the same way on older data |
Validate rejects the live record |
validation rejected the stored data; not restoring an older version over it |
No |
| A migration is missing or throws on the live record | no migration from schema vN to vN+1, migration vN failed: ... |
No |
| The migrated data does not fit the codec | migrated data does not fit the codec: ... |
No |
| Key already loaded or loading on this server | profile is already loaded on this server, profile is already loading on this server |
No |
| The driver is draining for shutdown | driver is shutting down |
No |
| Malformed envelope, data that will not decode, data that is not a table, unserializable stored data | stored data failed to decode: ... |
Yes: version history, then backups |
Only the last row permits a fallback. The record exists and is writable but its bytes are unreadable, so an older copy is the best data there is. Every other failure means the rules or the code changed, or ownership is unclear, and an older copy would roll the player back. The driver never writes the record in those cases; fix the rule, add the migration or deploy the newer build and the same record loads.
Reconciling defaults
Section titled “Reconciling defaults”Every load fills in Defaults fields the record lacks, deep-copied, and never overwrites a saved value. Each record keeps a ledger of the default paths it has received (Meta.DefaultPaths). A path in the ledger is never refilled, so an entry the game removed stays removed. Without the ledger, a starter item the player sold would come back on every load, which is a duplication path. Records written before the ledger existed get missing dictionary keys filled but never array entries refilled. Backups and version history candidates carry the ledger, so a restore keeps removed entries removed too. Before 0.9.1 a restore reconciled without it.
Load options
Section titled “Load options”| Option | Description |
|---|---|
UserIds |
{ number } merged into Meta.UserIds inside the load’s own write, so even a brand-new record is covered by GDPR erasure. attach passes the player’s UserId. |
HandoffSeconds |
How long to wait for a load or release of the same key on this server before giving up. Default LocalHandoffSeconds or 0. |
Saving
Section titled “Saving”profile:save() runs the same kind of atomic transform. Before anything reaches the backend, the data is snapshotted (later mutations during the yield do not leak into the write) and checked. A failed check refuses the save:
| Check | Error |
|---|---|
| Unserializable data (no codec): invalid UTF-8 in a string or key, NaN or infinite numbers, cycles, keys that are not strings or positive integers, mixed or sparse arrays | data is not serializable: Data.Inventory.3 is not a finite number |
Validate returned false or raised |
validation rejected the data; refusing to overwrite the stored record |
| The codec cannot encode the data | data does not match the codec schema: ... |
| The codec would read back a different value | the codec would store a different value at Data.Coins; refusing to overwrite the stored record |
| The envelope is over 4,194,304 bytes | profile is N bytes, over the 4194304-byte DataStore value limit |
Roblox serializers fail quietly on bad values (JSONEncode drops functions, DataStores reject NaN and mixed tables inconsistently), so the driver checks first and names the exact path.
A refused save publishes Data.SaveRefused(key, reason) and returns false, reason. The session lock stays fresh: when the lock is older than AutosaveSeconds, the driver writes a lock-only refresh. This server still holds newer data than the stored record, and letting the lock go stale would let another server load the older copy. The size check measures the envelope’s JSON size, counting a buffer at its base64 length, and the first time a profile passes 75% of the limit the driver warns once.
Retries and throttling
Section titled “Retries and throttling”Every backend call runs through BackoffSeconds. With the default ladder that is 5 attempts over 15 s of waiting.
- Throttling errors (a message containing “throttl”, or codes 301 to 306:
ExperienceThrottled,GameServerThrottled,KeyThrottledand full throttle queues) mean the request budget is spent. Those retries wait 1.5 to 2.5 times the ladder’s delay, jittered so servers stop retrying in lockstep, and the total wait still never exceeds the ladder’s sum. - The first throttled error of an episode publishes
Data.Throttled(key, errorText). The episode ends when any request through the driver succeeds. - Key name, value type and value size errors (codes 101 to 105) repeat on every attempt and are not retried.
- While the DataStore reports no
StandardReadorStandardWritebudget left, an ordinary autosave waits for the next pass, provided the lock would still be fresh by then. Leave saves and shutdown saves never wait. A warning counts the deferred saves.
Lock loss
Section titled “Lock loss”If the transform finds a lock that is not this profile’s, the save writes nothing. That happens when another server took the lock, either by age (this server’s saves stalled past LockTtlSeconds + LockSkewSeconds and no presence beat protected the lock) or because this server’s presence beat stopped. The profile deactivates, the driver warns, publishes Data.LockLost(key) and kicks the player. The other server holds the record now, and the player continues on that copy. Only acquisition may take a stale lock; saves need exact ownership.
Release
Section titled “Release”profile:release() is a save that also removes the lock and stamps Meta.ReleasedToken. When it succeeds the profile deactivates (Active = false) and any server waiting on a handoff is told the lock is free.
When the backend stays down through the whole ladder, the profile stays active and registered. That blocks a racing reload on this server and keeps the unsaved state. The release retries in the background after waits of 5, 15 and 30 s, then autosave passes and the shutdown drain keep attempting it. A player who rejoins this server meanwhile carries on with the retained profile. A release that committed but reported failure is recognized by its ReleasedToken: a retried release writes nothing, and a same-server rejoin retakes the lock because nobody has written since. A release requested while a trade holds the profile returns false, "profile is in a transaction" and runs when the trade ends.
Leave and shutdown
Section titled “Leave and shutdown”The leave save runs last. The session calls every bind cleanup and OnEnd listener first, then the finalizers from bindLast, where the driver’s release sits. Before 0.9.0 the final save ran first and writes from later cleanups were lost.
The attached driver binds its own BindToClose and drains in two steps:
- It waits for the kernel’s close stop phase (
Kernel.Stopped), up to 5 s. The server kernel runs servicestop()in reverse boot order during close, and astop()may still write profile data, such as an end-of-round payout. A player who leaves during the close waits for the same phase before their leave save. - It calls
releaseAllwith a deadline 27 s after the close began (the engine allows 30). Every active profile’s release retries every 0.25 s until it lands or the deadline passes. Loads that finish during the drain are released too, and new loads are refused withdriver is shutting down. ArestoreAtstaging that nobody saved or released is discarded instead: its lock is removed and the stored record stays as it was. Keys that have still not saved at the deadline are named in one warning.
driver:releaseAll(deadline?) takes an os.clock() deadline; without one it drains for 25 s.
Migrations
Section titled “Migrations”Schema changes are explicit: bump SchemaVersion and add one migration per step.
local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Wallet = { Coins = 0, Gems = 0 }, Inventory = {} }, SchemaVersion = 3, Migrations = { [1] = function(data) -- v1 to v2: Coins moves into Wallet data.Wallet = { Coins = data.Coins or 0 } data.Coins = nil return data end, [2] = function(data) -- v2 to v3: Gems added data.Wallet.Gems = 0 return data end, },})A v1 record runs [1] then [2] inside the transform and is written back at v3. With a codec, migrations run on the decoded data, so they are written against plain tables either way.
A migration that is missing or throws on the live record fails the load, and the record stays byte-identical. Deploy the fixed migration and it loads. A migration that yields fails the load once, by name, instead of repeating on every retry: a yield inside UpdateAsync fails the request, and every older copy would fail the same way.
Validate
Section titled “Validate”Validate(data) -> boolean runs on every save and on every load. It exists to catch corrupt or foreign records, so it should check types and shape, never game rules. The driver never writes data Validate rejects, so a rule a game bug can break (negative coins) refuses every save for the rest of the session, and the player’s progress stays unsaved until the data passes again.
On load, the driver treats a live record Validate rejects as a sign that the rules changed or differ on this server. The load fails instead of restoring an older version over the record. Relax the rule or add a migration before deploying a stricter Validate.
Codecs
Section titled “Codecs”By default Data is stored as a plain table. A Serde schema codec stores it as a packed buffer instead, which makes records much smaller:
local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Inventory = {}, Settings = { Music = true } }, Codec = Serde.schema({ Coins = "NumberU32", Inventory = { "NumberU16" }, Settings = { Music = "Boolean8" }, }),})The driver makes sure a codec never changes the data it stores. At boot, an explicit codec must encode and decode Defaults unchanged, and DataDriver.new errors otherwise, naming the field: the codec does not hold Defaults.X; add it to the schema or enable Extras. On every save, the encoded payload is decoded again and compared with the data, so a clamped integer, a dropped field or a list with a hole refuses the save. Numbers match within about 1e-3 relative (what an F16 field keeps), so a schema’s deliberate precision passes, and engine values such as Vector3 match by type.
A plain-table record from before the codec still loads, and its next write stores it encoded. An existing game can adopt a codec without a migration.
Codec = "Auto" infers a schema from Defaults with Extras enabled, so fields added before the schema catches up still persist. Every inferred number is NumberF64, exact for integers up to 2^53. Each Auto record carries the layout that wrote it (A = 2 and the inferred spec in S), and a reader decodes with that layout. Adding a Defaults field keeps older records readable, and a server still on the previous Defaults reads a newer record correctly instead of misreading and rewriting it. Auto records saved before 0.9.0 have no marker; they decode with the legacy integer layout and the load’s own write upgrades them to the F64 layout. Do not downgrade the framework after that upgrade.
Lock handoff
Section titled “Lock handoff”A player who rejoins within seconds, on this server or another, is handed over without a kick or a rollback. Same-server rejoins reuse the retained profile; cross-server rejoins ask the holder to release over MessagingService, and every server beats a MemoryStore presence key so a crashed server’s locks are taken in about 15 s. See Lock handoff.
Version history
Section titled “Version history”On DataStores, a record that will not decode restores the newest readable native version before any backup, and driver:restoreAt(key, unixMs) stages the version current at a moment for support tooling. See Version history.
Backups
Section titled “Backups”Config.Backup adds rotated copies in separate keys, written only by the lock holder, with the schedule stored in the record. See Backups for the schedule, the mirror slot, restore order and the support tooling (backupNow, listBackups, peekBackup).
Profile snapshots
Section titled “Profile snapshots”profile:snapshot() and profile:rollback(id) keep in-memory undo points for one profile. See Profile snapshots.
API reference
Section titled “API reference”DataDriver
Section titled “DataDriver”| Member | Description |
|---|---|
DataDriver.new(config) -> DataDriver |
See Configuration. Asserts Name, Defaults, LockTtlSeconds >= 2 * AutosaveSeconds, Backup.Keep >= 1, the backup store name length and, for an explicit codec, that it holds Defaults. Warns in Studio when the driver uses the live DataStore. |
driver:attach(kernel) |
Wires the per-player lifecycle: load on join into session.Profile, release on leave, autosave, the shutdown drain, lock handoff and the blocked-trade kick. |
driver:load(key, options?) -> (Profile?, err?) |
Lock-taking load with version and backup fallback. options: { UserIds?, HandoffSeconds? }. Never returns defaults for an existing record. |
driver:restoreAt(key, unixMs, options?) -> (Profile?, err?) |
Stages the version current at a Unix time in milliseconds over the locked live record. save() or release() commits it and discard() drops it; autosaves only refresh its lock. See Version history. |
driver:saveAll(spreadSeconds?) |
Saves every active profile, each on its own thread, spaced across spreadSeconds. Returns when every started save has finished. Ordinary saves wait while the request budget is spent; releases do not. |
driver:releaseAll(deadline?) |
Releases every active profile, retrying until each lands or the os.clock() deadline (default 25 s from now) passes, and warns with the keys that did not save. A restoreAt staging nobody saved or released is discarded, not committed. |
driver:backupNow(profile), driver:listBackups(key), driver:peekBackup(key, slot?) |
Backup tooling. See Backups. |
Profile
Section titled “Profile”| Member | Description |
|---|---|
profile.Key |
The storage key. |
profile.Data |
The persistent table. Read and write it synchronously; never keep it across a yield. |
profile.Meta |
Driver bookkeeping (see Mental model). Treat it as read-only. |
profile.Active |
false once released or once the lock was lost. An inactive profile never saves again. |
profile.RestoredFromBackup |
true only on the load that restored a version or backup, and on a profile returned by restoreAt. |
profile.LastSaveAt, profile.LastSaveError, profile.ConsecutiveSaveFailures |
Save diagnostics: time of the last successful save (driver Clock), the last error, and failures since the last success. |
profile.TransactionOwner, profile.TransactionBlocked |
Set while a trade holds the profile, and after a trade that needs recovery. See Transactions. |
profile:save() -> (ok, err?) |
Checked atomic write that refreshes the lock. |
profile:release() -> (ok, err?) |
Final save plus lock removal. Keeps the profile active and retries when the backend is down. |
profile:discard() -> (ok, err?) |
Drops a restoreAt staging: the lock is released and the stored record stays as it was. Returns false, "nothing staged: only a profile from restoreAt can be discarded" on any other profile, and true on an inactive one. When the release write fails, it returns false and the profile stays discarding: a later discard(), release() or autosave finishes it, and save() refuses with the staged restore was discarded; discard() or release() finishes it. Added in 0.9.1. |
profile:snapshot(label?), profile:snapshots(), profile:rollback(id) |
In-memory undo points. See Profile snapshots. |
Session fields
Section titled “Session fields”| Member | Description |
|---|---|
session.Profile |
The loaded profile, set by attach. nil until the load finishes. |
session.ProfilePending |
true while attach is loading this player’s profile. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Direction |
|---|---|---|
Kernel.ProfileLoaded |
session, profile |
Published when the attached lifecycle loads a profile. A milestone: Milestones.on replays it. |
Kernel.ProfileRestored |
session, profile |
Published after Kernel.ProfileLoaded when the load restored a version or a backup. |
Data.Throttled |
key, errorText |
Published once per throttling episode, until a request through the driver succeeds. |
Data.SaveRefused |
key, reason |
Published when a save refuses to write. The lock stays fresh. |
Data.LockLost |
key |
Published when a save, or a staged restore’s lock refresh, finds another server’s lock. The player is kicked. |
Commerce.TransactionEnded |
profileA, profileB, ok |
Consumed: a participant of this driver left TransactionBlocked is kicked so the next load recovers the trade. |
Kernel.Stopped |
reason |
Consumed: the shutdown drain waits for it, up to 5 s. |
Gotchas
Section titled “Gotchas”BackoffSeconds = {}means one attempt and no retries. It is useful in tests and dangerous in production.- Studio reads and writes the published game’s DataStores. A driver on
"DataStore"warns once in Studio; the scaffold’s profile uses"Memory"there. - Keep profiles far under 4 MB. Storage above
500 MB + 1 MB x lifetime usersis billed since April 2026, so store ids and counts instead of copies of catalog data.