MemoryDriver
Shares short-lived state between servers through MemoryStoreService, packing values into buffers, with sort keys, abort-on-nil updates and retries that never apply an update twice.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Data .MemoryDriver - Since
- v0.1.0
- Source
- Data/MemoryDriver.luau
MemoryDriver is for cross-server state: round status, global boss health, live leaderboards, matchmaking queues, anything every server needs to see now and nobody needs to keep. It wraps one MemoryStoreService map, and the decision that defines it is that values are buffer-serialized. MemoryStore quotas charge by size and cap each value at 32 KB, so every byte is quota, and packed buffers are much smaller than the JSON tables most code writes there. DataDriver owns storage and MessageDriver owns events; MemoryDriver owns shared state. Lock handoff uses it for server presence.
Mental model
Section titled “Mental model”One driver wraps one map. Kind picks the primitive:
"HashMap"(default): unordered key-value."SortedMap": ordered entries with optional sort keys, which enablesgetRange.
Every value goes through three steps on the way in, and the reverse on the way out:
- The value is encoded. With a
Codecit packs through that Serde schema, the smallest output, with typed fields. Without one,Serde.encodepacks it adaptively, still smaller than JSON and with no schema to maintain. - The driver finds out at runtime whether the store accepts raw
buffervalues. The first write tries a raw buffer, and only an error that mentionstypeproves buffers are unsupported. A throttle or timeout during that first write leaves the question open, because base64 costs about 33% more space on every value from then on. - The encoded value is stored as a raw buffer when supported, otherwise as a base64 string with the prefix
CK1:. The prefix is a versioned marker, so a plain string someone else wrote is never misread as packed data.
On read, a buffer or a CK1: string decodes through the codec. Anything else is a foreign value, written by code that is not a MemoryDriver, and is returned untouched. A driver can point at a map other code already uses.
Retries
Section titled “Retries”Every call runs through the BackoffSeconds ladder (default { 0.25, 0.5, 1 }, so 4 attempts). Each wait is jittered to between 75% and 125% of its entry, so servers stop retrying in lockstep during an incident, and a 0 entry retries without yielding a frame. Two kinds of error end the ladder early:
- Argument errors (the message contains “argument”, “invalid”, “must be”, “unable to cast”, “too large” or “too long”) repeat on every attempt, so they are returned at once.
updateretries only errors that prove the call never ran: throttling, quota and over-limit errors, andUpdateAsync’s own conflict give-up. A timeout or internal error may have committed the write, and running the transform again would apply it twice. Those returnfalsewithoutcome unknown (the update may have applied; re-read before retrying): <error>. Before 0.9.0updateretried them and could apply a transform twice.
Expirations
Section titled “Expirations”Every write carries an expiration between 1 and 3,888,000 seconds (45 days), the MemoryStore ceiling. MemoryDriver.new asserts that DefaultTtlSeconds is in range. A set or update with an expiration outside it returns false with MemoryStore expiration must be 1..3888000 seconds (45 days), got <n> before touching the store.
Shared world state, changed safely from any server:
local GlobalBoss = MemoryDriver.new({ Name = "GlobalBoss", Codec = Serde.schema({ Health = "NumberU32", Phase = "NumberU8" }), DefaultTtlSeconds = 300,})
local function damageBoss(damage: number): (boolean, any, string?) return GlobalBoss:update("current", function(state) state = state or { Health = 1000000, Phase = 1 } state.Health = math.max(0, state.Health - damage) return state -- returning nil aborts the write end)endupdate receives the decoded current value (nil for a missing key), and its return is encoded and written through UpdateAsync. Returning nil aborts and writes nothing, which is how a claim is expressed:
local Ok, Owner = Coordinator:update("owner", function(current) if current ~= nil then return nil -- someone else holds the claim; write nothing end return { JobId = game.JobId, At = os.time() }end, 30)A live leaderboard uses a SortedMap with the score as the sort key, so ranking does not depend on key order:
local Board = MemoryDriver.new({ Name = "WeeklyKills", Kind = "SortedMap", Codec = Serde.schema({ Kills = "NumberU32", Name = "String" }), DefaultTtlSeconds = 7 * 24 * 3600,})
local function recordKills(player: Player, kills: number) Board:set(tostring(player.UserId), { Kills = kills, Name = player.Name }, nil, kills)end
local function topTen(): { { key: string, value: any, sortKey: any } } local Ok, Rows = Board:getRange(Enum.SortDirection.Descending, 10) return if Ok and Rows then Rows else {}end
-- The next page starts after the last row of the previous onelocal function nextTen(last: { key: string, sortKey: any }) return Board:getRange(Enum.SortDirection.Descending, 10, nil, { key = last.key, sortKey = last.sortKey })endA SortedMap update transform receives (value, sortKey) and may return (value, sortKey). Returning only a value keeps the stored sort key:
Board:update(tostring(player.UserId), function(current, _sortKey) current = current or { Kills = 0, Name = player.Name } current.Kills += 1 return current, current.Killsend)For persistent, paged boards that outlive a week, use Leaderboards. MemoryDriver is the live tier.
Configuration
Section titled “Configuration”| Field | Default | Description |
|---|---|---|
Name |
required | The MemoryStore map name. |
Kind |
"HashMap" |
"HashMap" or "SortedMap". Only a SortedMap supports getRange and sort keys. |
Codec |
nil |
A Serde schema codec. Without one, values pack adaptively through Serde.encode. |
DefaultTtlSeconds |
300 |
Expiration when a call passes none. Asserted to be 1 to 3,888,000. |
BackoffSeconds |
{ 0.25, 0.5, 1 } |
Retry ladder; attempts are its length plus one. |
Store |
nil |
An injected map for specs. |
ForceBase64 |
false |
Skip buffer detection and always pack as CK1: strings. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
MemoryDriver.new(config) -> MemoryDriver |
Wraps GetHashMap(Name) or GetSortedMap(Name) unless Store is injected. |
driver:set(key, value, ttl?, sortKey?) -> (ok, err?) |
Encode, pack, SetAsync. sortKey (a string or number) orders a SortedMap. The first call settles buffer support. |
driver:get(key) -> (ok, value, err?) |
GetAsync and decode. A missing key is true, nil. A value that fails to decode is an error, never nil. |
driver:update(key, transform, ttl?) -> (ok, newValue, err?) |
Atomic UpdateAsync over the decoded value. transform(value, sortKey?) -> (value?, sortKey?); nil aborts. Retries only errors that prove the call never ran. |
driver:remove(key) -> (ok, err?) |
RemoveAsync, retried. |
driver:getRange(direction?, count?, exclusiveLowerBound?, exclusiveUpperBound?) -> (ok, rows?, err?) |
SortedMap only. Rows are { key, value, sortKey }, ordered by sort key and then key. Defaults: ascending, 100 rows. count is clamped to 1 to 200, the engine’s limit. Bounds are { key?, sortKey? }, built from the previous page’s last row. One entry that fails to decode fails the read, naming its key. |
Gotchas
Section titled “Gotchas”- Values are capped at 32 KB and quota is charged by size. A schema codec is the strongest lever: the specs pack
{ Score = 1234, Streak = 7 }into 5 bytes with a schema, less than the adaptive encoding and far less than JSON. - Everything expires. Pick
DefaultTtlSecondsto match the data’s real lifetime; state that must survive belongs in DataDriver. UpdateAsyncmay call a transform more than once under contention. Compute from the value passed in and keep side effects out.getandgetRangereturn an error for a value that does not decode, where a silentnilwould hide the problem. A schema change without a plan for live values shows up here. Foreign values are the exception and pass through.- The buffer fallback is per driver instance. Once a
typeerror proves buffers unsupported, that instance stays on base64.ForceBase64pins it for deterministic tests. - A value the codec cannot encode raises out of
setinstead of returningfalse.