DocsRecipes

Redis Seed Storage

The Redis keys, Lua scripts and lock that Galabet's demo API keeps around @galabet/fair, and how every key of a session is made to expire at the same moment.

@galabet/fair has no storage and there is no store package to install. What exists is the code Galabet's demo API runs: one class of nine methods over ioredis, five of them Lua scripts, two more scripts for chips, and a lock. This page goes through them key by key. Storing seeds and reserving nonces argues for the design. This is the Redis side of it.

The TypeScript and Lua on this page need Redis 7 and ioredis 5. The two JavaScript examples need neither and run anywhere.

Key Layout

Everything a demo session owns sits under the demo: prefix, one key per concern.

KeyTypeHoldsExpiry
demo:session:<id>String, JSONServer seed, commitment, client seed, betsUnderSeed, createdAtSession TTL, set on this key and the three below it by every seed-store script
demo:nonce:<id>String, integerThe next unused nonceSame as the session, to the millisecond
demo:revealed:<id>HashCommitment as field, revealed seed as valueSame as the session
demo:records:<id>ListThe latest 100 records as JSON, newest firstSame as the session
demo:mines:<id>String, JSONThe current or last Mines round: record with board, stake, mine count, picks, over, and outcome and payout once it has endedSame as the session, and set again by every save of the board
demo:ip:<address>String, integerSessions opened from that address86,400 seconds from the first one, never renewed
demo:chips:<id>String, integerPractice chip balanceSession TTL, renewed by debits and credits
demo:credited:<id>:<round>String, JSONThe chips paid for that round as payout, and for Mines the picks they paid forSession TTL from the credit, never renewed
demo:action-lock:<id>StringThe lock owner's random id30,000 milliseconds

The session TTL is DEMO_SESSION_TTL, 86,400 seconds unless you change it.

A Mines pick that doesn't end the round writes only the board, so it renews the board and not the session. The board can therefore outlast its session. It can't go first, because every script that renews the session renews the board with it.

<id> is a random UUID made when the session is created, so a key name is never used again after it expires. A chip balance or a credit marker left behind by a dead session can't be picked up by a new one.

The same Redis also holds the rate limiter's counters under throttle: and BullMQ's job queues. Neither belongs to fairness. They matter here because they share memory with the seeds.

The Store Class

seed-store.ts, the frame and the short methods (needs Redis and ioredis)
const TOUCH = `
  local function touch(ttl)
    for i = 1, #KEYS do redis.call('EXPIRE', KEYS[i], ttl) end
  end
`;

export class RedisSeedStore implements SeedStore {
  constructor(
    private readonly redis: Redis,
    private readonly linked: readonly string[] = [],
  ) {}
  private k(id: string, part: string) {
    return `demo:${part}:${id}`;
  }
  private keys(id: string) {
    const parts = ["session", "nonce", "revealed", "records", ...this.linked];
    return parts.map((part) => this.k(id, part));
  }
  private run(script: string, id: string, ...args: (string | number)[]) {
    const keys = this.keys(id);
    return this.redis.eval(TOUCH + script, keys.length, ...keys, ...args);
  }
  async peekNonce(id: string) {
    const value = await this.redis.get(this.k(id, "nonce"));
    if (value === null)
      throw new ConflictException(
        "session nonce missing; start a new demo session",
      );
    return Number(value);
  }
  async reveal(
    id: string,
    commitment: string,
    serverSeed: string,
    ttl: number,
  ) {
    await this.run(
      `redis.call('HSET', KEYS[3], ARGV[1], ARGV[2]); touch(ARGV[3])`,
      id,
      commitment,
      serverSeed,
      ttl,
    );
  }
  async revealed(id: string) {
    return this.redis.hgetall(this.k(id, "revealed"));
  }
  async pushRecord(id: string, record: FairRecord, keep: number, ttl: number) {
    await this.run(
      `
      redis.call('LPUSH', KEYS[4], ARGV[1])
      redis.call('LTRIM', KEYS[4], 0, tonumber(ARGV[2]) - 1)
      touch(ARGV[3])`,
      id,
      JSON.stringify(record),
      keep,
      ttl,
    );
  }
  async records(id: string, limit: number) {
    const raw = await this.redis.lrange(this.k(id, "records"), 0, limit - 1);
    return raw.map((r) => JSON.parse(r) as FairRecord);
  }
  async countSession(ip: string, window: number) {
    const key = `demo:ip:${ip}`;
    const res = await this.redis
      .multi()
      .incr(key)
      .expire(key, window, "NX")
      .exec();
    return Number(res?.[0]?.[1] ?? 1);
  }
}

That is apps/api/src/demo/seed-store.ts with its comments and imports taken out and three methods held back. get, put and reserveNonce are printed in the next two sections, and the DemoSession and SeedStore types are on the seed storage page.

Five methods go through run: get, put, reserveNonce, reveal and pushRecord. It hands Redis the session's four keys in a fixed order, session, nonce, revealed, records, then one key for each linked part, and puts touch in front of the script. touch is a loop that gives every one of those keys the same TTL. The demo links one part, with new RedisSeedStore(redis, ["mines"]), so an open Mines board lives exactly as long as its session. EXPIRE on a key that doesn't exist does nothing, and a session with no board never gets an empty one.

An earlier version of this class did the same job with MULTI: each write sent in one transaction with its own EXPIRE, one key at a time. A script goes further. Redis runs it to the end before it serves any other client, so it can read before it decides to write, and the write and all five expiries land as one step. One MULTI is left in the file, in countSession, which only ever touches its own key. The three plain reads, peekNonce, revealed and records, renew nothing.

countSession passes NX to EXPIRE, which sets a TTL only when the key has none. The first session from an address starts a 24 hour window and later ones don't move it. EXPIRE ... NX arrived in Redis 7.0. The project's Compose file runs redis:7-alpine, and the container we checked reported 7.4.11.

pushRecord is a capped log: push to the head, trim to keep. The demo passes 100. The 101st record pushes the oldest one out and nothing archives it, which is a demo's budget and no model for an operator.

Reading and Writing the Session

seed-store.ts, get and put (needs Redis and ioredis)
async get(id: string, ttl?: number) {
  const raw = (await this.run(
    `
    local session = redis.call('GET', KEYS[1])
    if not session then return nil end
    if redis.call('EXISTS', KEYS[2]) == 0 then return '' end
    if tonumber(ARGV[1]) > 0 then touch(ARGV[1]) end
    return session`,
    id,
    ttl ?? 0,
  )) as string | null;
  if (raw === "")
    throw new ConflictException(
      "session nonce missing; start a new demo session",
    );
  return raw ? (JSON.parse(raw) as DemoSession) : null;
}
async put(session: DemoSession, ttl: number, expectedCommitment?: string) {
  const saved = await this.run(
    `
    local old = redis.call('GET', KEYS[1])
    local next = cjson.decode(ARGV[1])
    local counter = redis.call('GET', KEYS[2])
    if old then
      local previous = cjson.decode(old)
      if previous.commitment ~= ARGV[3] then return 0 end
      if previous.commitment == next.commitment then
        if not counter then return 0 end
      else counter = '0' end
    else
      if ARGV[3] ~= '' then return 0 end
      counter = '0'
    end
    redis.call('MSET', KEYS[1], ARGV[1], KEYS[2], counter)
    touch(ARGV[2])
    return 1`,
    session.id,
    JSON.stringify(session),
    ttl,
    expectedCommitment ?? "",
  );
  if (saved !== 1)
    throw new ConflictException(
      "session changed, expired or nonce missing; start a new demo session",
    );
}

get has three answers. No session key gives null, which the service turns into 404 demo session not found or expired. A session with no counter beside it gives the empty string and a 409. Anything else is the session, and when the caller passes a TTL every key is renewed on the way out. The demo passes one on every load. So GET /api/demo/session and the history route keep a session alive as surely as a bet does.

put is a compare-and-set on the commitment. The caller names the commitment it expects to find, or none for a new session, and the script refuses if the stored one differs. The counter goes into the same MSET as the session. It is kept as it was when the commitment hasn't changed and set to 0 when it has, because a new server seed starts at nonce 0. There is no separate reset. The earlier class had a resetNonce that deleted the key after rotation had written the new seed, which made it two commands with a gap between them.

A refused put means one of three things: another request has changed the seed since this one loaded it, the session has expired, or its counter has gone. The second case is the one to notice. A slow request that loads a session and outlives it now can't write it back. We tried it on the project's Redis: with the session and counter keys deleted, a put that named the old commitment was refused and no session key was created. The earlier class's put was a plain SET ... EX, which would have brought the seed back with no counter beside it.

The Key Holds the Next Nonce

seed-store.ts, reserveNonce (needs Redis and ioredis)
async reserveNonce(id: string, ttl: number, expectedCommitment: string) {
  const value = Number(
    await this.run(
      `
    if redis.call('EXISTS', KEYS[1]) == 0 or redis.call('EXISTS', KEYS[2]) == 0 then return -1 end
    if cjson.decode(redis.call('GET', KEYS[1])).commitment ~= ARGV[2] then return -1 end
    local n = tonumber(redis.call('GET', KEYS[2]))
    if not n or n < 0 or n >= 9007199254740991 then return -1 end
    redis.call('INCR', KEYS[2])
    touch(ARGV[1])
    return n`,
      id,
      ttl,
      expectedCommitment,
    ),
  );
  if (value < 0)
    throw new ConflictException(
      "session nonce unavailable; start a new demo session",
    );
  return value;
}

The script reads the counter, raises it by one and returns the value it read. GFS nonces start at 0, so the value left in Redis is always the nonce the next bet will get. peekNonce leans on that to show a player their next nonce without spending it.

Could the script return the INCR reply instead and start nonces at 1? It could, and records would verify, since any non-negative integer is a valid nonce. But then "next nonce" and "bets placed under this seed" differ by one for ever, and every port and every player reading a history has to know that.

Before it touches the counter, the script checks four things and answers -1 if any of them fails: the session exists, the counter exists, the session still holds the commitment the caller loaded, and the counter reads as a number from 0 up to one below Number.MAX_SAFE_INTEGER. The method turns -1 into 409 session nonce unavailable; start a new demo session.

There is no fallback. The earlier class read the INCR reply with ?? 1 and answered nonce 0 whenever the reply was missing. We forced that case against a real Redis by writing a non-numeric string into the nonce key, and it came out as 0, the one nonce certain to have been used already. The same string in the key today gets the 409, which we checked on the project's Redis 7.4.11 container. So do a deleted counter and a mismatched commitment.

The commitment check covers a request that has lost the lock. A bet loads the session and then reserves. If a rotation slipped in between, which can only happen once the lock's 30 second lease has run out, the bet is still holding the old seed. Without the check it would take a nonce from the new seed's counter, and its write-back would put the old seed over the new one. With the check the reservation is refused, and put would refuse the write-back anyway.

An In-Memory Store with the Same Interface

The class below keeps the store's rules on a Map: one expiry for all of a session's keys, set by every write, and a 409 message wherever the scripts answer -1 or refuse. The clock is passed in so the example can move it by hand.

memory-store.mjs
import { commit, createServerSeed, play } from '@galabet/fair';

class MemorySeedStore {
  keys = new Map();
  constructor(now = Date.now, linked = []) { this.now = now; this.linked = linked; }

  read(key) {
    const entry = this.keys.get(key);
    if (entry && entry.expiresAt <= this.now()) this.keys.delete(key);
    return this.keys.get(key)?.value;
  }
  write(key, value) { this.keys.set(key, { value, expiresAt: Infinity }); } // touch() sets the expiry
  touch(id, ttl) { // EXPIRE on each of the session's keys, a no-op on one that doesn't exist
    for (const part of ['session', 'nonce', 'revealed', 'records', ...this.linked]) {
      if (this.read(`${part}:${id}`) !== undefined) this.keys.get(`${part}:${id}`).expiresAt = this.now() + ttl * 1000;
    }
  }

  async get(id, ttl = 0) {
    const raw = this.read(`session:${id}`);
    if (raw === undefined) return null;
    if (this.read(`nonce:${id}`) === undefined) throw new Error('session nonce missing; start a new demo session');
    if (ttl > 0) this.touch(id, ttl);
    return JSON.parse(raw);
  }
  async put(session, ttl, expected = '') {
    const old = this.read(`session:${session.id}`), previous = old && JSON.parse(old).commitment;
    let counter = this.read(`nonce:${session.id}`);
    if (old ? previous !== expected : expected !== '') counter = undefined;
    else if (!old || previous !== session.commitment) counter = 0; // a new seed starts at nonce 0
    if (counter === undefined) throw new Error('session changed, expired or nonce missing; start a new demo session');
    this.write(`session:${session.id}`, JSON.stringify(session)); // MSET, session and counter together
    this.write(`nonce:${session.id}`, counter);
    this.touch(session.id, ttl);
  }
  async reserveNonce(id, ttl, expected) {
    const raw = this.read(`session:${id}`), n = this.read(`nonce:${id}`);
    if (raw === undefined || n === undefined || JSON.parse(raw).commitment !== expected || !(n >= 0 && n < Number.MAX_SAFE_INTEGER)) {
      throw new Error('session nonce unavailable; start a new demo session'); // the script's -1
    }
    this.write(`nonce:${id}`, n + 1); // INCR
    this.touch(id, ttl);
    return n;
  }
  async peekNonce(id) {
    const n = this.read(`nonce:${id}`);
    if (n === undefined) throw new Error('session nonce missing; start a new demo session');
    return n;
  }
  async reveal(id, commitment, serverSeed, ttl) {
    this.write(`revealed:${id}`, { ...this.read(`revealed:${id}`), [commitment]: serverSeed }); // HSET
    this.touch(id, ttl);
  }
  async revealed(id) { return { ...this.read(`revealed:${id}`) }; }
  async pushRecord(id, record, keep, ttl) {
    this.write(`records:${id}`, [JSON.stringify(record), ...(this.read(`records:${id}`) ?? [])].slice(0, keep)); // LPUSH, LTRIM
    this.touch(id, ttl);
  }
  async records(id, limit) { return (this.read(`records:${id}`) ?? []).slice(0, limit).map((raw) => JSON.parse(raw)); }
  async countSession(ip, windowSeconds) {
    const count = (this.read(`ip:${ip}`) ?? 0) + 1;
    const expiresAt = this.keys.get(`ip:${ip}`)?.expiresAt ?? this.now() + windowSeconds * 1000; // EXPIRE NX
    this.keys.set(`ip:${ip}`, { value: count, expiresAt });
    return count;
  }
}

const TTL = 86_400, HOUR = 3_600_000;
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);

let clock = 0;
const store = new MemorySeedStore(() => clock, ['mines']); // no board is opened here, so touch() skips that key
await store.put({ id: 'ana', serverSeed, commitment, clientSeed: 'galabet', betsUnderSeed: 0, createdAt: 0 }, TTL);

// The demo's order: load, reserve, derive, write the session back, publish.
async function bet(meanwhile = async () => {}) {
  try {
    const session = await store.get('ana', TTL);
    if (!session) return 'session expired';
    await meanwhile();
    const nonce = await store.reserveNonce('ana', TTL, session.commitment);
    const { result } = await play({ game: 'dice', serverSeed: session.serverSeed, clientSeed: session.clientSeed, nonce });
    clock += 40; // the derivation and a round trip
    await store.put({ ...session, betsUnderSeed: session.betsUnderSeed + 1 }, TTL, session.commitment);
    await store.pushRecord('ana', { nonce, result }, 2, TTL);
    // The seed after rotation is random, so only the public seed's rolls are printed.
    return session.serverSeed === serverSeed ? `nonce ${nonce} rolled ${result}` : `nonce ${nonce}`;
  } catch (error) {
    return `refused: ${error.message}`;
  }
}

async function rotate() {
  const session = await store.get('ana', TTL);
  await store.reveal('ana', session.commitment, session.serverSeed, TTL);
  const next = await createServerSeed();
  await store.put({ ...session, serverSeed: next, commitment: (await commit(next)).commitment, betsUnderSeed: 0 }, TTL, session.commitment);
}

const present = () => ['session', 'nonce', 'revealed', 'records'].filter((part) => store.read(`${part}:ana`) !== undefined).join(' ') || 'none';

for (let i = 0; i < 3; i++) console.log(await bet());
console.log('next nonce:', await store.peekNonce('ana'), '| records kept:', (await store.records('ana', 10)).map((r) => r.nonce).join(' '));

clock = TTL * 1000 + 100; // a day after the third bet, give or take 100 ms
console.log('a day later, keys present:', present());
console.log('a day later:', await bet());

console.log('rotated between load and reserve:', await bet(rotate));
console.log('first bet on the new seed:', await bet());
for (let i = 0; i < 3; i++) { clock += 20 * HOUR; await bet(); }
console.log('60 hours after the reveal, revealed seeds:', Object.keys(await store.revealed('ana')).length, '| next nonce:', await store.peekNonce('ana'));

store.keys.delete('nonce:ana'); // a stray DEL in redis-cli
console.log('counter deleted:', await bet());
clock += 3 * TTL * 1000;
console.log('three days later:', await bet(), '| keys present:', present());

clock = 0;
const window = new MemorySeedStore(() => clock);
const counts = [];
for (const hour of [0, 20, 23, 25]) { clock = hour * HOUR; counts.push(await window.countSession('203.0.113.7', TTL)); }
console.log('sessions counted at hours 0, 20, 23, 25:', counts.join(' '));
Output
nonce 0 rolled 53.14
nonce 1 rolled 50.77
nonce 2 rolled 71.31
next nonce: 3 | records kept: 2 1
a day later, keys present: session nonce records
a day later: nonce 3 rolled 54.7
rotated between load and reserve: refused: session nonce unavailable; start a new demo session
first bet on the new seed: nonce 0
60 hours after the reveal, revealed seeds: 1 | next nonce: 4
counter deleted: refused: session nonce missing; start a new demo session
three days later: session expired | keys present: none
sessions counted at hours 0, 20, 23, 25: 1 2 3 1

Three bets, nonces 0 to 2, the key reading 3, and a record list capped at two with the newest first. The refused bet after that is the commitment check from the section above. A rotation got in between its load and its reservation, and instead of taking nonce 0 from the new seed's counter it was turned away, so nonce 0 went to the first bet that did use the new seed. The last line is EXPIRE ... NX at work: the window opened at hour 0 and closed at hour 24 whatever happened in between, so the visit at hour 25 counts as the first of a new day.

Every Session Key Shares One Expiry

The earlier class renewed each key when that key was written, and a bet writes its keys in a fixed order. The reservation renewed the counter. Then play ran, and only after that did put renew the session. The example charges 40 milliseconds for that step, so under the old rules the counter's deadline fell 40 milliseconds before the seed's. A bet that arrived in the gap, a day after the last one, found a live seed and no counter, and INCR started the counter again. It was handed nonce 0 and rolled 53.14, the roll the player had seen the day before. We found that by reading the code. The API's in-memory tests can't show it, since their Redis double has no clock.

The revealed-seed hash had a slower version of the same fault. Only a reveal renewed it. A player who rotated once and then went on betting for more than a day kept their session, and the records from before the rotation, but lost the seed that made those records checkable. The example's line for 60 hours after the reveal shows that seed still held. Each of the bets in between renewed the hash along with everything else.

touch is the fix for both. The four keys, and the board when there is one, get the same TTL from the same script, so their deadlines are identical. A day after the third bet the example finds the session, the counter and the records all still there, and the fourth bet gets nonce 3. A counter that goes missing anyway, through a stray DEL or eviction, is refused rather than started again. That is the counter deleted line, and three days later the session, the counter, the revealed seed and the records have gone together. Nonce reuse and replay lists the other roads to a repeated nonce.

The API has a test of this against a real Redis, in apps/api/test/demo-redis.test.ts. After each of get, reserveNonce, put, reveal and pushRecord it reads PTTL on the session, the counter, the revealed hash, the records and a Mines board, and requires all five to be equal. It skips unless REDIS_TEST_URL is set. With it pointed at the project's container, both of the file's tests passed.

Two kinds of session key are not in the set. The chip balance keeps its own clock, renewed by debits and credits, so a session that is only read for a day loses its balance and starts again at 1,000. The credit marker is set once, at the credit, and never renewed. That leaves one narrow path. A Mines cash-out is credited and the board write that should follow fails. The player then keeps the session alive for more than a day without retrying. When the marker expires, the board is still open, and a second cash-out would be paid. We read that from the code and haven't reproduced it.

Every seed-store script names the session's four keys plus one for each linked part, five in the demo. On Redis Cluster they all need one hash tag, such as demo:{<id>}:session and demo:{<id>}:nonce, or the call fails with CROSSSLOT. The demo runs one Redis and its key names have no tag.

A single key would get the seed and its counter the same guarantee with no script. Make the session a hash, keep the counter in it as a field and reserve with HINCRBY. One key has one TTL. The revealed seeds and the records would still be keys of their own, though, so something still has to renew them with the session, and in the demo that something is touch.

A cheap tripwire comes free with the demo's data, and the demo doesn't use it. betsUnderSeed lives in the session JSON and goes up by one on every derivation, and every path that resets it also writes the counter back to 0 in the same MSET. So at the moment of a bet the reserved nonce should be no lower than betsUnderSeed. It can be higher, when a reserved nonce never reached a record. If it's lower, the counter has gone backwards. Refuse the bet and rotate.

Debit and Credit in Lua

MULTI queues commands blind. It can't read a balance and decide whether to subtract. A script can, which is the same reason the seed store uses them.

DEBIT in chips.service.ts (needs Redis; KEYS[1] balance, ARGV: amount, starting chips, ttl)
local bal = redis.call('GET', KEYS[1])
if not bal then bal = ARGV[2]; redis.call('SET', KEYS[1], bal, 'EX', ARGV[3]) end
bal = tonumber(bal)
local amt = tonumber(ARGV[1])
if bal < amt then return -1 end
redis.call('DECRBY', KEYS[1], amt)
redis.call('EXPIRE', KEYS[1], ARGV[3])
return bal - amt

A session that has never bet has no balance key, so the script's second line opens the account with the starting stack, 1,000 chips by default. Then it compares, subtracts and renews. A reply of -1 means the stake wasn't covered, and the service turns it into 402 not enough chips. Two bets of 600 against a balance of 1,000 can arrive in the same millisecond and one of them will get -1. With a GET in Node and a DECRBY afterwards, both would pass the check.

The credit side has two forms. The plain credit is an INCRBY and an EXPIRE in a MULTI, since adding chips has no condition to check. creditOnce is a script, because it does have one: a marker key, demo:credited:<id>:<round>, tested and set in the same script as the INCRBY, so a retried cash-out pays once. The marker isn't a bare flag. It holds the amount paid as JSON, with whatever the caller adds, and Mines adds its picks, so a retry reads back what was paid instead of working it out again. Concealed games prints that script and explains what makes a good round id. It takes two keys, so the Cluster note above applies to it as well.

Every script in the demo is sent as text with EVAL on every call. ioredis has defineCommand, which registers a script once and afterwards sends its SHA-1 with EVALSHA. We counted what one Dice bet sends through the demo's own controller: seven EVAL calls carrying 2,451 bytes of script, the largest being put at 666. Nobody has measured whether that costs anything at the demo's volume.

Whether the debit, the nonce and the credit succeed or fail together is a separate question, and the answer in the demo is that they don't. Settling bets has the table of what each crash leaves behind.

Session Lock

session-lock.ts (needs Redis and ioredis)
const key = `demo:action-lock:${id}`, owner = randomUUID();
const acquired = await redis.set(key, owner, 'PX', 30_000, 'NX');
if (!acquired) throw new ConflictException('another action is being processed; retry after it completes');
try {
  return await operation();
} finally {
  await redis.eval(
    "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",
    1, key, owner,
  );
}

SET ... NX writes only if the key is absent, and PX 30000 makes the key delete itself after 30 seconds in case the worker holding it dies. The demo doesn't wait for a busy lock. The second request gets a 409 at once and the client retries.

The release is a script for the sake of one comparison. The value stored under the lock is a UUID made for this one acquisition, and the key is deleted only if it still holds that UUID. This model shows what the comparison buys:

lock.mjs
import { randomUUID } from 'node:crypto';

let clock = 0;
const locks = new Map();

function acquire(key, ms) { // SET key owner PX ms NX
  const held = locks.get(key);
  if (held && held.expiresAt > clock) return null;
  const owner = randomUUID();
  locks.set(key, { owner, expiresAt: clock + ms });
  return owner;
}
const releaseAnyway = (key) => Number(locks.delete(key)); // DEL
function releaseIfOwner(key, owner) {                     // the demo's script
  return locks.get(key)?.owner === owner ? Number(locks.delete(key)) : 0;
}

for (const release of [releaseAnyway, releaseIfOwner]) {
  clock = 0;
  locks.clear();
  const a = acquire('lock:ana', 30_000);
  clock = 10_000;
  console.log(release.name, '| B at 10 s:', acquire('lock:ana', 30_000) ? 'acquired' : 'refused');
  clock = 31_000; // A is still working and its lease has run out
  const b = acquire('lock:ana', 30_000);
  console.log(release.name, '| B at 31 s:', b ? 'acquired' : 'refused');
  console.log(release.name, '| A finishes and releases, keys deleted:', release('lock:ana', a));
  console.log(release.name, '| C while B is still working:', acquire('lock:ana', 30_000) ? 'acquired' : 'refused');
}
Output
releaseAnyway | B at 10 s: refused
releaseAnyway | B at 31 s: acquired
releaseAnyway | A finishes and releases, keys deleted: 1
releaseAnyway | C while B is still working: acquired
releaseIfOwner | B at 10 s: refused
releaseIfOwner | B at 31 s: acquired
releaseIfOwner | A finishes and releases, keys deleted: 0
releaseIfOwner | C while B is still working: refused

With a bare DEL, a slow A deletes the lock B is holding, and C walks in beside B. With the comparison, A's release deletes nothing.

Look at the second line of each run, though. B was let in at 31 seconds while A was still working, and no release rule can prevent that. A lease with no renewal is a bet that nothing takes 30 seconds. That's why the lock is only the second guard on nonces. The first is the store itself: the reservation is atomic and checks the commitment, and put refuses a session that changed underneath it, so the counter stays correct when the lock lapses. State that is read, changed and written back under the lock with a plain SET, such as a live Mines board, has no such backstop.

Noeviction and Persistence

docker-compose.yml
redis:
  image: redis:7-alpine
  command: ["redis-server", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "noeviction"]

We didn't choose noeviction for the seeds. BullMQ printed an IMPORTANT! Eviction policy warning at startup, since an evicting Redis can drop queued jobs, and the Compose file was changed to quiet it. It's number 10 in the project's list of mistakes. The setting turned out to matter more for this page's keys than for the queue. Under allkeys-lru a full Redis deletes whatever was touched least recently, and one session's keys all carry the same TTL but not the same last access. If the counter went, the scripts would now refuse the session instead of reusing a nonce. If the revealed hash or an open board went, nothing would notice. Under volatile-lru every key in the table at the top is a candidate, since all of them have a TTL. Under noeviction a full Redis refuses writes with an OOM error, the bet fails with a 500 and the counter is untouched. A refused bet can be retried. A reused nonce can't be taken back.

If the container was created before the change, recreating it is the only way to apply the flag: docker compose up -d --force-recreate redis. Check with CONFIG GET maxmemory-policy. Ours answers noeviction, and maxmemory answers 536870912.

--appendonly yes keeps a log of writes on disk. The project sets no appendfsync, and the running container reports the Redis default, everysec. So a power cut can lose up to the last second of writes, counters included, and the player has already seen the rolls from that second. The scripts can't catch this one, because the session and its counter come back together, both a second old. After such a restart the honest move is to rotate every session that was active. For play chips the demo doesn't bother. With stakes, keep the counter in the database that holds the ledger, or set appendfsync always and accept what it does to latency.