DataDriver
DataDriver is the server-only persistence layer: one driver per logical store, one Profile per loaded key. The design decision that defines it is fail closed: a record that cannot be loaded is never replaced with defaults, a record that cannot be validated is never saved, and a key locked by another live server is never touched. Defaults overwriting a real record is how data wipes happen; a load without a lock is how dupes happen. Every code path in this module is arranged so that neither can occur.
Mental model
Section titled “Mental model”Every stored record is an envelope: { Data, Meta }. Data is your table (or a codec-packed blob of it). Meta is the driver’s bookkeeping:
| Meta field | Meaning |
|---|---|
SchemaVersion |
The schema this record was last written under. Owns versioning — there is no codec version byte. |
CreatedAt |
First-write timestamp. |
UpdatedAt |
Last-write timestamp. |
Lock |
{ JobId, Timestamp } — the session lock. Absent when released. |
UserIds |
Set by attach() for Roblox’s GDPR erasure tracking; the DataStore backend forwards it to UpdateAsync. |
BackupAt, BackupCounter |
The backup schedule. Lives inside the record so it rides across servers. |
RestoredFromBackupAt, RestoredFromSlot |
Stamped by an automatic restore. |
Three invariants hold everywhere:
- Every write is an atomic read-modify-write. The driver only calls
backend:update(key, transform)— on DataStores that isUpdateAsync. The transform sees the stored envelope, checks the lock, and either returns a new envelope ornilto abort with nothing written. Loading is itself a write (it takes the lock), so even the load honors this. - The session lock is checked inside the transform. A lock owned by a different
JobIdwhoseTimestampis withinLockTtlSecondsaborts the write. Every save refreshes the lock timestamp, so the autosave is the lock heartbeat; a crashed server’s lock goes stale after the TTL and becomes stealable. - Failures are typed. A load failure is either recoverable (the record exists and is writable, but its data won’t decode or migrate — backup restore may proceed) or not (backend down, locked by another server, schema newer than this build — nothing may proceed).
The attached lifecycle
Section titled “The attached lifecycle”attach() wires the entire per-player lifecycle onto a kernel in one call:
local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Level = 1, Inventory = {}, Settings = { Music = true } }, SchemaVersion = 1, Validate = function(data) return type(data.Coins) == "number" and data.Coins >= 0 end,})PlayerData:attach(Kernel)
Kernel:onSession(function(session) -- session.Profile.Data is persistent; session.Data is per-visit scratch session.Profile.Data.Coins += 10end)What attach(kernel) does, exactly:
- On join — loads
Player_{UserId}and setssession.Profile. On load failure it warns and, unlessKickOnLoadFailure = false, kicks the player with “Your data could not be loaded. Please rejoin — nothing was lost.” Defaults are never substituted. If the player left while the load was yielding, the orphaned profile is released immediately — otherwise it would stay locked and autosave would keep re-locking it out of every server. - On leave —
session:bindreleases the profile: a final save that also removes the lock. - Autosave — a Scheduler task at
Priority.BackgroundeveryAutosaveSeconds(default 60). The pass runs off the frame viatask.spawn(scheduler tasks run inline in Heartbeat andsaveAllyields), a busy flag stops a slow pass from overlapping the next one, and saves are paced acrossAutosaveSeconds / 4so a full server doesn’t burn the DataStore request budget in one burst. - On shutdown —
BindToClosecallsreleaseAll(), which releases every active profile concurrently and waits up to 25 seconds (Roblox hard-kills the server at 30). If the deadline hits with releases still pending, it logs exactly which keys may not have saved. - Bus — publishes
Kernel.ProfileLoaded(session, profile)on every load, andKernel.ProfileRestored(session, profile)additionally when the load restored from a backup.
The manual lifecycle
Section titled “The manual lifecycle”Anything not player-keyed — clan banks, shared stashes — uses the same primitives directly:
local ClanData = DataDriver.new({ Name = "ClanData", Defaults = { Vault = 0, Members = {} },})
local Bank, Err = ClanData:load(`Clan_{clanId}`)if Bank then Bank.Data.Vault += deposit Bank:save() Bank:release() -- frees the session lock for other serversendload returns nil, err on any failure — check it. A manually loaded profile is your responsibility to release(); until you do, no other server can load the key.
Configuration
Section titled “Configuration”Everything DataDriver.new(config) accepts:
| Field | Default | Description |
|---|---|---|
Name |
— (required) | Store name. Also the base for the default backup store name ({Name}_Backups). |
Defaults |
— (required) | Template for brand-new keys, and the reconcile source for every load: fields missing from an old record are deep-copied in without ever overwriting saved values. |
Backend |
"DataStore" |
"DataStore", "Memory", "Http", or a backend instance. |
BackendConfig |
nil |
Passed to the named backend: { Scope? } for DataStore, { BaseUrl, Headers?, Request? } for Http. |
Backup |
nil |
Enables the backup subsystem. See the table below. |
Codec |
nil |
A Serde schema codec, or "Auto" to infer one from Defaults with extras preserved. nil stores plain JSON-safe tables. |
SchemaVersion |
1 |
The schema this build writes. Meta.SchemaVersion owns versioning. |
Migrations |
{} |
{ [n] = function(data) -> data } — upgrades a vn record to vn+1. Run inside the atomic transform; must not yield. |
Validate |
nil |
(data) -> boolean, gates every save. Returning false (or erroring) refuses the save and keeps the stored record intact. |
AutosaveSeconds |
60 |
Autosave cadence when attached. Every save is also the lock heartbeat. |
LockTtlSeconds |
180 |
Age at which a foreign lock counts as stale and stealable. Asserted >= 2 × AutosaveSeconds — a TTL without headroom over the heartbeat makes live locks stealable mid-session. |
BackoffSeconds |
{ 1, 2, 4, 8 } |
Retry ladder for every backend operation. Attempts = #BackoffSeconds + 1; a 0 entry retries without yielding a frame. |
KickOnLoadFailure |
true |
Attached lifecycle only: kick the player when their load fails. |
JobId |
game.JobId ("Studio" in Studio) |
Lock owner identity. Injectable for tests. |
Clock |
os.time |
Time source for locks and backup schedules. Injectable for tests. |
Config.Backup:
| Field | Default | Description |
|---|---|---|
Backend |
DataStore "{Name}_Backups" |
"Memory", "DataStore", "Http", or a backend instance. "Http" keeps copies entirely off Roblox. |
BackendConfig |
nil |
Config for the named backup backend. |
IntervalSeconds |
86400 |
Per-key backup cadence — exactly once per interval game-wide, no coordination. |
Keep |
3 |
Rotation slots per key (asserted >= 1). Storage per player is bounded forever. |
Mirror |
false |
Additionally copy every save to a live slot, so the fallback is never older than one autosave. |
Fallback |
true |
Restore the newest readable backup when the primary record is corrupt. |
Loading
Section titled “Loading”driver:load(key) performs one atomic update on the backend, retried up the backoff ladder. Inside the transform, in order:
- Lock check. A live foreign lock aborts everything — the lock always wins, even over a corrupt record.
- New key — no stored record means a deep copy of
DefaultsandMeta.SchemaVersion = Config.SchemaVersion. - Malformed envelope / undecodable data — aborts without writing. The driver never overwrites a record it couldn’t read.
- Downgrade check — a record at a schema newer than this build refuses to load (
"rolled-back deploy?"). Deliberately not recoverable: marking it recoverable would let the backup fallback restore an older copy over newer data. - Migrations — run sequentially from
Meta.SchemaVersiontoConfig.SchemaVersion. A missing step or a crashing migration aborts; the stored record is left untouched. - Lock + reconcile — the lock is written as
{ JobId, Timestamp }, defaults are reconciled in, and the envelope (codec-encoded if configured) is written back in the same atomic operation.
The failure taxonomy — this table is the fail-closed doctrine in one place:
| Failure | Error | Backup fallback? |
|---|---|---|
| Backend down through the whole retry ladder | the backend’s error | No — loading without a lock is a dupe vector |
| Lock held by another live server | profile is locked by another server |
No — corrupt ≠ unlocked; the lock always wins |
| Stored schema newer than this build | stored record is schema vN, newer than this server's vM (rolled-back deploy?) |
No — old code must not run over newer data |
| Key already loaded on this server | profile is already loaded on this server |
No |
| Envelope malformed or data won’t decode | stored data failed to decode: … |
Yes |
| Missing or crashing migration | no migration from schema vN to vN+1 / migration vN failed: … |
Yes |
Only the last two — record exists, record is writable, data is unreadable — permit an automatic restore. Everything else returns nil, err and, in the attached lifecycle, kicks.
Saving
Section titled “Saving”profile:save() runs the same atomic transform with the same lock check. Before anything touches the backend, three gates can refuse the save outright — the stored record is never overwritten with data the driver can’t stand behind:
| Gate | Error |
|---|---|
| Unserializable data (codec-less mode): functions, Instances, NaN, non-string keys, mixed array/dictionary tables, sparse arrays | data is not serializable: {path} … |
Validate returned false or errored |
validation rejected the data; refusing to overwrite the stored record |
| Codec encode failed | data does not match the codec schema: … |
Roblox serializers fail silently on bad values — JSONEncode drops functions, DataStores reject NaN and mixed-key tables inconsistently — so the driver validates up front and reports the exact path (Data.Inventory.3 is NaN).
Then the write itself:
- Lock loss. If the transform finds a live foreign lock, the save aborts, the profile deactivates (
Active = false), and the error issession lock was taken by another server. This server’s copy is now dead; it will never clobber the new owner. - One in-flight save per profile. A per-profile mutex serializes overlapping saves — two concurrent retry ladders could otherwise land an older snapshot after a newer one, or re-lock a key that
release()just unlocked. Deactivation on release happens inside the mutex for the same reason. - Retries. The full backoff ladder, same as loads.
profile:release() is a save that also removes the lock. If it fails because the backend is down through the whole ladder, the profile stays active and registered — blocking a racing reload on this server — and retries in the background at 5, 15, and 30 seconds. Only after all of those fail does it deactivate and warn that progress since the last successful save was lost.
Migrations
Section titled “Migrations”Schema changes are explicit: bump SchemaVersion and provide one migration per step.
local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Wallet = { Coins = 0, Gems = 0 }, Inventory = {} }, SchemaVersion = 3, Migrations = { [1] = function(data) -- v1 -> v2: Coins moves into Wallet data.Wallet = { Coins = data.Coins } data.Coins = nil return data end, [2] = function(data) -- v2 -> v3: Gems added data.Wallet.Gems = 0 return data end, },})A v1 record loaded by this build runs [1] then [2] inside the atomic transform and lands at v3. A record that can’t reach the target version (missing step, crashing migration) refuses to load and stays byte-identical in storage — deploy the fixed migration and it loads fine. With a codec configured, migrations run on the decoded data, so they are written against plain tables either way.
Codecs
Section titled “Codecs”By default Data is stored as a plain table. Add a Serde schema codec and it stores as a packed buffer instead — dramatically smaller records:
local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Inventory = {}, Settings = { Music = true } }, Codec = Serde.schema({ Coins = "NumberU32", Inventory = { "NumberU16" }, Settings = { Music = "Boolean8" }, }),})On the wire, Data becomes { F = "Buf", P = <buffer> } on backends that store buffers natively (DataStore), or { F = "B64", P = <base64 string> } on those that don’t. Three properties worth knowing:
- Legacy records upgrade transparently. A pre-codec plain-table record still loads and is re-written encoded on its next save. An existing game can adopt a codec without a migration.
Codec = "Auto"infers a typed schema fromDefaults(Serde.schema(Serde.infer(Defaults), { Extras = true })) — safe wide widths, and extras enabled so fields you add toDatabefore the schema catches up still persist adaptively instead of being dropped. Inference does not narrow to the default value’s magnitude: aCoins = 100default still infers a width that holds 4 billion.- Schema violations refuse to save, same doctrine as
Validate:data does not match the codec schema.
Backups
Section titled “Backups”Config.Backup turns on rotated backups with automatic disaster recovery. The mechanism that makes it cheap: only the lock holder writes backups, and the schedule rides the record.
Schedule and rotation
Section titled “Schedule and rotation”During a save, if Clock() - Meta.BackupAt >= IntervalSeconds, this save also produces a backup. BackupAt and an incrementing BackupCounter are written into the primary record’s Meta inside the same atomic save that produced the data — so a hundred servers can’t produce a hundred dailies, and a player hopping servers doesn’t reset the clock. A key that isn’t loaded anywhere doesn’t need re-backing-up: its data isn’t changing, so the last backup is still true.
The copy is written after the primary write lands, to slot ((Counter - 1) % Keep) + 1 under the key {key}/backup/{slot} — a fixed ring, never growth. A failed copy is remembered (PendingBackupSlot) and retried on the next save with fresh data; the interval claim is not re-taken. With Mirror = true, every save additionally copies to a live slot, so the newest fallback is never older than one autosave. Combine both for “fresh copy + daily history”:
local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Inventory = {} }, Backup = { IntervalSeconds = 86400, -- one backup per key per day (the default) Keep = 3, -- rotate the last 3 dailies per key Mirror = true, -- ALSO copy every save to a "live" slot -- Backend = "Http", BackendConfig = { BaseUrl = ... }, -- external DB as the target },})Each backup record carries its own Meta: SchemaVersion, CreatedAt, At (copy time), Counter, Key, and Source (the writing server’s JobId). If the backup backend differs from the primary in buffer support, the payload is re-encoded for it; codec-less payloads are deep-copied so the stored copy is detached from the live table.
Automatic restore
Section titled “Automatic restore”When load() hits a recoverable failure — the record exists and is writable but its data won’t decode or migrate — and Backup.Fallback isn’t false, the driver:
- Scans the
liveslot and allKeepring slots, picks the newest readable one byAt(the write counter breaks same-second ties). Corrupt slots are skipped; the rest are still tried. - Re-runs the load with that backup as the record: the backup is written over the primary inside the same lock-checked atomic transform.
MetagetsRestoredFromBackupAtandRestoredFromSlot, and the restored copy’sAtbecomes the currentBackupAt. - Flags
profile.RestoredFromBackup = true— transient, true only on the load that performed the restore — and the attached lifecycle publishesKernel.ProfileRestoredso you can tell the player, ping a webhook, or grant make-goods.
The restore honors every fail-closed rule: it never runs while another server holds a live lock (corrupt ≠ unlocked — the lock expires first, then the restore is allowed), and never when the backend is down, because loading without a lock is a dupe vector.
Ops tooling
Section titled “Ops tooling”| Member | Description |
|---|---|
driver:backupNow(profile) → (ok, err?) |
Forces a backup copy on the next write regardless of the schedule, then saves. |
driver:listBackups(key) → { { Slot, At, SchemaVersion } } |
Every readable slot for a key, newest first. |
driver:peekBackup(key, slot?) → (data?, err?) |
Read-only decoded deep copy of a backup (newest if no slot given). Never locks, never writes — safe while the player is online on another server. |
-- Support workflow: inspect a player's newest backup without touching their sessionlocal Snapshot, Err = PlayerData:peekBackup(`Player_{userId}`)if Snapshot then print(`backup coins: {Snapshot.Coins}`)endfor _, Entry in PlayerData:listBackups(`Player_{userId}`) do print(`slot {Entry.Slot}: {os.time() - Entry.At}s old, schema v{Entry.SchemaVersion}`)endRoblox’s own point-in-time versioning (ListVersionsAsync) still works underneath as a third layer, since the driver only ever writes through UpdateAsync.
API reference
Section titled “API reference”DataDriver
Section titled “DataDriver”| Member | Description |
|---|---|
DataDriver.new(config) → DataDriver |
See Configuration. Asserts Name, Defaults, LockTtlSeconds >= 2 × AutosaveSeconds, and Backup.Keep >= 1. |
driver:attach(kernel) |
Wires the full per-player lifecycle: load on join into session.Profile, release on leave, background autosave, BindToClose flush. |
driver:load(key) → (Profile?, err?) |
Atomic lock-taking load with backup fallback. Never returns defaults for an existing record. |
driver:saveAll(spreadSeconds?) |
Saves every active profile, pacing writes across spreadSeconds so a burst can’t exhaust the request budget. |
driver:releaseAll() |
Concurrently releases every active profile; waits up to 25s and logs keys whose release didn’t land. |
driver:backupNow(profile) → (ok, err?) |
Off-schedule backup copy + save. |
driver:listBackups(key) → { { Slot, At, SchemaVersion } } |
Backup slots for a key, newest first. |
driver:peekBackup(key, slot?) → (data?, err?) |
Lock-free read-only decoded backup copy. |
Profile
Section titled “Profile”| Member | Description |
|---|---|
Profile.Key |
The storage key. |
Profile.Data |
Your persistent table. Mutate it freely; saves snapshot it. |
Profile.Meta |
Driver bookkeeping (see Mental model). Treat 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 performed a backup restore. |
profile:save() → (ok, err?) |
Atomic validated write; refreshes the session lock. |
profile:release() → (ok, err?) |
Final save + lock removal. Retries in the background at 5/15/30s if the backend is down. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Kernel.ProfileLoaded |
session, profile |
Attached lifecycle finished loading a player’s profile. |
Kernel.ProfileRestored |
session, profile |
The load restored from a backup — alert the player, grant make-goods. |
Gotchas
Section titled “Gotchas”- Never cache
Profile.Datapast release. Afterrelease()or a lost lock,Activeisfalseand mutations go nowhere. Checksession.Profilefreshly in handlers. Validateruns on every save, including autosaves. Keep it fast, keep it pure, and make it a real invariant check — it is the last line between a bug and a persisted corrupt record.BackoffSeconds = {}means one attempt, no retries — useful in tests, dangerous in production.- Two drivers, one name is a data race. One
DataDriverinstance per store name per server; theActivemap that prevents same-server double-loads is per-instance. - For cross-server state (round status, live leaderboards) use MemoryDriver — DataDriver is storage, not messaging.