Skip to content

Guarantees

KeepBlox makes a short list of promises. Each one is checked by specs in the repository: unit specs in tests/unit/, and scenario specs in tests/scenario/ that run many virtual servers with random fault schedules in the simulator. A promise without a spec is not listed here.

Each spec below is named by its file and its exact title. Run them all with sh scripts/run-tests.sh.

The simulator (tests/sim/) runs whole servers in virtual time: players join, play, leave and rejoin, servers crash, MessagingService drops messages, and the data store fails for minutes. The seed decides the interleaving, so a failing run replays exactly from its seed.

The ledger (tests/sim/Ledger.luau) watches every write the fake data store commits and checks three invariants on each one:

  • ack-lost: a save the library acknowledged is later missing.
  • foreign-write: Data changed in a write by a server that did not hold the lock.
  • load-count-backwards: MetaData.SessionLoadCount went down.

A clean run proves nothing about the checker itself. So the ledger has its own specs, which feed it a deliberately unsafe library and require it to catch each breach:

  • tests/unit/Ledger.luau: “Ledger: two servers saving one key without a lock are caught”
  • tests/unit/Ledger.luau: “Ledger: a stale session overwriting newer saved progress is caught”
  • tests/unit/Ledger.luau: “Ledger: a load count that goes down is caught”
  • tests/unit/Ledger.luau: “Ledger: the owner’s own save is not a foreign write”

A green suite can still miss a fault. So the specs themselves are tested: tests/Mutate.luau makes 31 small slips, each in code that keeps a guarantee (a lock check dropped, a schema version not written, a copy that shares a table), and runs the whole suite once per slip. Every one must fail it, and every one does. The save check is also fuzzed against the store’s encoding with thousands of hostile values (tests/unit/Differential.luau).

The fakes follow what Roblox measurably does, and tests/live runs the same guarantees on the real DataStore, MessagingService and MemoryStore, from a Studio play server in a private test experience. 113 of 113 checks passed on 2026-09-30: the session lock, a hand-over from a live owner in 4.2-4.6 s (simulator 3.2 s), a crash takeover in 11.6-12.9 s with at most one beat lost (simulator 9.1 s), shutdown of 5 profiles in 1.35 s, messages handled exactly once, receipts, versions, releases that roll back, trades, compression, and ProfileStore and KeepBlox servers handing keys over both ways. Live calls take 0.3-0.6 s each, which is why live is slower. Its conformance checks confirm what the fakes model: how odd tables and numbers are stored, version ids and tombstones, message limits, and the order of ordered-store ties.

A load test there put 300 profiles on one player’s budget: 10 virtual servers, a player moving every second, two crashes. It saw no double owner; hand-overs carried the data exactly, a crash lost at most one step, and what was stored was each key’s last owner’s. Many loads timed out with "timeout" there, as they must: 300 profiles are far beyond what one server’s budget can move.

Only the server that holds the lock can write a profile. Every write is an UpdateAsync transform that decides from the stored record alone: it writes only while ActiveSession is this server and SessionLoadCount is this session’s. Otherwise it writes nothing, and the session ends as lost.

  • tests/unit/Lock.luau: “Lock: a session that no longer owns the key writes nothing”
  • tests/unit/Fencing.luau: “Fencing: a key removed and claimed afresh elsewhere is never written by its old session”
  • tests/scenario/KeepBlox.luau: “KeepBlox: hand-overs lose nothing and leave no stale owner”
  • tests/scenario/KeepBlox.luau: “KeepBlox: random play with crashes keeps every invariant”

A request to hand a key over names the session it was made to, by its load count, whether it comes by MessagingService or through the owner’s MemoryStore beat. A request that outlives its session is ignored. A load whose claim went unanswered and then gave up lets go of the key, but only if that very load holds it, so no live server keeps a key it never learnt it had. And a live owner whose MemoryStore beats hang says so in its record before a newcomer could judge it dead. A takeover on the beat’s word lands only while the record still vouches for the beat.

  • tests/unit/Fencing.luau: “Fencing: a hand-over request made to an old session never ends the owner’s next one”
  • tests/unit/Fencing.luau: “Fencing: a claim that landed unanswered and was then given up leaves the key free”
  • tests/unit/Lease.luau: “Lease: a live owner whose beats hang is not taken for dead”
  • tests/unit/Lock.luau: “Lock: a takeover on the beat’s word lands only while the record vouches for the beat”

When profile:save() returns true, or a release finishes, that data is in the store, and no later write from any server replaces it with older data.

  • tests/unit/Store.luau: “Store: load, change, save, release”
  • tests/unit/Complaints.luau: “Complaints: an autosave in flight never re-locks a released key (DocumentService#119)”
  • tests/scenario/KeepBlox.luau: “KeepBlox: hand-overs lose nothing and leave no stale owner”
  • tests/scenario/KeepBlox.luau: “KeepBlox: random play with crashes keeps every invariant”

Every claim raises MetaData.SessionLoadCount by one, and a stale session cannot write it back down. This is the ledger’s load-count-backwards invariant, checked on every committed write of every scenario.

  • tests/unit/Ledger.luau: “Ledger: a load count that goes down is caught”
  • tests/scenario/KeepBlox.luau: “KeepBlox: random play with crashes keeps every invariant”

A load that fails, or gives up, leaves the stored data as it was. A key it already claimed is given back unchanged. A failed read is never mistaken for a new player.

  • tests/unit/Store.luau: “Store: a cancelled load gives the key back”
  • tests/unit/Complaints.luau: “Complaints: a load after shutdown began is refused and writes nothing (DataStore2#126, Lapis#37)”
  • tests/unit/Complaints.luau: “Complaints: a load in flight when shutdown begins gives the key back (Lapis#30)”
  • tests/unit/Complaints.luau: “Complaints: a failed read never loads the template over saved data (ProfileStore p10 #195)”
  • tests/unit/Migrate.luau: “Migrate: data from a newer version is refused, the key let go, nothing written”
  • tests/unit/Migrate.luau: “Migrate: a failing migration refuses the load and leaves the data as it was”
  • tests/unit/Import.luau: “Import: an old lock that never goes fails the load, and nothing is written”
  • tests/unit/Import.luau: “Import: a failed read of the old store is retried, never taken for a new player”

Foreign data is quarantined, never overwritten

Section titled “Foreign data is quarantined, never overwritten”

A key holding something that is not a profile is not replaced by the template. Its value is copied to the data store <store>__quarantine, the load fails with "foreign", and store.onError reports it.

  • tests/unit/Lock.luau: “Lock: a foreign value is never overwritten”
  • tests/unit/Store.luau: “Store: a value that is not a profile is quarantined, never overwritten”

When a session ends for any reason, profile.Data is frozen, deeply. Code that keeps writing to it errors instead of changing a copy nobody will save. This matters most when another server took the key: trades and purchases must stop on the stale copy.

  • tests/unit/Store.luau: “Store: after the session ends, its data is frozen”
  • tests/unit/Trade.luau: “Trade: a profile lost to another server aborts the trade; the other side is put back”
  • tests/scenario/KeepBlox.luau: “KeepBlox: an owner cut off from messaging hands over at its next renewal (baseline: 244 s stale)”
  • tests/unit/Lease.luau: “Lease: an owner cut off from MessagingService hands over at its next beat, with a final save”

One bad value never costs the play around it

Section titled “One bad value never costs the play around it”

A data store rejects a whole write for one value it cannot encode (bad UTF-8, a function, an Instance), and silently changes others (measured live): it stores {1, 2, x = 3} as {1, 2}, drops the keys past a hole, and turns number keys into strings. KeepBlox checks the data before every write. A bad value is repaired in what is stored, and the rest of the data is stored as it is:

  • a string that is not valid UTF-8 keeps its text, with U+FFFD for each bad byte;
  • NaN and infinities (Roblox stores them, but they break every sum they touch), and values no data store holds (functions, Instances), are left out;
  • a key that is not valid UTF-8 is left out with its value;
  • a table that contains itself loses the reference that closes the loop.

store.onError names each repair once, with its path, such as stored with a repair: Data.inventory[12].name: string is not valid UTF-8; its bad bytes were replaced. The game’s own data is not touched. What cannot be repaired, a mixed table, an array with holes, a dictionary with number keys or data over the 4 MB limit, is refused with its path. Then the last snapshot that could be stored is what gets stored, the lease is still renewed, and the release still goes through. In the poison benchmark a bad string loses no play: 0 steps, against 258 for ProfileStore.

  • tests/unit/Validate.luau: “Validate: every refusal names the path of the bad value”
  • tests/unit/Validate.luau: “Validate: a cycle is refused, a shared table is not”
  • tests/unit/Validate.luau: “Validate: the size limit is exact and configurable”
  • tests/unit/Store.luau: “Store: a bad value is repaired in what is stored, reported once with its path”
  • tests/unit/Store.luau: “Store: a table that contains itself does not break the save; the loop is left out”
  • tests/unit/Store.luau: “Store: data whose shape cannot be stored is refused with its path; the release goes through”
  • tests/unit/Snapshot.luau: “Snapshot: a bad value does not stop the snapshots; a crash keeps the play around it”
  • tests/unit/Receipts.luau: “Receipts: a bad value the data store would refuse does not hold up a purchase: it is repaired”
  • tests/unit/Differential.luau: “Differential: repair leaves storable data alone, and leaves nothing but a table’s shape to refuse”
  • tests/unit/Snapshot.luau: “Snapshot: when the data turns unstorable, the last good snapshot is what gets stored”
  • tests/unit/Migrate.luau: “Migrate: a save that breaks the schema is refused with its path”
  • tests/scenario/KeepBlox.luau: “KeepBlox: bad data never blocks the release (baseline: the next server waits 46 s)”

A message leaves the queue only in the same write that stores what its handler did. Until that write lands it stays queued, so a crash redelivers it, and the effect still lands once.

  • tests/unit/Messages.luau: “Messages: a crash before the save redelivers the message; its effect lands once”
  • tests/unit/Messages.luau: “Messages: a handler that yields cannot let a second handler take the same message”
  • tests/unit/Messages.luau: “Messages: a full queue refuses, and nothing already queued is dropped”
  • tests/unit/Snapshot.luau: “Snapshot: a message processed into it is not processed again after the crash”
  • tests/scenario/Compat.luau: “Compat: messages cross between the libraries, both ways, handled once”

The grant and the receipt id are stored by the same write, and the purchase is reported granted only after that write succeeded. See Purchases.

  • tests/unit/Receipts.luau: “Receipts: granted into the data, stored with its id, then reported granted”
  • tests/unit/Receipts.luau: “Receipts: a failed save is not reported granted, and the retry does not grant again”
  • tests/unit/Receipts.luau: “Receipts: a crash before the save loses the grant with its id; the next session grants once”
  • tests/unit/Receipts.luau: “Receipts: data that cannot be stored means the purchase is not granted”
  • tests/unit/Snapshot.luau: “Snapshot: a purchase granted into it is not granted again after the crash”

On shutdown every store refuses new loads and releases all of its profiles in parallel. It waits at most shutdownDeadline seconds (25 by default; Roblox allows 30). Live, 5 profiles were released in 1.35 s. A release that cannot write before the deadline (the budget is spent, or the data store fails) leaves its lock, and its data goes into one last MemoryStore beat 3 s before the deadline, for profiles up to 24 KB. The next server takes the key over once this server’s beat stops, and stores that data first: nothing played before the shutdown is lost.

  • tests/unit/Store.luau: “Store: shutdown releases every profile in parallel”
  • tests/unit/Snapshot.luau: “Snapshot: a release that cannot land by the shutdown deadline leaves its data in a last beat”
  • tests/scenario/KeepBlox.luau: “KeepBlox: shutdown with 50 players on a slow data store fits the deadline”
  • tests/unit/Complaints.luau: “Complaints: a game BindToClose that spends the budget does not cost saves (ProfileStore p5 #104)”

Every data store call has a deadline (callTimeout, 20 seconds), and every load has one in all (loadTimeout, 120 seconds). A load that cannot finish returns "timeout"; it does not hang. Every step of a load counts against that one deadline: waiting for this server to finish a release, the import, the claim and a trade’s settling.

Under overload the budget goes to saves first. A load waits for the server’s data store budget instead of firing requests that can only be throttled, and its later tries leave 10 requests of each kind to saves: a save is what frees a key being handed over. The live check found the opposite loop, with loads polling a spent budget so the owners’ final saves failed and hand-overs stalled; the overload spec runs three servers of 100 profiles on one player’s budget each, with a player moving every quarter second. There 683-692 loads land instead of 383-396, and none runs past its deadline. Before the fix each step of a load counted loadTimeout afresh, and one load in the live test ran past 300 s.

  • tests/unit/Store.luau: “Store: a load that cannot finish fails with a timeout instead of hanging”
  • tests/unit/Import.luau: “Import: an old lock that never goes fails the load, and nothing is written”
  • tests/unit/Overload.luau: “Overload: every load answers within loadTimeout and one call, however spent the budget”
  • tests/unit/Overload.luau: “Overload: a load’s waits share one deadline: this server’s release, then another owner”

Before a write, the data is copied at once, then checked and encoded in slices: after every 2 ms of CPU time the check lets a frame pass, and the text is joined a chunk at a time within them. A large profile is checked over several frames. The frame cost is measured in bench/Run.luau: the longest single frame a 1 MB profile’s save takes is about 4.1 ms on an Apple M1, 1.7 ms of it the copy.

  • tests/unit/Validate.luau: “Validate: pause is called while a large value is checked”
  • tests/unit/Validate.luau: “Validate: a value of many parts is joined exactly as a small one”
  • tests/unit/Differential.luau: “Differential: a copy of storable data encodes the same and shares nothing with it”

The renewal loop stops starting writes while the server’s read or write budget is spent, so loads and releases are not starved. A profile whose data did not change is not written again.

That is the server’s own budget. Roblox also limits the whole experience: 300 + 40 x players reads and 300 + 20 x players writes a minute, shared by every server and by Studio sessions with API access. Requests over it fail (StandardReadExperienceThrottled), and no server’s budget shows it. Run load tests in a separate, empty experience, never from a live game’s Studio: one did throttle that live game.

  • tests/unit/Complaints.luau: “Complaints: a game BindToClose that spends the budget does not cost saves (ProfileStore p5 #104)”
  • tests/unit/Store.luau: “Store: unchanged data is not written again; changed data is, on the renewal”
  • tests/unit/Complaints.luau: “Complaints: a save loop becomes a few writes, not an endless queue (Lapis#41)”

Every server beats in MemoryStore every 4 seconds, and the beat carries a snapshot of each profile whose data changed since it was stored: the whole data up to snapshotBytes (1000 bytes), and for a larger profile its edits since it was stored. A newcomer that takes over from a dead server stores that snapshot first; edits only onto the data they were made against. A profile whose edits are too large for a snapshot is written every renewFallback (30 seconds) instead, so that is the most it can lose. In the benchmark, a crash lost 4 steps of play at worst, against 287 for ProfileStore. On Roblox’s real services a crashed owner was taken over in 11.6-12.9 s, losing at most one beat.

  • tests/unit/Snapshot.luau: “Snapshot: a crash loses about one beat of play, not a renewal”
  • tests/unit/Snapshot.luau: “Snapshot: one older than the stored data is not used”
  • tests/unit/SnapshotDelta.luau: “SnapshotDelta: a large profile loses about one beat of play to a crash, not a renewal”
  • tests/unit/SnapshotDelta.luau: “SnapshotDelta: things added and removed between writes come back after a crash”
  • tests/unit/SnapshotDelta.luau: “SnapshotDelta: edits made against other data than the record’s are not used”
  • tests/unit/SnapshotDelta.luau: “SnapshotDelta: a large profile whose edits do not fit is written every renewFallback”
  • tests/unit/SnapshotDelta.luau: “SnapshotDelta: a purchase granted into a large profile’s edits is not granted again”
  • tests/unit/Delta.luau: “Delta: applying the edits to the base gives the new value, through JSON too”
  • tests/unit/Lease.luau: “Lease: a crashed owner is taken over within its beat’s expiry, not the lease’s 35 s”
  • tests/unit/Lease.luau: “Lease: with MemoryStore down, the lease rule still takes over a crashed owner, safely”
  • tests/scenario/KeepBlox.luau: “KeepBlox: a crash loses at most one renewal interval (baseline: 287 s)”

A migration may have a way back, { up, down }, and a store’s writeVersion then stores data at the older version, whatever writes it: a save, a new player, a trade and its journal, or a crash snapshot. The release before reads all of it, so rolling a release back locks no player out. The store refuses a down that does not undo its up, checked on its template when it is made. Every write stores the schema version with its data, so no migration runs twice.

  • tests/unit/Releases.luau: “Releases: a release with writeVersion can be rolled back, and back again, losing nothing”
  • tests/unit/Releases.luau: “Releases: a crashed release-2 server’s snapshot is taken over by release 1”
  • tests/unit/Releases.luau: “Releases: a trade on release 2 is read by release 1”
  • tests/unit/Releases.luau: “Releases: a trade right after a migrating load stores the schema version with its data”
  • tests/unit/Releases.luau: “Releases: writeVersion is checked, and migrateDown runs the down steps on a fixture”

KeepBlox reads and writes ProfileStore’s records and speaks its lock protocol. Sessions hand over between the two libraries in both directions, a rolling migration with crashes keeps every invariant, and game code written for ProfileStore runs unchanged on the compatibility layer.

  • tests/scenario/Compat.luau: “Compat: a session hands over between the libraries, both ways, losing nothing”
  • tests/scenario/Compat.luau: “Compat: a crashed owner of either library is taken over by ProfileStore’s rules”
  • tests/scenario/Compat.luau: “Compat: a rolling migration with crashes keeps every invariant”
  • tests/scenario/Compat.luau: “Compat: ProfileStore keeps our MetaData.KeepBlox when it rewrites a key”
  • tests/scenario/Compat.luau: “Compat: a ProfileService-era lock (no GUID) is honoured, and taken over by ProfileStore’s rules”
  • tests/unit/CompatProfileStore.luau: “Compat API: game code written for ProfileStore runs every scenario unchanged”