Cooldown stores
Keep cooldown counts in Redis, across shards, or in PostgreSQL, SQLite or MongoDB, and check a store of your own.
Before this
By default, @Cooldown counts calls in the bot's memory: they're gone on a restart, and with
process sharding each shard counts on its own. A store keeps them elsewhere. Pick one by where the bot runs:
| The bot runs | Store |
|---|---|
| In one process, restarts are fine | MemoryCooldownStore, the default |
| As process shards on one host | ShardedCooldownStore, with no database |
| On several hosts, or must survive restarts | RedisCooldownStore, or a store for your database |
The code
RedisCooldownStore depends on no Redis client. Give RedisCooldownStore.using a function that runs a script with
the one you have. With node-redis:
// node-redis runs the script; with evalsha it is sent by its SHA1, and in full only when the server lacks it
export const redisCooldownStore = (redis: ScriptCommands) =>
RedisCooldownStore.using((script, keys, args) => redis.eval(script, { keys, arguments: args }), {
evalsha: (sha, keys, args) => redis.evalSha(sha, { keys, arguments: args }),
})using returns a class, which the app binds as its store:
const redis = await createClient({ url: process.env.REDIS_URL }).connect()
@MeoCord({
controllers: [CheckInButtonController],
clientOptions: { intents: [GatewayIntentBits.Guilds] },
cooldownStore: redisCooldownStore(redis),
})
export default class App {}How it works
A store is a service that extends CooldownStore. @Cooldown calls its consumeMany(entries)
once per call, with every stacked cooldown the call doesn't bypass. With messages.dmOnCooldown, a refused message
command's notice is counted in the store too, under the refusing key followed by :notice:. Three things make a store
correct:
- One step. The check and the record happen together, so two calls at the limit can't both pass.
- One clock. Processes on several hosts count by the database's clock, not each host's own.
- Every key expires. A key whose calls have all left their window is removed, so the store doesn't grow forever.
RedisCooldownStore counts each key in a sorted set. One Lua script trims, counts and adds to every key of the
call, timed by the server's TIME, and sets each key to expire. So a call costs one round trip however many
cooldowns it has, and a call one of them refuses counts against none.
evalshais optional. With it, the script is sent by its SHA1, and in full only when the server answersNOSCRIPT. Without it, every call sends the whole script.- Keys start with
meocord:cooldown:. Pass{ prefix }for your own, to keep two bots on one server apart. - Servers: the same script runs on Redis 5 and later, Valkey, KeyDB, Dragonfly and Upstash. Garnet runs Lua only in part, so check it before relying on it.
Variations
ioredis
Run the script as (script, keys, args) => redis.eval(script, keys.length, ...keys, ...args).
Redis Cluster
A handler's keys usually sit in different slots, which one script can't reach. The store then counts each key with a
script of its own, in order, and gives back the uses counted before a refusal, so a refused call counts against none
unless a give-back fails. Pass { hashTag: 'handler' } to keep each handler's keys in one slot, and its cooldowns in
one step. Every call to that handler then lands on that slot.
Process sharding on one host
ShardedCooldownStore needs no database. Each shard asks the shard manager, which counts every shard's calls in its
memory, over the IPC the shards already use:
@MeoCord({
controllers: [CheckInButtonController],
clientOptions: { intents: [GatewayIntentBits.Guilds] },
// Every shard's calls counted in the shard manager, over the IPC the shards already use
cooldownStore: ShardedCooldownStore,
})
export default class App {}The counts last while the manager runs: a shard that restarts keeps them, but they start again when the whole bot restarts. A manager that doesn't answer in time is a store failure. Without process sharding, it counts in the one process.
PostgreSQL
A row per call:
export const cooldownCallsTable = `
CREATE TABLE IF NOT EXISTS cooldown_calls (
id bigserial PRIMARY KEY,
key text NOT NULL,
at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS cooldown_calls_key_at ON cooldown_calls (key, at);`The store counts a key's calls in a transaction that first takes an advisory lock on the key, so even its first
call, which has no row yet to lock, runs one at a time. clock_timestamp() times each row by the database's clock:
// A row per call, counted in a transaction that holds an advisory lock on the key, by the database's clock
@Service()
export class PostgresCooldownStore extends CooldownStore {
constructor(@Inject(DATABASE) private readonly pool: pg.Pool) {
super()
}
async consume(key: string, { uses, windowMs }: CooldownLimit): Promise<CooldownVerdict> {
const client = await this.pool.connect()
try {
await client.query('BEGIN')
// Serialises the key's calls, even its first, which has no row yet to lock
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [key])
await client.query(
`DELETE FROM cooldown_calls WHERE key = $1 AND at <= clock_timestamp() - $2::float8 * interval '1 millisecond'`,
[key, windowMs],
)
const { rows } = await client.query<{ count: number; retry: number | null }>(
`SELECT count(*)::int AS count,
ceil(extract(epoch FROM min(at) + $2::float8 * interval '1 millisecond' - clock_timestamp()) * 1000)::int AS retry
FROM cooldown_calls WHERE key = $1`,
[key, windowMs],
)
const allowed = rows[0].count < uses
if (allowed) await client.query('INSERT INTO cooldown_calls (key) VALUES ($1)', [key])
await client.query('COMMIT')
return { allowed, retryAfterMs: allowed ? 0 : rows[0].retry! }
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}
}
}It injects the pool from the database recipe:
@MeoCord({
controllers: [CheckInButtonController],
// The pool the store injects
providers: [databaseProvider],
clientOptions: { intents: [GatewayIntentBits.Guilds] },
cooldownStore: PostgresCooldownStore,
})
export default class App {}A key's rows go when it's next used. Clear the rest from a scheduled task, with
DELETE FROM cooldown_calls WHERE at < now() - interval '1 day' or your longest window.
SQLite
node:sqlite is built into Node 22.13 and later, and Bun. The same rows:
export const cooldownCallsTable = `
CREATE TABLE IF NOT EXISTS cooldown_calls (id INTEGER PRIMARY KEY, key TEXT NOT NULL, at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS cooldown_calls_key_at ON cooldown_calls (key, at);`The store counts in an IMMEDIATE transaction, which takes the write lock before it reads, so two processes on one
file can't both take the last use:
// A row per call, in an IMMEDIATE transaction: it takes the write lock before reading, so two processes on one
// database file cannot both take the last use. SQLite runs on one host, so Date.now() is one clock.
@Service()
export class SqliteCooldownStore extends CooldownStore {
constructor(@Inject(SQLITE) private readonly db: DatabaseSync) {
super()
}
consume(key: string, { uses, windowMs }: CooldownLimit): Promise<CooldownVerdict> {
const now = Date.now()
this.db.exec('BEGIN IMMEDIATE')
try {
this.db.prepare('DELETE FROM cooldown_calls WHERE key = ? AND at <= ?').run(key, now - windowMs)
const { count, oldest } = this.db
.prepare('SELECT count(*) AS count, min(at) AS oldest FROM cooldown_calls WHERE key = ?')
.get(key) as { count: number; oldest: number | null }
const allowed = count < uses
if (allowed) this.db.prepare('INSERT INTO cooldown_calls (key, at) VALUES (?, ?)').run(key, now)
this.db.exec('COMMIT')
return Promise.resolve({ allowed, retryAfterMs: allowed ? 0 : oldest! + windowMs - now })
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
}busy_timeout makes a call wait while another process holds the lock, rather than fail. node:sqlite is
synchronous, so that wait blocks the process. SQLite suits processes on one host, which is why Date.now() serves
as its clock:
export const sqliteProvider: Provider = {
provide: SQLITE,
useFactory: () => {
const db = new DatabaseSync(process.env.SQLITE_PATH ?? 'bot.db')
// Another process's write is waited for, up to five seconds, rather than failing the call at once
db.exec('PRAGMA busy_timeout = 5000')
db.exec(cooldownCallsTable)
return Object.assign(db, { onShutdown: () => db.close() } satisfies OnShutdown)
},
}MongoDB
One document per key. A single findOneAndUpdate with an update pipeline trims the key's calls, counts them and
appends this one, timed by the server's $$NOW. Each call carries an id of its own, so the store can tell whether
the write recorded it:
// One document per key, trimmed, counted and appended to by one findOneAndUpdate with an update pipeline, so
// the check and the record are one atomic write, timed by the server's $$NOW
@Service()
export class MongoCooldownStore extends CooldownStore {
constructor(@Inject(COOLDOWNS) private readonly cooldowns: Collection<CooldownDocument>) {
super()
}
async consume(key: string, { uses, windowMs }: CooldownLimit): Promise<CooldownVerdict> {
// Tells this call apart from another in the same millisecond
const id = randomUUID()
const inWindow = {
$filter: { input: { $ifNull: ['$calls', []] }, cond: { $gt: ['$$this.at', { $subtract: ['$$NOW', windowMs] }] } },
}
const doc = await this.cooldowns.findOneAndUpdate(
{ _id: key },
[
{ $set: { calls: inWindow } },
{
$set: {
calls: {
$cond: [
{ $lt: [{ $size: '$calls' }, uses] },
{ $concatArrays: ['$calls', [{ at: '$$NOW', id }]] },
'$calls',
],
},
expiresAt: { $add: ['$$NOW', windowMs] },
now: '$$NOW',
},
},
],
{ upsert: true, returnDocument: 'after' },
)
if (doc!.calls.some(call => call.id === id)) return { allowed: true, retryAfterMs: 0 }
return { allowed: false, retryAfterMs: doc!.calls[0].at.getTime() + windowMs - doc!.now.getTime() }
}
}A TTL index removes a key once its window has passed:
// A TTL index on expiresAt removes a key's document once its window has passed
export const createCooldownIndex = (cooldowns: Collection<CooldownDocument>) =>
cooldowns.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })
export const cooldownsProvider: Provider = {
provide: COOLDOWNS,
useFactory: async () => {
const client = await new MongoClient(process.env.MONGODB_URL!).connect()
const cooldowns = client.db().collection<CooldownDocument>('cooldowns')
await createCooldownIndex(cooldowns)
return Object.assign(cooldowns, { onShutdown: () => client.close() } satisfies OnShutdown)
},
}A store of your own
Extend CooldownStore and implement consume(key, { uses, windowMs }) in one step. The default consumeMany
calls consume for each cooldown in order and stops at the first refusal, so a call one refuses has counted against
those before it. Override consumeMany to check them all and record the call only if all allow it, in one round
trip, as the built-in stores do. It's worth it for any store behind a network.
Override peekMany(entries) too, to check the entries and record nothing. MeoCord calls it before a message command
fetches the members, users, roles or channels it names, so a caller on cooldown costs no request. The default allows
every call, so a store without its own peekMany refuses only at consumeMany, after the fetch.
A store that connects can do it in its own lifecycle hooks. Its onReady runs before the
services', and a call that comes meanwhile waits for it, up to cooldownStoreTimeoutMs; one that would wait longer
meets the cooldownStoreFailure policy. Its onShutdown runs after the
services', once the calls under way have finished, along with every store operation they started, so the store closes
after its last write. A service that injects CooldownStore gets the app's store, whose hooks still run once.
Giving back a refused call's use
When a store answers after cooldownStoreTimeoutMs, @Cooldown has already refused the call under 'deny'. If the
late answer recorded the call, the caller would lose a use for a call that never ran, so @Cooldown calls the
verdict's release() to undo it. The built-in stores give release. A store of your own can add it to the verdict
its consumeMany returns:
@Service()
export class ReleasingCooldownStore extends CooldownStore {
constructor(private readonly queries: CooldownQueries) {
super()
}
consume(key: string, limit: CooldownLimit): Promise<CooldownVerdict> {
return this.consumeMany([{ key, limit }])
}
async consumeMany(entries: readonly CooldownEntry[]): Promise<CooldownBatchVerdict> {
const call = randomUUID()
const verdict = await this.queries.consumeMany(entries, call)
// Undoes this call alone, should it be counted after @Cooldown stopped waiting
return verdict.allowed ? { ...verdict, release: () => this.queries.forget(entries, call) } : verdict
}
}A store without release keeps such a call counted. Under 'allow' the call ran uncounted, so the late count stays.
testCooldownStore checks a store's release when it gives one. A release still under way when the bot stops
finishes before the store's onShutdown runs, so a store can close its connection there.
Telling one wait from the next
A refusal can also give retryTimestamp: when the next call is allowed, as a Unix timestamp in milliseconds on the
store's own clock, the same for every refusal in one wait. messages.dmOnCooldown tells one wait from the next by
it, so a retry whose answer arrives late, or from another shard sharing the store, isn't told about the same wait
again. The built-in stores give it. A store without it is told apart by retryAfterMs and the bot's clock instead.
Checking a store
testCooldownStore from meocord/testing runs the behaviour MemoryCooldownStore defines against yours, under
Vitest, Jest or any runner with describe, it and expect:
// Outside process sharding the store counts in this process, and checks as any other store does
testCooldownStore('ShardedCooldownStore', () => new ShardedCooldownStore(), { describe, it, expect })It checks that:
- a key allows
usescalls within the window, and the window slides; retryAfterMscounts from the oldest call still in the window, and every refusal in one wait gives the sameretryTimestamp, when the store gives one;- each key counts on its own, and calls in the same millisecond stay distinct;
- of several concurrent calls at the limit, exactly one passes;
- a batch is counted against all its cooldowns at once, and a refusal names the longest wait;
- a refused batch records nothing, or, with the default
consumeMany, counts the cooldowns before the one that refuses, as one call toconsumeafter another does; - of several concurrent batches at the limit, exactly one passes;
- a peek records nothing, answers a refusal with the wait
consumegives, and names a batch's longest wait, or, with the defaultpeekMany, allows every call.
It uses real time with short windows, so it takes a few seconds. Each case counts under keys of its own, so it can run against a database that outlives the test. It can't see whether every key expires; check that yourself.
Every windowMs a store gets is a whole number of milliseconds, from 1 to 4320000000000000: @Cooldown rounds
seconds to the millisecond and refuses one outside 0.001 to 4320000000000. A database that takes only whole
milliseconds, as Redis's PEXPIRE does, can store it as it is.
Next steps
- Cooldowns: the limits a store counts, and what a call gets when the store fails.
- A database: the pool the PostgreSQL store injects.
- Sharding: when process sharding needs a shared store.