Skip to content

Backups

The backup subsystem is DataDriver’s disaster-recovery layer: rotated periodic copies of a profile’s record to a second location, with automatic restore when the primary is corrupt. The mechanism that makes it cheap: only the lock holder writes backups, and the schedule rides the record — no coordinator, no separate cron, no server-to-server awareness of who backed up what last.

Config.Backup turns the feature on. A key’s backup schedule lives inside its own MetaBackupAt and an incrementing BackupCounter — so it survives a player hopping between servers, and a hundred servers touching the same key can’t produce a hundred dailies:

During a save, if Clock() - Meta.BackupAt >= IntervalSeconds, that save also produces a backup copy, 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. 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.

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

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.

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
},
})

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.

When driver: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.

-- 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
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.
Topic Payload Fired
Kernel.ProfileRestored session, profile The load restored from a backup — alert the player, grant make-goods. See Bus.
  • Ops tooling is read-only and lock-free by design: listBackups and peekBackup never take the session lock, so support tooling can inspect a player’s backups while they’re online on another server.
  • Restore never bypasses session locks and never runs with the backup backend down — the same fail-closed doctrine that governs every other DataDriver load path applies here too.