Skip to main content

Cache & Background Jobs

Bot-module handlers have two complementary APIs: ctx.cache for fast, Redis-backed storage per installation, and ctx.defer() for work that runs after the handler has returned its result.

Each API works on its own. You can use the cache without defer, defer without the cache, or combine them.

Availability

ctx.cache, ctx.defer(), ctx.fetch, ctx.secrets and ctx.db work in bot-module handlers. Handler server functions (query() / mutation() / action()) run on the same worker and have them too; their ctx.fetch refuses every host. Automation nodes do not have ctx.cache or ctx.defer() yet; there these calls do nothing.

ctx.cache - Per-install cache​

A key-value store backed by Redis and scoped to the extension installation. Every key expires; the cache is for counters, cooldowns and short-lived state, not for permanent data.

API​

ctx.cache = {
get: (key: string) => Promise<unknown | null>,
set: (key: string, value: unknown, ttl?: number) => Promise<void>,
delete: (key: string) => Promise<void>,
increment: (key: string, by?: number, ttl?: number) => Promise<number>,
};

Values are stored as JSON, so get returns what you stored with set (objects, arrays, numbers, strings).

Basic usage​

import { command } from "@zaflun/lumio-sdk/server";

export const counter = command("count", async (ctx) => {
// Creates the key at 0 if it does not exist, then adds 1.
const count = await ctx.cache.increment("message_count");
return { reply: `Message count: ${count}` };
});

Setting values with TTL​

// Store a value for 5 minutes
await ctx.cache.set("last_clipper", ctx.user.name, 300);

// Store a value with the default TTL (1 hour)
await ctx.cache.set("status", "active");

// Read it back: "active", or null once it has expired
const status = await ctx.cache.get("status");

set always applies the TTL you pass (or the default), also when it overwrites a key. increment sets the TTL only when it creates the key; later increments keep the original expiry.

Deleting values​

await ctx.cache.delete("spam_score:user123");

Key scoping​

Your extension only names the key. Lumio stores all keys of one installation in a single Redis hash, lumio:ext:cache:\{install_id\}, and your key is a field of that hash. A handler cannot address another installation's keys or any internal Lumio key. Uninstalling the extension deletes the installation's cache immediately.

Key format rules​

Keys must match ^[A-Za-z0-9:_-]{1,128}$ (ASCII letters, digits, :, _, -). The check runs in Rust, outside your handler, so it cannot be bypassed. An invalid key throws.

Limits​

LimitValue
Default TTL1 hour (3,600 seconds)
Max TTL24 hours (86,400 seconds)
Max key length128 characters
Max value size4 KB (serialized JSON)
Max keys per install256
Cache calls per handler run50
Cache operations per install200 per minute

When a limit is reached the call throws, for example ctx.cache: cache is full (256 keys per install) or ctx.cache: rate limit exceeded. Writing to an existing key is still allowed when the cache is full. When Lumio's Redis is under memory pressure, set and increment throw ctx.cache is unavailable while reads keep working. Handle these errors if your module must keep running without the cache.

There are no wildcard operations: an extension cannot list its own keys.

When to use the cache​

Use casectx.cache
Cooldown countersYes
Spam scoresYes
"Last chatter" queueYes
Temporary vote countsYes
Points that must survive longer than 24 hoursNo: use ctx.db
Leaderboards, history, permanent user dataNo: use ctx.db

ctx.defer() - Background jobs​

Queues a function that runs after the handler has returned. The bot posts the reply (or applies the moderation verdict) first; the deferred work runs afterwards in the same sandbox and does not count against the 500 ms deadline of command and moderation handlers.

API​

ctx.defer(fn: () => Promise<void>): void

Basic usage​

import { command } from "@zaflun/lumio-sdk/server";

export const points = command("points", async (ctx) => {
const key = `pts:${ctx.user?.id ?? "anon"}`;
const balance = ((await ctx.cache.get(key)) as number | null) ?? 0;

// Runs after the reply has been sent.
ctx.defer(async () => {
await ctx.fetch("https://api.example.com/points", {
method: "POST",
body: { user: ctx.user?.id, balance },
});
});

return { reply: `${ctx.user?.name}: ${balance} points` };
});

api.example.com must be in the manifest's egress.allowHosts.

Limits​

LimitValue
Deferred jobs per handler run5 (the 6th ctx.defer() throws)
Time budget for all deferred jobs of one run10 seconds
Deferred jobs per install10 per minute
Available APIsctx.cache, ctx.fetch (same per-run call limits as the handler)
Runs whenOnly after the handler finished without an error

Jobs run one after another in the order you queued them. A job that throws does not stop the next one. When the per-minute limit is reached, the remaining jobs of that run are skipped. Deferred jobs are not persisted: if the worker restarts before they run, they are lost. Do not use them for work that must happen.

Error handling​

A failing or timed-out deferred job never reaches the chat user; the reply has already been sent. The error is logged by the worker.


Combined pattern: reply fast, report later​

import { command } from "@zaflun/lumio-sdk/server";

export const vote = command("vote", async (ctx, args) => {
const option = args[0] ?? "yes";
// Fast: one Redis round trip
const total = await ctx.cache.increment(`votes:${option}`, 1, 600);

// After the reply: push the tally to your own backend
ctx.defer(async () => {
await ctx.fetch("https://api.example.com/votes", {
method: "POST",
body: { option, total },
});
});

return { reply: `${option}: ${total} votes` };
});

Comparison​

ctx.cachectx.defer()
BackendRedis (one hash per install)Same sandbox, after the handler returned
PersistenceUntil the TTL expires (max 24 h)Not persisted
LatencyOne Redis round tripAfter the reply
Use caseCounters, cooldownsNotifications, reporting