Skip to content

RFC: Pluggable cache layer for @wolfstar/plugin-gateway (Cache, EntityCache, CachedManager) #55

Description

@RedStar071

Introduction

This is a companion to the Gateway RFC (#54), split out on its own because the caching design deserves independent review: wolfstar-project/stars-components#51 specifically asks for a package with "local and Redis" storage support and "compression, be it with an algorithm or Redis", and that's a large enough surface to deserve its own discussion.

The Gateway RFC's GatewayClient doesn't hand raw API JSON to event listeners — it hands Structure instances (Message, User, Guild, …), built by per-entity Managers via createStructure, following the pattern discordjs/discord.js#10983 is prototyping upstream. That split raises the exact question that PR's reviewers debated and left unresolved: should the cache hold raw API data, or fully-built Structure instances? This RFC takes an explicit position: the Cache holds only raw, JSON-serializable data. Structure construction happens above the cache, in the Managers. Reasoning:

  • Structures wrap raw data behind symbols (kData, kPatch, …) and aren't trivially JSON.stringify-able in a way that round-trips through Redis.
  • A Redis-backed store needs to serialize/deserialize/compress values regardless — that's a much simpler contract against plain objects than against live class instances.
  • Managers already need a createStructure(raw) step to build the initial instance from a Gateway payload; reusing that same step to build it from a cache hit costs nothing extra and keeps the cache genuinely storage-agnostic.

Interface-wise this still closely follows discordjs/discord.js#11426 and the storage swap already prototyped in suneettipirneni's add-cache-package branch (@discordjs/cache), which nails the "swap the store, keep the call sites" ergonomics stars-components#51 is asking for:

import { GatewayDispatchEvents } from 'discord-api-types/v10';
import { Client } from '@discordjs/core';
import { WebSocketShardEvents } from '@discordjs/ws';
import type { Cache } from '@discordjs/cache';
import { createInMemoryCache } from '@discordjs/cache/inmemory';

const client = new Client({ rest, gateway });

const cache: Cache = createInMemoryCache({
	mappers: {
		// Only store a subset of fields we care about. Omit to store the full API object.
		guild: ({ raw }) => ({ id: raw.id, name: 'name' in raw ? raw.name : raw.id }),
	},
});

gateway.on(WebSocketShardEvents.Dispatch, (payload, shardId) => {
	cache.handleGatewayDispatch(payload, shardId);
});

client.on(GatewayDispatchEvents.InteractionCreate, async ({ api, data }) => {
	if (!data.guild_id) return;
	// Type auto-inferred to `{ id: string, name: string }`
	const guild = cache.guilds.get(data.guild_id);
	await api.interactions.reply(data.id, data.token, {
		content: `Cached guild name: ${guild?.name ?? 'unknown'}`,
	});
});

The proposal here is @wolfstar/plugin-cache: the same per-entity, swappable-store shape, but plugged into @wolfstar/plugin-gateway's CachedManager/createStructure pattern so what comes back out is always a Structure, not a narrowed plain object.

Design principles

  1. Async-first. Every method returns Awaitable<T> (T | Promise<T>), so an in-memory Map-backed store and an ioredis-backed store implement the exact same interface without either one faking synchronicity it doesn't have.
  2. Raw in, Structures out. The Cache only ever stores and returns raw API shapes. Turning that into a User/Guild/Message is always the calling Manager's job via createStructure, never the cache's.
  3. Map-like, not bespoke. get/set/has/delete/clear mirror the built-in Map API on purpose, so the learning curve is close to zero.
  4. Per-entity sub-caches instead of ad-hoc mappers. @discordjs/cache's mappers option is a clever way to narrow what's stored, but it fights the Manager/createStructure split above — the equivalent narrowing now belongs on each Structure's DataTemplate/optimizeData() (from @discordjs/structures), not on the cache. The Cache interface itself stays a plain, typed, per-entity key-value store.
  5. Storage-agnostic, compression as a store concern. Compression only makes sense for a store that pays a serialization/network cost (Redis), not for an in-memory Map. It's a Redis-store option, not a property of the shared interface.

The Cache interface

export type Awaitable<T> = T | Promise<T>;

export interface EntityCache<Raw> {
	get(key: string): Awaitable<Raw | undefined>;
	set(key: string, value: Raw): Awaitable<void>;
	has(key: string): Awaitable<boolean>;
	delete(key: string): Awaitable<boolean>;
	clear(): Awaitable<void>;
	getSize(): Awaitable<number>;
}

export interface Cache {
	readonly guilds: EntityCache<APIGuild>;
	readonly users: EntityCache<APIUser>;
	readonly channels: EntityCache<APIChannel>;
	readonly messages: EntityCache<APIMessage>;
	// ...one EntityCache per entity `GatewayClient` ships a Manager for
}

GatewayClient (see the Gateway RFC) is the only thing that calls into Cache directly — on every dispatch it writes the raw payload into the matching EntityCache, then hands that same raw payload to the Manager's createStructure before emitting the built Structure on its event bus. Reads go the other direction: UserManager#get(id) reads cache.users.get(id) and, on a hit, wraps it with createStructure before returning it — the cache itself never sees a Structure.

Problems

  • Same open question as the original discord.js RFC: do we need keys() / values() / entries() for iteration on EntityCache, and if so do they return AsyncIterable unconditionally (even for the in-memory store, for interface consistency) or Iterable | AsyncIterable depending on the backing store?
  • Per-entity TTL: Discord doesn't tell us when a guild/user is "stale," so is expiry purely LRU/size-bound (in-memory) or does the Redis store get a configurable TTL per entity type? Needed for compression to actually save memory rather than just deferring the problem.
  • Deletes: GUILD_DELETE/CHANNEL_DELETE/etc. need to reach every relevant EntityCache (e.g. a guild delete should probably also drop that guild's channels from the channel cache). Does that cascade live in GatewayClient's dispatch handling, or does Cache itself need entity-relationship awareness? Leaning towards the former to keep Cache genuinely dumb, but flagging it as a real design question.
  • DataTemplate/optimizeData() narrowing (point 4 above) is per-Structure, decided at compile time by whoever writes the Structure subclass — unlike @discordjs/cache's runtime mappers option, a consumer can't narrow further without subclassing. Is that an acceptable trade-off for the simpler Cache contract, or do we need a lighter, per-GatewayClient-instance override too?

Store backends

export interface InMemoryCacheOptions {
	maxSize?: number;
}

export function createInMemoryCache(options?: InMemoryCacheOptions): Cache;

export interface RedisCacheOptions {
	/** An existing ioredis-compatible client, or connection options to create one. */
	redis: Redis | RedisOptions;
	/** Compress values before writing to Redis and decompress on read. Off by default. */
	compression?: 'gzip' | 'none';
	/** Per-entity TTL, in seconds. */
	ttl?: Partial<Record<'guilds' | 'users' | 'channels' | 'messages', number>>;
}

export function createRedisCache(options: RedisCacheOptions): Cache;

createInMemoryCache mirrors @discordjs/cache's implementation directly, minus the mappers option (superseded by Structure-level DataTemplate/optimizeData(), per the design principles above). createRedisCache is the new piece stars-components#51 is actually asking for: same Cache contract, backed by ioredis, with compression: 'gzip' as an opt-in for larger cached payloads (guild/member objects) where the network/memory savings are worth the CPU cost — left off by default.

Usage example

import { GatewayClient } from '@wolfstar/plugin-gateway';
import { createRedisCache } from '@wolfstar/plugin-cache';
import { GatewayIntentBits } from 'discord-api-types/v10';
import Redis from 'ioredis';

const client = new GatewayClient({
	discordToken: process.env.DISCORD_TOKEN,
	intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages,
	cache: createRedisCache({
		redis: new Redis(process.env.REDIS_URL!),
		compression: 'gzip',
		ttl: { guilds: 60 * 60, users: 30 * 60 },
	}),
});

client.on('messageCreate', async (message) => {
	const guild = message.guildId ? await client.guilds.get(message.guildId) : undefined;
	console.log(`${message.author.username} in ${guild?.name ?? 'a DM'}: ${message.content}`);
});

await client.connect();

message and guild above are Structure instances built by GatewayClient's Managers; client.guilds.get() never returns raw Redis bytes directly. Swapping createRedisCache for createInMemoryCache changes nothing else in this snippet, which is the entire point of the shared Cache interface.

Alternatives considered

  • Keep @discordjs/cache's mappers option, cache narrowed plain objects directly. This is what an earlier revision of this RFC proposed. Rejected in favor of the Cache-stores-raw / Structure-narrows-via-DataTemplate split once the Gateway RFC settled on emitting Structure instances from events — keeping both a "narrowed plain object" shape and a Structure shape alive at once would mean two competing representations of the same cached entity.
  • Cache Structure instances directly, matching what event listeners receive. Rejected: Structures carry non-serializable internals (symbol-keyed data, patch/clone methods) that don't map cleanly onto a Redis value, and doing so would make the in-memory and Redis stores behave subtly differently (one holding live references, the other needing constant serialize/deserialize) — exactly the ambiguity discord.js#10983's reviewers flagged and didn't resolve. Keeping Cache raw-only sidesteps it.
  • Depend on @discordjs/cache directly instead of building @wolfstar/plugin-cache. Simpler short-term, but ties @wolfstar/plugin-gateway to a package that's still an active RFC upstream (RFC: Async Caching Layer & Simpler Manager Pattern for @discordjs/next discordjs/discord.js#11426 isn't merged) and gives us no room to add the Redis store or the Structure-aware Manager integration this proposal needs.
  • No abstraction, Redis-only. Rejected — stars-components#51 explicitly asks for both local and Redis storage, and a lot of self-hosted wolfstar deployments won't want to run Redis just to cache a handful of guild objects.

Footnotes

Addendum: verified contributor-branch starting point (September 2026)

The add-cache-package branch contains a concrete packages/cache implementation. Its README demonstrates memory and Redis factories, per-entity stores, custom mappers and handleGatewayDispatch. types.ts defines separate synchronous CacheStore/Cache and asynchronous AsyncCacheStore/AsyncCache interfaces: the branch does not yet implement this RFC's single Awaitable interface.

Reusable pieces include typed per-entity stores, composite key helpers, dispatch-operation mapping, mapper context (event, existing, raw and shardId), and memory/Redis tests. Adapt these while retaining raw storage and Manager-owned Structure construction, consistent with discord.js#11426 review. Reconsider the claim above that Structure DataTemplate fully replaces runtime mappers: the branch allows field selection per cache instance, whereas a DataTemplate is defined by the Structure author.

Gaps before borrowing the Redis path: RedisStore stores JSON and a separate set of keys for enumeration, but its set call has no TTL or compression. Clear and prefix deletion enumerate entries; TTL would also require preventing stale index growth. attachCacheToGateway discards the async result with void; #54 needs awaited, ordered dispatch and observable errors. Compression, bounded memory/TTL and a no-op backend remain wolfstar work items.

Revised decisions

  • Keep get/set/has/delete as the minimal Awaitable storage operations. Be map-like without claiming to implement Map: upstream found return-type conflicts. Make clear, getSize and iteration optional capabilities or document their potentially expensive Redis behavior. The interface above remains illustrative pending this decision.
  • Define Manager-owned partial merge and construction so update events preserve fields and expose independent old/new Structures. Do not promise live reference mutation across memory and Redis. Upstream identity question.
  • Distinguish cache miss from cache failure. A miss returns undefined; Redis outage, parse and decompression errors must be observable. Choose a documented Gateway and Manager policy, such as configurable uncached fallback versus hard error. Upstream error-handling discussion.
  • Specify composite keys, nested references, deletion cascades and reconnect reconciliation for members, voice states, reactions and message authors. See upstream discussion and stale guild report.
  • Benchmark small and large values before choosing gzip or TTL defaults; specify serialization version, Redis namespace and malformed-value handling.

Acceptance checks

  1. Equivalent Manager behavior with no-op, memory and Redis stores; REST-capable fetch works after a miss.
  2. Tests for partial update, composite keys, old/new snapshots, deletion, nested relationships and reconnect.
  3. Defined behavior for Redis outage, timeout, invalid JSON and invalid compressed bytes; per-partition event order in RFC: GatewayClient — Gateway (WebSocket) support for @wolfstar/http-framework #54.
  4. TTL does not leave an ever-growing Redis key index; clear/size behavior and compression trade-offs are documented.

discord.js#11556 separately discusses iteration, partial events and backpressure; it is a proposal, not an accepted resolution of #11426.

Status and roadmap (updated 2026-09-24, after #104)

Implemented in #92 as @wolfstar/plugin-cache; #93 (#54) builds on it. The RFC text above is kept as written; this section records the decisions made while building it and what is still open. Remaining phases are meant to land as small or medium PRs, numbered like #54's roadmap so both issues stay in step.

Decisions taken

  • Raw in, structures out, as proposed: the cache stores raw API payloads only. Managers in RFC: GatewayClient — Gateway (WebSocket) support for @wolfstar/http-framework #54 build the structures.
  • Interface: EntityCache<Raw> has get / set / has / delete / clear / getSize plus keys / values / entries, every method Awaitable. The last three return array snapshots rather than AsyncIterable: simpler to implement for both stores, but a large Redis entity cache gets fully materialised. This goes further than the addendum (optional clear / getSize / iteration), so it is up for revision in phase 4.
  • Cache: 23 entity caches, the ones @discordjs/cache has, minus interactions, which are never cached.
  • Cascades stay outside the cache. applyGatewayDispatch(cache, payload) (on top of createCacheOperations, adapted from the add-cache-package branch) turns a dispatch into upserts, merges and deletes. Any custom Cache gets the cascades for free: CHANNEL_DELETE drops its messages, GUILD_DELETE drops everything of the guild, an unavailable guild delete keeps the data.
  • Reactions and poll votes are counted into the cached message by a new update operation (read, transform, write; nothing is written for an uncached message). Their dispatches only carry the voter's ID, so createCacheOperations / applyGatewayDispatch take an optional { clientUserId } context for the me and me_voted flags (feat: count reactions and poll votes in the cache and emit their gateway events #104).
  • Composite keys: helpers such as memberKey, messageKey, roleKey, voiceStateKey and threadMemberKey are exported.
  • Memory store: createInMemoryCache({ maxSize }) is an LRU, bounded globally or per entity. No TTL.
  • Redis store: createRedisCache({ redis, prefix, compression, compressionThreshold, ttl }) takes a client instance (RedisClientLike, which ioredis Redis and Cluster satisfy, checked by a type test), not RedisOptions, so ioredis is not a dependency. Values are JSON, optionally gzip or brotli above a size threshold. Compressed values carry a tag, so switching compression on or off never breaks reading older values. A sorted set per entity cache indexes keys with their expiry as score.
  • Misses vs failures: a miss returns undefined. Redis errors, invalid JSON and bad compressed bytes throw, and RFC: GatewayClient — Gateway (WebSocket) support for @wolfstar/http-framework #54 surfaces them as an error event.

Addendum criteria: where we are

Phases

  1. ✅ Cache / EntityCache, dispatch operations with cascades, memory and Redis stores (feat: add @wolfstar/plugin-cache, a storage-agnostic entity cache #92).
  2. 🔄 Store hardening (shared with RFC: GatewayClient — Gateway (WebSocket) support for @wolfstar/http-framework #54's phase 4): MULTI, prune on write, CacheValueError, outage/corrupt-value tests, manager-level tests against Redis are in feat: make plugin-cache Redis writes atomic and reads fail loudly #94. Left: a serialization version tag, and deciding which EntityCache methods stay mandatory.
  3. 🔄 Reactions and poll votes counted in the cached message, update operation, clientUserId context (feat: count reactions and poll votes in the cache and emit their gateway events #104).
  • Later: AsyncIterable iteration for large stores, per-instance mappers, TTL for the memory store, compression benchmarks to pick defaults.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions