Skip to content

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.

One driver wraps one map. Kind picks the primitive:

  • "HashMap" (default): unordered key-value.
  • "SortedMap": ordered entries with optional sort keys, which enables getRange.

Every value goes through three steps on the way in, and the reverse on the way out:

  1. The value is encoded. With a Codec it packs through that Serde schema, the smallest output, with typed fields. Without one, Serde.encode packs it adaptively, still smaller than JSON and with no schema to maintain.
  2. The driver finds out at runtime whether the store accepts raw buffer values. The first write tries a raw buffer, and only an error that mentions type proves 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.
  3. 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.

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.
  • update retries only errors that prove the call never ran: throttling, quota and over-limit errors, and UpdateAsync’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 return false with outcome unknown (the update may have applied; re-read before retrying): <error>. Before 0.9.0 update retried them and could apply a transform twice.

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)
end

update 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 one
local function nextTen(last: { key: string, sortKey: any })
return Board:getRange(Enum.SortDirection.Descending, 10, nil, { key = last.key, sortKey = last.sortKey })
end

A 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.Kills
end)

For persistent, paged boards that outlive a week, use Leaderboards. MemoryDriver is the live tier.

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.
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.
  • 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 DefaultTtlSeconds to match the data’s real lifetime; state that must survive belongs in DataDriver.
  • UpdateAsync may call a transform more than once under contention. Compute from the value passed in and keep side effects out.
  • get and getRange return an error for a value that does not decode, where a silent nil would 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 type error proves buffers unsupported, that instance stays on base64. ForceBase64 pins it for deterministic tests.
  • A value the codec cannot encode raises out of set instead of returning false.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.