Skip to content

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.

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:

  1. Every write is an atomic read-modify-write. The driver only calls backend:update(key, transform) — on DataStores that is UpdateAsync. The transform sees the stored envelope, checks the lock, and either returns a new envelope or nil to abort with nothing written. Loading is itself a write (it takes the lock), so even the load honors this.
  2. The session lock is checked inside the transform. A lock owned by a different JobId whose Timestamp is within LockTtlSeconds aborts 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.
  3. 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).

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 += 10
end)

What attach(kernel) does, exactly:

  • On join — loads Player_{UserId} and sets session.Profile. On load failure it warns and, unless KickOnLoadFailure = 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 leavesession:bind releases the profile: a final save that also removes the lock.
  • Autosave — a Scheduler task at Priority.Background every AutosaveSeconds (default 60). The pass runs off the frame via task.spawn (scheduler tasks run inline in Heartbeat and saveAll yields), a busy flag stops a slow pass from overlapping the next one, and saves are paced across AutosaveSeconds / 4 so a full server doesn’t burn the DataStore request budget in one burst.
  • On shutdownBindToClose calls releaseAll(), 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, and Kernel.ProfileRestored(session, profile) additionally when the load restored from a backup.

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 servers
end

load 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.

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.

driver:load(key) performs one atomic update on the backend, retried up the backoff ladder. Inside the transform, in order:

  1. Lock check. A live foreign lock aborts everything — the lock always wins, even over a corrupt record.
  2. New key — no stored record means a deep copy of Defaults and Meta.SchemaVersion = Config.SchemaVersion.
  3. Malformed envelope / undecodable data — aborts without writing. The driver never overwrites a record it couldn’t read.
  4. 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.
  5. Migrations — run sequentially from Meta.SchemaVersion to Config.SchemaVersion. A missing step or a crashing migration aborts; the stored record is left untouched.
  6. 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.

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 is session 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.

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.

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 from Defaults (Serde.schema(Serde.infer(Defaults), { Extras = true })) — safe wide widths, and extras enabled so fields you add to Data before the schema catches up still persist adaptively instead of being dropped. Inference does not narrow to the default value’s magnitude: a Coins = 100 default still infers a width that holds 4 billion.
  • Schema violations refuse to save, same doctrine as Validate: data does not match the codec schema.

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.

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.

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:

  1. Scans the live slot and all Keep ring slots, picks the newest readable one by At (the write counter breaks same-second ties). Corrupt slots are skipped; the rest are still tried.
  2. Re-runs the load with that backup as the record: the backup is written over the primary inside the same lock-checked atomic transform. Meta gets RestoredFromBackupAt and RestoredFromSlot, and the restored copy’s At becomes the current BackupAt.
  3. Flags profile.RestoredFromBackup = true — transient, true only on the load that performed the restore — and the attached lifecycle publishes Kernel.ProfileRestored so 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.

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 session
local Snapshot, Err = PlayerData:peekBackup(`Player_{userId}`)
if Snapshot then
print(`backup coins: {Snapshot.Coins}`)
end
for _, Entry in PlayerData:listBackups(`Player_{userId}`) do
print(`slot {Entry.Slot}: {os.time() - Entry.At}s old, schema v{Entry.SchemaVersion}`)
end

Roblox’s own point-in-time versioning (ListVersionsAsync) still works underneath as a third layer, since the driver only ever writes through UpdateAsync.

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.
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.
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.
  • Never cache Profile.Data past release. After release() or a lost lock, Active is false and mutations go nowhere. Check session.Profile freshly in handlers.
  • Validate runs 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 DataDriver instance per store name per server; the Active map that prevents same-server double-loads is per-instance.
  • For cross-server state (round status, live leaderboards) use MemoryDriver — DataDriver is storage, not messaging.