Version history
Restores a corrupt DataDriver record from Roblox's native DataStore versions, and stages the version current at a given time for support rollbacks without writing until the caller commits.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Data - Since
- v0.9.0
- Source
- Data/init.luauBackends/DataStoreBackend.luau
Roblox keeps free versions of every DataStore key. Version history is DataDriver’s use of them in two places: when a record exists but will not decode, the load walks the versions newest first and restores the first one that passes, and driver:restoreAt(key, unixMs) puts a key back to a moment for support tooling. The defining decision is that a version never gets a shortcut. A restore goes through the same locked, checked load path as any other load, and restoreAt only stages the old data in memory, writing nothing until the caller saves or releases, or drops it with discard(). Backups are the paid alternative for retention you control; the DataStore backend supplies the version reads.
How native versions work
Section titled “How native versions work”Versions are hourly. The first write to a key in each UTC hour creates a version, and later writes in that hour overwrite it, so a key has at most one version per hour. Roblox expires an overwritten version after 30 days. Earlier docs said every write is a version; that was never true.
Two consequences shape the driver:
- The newest version can be up to an hour behind the last save. A
Backupmirror slot, copied on every save, is usually fresher, and the fallback prefers it when it is. restoreAthas hour granularity: every write inside one UTC hour shares a version.
Versions cost nothing. Backup keys are separate keys and count toward billed storage.
Automatic fallback
Section titled “Automatic fallback”The fallback runs only when a load fails with a recoverable error: the record exists and is writable, but the envelope is malformed, its data will not decode, is not a table, or holds values that cannot be stored. Every other failure fails closed (see What fails closed).
It is on when the backend supports versions (only the DataStore backend sets SupportsVersions = true) and Versions.Fallback is not false. When Versions.Fallback is unset, Backup.Fallback = false turns it off too. Since 0.9.0 the fallback is on by default for DataStore profiles.
The walk:
- When
Backupis configured with its fallback on, the driver reads the newest readable backup first, to know how old it is. - It lists up to
MaxVersions(default 10) versions, newest first, skipping deleted ones (ListVersionsAsync). - For each version it reads the value (
GetVersionAsync) and builds a candidate. A candidate is skipped when its envelope is malformed, when it was written mid-trade (itsPendingTransactionjournal pointer cannot be carried by a restore), when no migration path leads to the currentSchemaVersion, or when its data does not decode or is not a table. - If the newest readable candidate is older than the newest backup, the walk stops and the backup is restored instead.
- Otherwise the candidate is loaded as the record: the same lock check, migrations, reconcile and
Validateas a live load, written over the primary inside the same atomic transform. A candidate thatValidateor a migration rejects is skipped for the next older one. A held lock or a backend outage ends the walk, because no older copy fixes either. - When no version restores, the driver tries the newest backup. Without one, the load fails with the original error and the version walk’s error appended.
A restored profile has RestoredFromBackup = true, Meta.RestoredFromSlot = "version <id>" and Meta.RestoredFromBackupAt set to the restore time. It keeps the version’s own backup schedule, replay ids, UserIds and reconcile ledger (Meta.DefaultPaths), so default entries the record had removed stay removed. The driver warns <key> restored from version <id> (<n>s old), and the attached lifecycle publishes Kernel.ProfileRestored(session, profile) after Kernel.ProfileLoaded, so the game can tell the player or grant make-goods.
Restoring to a moment
Section titled “Restoring to a moment”driver:restoreAt(key, unixMs, options?) loads key with the version that was current at a Unix time in milliseconds. It exists for support: a player reports a loss, and an operator rolls their record back to before it happened.
- It reads the version current at
unixMs(GetVersionAtTimeAsync). No version then returnsno version was current at that time; an unreadable one returnsthe version at that time is unreadable: .... - It takes the session lock over the current live record with a normal load. Every load rule applies: another server’s lock fails the call, and so does a key already loaded on this server. If the live record itself is unreadable, the version is written over it directly through the restore path.
- It stages the old data. The version is migrated to the current
SchemaVersion, missingDefaultsfields are filled in following the version’s own ledger (entries it had removed stay removed), and the result is copied intoprofile.Datain place. The live record is unchanged. A version that does not migrate releases the live profile untouched and returnsthe version at that time does not migrate: .... - It returns the profile with
RestoredFromBackup = trueandMeta.RestoredFromSlot = "time <unixMs>", and warns<key> staged the version current at <unixMs>; save or release commits it, discard drops it.
Only profile:save() or profile:release() commits the staged data, through the usual save checks. profile:discard() releases the lock and leaves the live record as it was. Until the caller decides, an attached driver’s autosave passes only refresh the staged profile’s lock, and the shutdown drain discards the staging. Before 0.9.1 the next autosave committed it. If another server took the lock meanwhile, the refresh ends the staging through the lost-lock path, like a save: the profile goes inactive and Data.LockLost publishes. A discard() whose write failed is still a discard: save() refuses to commit the staging after it, and discard(), release() or the next autosave finishes it. Both behaviors come from the Unreleased fixes after 0.9.1.
-- Support tooling: roll an offline player's record back to a moment, once support confirms the old copy.local function rollBack(userId: number, unixMs: number, confirm: (data: any) -> boolean): (boolean, string?) local Profile, Err = PlayerData:restoreAt(`Player_{userId}`, unixMs, { UserIds = { userId } }) if not Profile then return false, Err end -- The old data is staged in memory. Release commits it; discard keeps the live record. Both free the lock. if not confirm(Profile.Data) then return Profile:discard() end return Profile:release()end
local Ok, Err = rollBack(123456, DateTime.fromUniversalTime(2026, 9, 27, 18, 0, 0).UnixTimestampMillis, function(data) -- The player reported losing their Dragon: only roll back to a copy that has it return table.find(data.Pets, "Dragon") ~= nilend)What fails closed
Section titled “What fails closed”A version or backup is never restored over data that might be newer or owned elsewhere:
| Situation | Result |
|---|---|
| Another server holds the lock | The load fails. Corrupt does not mean unlocked. |
| The backend is down | The load fails. Loading without a lock is a duplication path. |
The live record has a newer SchemaVersion |
The load fails. Old code must not run over newer data. |
Validate or a migration rejects the live record |
The load fails. The rules changed; an older version would roll the player back. |
A migration or Validate yields |
The load fails. The same code would fail on every version. |
| A version was written mid-trade | That version is skipped. |
| A version is older than the newest backup | The backup is restored instead. |
Configuration
Section titled “Configuration”| Field | Default | Description |
|---|---|---|
Versions.Fallback |
true |
Restore the newest readable version when the record is corrupt. Defaults to false when Backup.Fallback = false. |
Versions.MaxVersions |
10 |
Versions tried per fallback, newest first. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
driver:restoreAt(key, unixMs, options?) -> (Profile?, err?) |
Stages the version current at unixMs (Unix milliseconds, asserted positive) over the locked live record. options: { UserIds?, HandoffSeconds? }. The caller owns the profile and ends the staging with save(), release() or discard(). |
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. |
backend:versions(key, limit) -> (ok, { { Version, CreatedTime } }?, err?) |
Up to limit versions, newest first, deleted ones skipped. CreatedTime is Unix milliseconds. |
backend:readVersion(key, version) -> (ok, value, err?) |
One version’s stored envelope. |
backend:readVersionAt(key, unixMs) -> (ok, value, err?, writtenAtMs?) |
The envelope current at unixMs, or nil when none was, plus that version’s write time. |
backend.SupportsVersions |
true on the DataStore backend. Turns the automatic fallback on. |
The Memory and Http backends implement the three reads and return unsupported: the Memory backend keeps no version history (or the Http equivalent), so restoreAt on them fails with that error and the fallback skips them.
Gotchas
Section titled “Gotchas”- A restore can lose up to an hour of progress, because a version holds the last write of its hour. Turn on
BackupwithMirror = truewhen that matters. - A version written before the reconcile ledger existed (before 0.9.0) has no
Meta.DefaultPaths. A restore of one reconciles like any record from before the ledger, so dictionary entries fromDefaultsthat the game had removed are filled in again, once. - In Studio with the scaffold’s Memory backend there is no version history;
restoreAtreturns the unsupported error.