Backups
Copies DataDriver profiles into rotated backup keys once per interval game-wide, optionally mirrors every save, restores them when a record is corrupt, and reads them for support without taking a lock.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Data - Since
- v0.3.0
- Source
- Data/init.luau
Backups are DataDriver’s optional retention in separate keys: rotated copies of each profile in a second backend, restored when the primary record is corrupt, and readable by support tooling without touching the player’s session. Since 0.9.0 the driver restores from Roblox’s free version history first, so backups are for retention you need to control: history beyond the version window, a copy fresher than the hourly version, or a copy off Roblox in your own database. What keeps them cheap is that only the lock holder writes a backup and the schedule is stored inside the record, so no coordinator or cross-server bookkeeping is needed.
Mental model
Section titled “Mental model”Config.Backup turns backups on. Each key’s schedule lives in its own Meta as BackupAt and an incrementing BackupCounter, so it follows the player between servers, and a hundred servers touching the same key still produce one backup per interval.
During a save, when Clock() - Meta.BackupAt >= IntervalSeconds, the save claims the next slot: the counter goes up by one, the slot is ((Counter - 1) % Keep) + 1, and the new BackupAt is written inside the same atomic save as the data. The copy itself is written after the primary write lands, to the key {key}/backup/{slot}, so a backup is never newer than the record it backs up. The slots form a fixed ring: storage per player is bounded.
The claim commits only with a successful primary write. A primary save that fails leaves the backup due for the next save. A copy that fails after the primary landed is remembered and retried on the next save with fresh data, without taking a new claim.
With Mirror = true, saves also copy to a live slot, so the newest backup is at most one autosave old. MirrorIntervalSeconds spaces those copies out; a release always attempts one, and a failed mirror copy is retried on the next save. Combine the ring and the mirror for a fresh copy plus daily history.
A key that is not loaded anywhere is not backed up again. Its data is not changing, so its last backup is still current.
Copy writes and cooldown
Section titled “Copy writes and cooldown”Backups are optional, so a copy never multiplies the primary save’s retry ladder: each copy is one update call. After a failed copy the backup backend cools down for 15 s, doubling with each consecutive failure up to 300 s. During the cooldown copies fail at once (backup backend is cooling down) and stay pending for a later save. One successful copy clears the cooldown. The cooldown is per driver.
What a backup holds
Section titled “What a backup holds”Each backup record is { Data, Meta }. Data is the same encoded payload the primary save wrote, re-encoded between buffer and base64 form when the two backends disagree on buffer support. Plain-table payloads are the same frozen snapshot the primary write used. Meta carries:
| Field | Meaning |
|---|---|
SchemaVersion, CreatedAt |
Copied from the profile. |
At |
Copy time, from the driver’s Clock. |
Counter |
The profile’s BackupCounter when the copy was written. |
TransactionIds |
The profile’s trade replay ids, so a restore keeps replay protection. |
UserIds |
The profile’s owners. A backup holds the same personal data, so the DataStore backend tags it for GDPR erasure like the primary. Before 0.9.0 backups were written without them. |
DefaultPaths |
The profile’s reconcile ledger, so default entries the game had removed stay removed after a restore. Backups written before 0.9.1 do not have it. |
Key, Source |
The profile key and the JobId of the server that wrote the copy. |
Where backups go
Section titled “Where backups go”| Primary | Backup.Backend unset |
Notes |
|---|---|---|
"DataStore" (or unset) |
DataStore {Name}_Backups, with Backup.BackendConfig |
Name must be at most 42 characters: DataStore names stop at 50, and DataDriver.new errors on a longer backup name. |
"Memory" |
A Memory backend | Before 0.9.0 a Memory primary wrote its backups to the production DataStore. |
"Http" or an instance |
DataStore {Name}_Backups |
Set Backup.Backend explicitly. |
Backup.Backend accepts "Memory", "DataStore", "Http" or a backend instance. "Http" keeps copies entirely off Roblox.
Configuring the schedule
Section titled “Configuring the schedule”local PlayerData = DataDriver.new({ Name = "PlayerData", Defaults = { Coins = 0, Inventory = {} }, Backup = { IntervalSeconds = 86400, -- one ring copy per key per day Keep = 3, -- the last 3 dailies per key Mirror = true, -- and a "live" copy of every save MirrorIntervalSeconds = 300, -- at most one mirror copy per 5 minutes (releases always copy) },})Config.Backup:
| Field | Default | Description |
|---|---|---|
Backend |
see Where backups go | "Memory", "DataStore", "Http" or a backend instance. |
BackendConfig |
nil |
Config for the named backup backend. |
IntervalSeconds |
86400 |
Per-key cadence of ring copies, once per interval game-wide. |
Keep |
3 |
Ring slots per key. Asserted to be at least 1. |
Mirror |
false |
Also copy saves to the live slot. |
MirrorIntervalSeconds |
0 |
Minimum spacing between mirror copies. Releases always copy. |
Fallback |
true |
Restore the newest readable backup when the primary is corrupt. false also turns the version fallback off unless Versions.Fallback is set. |
Automatic restore
Section titled “Automatic restore”When a load fails with a recoverable error (the record exists and is writable, but its data will not decode) and Backup.Fallback is not false:
- On DataStores the driver walks version history first. The newest readable backup is read up front, and if it is newer than every readable version, the version walk stops and the backup wins. A mirror copy is usually fresher than an hourly version.
- The backup restore scans the
liveslot and every ring slot and picks the newest readable one byAt, with the counter breaking same-second ties. A slot that fails to read or decode is skipped; the others are still tried. - The load runs again with that backup as the record: the lock check, migrations, reconcile (following the backup’s ledger) and
Validateall apply, and the backup is written over the primary inside the same atomic transform.MetagetsRestoredFromBackupAtandRestoredFromSlot, and the restored copy’sAtbecomes the currentBackupAt. profile.RestoredFromBackupistrueon that load only. The driver warns<key> restored from backup slot <slot> (<n>s old), and the attached lifecycle publishesKernel.ProfileRestoredafterKernel.ProfileLoaded, so the game can tell the player or grant make-goods.
A restore follows every fail-closed rule. It never runs while another server holds a live lock (corrupt does not mean unlocked), never while the backend is down (loading without a lock is a duplication path), and never over a live record that Validate or a migration rejects.
Support tooling
Section titled “Support tooling”-- 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}`)endlistBackups and peekBackup never lock or write, so they are safe while the player is online on another server.
Storage cost
Section titled “Storage cost”Since April 9, 2026, DataStore storage above 500 MB + 1 MB x lifetime users is billed. Each ring slot and the mirror slot is a separate key whose latest version counts toward storage, so Keep = 3 plus Mirror can add up to four times the primary profile’s size. Native version history, which the driver restores from first, is free. Leave Backup off unless you need retention beyond the version history or a copy off Roblox.
API reference
Section titled “API reference”| Member | Description |
|---|---|
driver:backupNow(profile) -> (ok, err?) |
Forces a ring 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. Slot is a string ("live", "1", …). Empty when Backup is off. |
driver:peekBackup(key, slot?) -> (data?, err?) |
A decoded deep copy of one backup’s data, or the newest readable one when slot is omitted. Never locks, never writes. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Kernel.ProfileRestored |
session, profile |
The attached lifecycle loaded a profile restored from a backup or a version. |
Gotchas
Section titled “Gotchas”- Backups are written only by the server holding the lock, and only after a successful primary save. A profile whose saves are refused writes no backups.
- A backup written before 0.9.1 does not carry the record’s reconcile ledger (
Meta.DefaultPaths). Restoring one reconciles like a record from before the ledger, so dictionary entries fromDefaultsthat the game had removed are filled in again, once. Backup.Fallback = falsealso turns off the version fallback whenVersions.Fallbackis unset.- A restore rolls back one record. If the player traded after the backup was taken, their side of the trade is undone and the other participant keeps theirs.