diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9d040d1b..988cfc62 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -118,6 +118,13 @@ jobs: - run: pnpm run test:cluster:live + # The v2/v3 storage layout again, with both backends on their cluster + # code paths and the entries spread over every master. See + # https://github.com/strapi-community/plugin-rest-cache/pull/239 + - run: pnpm run test:continuity + env: + REDIS_CLUSTER_NODES: 127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002 + linters: runs-on: ubuntu-latest strategy: @@ -269,3 +276,12 @@ jobs: run: pnpm run test:redis:live env: REDIS_URL: redis://127.0.0.1:6379/15 + + # The provider's plugin-redis v2 and v3 backends must share one storage + # layout, or an upgrade orphans every entry cached before it. Needs a + # real Redis, so it runs here rather than in provider_smoke. See + # https://github.com/strapi-community/plugin-rest-cache/pull/239 + - name: Check v2/v3 key continuity + run: pnpm run test:continuity + env: + REDIS_URL: redis://127.0.0.1:6379/15 diff --git a/docs/guide/providers/redis.md b/docs/guide/providers/redis.md index b94384d3..22248d24 100644 --- a/docs/guide/providers/redis.md +++ b/docs/guide/providers/redis.md @@ -158,10 +158,10 @@ The plugin passes an explicit lifetime on every write, taken from `strategy.maxA ### Other options -Anything else in `options` is forwarded to [`@keyv/redis`](https://github.com/jaredwray/keyv/tree/main/packages/redis#options) untouched — `namespace`, `useRedisSets`, and so on. +On plugin-redis v2, anything else in `options` is forwarded to [`@keyv/redis`](https://github.com/jaredwray/keyv/tree/main/packages/redis#options) untouched — `namespace`, `useRedisSets`, and so on. On [plugin-redis v3](#plugin-redis-v3) the provider runs on its own store, which honours `useRedisSets` and ignores the rest. ::: warning -Leave `useRedisSets` enabled. The provider relies on the key-tracking set that `@keyv/redis` maintains, both to enumerate keys efficiently and to purge correctly on a cluster. With it disabled the provider falls back to `SCAN`, which is far slower and, on a cluster, incomplete — see below. +Leave `useRedisSets` enabled. The provider lists keys for every pattern purge, and with the key-tracking set that is a single `SMEMBERS`. With it disabled there is no set to read, so the provider finds its entries with `SCAN` instead — on every master of a cluster, so purges are still complete, but each listing walks the whole keyspace of every node, which is far slower on a large or shared database. ::: ## Key prefixes @@ -239,11 +239,21 @@ Two things differ when clustered, and both are handled for you: Without a `MULTI`, a purge and a response being cached for the same key can interleave, so the provider removes a key from the tracking set _before_ deleting the entry. The other way round, a write landing between the two left a live entry outside the set, which no purge could find until it expired. The trade-off is on the failure path: if Redis fails the delete after the entry has been untracked, the entry stays cached, out of reach of purges, until its `maxAge`. -- **Key listing does not use `SCAN`.** `SCAN` addresses one node, so on a cluster it returns only the portion of the keyspace that node holds — a purge built on it silently misses everything on the other nodes. Reading the tracking set avoids that entirely, because the set is one key and therefore complete. +- **Key listing reads the tracking set.** The set is one key, and therefore complete wherever it lives. `SCAN` addresses one node, so on a cluster it returns only the portion of the keyspace that node holds. -::: warning -This is the concrete reason not to disable `useRedisSets` on a cluster: doing so drops the provider onto the `SCAN` fallback, and purges will quietly under-delete. -::: + With `useRedisSets` disabled there is no set, so the provider sends `SCAN` to every master and combines the results ([#245](https://github.com/strapi-community/plugin-rest-cache/pull/245)). Purges are complete either way; the tracking set is just much cheaper to read. + +## plugin-redis v3 + +[`@strapi-community/plugin-redis`](https://github.com/strapi-community/plugin-redis) v3 moves from ioredis to node-redis. The provider supports v2 and v3 alike — its peer range is `^2.0.0 || ^3.0.0-0` — and needs no configuration change for either: + +- **Detection.** The provider checks for v3's `connection` service at bootstrap, by what it can do rather than by version number. If it is there, the provider waits on `whenReady(connection)`, which rejects with the reason rather than hanging when the connection cannot be established. Otherwise it runs exactly as on v2. +- **Client.** On v3 the provider runs on the connection's node-redis client, `.native`. v3 also offers an ioredis-compatible `.client`, but that is deprecated and removed in v4, so the provider only keeps it on `provider.client` for code typed against it and never sends a command through it. +- **Client swap.** v3 replaces the node-redis client of a cluster or sentinel connection that has to reconnect from scratch. The provider reads `.native` for every command instead of keeping the client it started with, so it follows the swap. +- **Same data.** Entries are stored exactly as on v2 — the same key names, the same tracking set, lifetimes in milliseconds — so entries cached before an upgrade are still read and purged after it, and after a rollback. Cluster deletes, key listing without `useRedisSets` and the tracking-set prune behave as described above. +- **Shared connection.** The client belongs to the redis plugin and other plugins may use it. The provider never closes it. + +Node.js 20 is not supported by plugin-redis v3. On Node 20, stay on plugin-redis v2: the provider keeps working on it as before. ## KeyDB diff --git a/package.json b/package.json index 92638b62..678e33fb 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "docs:build:only": "vitepress build docs", "build:providers": "pnpm --filter \"./packages/provider-*\" run build", "test:cluster:live": "node scripts/redis-cluster-live.cjs", - "test:redis:live": "node scripts/redis-standalone-live.cjs" + "test:redis:live": "node scripts/redis-standalone-live.cjs", + "test:continuity": "node scripts/redis-key-continuity.cjs" }, "license": "MIT", "devDependencies": { diff --git a/packages/provider-rest-cache-redis/package.json b/packages/provider-rest-cache-redis/package.json index 7de6fafd..9df4f7d1 100644 --- a/packages/provider-rest-cache-redis/package.json +++ b/packages/provider-rest-cache-redis/package.json @@ -74,16 +74,19 @@ "keyv": "^5.5.0" }, "devDependencies": { + "@redis/client": "^6.3.0", "@strapi-community/plugin-redis": "^2.0.0", "@strapi-community/plugin-rest-cache": "workspace:*", "@types/node": "^26.2.0", + "ioredis": "^5.11.1", "npm-run-all": "^4.1.5", "prettier": "^3.9.6", "typescript": "^5.9.3" }, "peerDependencies": { "@strapi-community/plugin-rest-cache": "workspace:^", - "@strapi/strapi": "^5.0.0" + "@strapi/strapi": "^5.0.0", + "@strapi-community/plugin-redis": "^2.0.0 || ^3.0.0-0" }, "engines": { "node": ">=20.0.0 <=26.x.x" diff --git a/packages/provider-rest-cache-redis/src/NodeRedisStore.ts b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts new file mode 100644 index 00000000..3f94fb6c --- /dev/null +++ b/packages/provider-rest-cache-redis/src/NodeRedisStore.ts @@ -0,0 +1,413 @@ +import { EventEmitter } from 'events'; + +/** A queued MULTI, as node-redis returns it from `multi()`. */ +export interface NodeRedisMulti { + set(key: string, value: string, options?: NodeRedisSetOptions): NodeRedisMulti; + sAdd(key: string, members: string | string[]): NodeRedisMulti; + sRem(key: string, members: string | string[]): NodeRedisMulti; + unlink(keys: string | string[]): NodeRedisMulti; + exec(): Promise; +} + +interface NodeRedisSetOptions { + expiration?: { type: 'PX'; value: number }; +} + +/** + * The node-redis client, as `@strapi-community/plugin-redis` v3 hands it over + * on `connections[name].native` - a standalone client or a cluster. + * + * Described structurally for the same reason RedisClient is: the connection, + * and therefore the @redis/client version, belongs to the redis plugin. + * + * Only the members this store touches are listed. + */ +export interface NodeRedisClient { + isReady: boolean; + get(key: string): Promise; + mGet(keys: string[]): Promise>; + set(key: string, value: string, options?: NodeRedisSetOptions): Promise; + exists(keys: string | string[]): Promise; + sAdd(key: string, members: string | string[]): Promise; + sRem(key: string, members: string | string[]): Promise; + sMembers(key: string): Promise; + unlink(keys: string | string[]): Promise; + scan( + cursor: string, + options?: { MATCH?: string; COUNT?: number } + ): Promise<{ cursor: string; keys: string[] }>; + sScan( + key: string, + cursor: string, + options?: { MATCH?: string; COUNT?: number } + ): Promise<{ cursor: string; members: string[] }>; + multi(): NodeRedisMulti; + /** On a cluster (@redis/client 5+): one entry per master, for nodeClient(). */ + readonly masters?: unknown[]; + /** On a cluster: the client connected to one node of `masters`. */ + nodeClient?(node: unknown): Promise>; +} + +/** + * The tracking set, as the provider's background prune sees it. + * + * The prune's logic - SSCAN a batch, check which members still exist, SREM the + * dead, re-check and re-add - lives once, in RedisCacheProvider, and runs on + * plugin-redis v2's ioredis client and v3's node-redis client alike through + * this. See https://github.com/strapi-community/plugin-rest-cache/issues/131 + */ +export interface TrackingSet { + /** One SSCAN step: the next cursor ('0' when done) and a batch of members. */ + sscan(cursor: string, count: number): Promise<[string, string[]]>; + /** + * Whether each key exists. Never rejects: a key that could not be checked is + * reported as existing, because untracking a live entry hides it from every + * purge while keeping a dead one only costs a wasted UNLINK later. + */ + existing(keys: string[]): Promise; + srem(members: string[]): Promise; + sadd(members: string[]): Promise; +} + +export interface NodeRedisStoreOptions { + /** Whether `client` is a cluster. Decides which commands are slot-safe. */ + isCluster: boolean; + /** As @keyv/redis' option of the same name. Defaults to true. */ + useRedisSets?: boolean; +} + +const UNLINK_CONCURRENCY = 16; +const CHUNK = 1000; + +/** + * A keyv storage adapter over node-redis that stores exactly what @keyv/redis 3 + * stores. + * + * On plugin-redis v2 the provider runs on @keyv/redis 3 and the plugin's + * ioredis client. Entries written that way outlive an upgrade to plugin-redis + * v3, so this store has to find, read and purge them - and @keyv/redis 3 has to + * do the same with this store's entries after a rollback. Every key name, the + * `namespace:` tracking set, and the PX expiry below mirror + * @keyv/redis@3.0.1, including its `useRedisSets: false` key naming. + * + * @keyv/redis 5 also runs on node-redis but is not usable here: it keeps no + * tracking set, separates keys with "::", and destroys the client it was given + * when a connection fails - which, with a shared plugin connection, would take + * it away from every other consumer. + * + * The client belongs to the redis plugin. Nothing here ever closes it. + * + * @see https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +export class NodeRedisStore extends EventEmitter { + readonly ttlSupport = true; + + /** Set by Keyv. Keyv also prefixes every key with it before calling us. */ + namespace?: string; + + readonly opts: { dialect: 'redis'; url: string; useRedisSets: boolean }; + + /** + * The client to use right now. + * + * plugin-redis v3 replaces the node-redis client of a cluster or sentinel + * connection when it has to reconnect from scratch, destroying the old one; + * `connection.native` is a getter for whichever is current. So the client is + * resolved on every use rather than kept: a reference taken at bootstrap + * would go on pointing at a destroyed client. + */ + get client(): NodeRedisClient { + return this.resolveClient(); + } + + private readonly resolveClient: () => NodeRedisClient; + + readonly isCluster: boolean; + + /** + * @param client the client, or a function returning the current one - pass + * `() => connection.native` for a plugin-redis v3 connection. + */ + constructor( + client: NodeRedisClient | (() => NodeRedisClient), + options: NodeRedisStoreOptions + ) { + super(); + this.resolveClient = typeof client === 'function' ? client : () => client; + this.isCluster = options.isCluster; + // `dialect` is what makes Keyv treat this store as iterable. + this.opts = { + dialect: 'redis', + url: 'redis://', + useRedisSets: options.useRedisSets !== false, + }; + } + + /** The tracking set's key, as @keyv/redis names it. */ + _getNamespace(): string { + return `namespace:${this.namespace}`; + } + + private keyName(key: string): string { + return this.opts.useRedisSets ? key : `sets:${this._getNamespace()}:${key}`; + } + + private static setOptions(ttl?: number): NodeRedisSetOptions | undefined { + // A ttl in milliseconds, written as PX. Never converted. + return typeof ttl === 'number' ? { expiration: { type: 'PX', value: ttl } } : undefined; + } + + async get(key: string): Promise { + const value = await this.client.get(this.keyName(key)); + return value === null ? undefined : value; + } + + async getMany(keys: string[]): Promise> { + const names = keys.map((key) => this.keyName(key)); + // MGET across hash slots is a CROSSSLOT error on a cluster. + if (this.isCluster) { + return Promise.all(names.map((name) => this.client.get(name))); + } + return this.client.mGet(names); + } + + async set(key: string, value: string | undefined, ttl?: number): Promise { + if (value === undefined) { + return; + } + const name = this.keyName(key); + const options = NodeRedisStore.setOptions(ttl); + + if (!this.opts.useRedisSets) { + await this.client.set(name, value, options); + return; + } + + // The entry and the tracking set are two keys in different hash slots, so + // on a cluster they cannot share a MULTI. Write the entry first: a crash in + // between leaves an untracked entry that expires, never a tracked key with + // nothing behind it. + if (this.isCluster) { + await this.client.set(name, value, options); + await this.client.sAdd(this._getNamespace(), name); + return; + } + + await this.client.multi().set(name, value, options).sAdd(this._getNamespace(), name).exec(); + } + + async delete(key: string): Promise { + const name = this.keyName(key); + + if (!this.opts.useRedisSets) { + return (await this.client.unlink(name)) > 0; + } + + // Untrack first, then unlink, as the plugin-redis v2 path does on a + // cluster (#244). Without a MULTI a delete interleaves with a write of the + // same key, and unlinking first could run UNLINK (delete) -> SET, SADD + // (write) -> SREM (delete): a live entry missing from the tracking set, + // which keys() cannot list and so no purge can ever find. In this order the + // race leaves at worst a tracked key with nothing behind it, which the + // prune removes. An error between the two now leaves an untracked entry + // until its maxAge instead; makeClusterSafe in RedisCacheProvider has the + // trade-off. See https://github.com/strapi-community/plugin-rest-cache/issues/100 + if (this.isCluster) { + await this.client.sRem(this._getNamespace(), name); + return (await this.client.unlink(name)) > 0; + } + + const [removed] = await this.client + .multi() + .unlink(name) + .sRem(this._getNamespace(), name) + .exec(); + return Number(removed) > 0; + } + + async deleteMany(keys: string[]): Promise { + const results = await Promise.all(keys.map((key) => this.delete(key))); + return results.every(Boolean); + } + + async has(key: string): Promise { + return (await this.client.exists(this.keyName(key))) !== 0; + } + + async clear(): Promise { + if (this.opts.useRedisSets) { + await this.unlinkTracked(await this.client.sMembers(this._getNamespace())); + return; + } + + // Every key is named under the namespace, as @keyv/redis 3 names it, so + // one pattern finds them all - on every master of a cluster. + const keys = await this.namedKeys(); + if (this.isCluster) { + // A multi-key UNLINK spanning hash slots is CROSSSLOT on a cluster. + for (const batch of chunks(keys, UNLINK_CONCURRENCY)) { + await Promise.all(batch.map((key) => this.client.unlink(key))); + } + return; + } + for (const batch of chunks(keys, CHUNK)) { + await this.client.unlink(batch); + } + } + + /** + * Every entry's raw key when redis sets are off: `sets:namespace::`. + * + * There is no tracking set to read then, so the keys are found by that name. + * SCAN only sees the node it is sent to, so on a cluster each master is + * scanned through its own client; scanning one node made pattern purges miss + * every entry held elsewhere (#245, as on the plugin-redis v2 path). + */ + async namedKeys(): Promise { + const MATCH = `sets:${this._getNamespace()}:*`; + const scanAll = async (node: Pick, found: string[]) => { + let cursor = '0'; + do { + const reply = await node.scan(cursor, { MATCH, COUNT: CHUNK }); + cursor = String(reply.cursor); + found.push(...reply.keys); + } while (cursor !== '0'); + }; + + const found: string[] = []; + if (!this.isCluster) { + await scanAll(this.client, found); + return found; + } + + const { masters } = this.client; + if (!masters || typeof this.client.nodeClient !== 'function') { + // Better a failed purge than one that reports success having looked at + // a single node. + throw new Error( + 'REST Cache provider "redis": this node-redis cluster client cannot list its masters' + ); + } + for (const master of masters) { + await scanAll(await this.client.nodeClient(master), found); + } + return found; + } + + /** + * Yields [key, value] for keys under `namespace`, as @keyv/redis 3 does. + * + * Kept for Keyv's own iterator(). The provider never calls it: it reads the + * tracking set, or namedKeys() when redis sets are off. SCAN addresses a + * single node, so on a cluster this would silently return part of the + * keyspace - refuse rather than mislead. + */ + async *iterator(namespace?: string): AsyncGenerator<[string, string | null]> { + if (this.isCluster) { + throw new Error( + 'REST Cache provider "redis": enumerating keys on a cluster needs useRedisSets enabled' + ); + } + + let cursor = '0'; + do { + const reply = await this.client.scan(cursor, { MATCH: `${namespace}:*` }); + cursor = String(reply.cursor); + if (reply.keys.length > 0) { + const values = await this.client.mGet(reply.keys); + for (let i = 0; i < reply.keys.length; i += 1) { + yield [reply.keys[i], values[i]]; + } + } + } while (cursor !== '0'); + } + + /** + * Remove fully qualified keys and their tracking-set members. + * + * One MULTI per chunk on a standalone server. A cluster rejects a multi-key + * UNLINK or a MULTI spanning hash slots, so there each key is unlinked on its + * own; the tracking set is a single key, so SREM against it stays batched. + * See https://github.com/strapi-community/plugin-rest-cache/issues/100 + */ + async unlinkTracked(qualified: string[]): Promise { + if (!qualified.length) return; + const set = this._getNamespace(); + + // Untracked before anything is unlinked, for the reason given in + // delete(): unlinking first loses entries to a racing write (#244). + if (this.isCluster) { + for (const batch of chunks(qualified, CHUNK)) { + await this.client.sRem(set, batch); + } + for (const batch of chunks(qualified, UNLINK_CONCURRENCY)) { + await Promise.all(batch.map((key) => this.client.unlink(key))); + } + return; + } + + for (const batch of chunks(qualified, CHUNK)) { + await this.client.multi().unlink(batch).sRem(set, batch).exec(); + } + } + + /** Every tracked key, fully qualified. */ + async trackedKeys(): Promise { + return this.client.sMembers(this._getNamespace()); + } + + /** + * The tracking set, for the provider's prune; undefined when there is none. + * + * Every command resolves `this.client` as it is sent, never once per pass: + * a pass takes seconds on a large set, and plugin-redis v3 may replace the + * client in the middle of one. + */ + trackingSet(): TrackingSet | undefined { + if (!this.opts.useRedisSets || !this.namespace) return undefined; + const set = this._getNamespace(); + + return { + sscan: async (cursor, count) => { + const reply = await this.client.sScan(set, cursor, { COUNT: count }); + return [String(reply.cursor), reply.members]; + }, + // One EXISTS per key: a variadic EXISTS only returns a count, and on a + // cluster it would span hash slots. Issued together, so node-redis sends + // them as one pipeline on a standalone server (it batches every command + // made in the same tick) and routes each to its node on a cluster. + existing: async (keys) => { + try { + return await Promise.all( + keys.map((key) => + this.client.exists(key).then( + (count) => Number(count) > 0, + () => true + ) + ) + ); + } catch { + return keys.map(() => true); + } + }, + // One key, the set, so a batched SREM or SADD is slot-safe on a cluster. + srem: (members) => this.client.sRem(set, members), + sadd: (members) => this.client.sAdd(set, members), + }; + } + + get ready(): boolean { + return this.client.isReady === true; + } + + /** The client is the redis plugin's, shared with every other consumer. */ + async disconnect(): Promise {} +} + +function chunks(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +} diff --git a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts index 94b1640d..2cdd9c1e 100644 --- a/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts +++ b/packages/provider-rest-cache-redis/src/RedisCacheProvider.ts @@ -2,6 +2,8 @@ import { createCache, type Cache } from 'cache-manager'; import { CacheProvider } from '@strapi-community/plugin-rest-cache/types'; import type { CacheKey, Milliseconds } from '@strapi-community/plugin-rest-cache/types'; +import { NodeRedisStore, type NodeRedisClient, type TrackingSet } from './NodeRedisStore'; + // keyv 4 exports the constructor directly, keyv 5 exports it as `.default`, and // @keyv/redis exposes only `.default` from its CJS build. Accept either shape: // which one we get depends on what else in the host application's dependency @@ -221,7 +223,8 @@ interface PruneState { } /** - * Prune bookkeeping, keyed by the @keyv/redis store. + * Prune bookkeeping, keyed by the store: @keyv/redis, or NodeRedisStore on + * plugin-redis v3. * * Not a field on the provider: the plugin freezes the instance it gets back, * so it could never be written after construction. @@ -252,6 +255,31 @@ function prunable(store: KeyvRedisStore): store is KeyvRedisStore & { redis: Red ); } +/** + * The tracking set of @keyv/redis on plugin-redis v2's ioredis client, as the + * prune drives it. The same commands the prune always issued there; v3's + * node-redis store provides its own (NodeRedisStore#trackingSet). + */ +function ioredisTrackingSet(redis: RedisClient, set: string): TrackingSet { + return { + sscan: (cursor, count) => redis.sscan!(set, cursor, 'COUNT', count), + existing: (keys) => existing(redis, keys), + srem: (members) => redis.srem(set, ...members), + sadd: (members) => redis.sadd(set, ...members), + }; +} + +/** + * The tracking set to prune, on whichever store the cache runs on, or + * undefined when it keeps none this provider knows how to prune. + */ +function trackingSetOf(store: object): TrackingSet | undefined { + if (store instanceof NodeRedisStore) return store.trackingSet(); + const keyvRedis = store as KeyvRedisStore; + if (!prunable(keyvRedis)) return undefined; + return ioredisTrackingSet(keyvRedis.redis, keyvRedis._getNamespace!()); +} + /** * Put members back in the tracking set, trying twice. * @@ -260,10 +288,10 @@ function prunable(store: KeyvRedisStore): store is KeyvRedisStore & { redis: Red * purges and writes, and a member it could not restore is the one residual * case documented on untrackDead. */ -async function retrack(redis: RedisClient, set: string, members: string[]): Promise { +async function retrack(tracking: TrackingSet, members: string[]): Promise { for (let attempt = 0; attempt < 2; attempt++) { try { - await redis.sadd(set, ...members); + await tracking.sadd(members); return; } catch { // Tried again once; see above. @@ -289,23 +317,23 @@ async function retrack(redis: RedisClient, set: string, members: string[]): Prom * script would close it on a standalone server, but not on a cluster, where * the entries live in other slots than the set. */ -async function untrackDead(redis: RedisClient, set: string, members: string[]): Promise { - const found = await existing(redis, members); +async function untrackDead(tracking: TrackingSet, members: string[]): Promise { + const found = await tracking.existing(members); const dead = members.filter((_, i) => !found[i]); if (!dead.length) return; try { // One key, the set, so a batched SREM is slot-safe on a cluster too. - await redis.srem(set, ...dead); + await tracking.srem(dead); } catch { // It may still have applied; the re-check below decides. } // existing() reports every member it could not check as live, so a failed // re-check re-tracks all of them. - const recheck = await existing(redis, dead); + const recheck = await tracking.existing(dead); const rewritten = dead.filter((_, i) => recheck[i]); - if (rewritten.length) await retrack(redis, set, rewritten); + if (rewritten.length) await retrack(tracking, rewritten); } /** @@ -327,13 +355,13 @@ async function untrackDead(redis: RedisClient, set: string, members: string[]): * * See https://github.com/strapi-community/plugin-rest-cache/issues/131 */ -async function pruneTrackingSet(redis: RedisClient, set: string): Promise { +async function pruneTrackingSet(tracking: TrackingSet): Promise { try { let cursor = '0'; do { - const [next, batch] = await redis.sscan!(set, cursor, 'COUNT', PRUNE_BATCH); + const [next, batch] = await tracking.sscan(cursor, PRUNE_BATCH); cursor = next; - if (batch.length) await untrackDead(redis, set, batch); + if (batch.length) await untrackDead(tracking, batch); } while (cursor !== '0'); } catch { // See above: the next pass picks up where this one could not. @@ -349,20 +377,40 @@ async function pruneTrackingSet(redis: RedisClient, set: string): Promise * members took a pattern purge from ~70ms to 0.5s standalone and over 2s on a * cluster, on every content save. */ -function startPrune(store: KeyvRedisStore, interval: number): Promise | null { - if (!prunable(store)) return null; +function startPrune(store: object, interval: number): Promise | null { + const tracking = trackingSetOf(store); + if (!tracking) return null; const state = pruneStateOf(store); if (state.running) return state.running; if (Date.now() - state.lastStarted < interval) return null; state.lastStarted = Date.now(); - state.running = pruneTrackingSet(store.redis, store._getNamespace!()).finally(() => { + state.running = pruneTrackingSet(tracking).finally(() => { state.running = null; }); return state.running; } +/** The store, when the cache runs on plugin-redis v3's node-redis client. */ +const nodeRedisStoreOf = (cache: Cache): NodeRedisStore | undefined => { + const store: unknown = cache.stores[0].opts.store; + return store instanceof NodeRedisStore ? store : undefined; +}; + +/** + * plugin-redis v3's node-redis connection, `connections[name].native`. + * + * When given, the cache runs on it through NodeRedisStore. Without it the + * provider runs on @keyv/redis 3 and the ioredis client, as on plugin-redis v2. + * See https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +export interface NativeRedisConnection { + /** The client, or a function returning the current one; see NodeRedisStore#client. */ + client: NodeRedisClient | (() => NodeRedisClient); + isCluster: boolean; +} + /** Everything accepted under `provider.options` in the plugin configuration. */ export interface RedisCacheProviderOptions { /** Name of a connection declared on the redis plugin. */ @@ -377,14 +425,35 @@ export class RedisCacheProvider extends CacheProvider { private readonly cache: Cache; - constructor(client: RedisClient, options: RedisCacheProviderOptions) { + /** + * @param client the ioredis client: plugin-redis v2's connection, or v3's + * deprecated ioredis-compatible shim. Kept as `client` for anyone typed + * against it; with `native` given it is never used. + * @param native plugin-redis v3's node-redis connection, when there is one. + */ + constructor( + client: RedisClient, + options: RedisCacheProviderOptions, + native?: NativeRedisConnection + ) { super(); const { ttl, ...adapterOptions } = options; - const store = new KeyvRedis(client, adapterOptions); - if (client.isCluster) { - makeClusterSafe(store); + // On plugin-redis v3 the ioredis client is only its deprecated shim, so + // @keyv/redis is never built over it: NodeRedisStore runs on `.native`. + let store: unknown; + if (native) { + store = new NodeRedisStore(native.client, { + isCluster: native.isCluster, + useRedisSets: adapterOptions.useRedisSets !== false, + }); + } else { + const keyvRedis = new KeyvRedis(client, adapterOptions); + if (client.isCluster) { + makeClusterSafe(keyvRedis); + } + store = keyvRedis; } this.client = client; @@ -441,6 +510,16 @@ export class RedisCacheProvider extends CacheProvider { async delMany(keys: Array): Promise { if (!keys.length) return; + const nodeStore = nodeRedisStoreOf(this.cache); + if (nodeStore?.opts.useRedisSets && nodeStore.namespace) { + await nodeStore.unlinkTracked(keys.map((key) => `${nodeStore.namespace}:${key}`)); + return; + } + if (nodeStore) { + await this.cache.mdel(keys as string[]); + return; + } + const store = redisStoreOf(this.cache); const namespace = store._getNamespace?.(); @@ -511,6 +590,25 @@ export class RedisCacheProvider extends CacheProvider { // trip here. // // See https://github.com/strapi-community/plugin-rest-cache/issues/131 + const nodeStore = nodeRedisStoreOf(this.cache); + if (nodeStore) { + // The same keys, found the same way, as on @keyv/redis below: by their + // `sets:` names when redis sets are off (#245), otherwise from the + // tracking set, pruned in the background (#131). + const prefix = `${nodeStore.namespace}:`; + const unqualified = (key: string) => + key.startsWith(prefix) ? key.slice(prefix.length) : key; + + if (!nodeStore.opts.useRedisSets) { + const named = `sets:${nodeStore._getNamespace()}:`; + return (await nodeStore.namedKeys()).map((key) => unqualified(key.slice(named.length))); + } + + const members = await nodeStore.trackedKeys(); + startPrune(nodeStore, PRUNE_INTERVAL); + return members.map(unqualified); + } + const store = redisStoreOf(this.cache); const namespace = store._getNamespace?.(); @@ -578,6 +676,12 @@ export class RedisCacheProvider extends CacheProvider { } async clear(): Promise { + // NodeRedisStore.clear() is already slot-safe on a cluster. + if (nodeRedisStoreOf(this.cache)) { + await this.cache.clear(); + return; + } + const store = redisStoreOf(this.cache); // @keyv/redis's clear() unlinks every key in a single multi-key command, @@ -593,6 +697,11 @@ export class RedisCacheProvider extends CacheProvider { } get ready(): boolean { + const nodeStore = nodeRedisStoreOf(this.cache); + if (nodeStore) { + return nodeStore.ready; + } + const client = redisStoreOf(this.cache).redis; return client.status === 'ready'; } diff --git a/packages/provider-rest-cache-redis/src/index.ts b/packages/provider-rest-cache-redis/src/index.ts index d7fb5b62..380f4a0f 100644 --- a/packages/provider-rest-cache-redis/src/index.ts +++ b/packages/provider-rest-cache-redis/src/index.ts @@ -3,6 +3,7 @@ import { type RedisCacheProviderOptions, type RedisClient, } from './RedisCacheProvider'; +import type { NodeRedisClient } from './NodeRedisStore'; /** * The Strapi global. @@ -14,11 +15,41 @@ declare const strapi: { log: { info(message: string): void }; }; +/** plugin-redis v3's connection service - the part this provider uses. */ +interface RedisConnectionService { + whenReady( + name: string + ): Promise<{ client: RedisClient; native: NodeRedisClient; type?: string }>; +} + /** Strapi as this provider needs to see it: whatever the redis plugin added. */ interface StrapiWithRedis { + log: { info(message: string): void }; redis?: { connections: Record; }; + plugin?(name: string): { service?(name: string): unknown } | undefined; +} + +/** + * plugin-redis v3's connection service, or undefined on v2. + * + * Detected by capability, not by version: v3 registers a `connection` service + * with `whenReady`, v2 registers none. The provider initialises in bootstrap, + * after every plugin's register, so the service exists by then if it ever will. + * See https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +function v3ConnectionService(strapi: StrapiWithRedis): RedisConnectionService | undefined { + try { + const service = strapi.plugin?.('redis')?.service?.('connection') as + | Partial + | undefined; + return typeof service?.whenReady === 'function' + ? (service as RedisConnectionService) + : undefined; + } catch { + return undefined; + } } function waitForRedis(client: RedisClient): Promise { @@ -60,6 +91,30 @@ export = { } const connectionName = options.connection || 'default'; + + // plugin-redis v3: wait through its own service, which rejects rather than + // hanging, and run on the node-redis client. Its ioredis shim is kept as + // `provider.client` only, for anyone typed against it. + const connections = v3ConnectionService(strapi); + if (connections) { + let connection: Awaited>; + try { + connection = await connections.whenReady(connectionName); + } catch (error) { + throw new Error( + `Could not initialize REST Cache provider "redis": ${(error as Error)?.message}` + ); + } + + strapi.log.info('REST Cache provider "redis": connection established'); + return new RedisCacheProvider(connection.client, options, { + // Read on every use, never kept: v3 swaps the client of a cluster or + // sentinel connection that has to reconnect from scratch. + client: () => connection.native, + isCluster: connection.type === 'cluster', + }); + } + const { client } = strapi.redis.connections[connectionName] ?? {}; if (!client) { diff --git a/playgrounds/redis/config/plugins.js b/playgrounds/redis/config/plugins.js index efd06666..5be4441f 100644 --- a/playgrounds/redis/config/plugins.js +++ b/playgrounds/redis/config/plugins.js @@ -6,7 +6,6 @@ module.exports = ({ env }) => ({ // https://github.com/strapi-community/plugin-rest-cache/issues/119 redis: { config: { - debug: true, connections: { default: { connection: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aad52992..886e981c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: specifier: ^5.5.0 version: 5.6.0 devDependencies: + '@redis/client': + specifier: ^6.3.0 + version: 6.3.0(@opentelemetry/api@1.9.1) '@strapi-community/plugin-redis': specifier: ^2.0.0 version: 2.0.0(@strapi/strapi@5.57.0(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@26.6.5)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(esbuild@0.28.2)(hono@4.13.1)(jiti@2.7.0)(koa@2.16.4)(lightningcss@1.32.0)(postcss@8.5.29)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.51.2)(ts-toolbelt@9.6.0)(type-fest@4.41.0)(yaml@2.9.1)) @@ -178,6 +181,9 @@ importers: '@types/node': specifier: ^26.2.0 version: 26.6.5 + ioredis: + specifier: ^5.11.1 + version: 5.11.1 npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -2681,6 +2687,18 @@ packages: '@react-dnd/shallowequal@4.0.2': resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==} + '@redis/client@6.3.0': + resolution: {integrity: sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + '@reduxjs/toolkit@1.9.7': resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==} peerDependencies: @@ -12531,6 +12549,10 @@ snapshots: '@react-dnd/shallowequal@4.0.2': {} + '@redis/client@6.3.0(@opentelemetry/api@1.9.1)': + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@reduxjs/toolkit@1.9.7(react-redux@8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1))(react@18.3.1)': dependencies: immer: 9.0.21 diff --git a/scripts/provider-smoke.cjs b/scripts/provider-smoke.cjs index 6f847291..4cdfa1b8 100644 --- a/scripts/provider-smoke.cjs +++ b/scripts/provider-smoke.cjs @@ -81,12 +81,156 @@ async function redisProviderModuleLoads() { assert.strictEqual(typeof RedisCacheProvider, 'function'); } +/** + * The redis provider runs on plugin-redis v3's node-redis connection when v3 is + * installed, and on v2's ioredis client otherwise. Neither path needs a server + * to choose; these drive init() against stand-ins for each plugin version. + * See https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +function redisProviderModule() { + return { + provider: require(path.join(ROOT, 'packages/provider-rest-cache-redis/dist/index.js')), + ...require(path.join(ROOT, 'packages/provider-rest-cache-redis/dist/NodeRedisStore.js')), + }; +} + +const quietStrapi = (extra) => ({ log: { info() {}, warn() {} }, ...extra }); + +/** An ioredis client that must never be used. */ +const untouchable = new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') return undefined; + throw new Error(`touched the ioredis client (.${String(prop)})`); + }, + } +); + +function v3Strapi({ whenReady }) { + return quietStrapi({ + redis: { connections: {} }, + plugin: (name) => + name === 'redis' + ? { service: (svc) => (svc === 'connection' ? { whenReady } : undefined) } + : undefined, + }); +} + +async function redisProviderUsesV3Native() { + const { provider, NodeRedisStore } = redisProviderModule(); + const native = { isReady: true }; + const asked = []; + const strapi = v3Strapi({ + whenReady: async (name) => { + asked.push(name); + return { client: untouchable, native, type: 'standalone', ready: true }; + }, + }); + + const instance = await provider.init({ connection: 'cache' }, { strapi }); + const store = instance.cache.stores[0].opts.store; + + assert.deepStrictEqual(asked, ['cache'], 'should wait on the configured connection'); + assert.ok(store instanceof NodeRedisStore, 'v3 should run on NodeRedisStore'); + assert.strictEqual(store.client, native, 'v3 should use connections[name].native'); + assert.strictEqual(store.isCluster, false); + assert.strictEqual(instance.ready, true); +} + +// plugin-redis v3 replaces the node-redis client of a cluster or sentinel +// connection when it has to reconnect from scratch, and `connection.native` +// is a getter for whichever client is current. A store that kept the client +// it was first given would go on using a destroyed one. +async function redisProviderFollowsV3ClientSwap() { + const { provider } = redisProviderModule(); + const before = { isReady: true, async get() { return 'before'; } }; + const after = { isReady: true, async get() { return 'after'; } }; + let current = before; + const connection = { client: untouchable, type: 'cluster', ready: true }; + Object.defineProperty(connection, 'native', { get: () => current, enumerable: false }); + const strapi = v3Strapi({ whenReady: async () => connection }); + + const instance = await provider.init({}, { strapi }); + const store = instance.cache.stores[0].opts.store; + assert.strictEqual(await store.get('k'), 'before'); + + current = after; + assert.strictEqual(store.client, after, 'the store kept the replaced client'); + assert.strictEqual(await store.get('k'), 'after'); + + after.isReady = false; + assert.strictEqual(instance.ready, false, 'ready should follow the current client'); +} + +async function redisProviderDetectsV3Cluster() { + const { provider } = redisProviderModule(); + const strapi = v3Strapi({ + whenReady: async () => ({ client: untouchable, native: { isReady: true }, type: 'cluster' }), + }); + + const instance = await provider.init({}, { strapi }); + assert.strictEqual(instance.cache.stores[0].opts.store.isCluster, true); +} + +async function redisProviderReportsV3Failure() { + const { provider } = redisProviderModule(); + const strapi = v3Strapi({ + whenReady: async () => { + throw new Error('connection "default" failed: ECONNREFUSED'); + }, + }); + + await assert.rejects(provider.init({}, { strapi }), (error) => { + assert.match(error.message, /Could not initialize REST Cache provider "redis"/); + assert.match(error.message, /ECONNREFUSED/); + return true; + }); +} + +async function redisProviderKeepsV2Path() { + const { provider, NodeRedisStore } = redisProviderModule(); + const listeners = {}; + const client = { + status: 'ready', + options: { family: 4 }, + on(event, fn) { + (listeners[event] ||= []).push(fn); + }, + once() {}, + off() {}, + }; + // plugin-redis v2 registers no "connection" service. + const strapi = quietStrapi({ + redis: { connections: { default: { client } } }, + plugin: (name) => (name === 'redis' ? { service: () => undefined } : undefined), + }); + + // The v2 path logs through the global, as it always has. + global.strapi = strapi; + let instance; + try { + instance = await provider.init({}, { strapi }); + } finally { + delete global.strapi; + } + const store = instance.cache.stores[0].opts.store; + + assert.ok(!(store instanceof NodeRedisStore), 'v2 should stay on @keyv/redis'); + assert.strictEqual(store.redis, client, 'v2 should use connections[name].client'); +} + (async () => { console.log(`provider smoke test - Node ${process.version}\n`); await record('memory provider: set/get/keys/del', memoryProvider); await record('memory provider: honours maxAge in ms', memoryProviderRespectsTtl); await record('redis provider: module loads', redisProviderModuleLoads); + await record('redis provider: uses plugin-redis v3 native client', redisProviderUsesV3Native); + await record('redis provider: detects a v3 cluster from .type', redisProviderDetectsV3Cluster); + await record('redis provider: follows a v3 client swap', redisProviderFollowsV3ClientSwap); + await record('redis provider: reports a v3 connection failure', redisProviderReportsV3Failure); + await record('redis provider: keeps the plugin-redis v2 path', redisProviderKeepsV2Path); for (const [ok, name, detail] of results) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`); diff --git a/scripts/redis-cluster-check.cjs b/scripts/redis-cluster-check.cjs index 698cea11..93d46a69 100644 --- a/scripts/redis-cluster-check.cjs +++ b/scripts/redis-cluster-check.cjs @@ -109,6 +109,166 @@ function recordingClient({ isCluster }) { return client; } +/** + * A node-redis client, standalone or cluster, that records what it is asked. + * Same command names and argument shapes as @redis/client: arrays for + * multi-key commands, camelCase methods, a chainable multi(). + */ +function recordingNodeRedis({ isCluster, masters = 3 }) { + const issued = []; + const members = new Set(); + // The entries themselves, as distinct from the tracking set's members. + const entries = new Set(); + const flat = (k) => [k].flat(); + + const run = { + set: (key) => { + issued.push({ cmd: 'set', keys: [key] }); + entries.add(key); + return 'OK'; + }, + sAdd: (_set, m) => { + issued.push({ cmd: 'sadd', keys: flat(m) }); + for (const k of flat(m)) members.add(k); + return flat(m).length; + }, + sRem: (_set, m) => { + issued.push({ cmd: 'srem', keys: flat(m) }); + // Before the members go, as on the ioredis recorder: a write landing + // between the prune's check and its SREM. + if (client.onSrem) client.onSrem(); + for (const k of flat(m)) members.delete(k); + return flat(m).length; + }, + unlink: (k) => { + issued.push({ cmd: 'unlink', keys: flat(k) }); + for (const key of flat(k)) { + members.delete(key); + entries.delete(key); + } + return flat(k).length; + }, + }; + + /** + * One client per master, each holding the entries that hash to it - here, + * every `masters`-th one. SCAN sent to the cluster object itself would only + * see whichever node it landed on, so it throws. + */ + const nodes = Array.from({ length: masters }, (_, i) => ({ address: `node-${i}` })); + const nodeClients = nodes.map((node, i) => ({ + async scan(cursor, options = {}) { + issued.push({ cmd: 'scan', node: node.address, cursor, keys: [] }); + const pattern = String(options.MATCH || '*').replace(/\*$/, ''); + const held = [...entries].filter((_, n) => n % masters === i); + return { cursor: '0', keys: held.filter((key) => key.startsWith(pattern)) }; + }, + async unlink(k) { + return run.unlink(k); + }, + })); + + const client = { + isCluster, + isReady: true, + issued, + members, + entries, + onSrem: null, + seed(keys) { + for (const key of keys) { + members.add(key); + entries.add(key); + } + }, + /** An entry Redis expired: gone, but still a member of the tracking set. */ + expire(key) { + entries.delete(key); + }, + async exists(k) { + issued.push({ cmd: 'exists', keys: flat(k) }); + return flat(k).filter((key) => entries.has(key)).length; + }, + async sScan(_set, cursor, options = {}) { + // Pages of COUNT, cursor = offset: enough to drive a multi-page pass. + issued.push({ cmd: 'sscan', cursor, keys: [] }); + const all = [...members]; + const start = Number(cursor); + const next = start + Number(options.COUNT || 10); + return { cursor: next >= all.length ? '0' : String(next), members: all.slice(start, next) }; + }, + async keys(pattern) { + issued.push({ cmd: 'keys', keys: [] }); + const prefix = pattern.replace(/\*$/, ''); + return [...entries].filter((key) => key.startsWith(prefix)); + }, + ...(isCluster + ? { + masters: nodes, + async nodeClient(node) { + return nodeClients[nodes.indexOf(node)]; + }, + } + : {}), + async get(key) { + issued.push({ cmd: 'get', keys: [key] }); + return null; + }, + async mGet(keys) { + issued.push({ cmd: 'mget', keys }); + return keys.map(() => null); + }, + async set(key) { + return run.set(key); + }, + async sAdd(set, m) { + return run.sAdd(set, m); + }, + async sRem(set, m) { + return run.sRem(set, m); + }, + async unlink(k) { + return run.unlink(k); + }, + async sMembers() { + return [...members]; + }, + async scan(cursor, options) { + // On a cluster, only the node the command lands on: the first. + if (isCluster) { + const reply = await nodeClients[0].scan(cursor, options); + issued[issued.length - 1].node = 'cluster'; + return reply; + } + issued.push({ cmd: 'scan', cursor, keys: [] }); + const pattern = String((options && options.MATCH) || '*').replace(/\*$/, ''); + return { cursor: '0', keys: [...entries].filter((key) => key.startsWith(pattern)) }; + }, + multi() { + const queued = []; + const chain = { + set: (key) => (queued.push({ cmd: 'set', keys: [key] }), chain), + sAdd: (set, m) => (queued.push({ cmd: 'sadd', set, keys: flat(m) }), chain), + sRem: (set, m) => (queued.push({ cmd: 'srem', set, keys: flat(m) }), chain), + unlink: (k) => (queued.push({ cmd: 'unlink', keys: flat(k) }), chain), + async exec() { + issued.push({ cmd: 'multi', queued }); + for (const q of queued) { + if (q.cmd === 'unlink' || q.cmd === 'srem') for (const k of q.keys) members.delete(k); + if (q.cmd === 'unlink') for (const k of q.keys) entries.delete(k); + if (q.cmd === 'set') for (const k of q.keys) entries.add(k); + if (q.cmd === 'sadd') for (const k of q.keys) members.add(k); + } + return queued.map(() => 1); + }, + }; + return chain; + }, + }; + + return client; +} + /** Swap the provider's underlying store for our recorder. */ function withClient(provider, client) { provider.cache.stores[0].opts.store = { @@ -444,6 +604,223 @@ async function check(name, fn) { assert.strictEqual(multis[0].queued[0].keys.length, KEYS.length); }); + // plugin-redis v3: the same guarantees on the node-redis store. Driven + // through a real provider, constructed the way init() does on v3, so the + // store and its wiring are what is under test. See + // https://github.com/strapi-community/plugin-rest-cache/pull/239 + const v3 = (isCluster) => { + const client = recordingNodeRedis({ isCluster }); + const provider = new RedisCacheProvider(null, {}, { client, isCluster }); + return { client, provider }; + }; + + await check('v3 cluster: set() never uses MULTI', async () => { + const { client, provider } = v3(true); + await provider.set(KEYS[0], { a: 1 }, 60000); + + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'set() used MULTI'); + assert.ok(client.members.has(qualified[0]), 'set() did not track the key'); + }); + + await check('v3 cluster: delMany issues no multi-key command', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.delMany(KEYS); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], `issued ${JSON.stringify(offending[0])}`); + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'used MULTI'); + assert.strictEqual(client.issued.filter((c) => c.cmd === 'unlink').length, KEYS.length); + assert.ok(client.issued.filter((c) => c.cmd === 'srem').length < KEYS.length); + }); + + await check('v3 cluster: clear() deletes key by key', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.clear(); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], 'clear() issued a multi-key command'); + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'clear() used MULTI'); + assert.strictEqual(client.members.size, 0, 'clear() left tracked keys'); + }); + + await check('v3 cluster: keys() reads the tracking set, never SCAN', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + const keys = await provider.keys(); + + assert.strictEqual(client.issued.some((c) => c.cmd === 'scan'), false); + assert.strictEqual(keys.length, KEYS.length); + assert.ok(keys.every((k) => !k.startsWith('keyv:')), 'keys should be unqualified'); + }); + + await check('v3 cluster: reading several entries never uses MGET', async () => { + const { client, provider } = v3(true); + await provider.cache.mget(KEYS.slice(0, 5)); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], `issued ${JSON.stringify(offending[0])}`); + }); + + await check('v3 standalone: delMany still batches into MULTI', async () => { + const { client, provider } = v3(false); + client.seed(qualified); + await provider.delMany(KEYS); + + const multis = client.issued.filter((c) => c.cmd === 'multi'); + assert.strictEqual(multis.length, 1, 'expected a single batched MULTI'); + assert.strictEqual(multis[0].queued[0].keys.length, KEYS.length); + }); + + // The v3 store is not one MULTI per write or delete on a cluster either, so + // the same order applies, for the same reason: UNLINK (delete) -> SET, SADD + // (write) -> SREM (delete) leaves a live entry no purge can find. + await check('v3 cluster: delMany untracks before it unlinks', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.delMany(KEYS); + + const order = client.issued.map((c) => c.cmd); + const lastSrem = order.lastIndexOf('srem'); + const firstUnlink = order.indexOf('unlink'); + assert.ok(lastSrem !== -1 && firstUnlink !== -1, `unexpected commands: ${order.join(',')}`); + assert.ok(lastSrem < firstUnlink, 'unlinked an entry before removing it from the tracking set'); + }); + + await check('v3 cluster: del() untracks before it unlinks', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.del(KEYS[0]); + + const order = client.issued.map((c) => c.cmd).filter((cmd) => cmd === 'srem' || cmd === 'unlink'); + assert.deepStrictEqual(order, ['srem', 'unlink'], `issued ${order.join(' -> ')}`); + assert.ok(!client.members.has(qualified[0]), 'still tracked after del()'); + }); + + await check('v3 cluster: clear() untracks before it unlinks', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + await provider.clear(); + + const order = client.issued.map((c) => c.cmd); + assert.ok(order.lastIndexOf('srem') < order.indexOf('unlink'), `issued ${order.join(',')}`); + }); + + await check('v3 cluster: prune() untracks expired entries, checking key by key', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + client.expire(qualified[0]); + + await provider.prune(); + const keys = await provider.keys(); + assert.ok(!keys.includes(KEYS[0]), 'listed an expired entry after a prune'); + assert.strictEqual(keys.length, KEYS.length - 1, `listed ${keys.length} of ${KEYS.length - 1} live entries`); + + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], 'the prune issued a multi-key command'); + assert.strictEqual(client.issued.some((c) => c.cmd === 'multi'), false, 'used MULTI'); + assert.ok( + client.issued.some((c) => c.cmd === 'srem' && c.keys.includes(qualified[0])), + 'the expired entry was not removed from the tracking set' + ); + }); + + await check('v3 cluster: prune() keeps an entry written while it was being pruned', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + client.expire(qualified[0]); + client.onSrem = () => { + client.onSrem = null; + client.seed([qualified[0]]); + }; + + await provider.prune(); + assert.strictEqual(client.onSrem, null, 'the prune issued no SREM'); + assert.ok(client.members.has(qualified[0]), 'the rewritten entry was left out of the tracking set'); + }); + + await check('v3: prune() re-tracks what it removed when the re-check fails', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + client.expire(qualified[0]); + client.onSrem = () => { + client.onSrem = null; + client.seed([qualified[0]]); + client.exists = async () => { + throw new Error('connection lost'); + }; + }; + + await provider.prune(); + // Without this, a prune that never untracked anything would pass. + assert.strictEqual(client.onSrem, null, 'the prune issued no SREM'); + assert.ok(client.members.has(qualified[0]), 'a possibly-live member was left untracked'); + }); + + await check('v3: set() starts a background prune, at most once per interval', async () => { + const { client, provider } = v3(true); + client.seed(qualified); + const passes = () => client.issued.filter((c) => c.cmd === 'sscan' && c.cursor === '0').length; + for (let i = 0; i < 3; i++) { + await provider.set(KEYS[i], { i }, 60000); + await provider.keys(); + await new Promise((resolve) => setImmediate(resolve)); + } + assert.strictEqual(passes(), 1, `started ${passes()} passes`); + }); + + // plugin-redis v3 replaces the node-redis client of a cluster connection + // that has to reconnect from scratch, and destroys the old one. A prune that + // took the client once, at the start of a pass, would go on sending to the + // destroyed one for the rest of it. + await check('v3: prune() resolves the client for every command', async () => { + const before = recordingNodeRedis({ isCluster: true }); + const after = recordingNodeRedis({ isCluster: true }); + let current = before; + const provider = new RedisCacheProvider(null, {}, { client: () => current, isCluster: true }); + for (const client of [before, after]) { + client.seed(qualified); + client.expire(qualified[0]); + } + const sScan = before.sScan; + before.sScan = async (...args) => { + const reply = await sScan(...args); + current = after; + return reply; + }; + + await provider.prune(); + const late = before.issued.filter((c) => c.cmd !== 'sscan'); + assert.deepStrictEqual(late, [], `sent ${late.map((c) => c.cmd).join(',')} to the replaced client`); + assert.ok(!after.members.has(qualified[0]), 'the current client was not pruned'); + }); + + // Without redis sets the entries are named `sets:namespace:keyv:keyv:` + // and there is nothing to read them from, so keys() has to SCAN for them - + // on every master, since SCAN only sees the node it is sent to. + await check('v3 cluster without redis sets: keys() scans every master', async () => { + const client = recordingNodeRedis({ isCluster: true }); + const provider = new RedisCacheProvider(null, { useRedisSets: false }, { client, isCluster: true }); + client.seed(qualified.map((k) => `sets:namespace:keyv:${k}`)); + + const keys = await provider.keys(); + assert.deepStrictEqual(keys.sort(), [...KEYS].sort()); + const scanned = new Set(client.issued.filter((c) => c.cmd === 'scan').map((c) => c.node)); + assert.deepStrictEqual([...scanned].sort(), ['node-0', 'node-1', 'node-2'], `scanned ${[...scanned]}`); + }); + + await check('v3 cluster without redis sets: clear() issues no multi-key command', async () => { + const client = recordingNodeRedis({ isCluster: true }); + const provider = new RedisCacheProvider(null, { useRedisSets: false }, { client, isCluster: true }); + client.seed(qualified.map((k) => `sets:namespace:keyv:${k}`)); + + await provider.clear(); + const offending = client.issued.filter((c) => MULTI_KEY.has(c.cmd) && c.keys.length > 1); + assert.deepStrictEqual(offending, [], `issued ${JSON.stringify(offending[0])}`); + assert.strictEqual(client.entries.size, 0, `left ${client.entries.size} entries`); + }); + console.log(`redis cluster safety - ${results.length} checks\n`); for (const [ok, name, detail] of results) { console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`); diff --git a/scripts/redis-cluster-live.cjs b/scripts/redis-cluster-live.cjs index 84039bf3..46bd19c2 100644 --- a/scripts/redis-cluster-live.cjs +++ b/scripts/redis-cluster-live.cjs @@ -29,6 +29,8 @@ const PROVIDER = path.join(ROOT, 'packages/provider-rest-cache-redis'); const requireFromProvider = createRequire(path.join(PROVIDER, 'package.json')); // The ioredis the provider actually runs on is @keyv/redis's. const Redis = createRequire(requireFromProvider.resolve('@keyv/redis'))('ioredis'); +// The node-redis plugin-redis v3 hands over as `.native`. +const { createCluster } = requireFromProvider('@redis/client'); const { RedisCacheProvider } = require(path.join(PROVIDER, 'dist/RedisCacheProvider.js')); const NODES = (process.env.REDIS_CLUSTER_NODES || '127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002') @@ -62,18 +64,54 @@ async function allKeys(cluster) { return per.flat().sort(); } +/** + * The two ways the provider runs: on plugin-redis v2's ioredis client through + * @keyv/redis, and on plugin-redis v3's node-redis client through + * NodeRedisStore, constructed the way init() does on v3 - with `null` standing + * for v3's ioredis shim, which that path must never touch. Every check below + * runs against both. See + * https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ +const BACKENDS = [ + { + label: '', + async connect() { + const client = new Redis.Cluster(NODES); + await new Promise((resolve, reject) => { + client.once('ready', resolve); + client.once('error', reject); + }); + return client; + }, + make: (client, options) => new RedisCacheProvider(client, options), + // What a write, a delete or a prune issues, by this client's names. + commands: ['set', 'sadd', 'srem', 'unlink', 'exists', 'sscan'], + close: (client) => client.disconnect(), + }, + { + label: 'v3 ', + async connect() { + const client = createCluster({ + rootNodes: NODES.map(({ host, port }) => ({ url: `redis://${host}:${port}` })), + }); + await client.connect(); + return client; + }, + make: (client, options) => + new RedisCacheProvider(null, options, { client: () => client, isCluster: true }), + commands: ['set', 'sAdd', 'sRem', 'unlink', 'exists', 'sScan'], + close: (client) => client.close(), + }, +]; + /** * Its own cluster connection, with a few milliseconds of jitter on every * command a write, a delete or a prune issues - as a busy network would add. * Locally a round trip is far shorter than the gaps the races below need. */ -async function jittered() { - const client = new Redis.Cluster(NODES); - await new Promise((resolve, reject) => { - client.once('ready', resolve); - client.once('error', reject); - }); - for (const command of ['set', 'sadd', 'srem', 'unlink', 'exists', 'sscan']) { +async function jittered(backend) { + const client = await backend.connect(); + for (const command of backend.commands) { const send = client[command].bind(client); client[command] = async (...args) => { await new Promise((resolve) => setTimeout(resolve, Math.random() * 3)); @@ -83,14 +121,11 @@ async function jittered() { return client; } -(async () => { - const cluster = new Redis.Cluster(NODES); - await new Promise((resolve, reject) => { - cluster.once('ready', resolve); - cluster.once('error', reject); - }); - - const provider = new RedisCacheProvider(cluster, {}); +/** Every check, against one backend. `cluster` is an ioredis client for raw access. */ +async function suite(backend, cluster) { + const { label } = backend; + const own = await backend.connect(); + const provider = backend.make(own, {}); // Keyv reports store failures as events, not rejections. Collect them, so a // swallowed CROSSSLOT fails a check instead of passing it. const storeErrors = []; @@ -98,32 +133,41 @@ async function jittered() { const noStoreErrors = () => assert.deepStrictEqual(storeErrors.splice(0), [], 'the store reported an error'); - await check('set() stores entries that get() returns', async () => { + /** Live entries keys() does not list, which no purge can reach. */ + const unseenByKeys = async () => { + const listed = new Set(await provider.keys()); + return (await allKeys(cluster)) + .filter((key) => key.startsWith('keyv:')) + .map((key) => key.slice('keyv:'.length)) + .filter((key) => !listed.has(key)); + }; + + await check(`${label}set() stores entries that get() returns`, async () => { await flushAll(cluster); for (const key of KEYS) await provider.set(key, { key }, MAX_AGE); noStoreErrors(); for (const key of KEYS) assert.deepStrictEqual(await provider.get(key), { key }, key); }); - await check('set() tracks every entry, with its lifetime', async () => { + await check(`${label}set() tracks every entry, with its lifetime`, async () => { const tracked = (await cluster.smembers('namespace:keyv')).sort(); assert.deepStrictEqual(tracked, KEYS.map((k) => `keyv:${k}`).sort()); const ttl = await cluster.pttl(`keyv:${KEYS[0]}`); assert.ok(ttl > MAX_AGE - 10000 && ttl <= MAX_AGE, `lifetime ${ttl}ms is not maxAge`); }); - await check('keys() lists every entry', async () => { + await check(`${label}keys() lists every entry`, async () => { assert.deepStrictEqual((await provider.keys()).sort(), [...KEYS].sort()); }); - await check('del() removes the entry and its tracking', async () => { + await check(`${label}del() removes the entry and its tracking`, async () => { await provider.del(KEYS[0]); noStoreErrors(); assert.ok((await provider.get(KEYS[0])) == null, 'entry survived del()'); assert.strictEqual(await cluster.sismember('namespace:keyv', `keyv:${KEYS[0]}`), 0); }); - await check('delMany() removes entries across slots', async () => { + await check(`${label}delMany() removes entries across slots`, async () => { await provider.delMany(KEYS.slice(1, 15)); noStoreErrors(); for (const key of KEYS.slice(1, 15)) { @@ -132,7 +176,7 @@ async function jittered() { assert.deepStrictEqual((await provider.keys()).sort(), KEYS.slice(15).sort()); }); - await check('clear() empties the cluster', async () => { + await check(`${label}clear() empties the cluster`, async () => { await provider.clear(); noStoreErrors(); assert.deepStrictEqual(await allKeys(cluster), []); @@ -144,7 +188,7 @@ async function jittered() { // missing from the tracking set: invisible to keys(), so no later purge could // find it and it was served stale until maxAge. Every live entry must stay // reachable through keys(). - await check('delMany() racing set() never leaves a live entry keys() cannot see', async () => { + await check(`${label}delMany() racing set() never leaves a live entry keys() cannot see`, async () => { await flushAll(cluster); const keys = Array.from({ length: 200 }, (_, i) => `/api/articles?page=${i}&`); for (let round = 0; round < 20; round++) { @@ -155,11 +199,7 @@ async function jittered() { ]); } noStoreErrors(); - const listed = new Set(await provider.keys()); - const unseen = (await allKeys(cluster)) - .filter((key) => key.startsWith('keyv:')) - .map((key) => key.slice('keyv:'.length)) - .filter((key) => !listed.has(key)); + const unseen = await unseenByKeys(); assert.deepStrictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); }); @@ -168,11 +208,11 @@ async function jittered() { // commands on a loaded network, so the race almost never shows. Here the // deleting and the writing instance each get their own connection and a few // milliseconds of jitter per command, as a busy network would add. - await check('del() racing set() never leaves a live entry keys() cannot see', async () => { + await check(`${label}del() racing set() never leaves a live entry keys() cannot see`, async () => { await flushAll(cluster); - const clients = [await jittered(), await jittered()]; + const clients = [await jittered(backend), await jittered(backend)]; const [deleter, writer] = clients.map((client) => { - const instance = new RedisCacheProvider(client, {}); + const instance = backend.make(client, {}); instance.cache.stores[0].on('error', (error) => storeErrors.push(error.message)); return instance; }); @@ -185,14 +225,10 @@ async function jittered() { ); } } finally { - for (const client of clients) client.disconnect(); + await Promise.all(clients.map((client) => backend.close(client))); } noStoreErrors(); - const listed = new Set(await provider.keys()); - const unseen = (await allKeys(cluster)) - .filter((key) => key.startsWith('keyv:')) - .map((key) => key.slice('keyv:'.length)) - .filter((key) => !listed.has(key)); + const unseen = await unseenByKeys(); assert.deepStrictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); }); @@ -200,7 +236,7 @@ async function jittered() { // not: @keyv/redis only SREMs on an explicit delete, and the set itself has // no lifetime. keys() reported the dead entry, and the set grew forever. // keys() lists what the set holds; the prune is what removes such members. - await check('prune() untracks expired entries', async () => { + await check(`${label}prune() untracks expired entries`, async () => { await flushAll(cluster); await provider.set('/api/short', { short: true }, 50); await provider.set('/api/long', { long: true }, MAX_AGE); @@ -213,26 +249,44 @@ async function jittered() { assert.deepStrictEqual(await provider.get('/api/long'), { long: true }); }); + // The pass is incremental (SSCAN), so it has to follow the cursor past the + // first batch - on a cluster through whatever routing the client does for a + // keyed SSCAN. + await check(`${label}prune() covers a set larger than one batch`, async () => { + await flushAll(cluster); + const live = Array.from({ length: 1500 }, (_, i) => `/api/live?page=${i}&`); + const dead = Array.from({ length: 1500 }, (_, i) => `keyv:/api/dead?page=${i}&`); + await Promise.all(live.map((key) => provider.set(key, { key }, MAX_AGE))); + await cluster.sadd('namespace:keyv', ...dead); + await provider.prune(); + assert.deepStrictEqual((await provider.keys()).sort(), [...live].sort()); + }); + // The prune checks a member, SREMs it if its entry is gone, then checks // again and re-adds any that came back. A response cached for that key // between the first check and the SREM is untracked by the SREM, and only the // re-check keeps it findable by purges. Responses here are cached for keys // whose entries have expired - exactly the members a prune removes - while // it runs. - await check('prune() racing set() never leaves a live entry keys() cannot see', async () => { + await check(`${label}prune() racing set() never leaves a live entry keys() cannot see`, async () => { await flushAll(cluster); - const clients = [await jittered(), await jittered()]; + const clients = [await jittered(backend), await jittered(backend)]; const [pruner, writer] = clients.map((client) => { - const instance = new RedisCacheProvider(client, {}); + const instance = backend.make(client, {}); instance.cache.stores[0].on('error', (error) => storeErrors.push(error.message)); return instance; }); + // Expired entries nobody writes again. A prune that does nothing would + // pass the race check below trivially, since every write tracks its own + // entry, so these have to be seen untracked. + const gone = Array.from({ length: 20 }, (_, i) => `keyv:/api/gone?page=${i}&`); + let leftTracked = 0; try { const keys = Array.from({ length: 300 }, (_, i) => `/api/articles?page=${i}&`); for (let round = 0; round < 10; round++) { await flushAll(cluster); // Tracked, with nothing behind them: entries that have expired. - await cluster.sadd('namespace:keyv', ...keys.map((key) => `keyv:${key}`)); + await cluster.sadd('namespace:keyv', ...keys.map((key) => `keyv:${key}`), ...gone); // Writes spread over the few milliseconds the prune spends between // its check and its SREM, rather than all landing before the check. const later = (key) => @@ -241,26 +295,25 @@ async function jittered() { ); await Promise.all([pruner.prune(), ...keys.map(later)]); await writer.prune(); + const tracked = new Set(await cluster.smembers('namespace:keyv')); + leftTracked += gone.filter((member) => tracked.has(member)).length; } } finally { - for (const client of clients) client.disconnect(); + await Promise.all(clients.map((client) => backend.close(client))); } noStoreErrors(); - const listed = new Set(await provider.keys()); - const unseen = (await allKeys(cluster)) - .filter((key) => key.startsWith('keyv:')) - .map((key) => key.slice('keyv:'.length)) - .filter((key) => !listed.has(key)); + assert.strictEqual(leftTracked, 0, `${leftTracked} expired members were never untracked`); + const unseen = await unseenByKeys(); assert.deepStrictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); }); // Without redis sets there is no tracking set to read, so keys() has to find // entries itself - on every master, since SCAN only sees the node it is sent // to. Reading one node, or the empty set, made pattern purges match nothing. - const unset = new RedisCacheProvider(cluster, { useRedisSets: false }); + const unset = backend.make(own, { useRedisSets: false }); unset.cache.stores[0].on('error', (error) => storeErrors.push(error.message)); - await check('without redis sets: keys() finds entries on every master', async () => { + await check(`${label}without redis sets: keys() finds entries on every master`, async () => { await flushAll(cluster); for (const key of KEYS) await unset.set(key, { key }, MAX_AGE); noStoreErrors(); @@ -271,12 +324,32 @@ async function jittered() { assert.deepStrictEqual((await unset.keys()).sort(), [...KEYS].sort()); }); - await check('without redis sets: purging what keys() lists removes it', async () => { + await check(`${label}without redis sets: purging what keys() lists removes it`, async () => { await unset.delMany(await unset.keys()); noStoreErrors(); assert.deepStrictEqual(await allKeys(cluster), []); }); + await check(`${label}without redis sets: clear() empties every master`, async () => { + await flushAll(cluster); + for (const key of KEYS) await unset.set(key, { key }, MAX_AGE); + await unset.clear(); + noStoreErrors(); + assert.deepStrictEqual(await allKeys(cluster), []); + }); + + await backend.close(own); +} + +(async () => { + const cluster = new Redis.Cluster(NODES); + await new Promise((resolve, reject) => { + cluster.once('ready', resolve); + cluster.once('error', reject); + }); + + for (const backend of BACKENDS) await suite(backend, cluster); + await flushAll(cluster); cluster.disconnect(); diff --git a/scripts/redis-key-continuity.cjs b/scripts/redis-key-continuity.cjs new file mode 100644 index 00000000..44fcbf58 --- /dev/null +++ b/scripts/redis-key-continuity.cjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Verifies the redis provider's two backends share one storage layout. + * + * On @strapi-community/plugin-redis v2 the provider stores entries through + * @keyv/redis 3 on the plugin's ioredis client. On v3 it uses its own store on + * the plugin's node-redis client. A deployment that upgrades the redis plugin + * keeps every entry cached before the upgrade, so the new store has to read + * them, list them and purge them - otherwise purges after the upgrade miss + * every older entry until its maxAge runs out. The same has to hold the other + * way round for a rollback. + * + * So this writes with one backend and reads, lists and purges with the other, + * against a real Redis, and compares the raw keys each one leaves behind. + * + * Needs a Redis it may flush: REDIS_URL (default redis://127.0.0.1:6379/15). + * With REDIS_CLUSTER_NODES set (comma-separated host:port) it runs against + * that cluster instead, where both backends take their cluster code paths and + * the entries spread over every master. + * + * @see https://github.com/strapi-community/plugin-rest-cache/pull/239 + */ + +const assert = require('assert'); +const path = require('path'); +const { createRequire } = require('module'); + +const ROOT = path.join(__dirname, '..'); +const PROVIDER = path.join(ROOT, 'packages/provider-rest-cache-redis'); +const requireFromProvider = createRequire(path.join(PROVIDER, 'package.json')); + +const Redis = requireFromProvider('ioredis'); +const { createClient, createCluster } = requireFromProvider('@redis/client'); +const { RedisCacheProvider } = require(path.join(PROVIDER, 'dist/RedisCacheProvider.js')); + +const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379/15'; +const CLUSTER_NODES = process.env.REDIS_CLUSTER_NODES + ? process.env.REDIS_CLUSTER_NODES.split(',').map((node) => { + const [host, port] = node.split(':'); + return { host, port: Number(port) }; + }) + : null; + +const ENTRIES = [ + ['/api/articles?', { data: [{ id: 1 }] }], + ['/api/articles/1?populate=*', { data: { id: 1, title: 'one' } }], + ['/api/categories?locale=fr', { data: [] }], + ['/api/homepage?_etag', 'W/"abc"'], + // Enough paths that a cluster spreads them over every master. + ...Array.from({ length: 12 }, (_, i) => [`/api/pages?page=${i}&`, { page: i }]), +]; +const MAX_AGE = 600000; + +const results = []; +async function check(name, fn) { + try { + await fn(); + results.push([true, name, '']); + } catch (error) { + results.push([false, name, error.message.split('\n')[0]]); + } +} + +/** + * Connections to the target. `raw` is an ioredis client for inspecting it; + * `nodes()` the clients holding its keys - every master on a cluster. + */ +async function connect() { + if (!CLUSTER_NODES) { + const raw = new Redis(REDIS_URL); + const io = new Redis(REDIS_URL); + const node = createClient({ url: REDIS_URL }); + await node.connect(); + return { label: 'standalone', isCluster: false, raw, io, node, nodes: () => [raw] }; + } + + const ready = (client) => + new Promise((resolve, reject) => { + client.once('ready', () => resolve(client)); + client.once('error', reject); + }); + const raw = await ready(new Redis.Cluster(CLUSTER_NODES)); + const io = await ready(new Redis.Cluster(CLUSTER_NODES)); + const node = createCluster({ + rootNodes: CLUSTER_NODES.map(({ host, port }) => ({ url: `redis://${host}:${port}` })), + }); + await node.connect(); + return { label: 'cluster', isCluster: true, raw, io, node, nodes: () => raw.nodes('master') }; +} + +(async () => { + const target = await connect(); + const { raw, io, node, isCluster } = target; + + const flush = () => Promise.all(target.nodes().map((n) => n.flushdb())); + const allKeys = async () => + (await Promise.all(target.nodes().map((n) => n.keys('*')))).flat().sort(); + + /** Every raw key with its type and remaining lifetime. */ + async function snapshot() { + const out = []; + for (const key of await allKeys()) { + const type = await raw.type(key); + const ttl = await raw.pttl(key); + const members = type === 'set' ? (await raw.smembers(key)).sort() : undefined; + // Lifetimes drift by the time between writes, so compare a band rather + // than the exact value - but a tight one: maxAge is milliseconds and a + // unit slip (#126) lands orders of magnitude outside it. + const lifetime = + ttl < 0 ? 'none' : ttl <= MAX_AGE && ttl > MAX_AGE - 10000 ? 'maxAge' : `off: ${ttl}ms`; + out.push({ key, type, lifetime, members }); + } + return out; + } + + // On v3 the provider is handed the plugin's deprecated ioredis shim as well, + // and keeps it as `provider.client` for anyone typed against it - but must + // never use it. A shim that throws on any call makes "v3 quietly ran on the + // v2 path" fail here instead of passing. + const untouchable = new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'then') return undefined; + throw new Error(`v3 backend touched the ioredis client (.${String(prop)})`); + }, + } + ); + + const backends = { + v2: (options) => new RedisCacheProvider(io, options), + v3: (options) => new RedisCacheProvider(untouchable, options, { client: node, isCluster }), + }; + + for (const options of [{}, { useRedisSets: false }]) { + const mode = `${target.label}, ${options.useRedisSets === false ? 'without' : 'with'} redis sets`; + + await check(`${mode}: v2 and v3 write byte-identical keys`, async () => { + const layouts = {}; + for (const [name, make] of Object.entries(backends)) { + await flush(); + const provider = make(options); + for (const [key, value] of ENTRIES) await provider.set(key, value, MAX_AGE); + layouts[name] = await snapshot(); + } + assert.ok(layouts.v2.length > 0, 'v2 wrote nothing'); + if (isCluster) { + const populated = (await Promise.all(target.nodes().map((n) => n.dbsize()))).filter( + (size) => size > 0 + ).length; + assert.ok(populated > 1, 'entries did not spread across masters'); + } + assert.deepStrictEqual(layouts.v3, layouts.v2); + }); + + for (const [writer, reader] of [ + ['v2', 'v3'], + ['v3', 'v2'], + ]) { + await check(`${mode}: ${reader} reads, lists and purges what ${writer} wrote`, async () => { + await flush(); + const before = backends[writer](options); + for (const [key, value] of ENTRIES) await before.set(key, value, MAX_AGE); + + const after = backends[reader](options); + for (const [key, value] of ENTRIES) { + assert.deepStrictEqual(await after.get(key), value, `get(${key})`); + } + + // keys() is what pattern purges match against, so each backend has to + // list every entry the other wrote - with redis sets from the tracking + // set, without them by the `sets:` names (#245). + const listed = (await after.keys()).sort(); + assert.deepStrictEqual(listed, ENTRIES.map(([key]) => key).sort(), 'keys() missed entries'); + assert.deepStrictEqual(listed, (await before.keys()).sort(), 'keys() differs between backends'); + + await after.delMany(listed); + for (const [key] of ENTRIES) { + assert.ok((await before.get(key)) == null, `${key} survived the purge`); + } + const left = (await allKeys()).filter((k) => !k.startsWith('namespace:')); + assert.deepStrictEqual(left, [], 'purge left entries behind'); + if (options.useRedisSets !== false) { + assert.strictEqual(await raw.scard('namespace:keyv'), 0, 'tracking set not emptied'); + } + }); + } + + await check(`${mode}: v3 clear() empties what v2 wrote`, async () => { + await flush(); + const before = backends.v2(options); + for (const [key, value] of ENTRIES) await before.set(key, value, MAX_AGE); + + await backends.v3(options).clear(); + + assert.deepStrictEqual(await allKeys(), []); + }); + } + + await check(`${target.label}: v3 never closes the shared client`, async () => { + const provider = backends.v3({}); + await provider.set('/api/x', 1, MAX_AGE); + await provider.cache.disconnect(); + assert.strictEqual(node.isReady, true, 'the node-redis client was closed'); + // A cluster has no single node to PING; any routed command proves it works. + if (isCluster) assert.strictEqual(await node.get('continuity-probe'), null); + else assert.strictEqual(await node.ping(), 'PONG'); + }); + + await check(`${target.label}: v3 reports ready from the node-redis client`, async () => { + assert.strictEqual(backends.v3({}).ready, true); + }); + + await flush(); + await Promise.all([raw.quit(), io.quit(), node.close()]); + + for (const [ok, name, detail] of results) { + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n ${detail}` : ''}`); + } + const failed = results.filter(([ok]) => !ok).length; + console.log(`\n${results.length - failed}/${results.length} passed`); + process.exit(failed > 0 ? 1 : 0); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/redis-standalone-live.cjs b/scripts/redis-standalone-live.cjs index 83e6a0b6..27ba6a09 100644 --- a/scripts/redis-standalone-live.cjs +++ b/scripts/redis-standalone-live.cjs @@ -17,6 +17,8 @@ const PROVIDER = path.join(ROOT, 'packages/provider-rest-cache-redis'); const requireFromProvider = createRequire(path.join(PROVIDER, 'package.json')); // The ioredis the provider actually runs on is @keyv/redis's. const Redis = createRequire(requireFromProvider.resolve('@keyv/redis'))('ioredis'); +// The node-redis plugin-redis v3 hands over as `.native`. +const { createClient } = requireFromProvider('@redis/client'); const { RedisCacheProvider } = require(path.join(PROVIDER, 'dist/RedisCacheProvider.js')); const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379/15'; @@ -166,6 +168,197 @@ async function check(name, fn) { assert.deepStrictEqual(await provider.keys(), ['/api/raced'], 'a live entry was left untracked'); }); + // plugin-redis v3: the same behaviour from NodeRedisStore, on a real + // node-redis client, constructed the way init() does on v3. `null` stands + // for v3's ioredis shim, which the v3 path must never touch. + // See https://github.com/strapi-community/plugin-rest-cache/pull/239 + const node = createClient({ url: REDIS_URL }); + await node.connect(); + const v3 = (options, native = node) => + new RedisCacheProvider(null, options, { client: () => native, isCluster: false }); + + for (const options of [{}, { useRedisSets: false }]) { + const mode = options.useRedisSets === false ? 'without redis sets' : 'with redis sets'; + const provider = v3(options); + + await check(`v3 ${mode}: keys() lists every entry, unqualified`, async () => { + await client.flushdb(); + for (const key of KEYS) await provider.set(key, { key }, MAX_AGE); + assert.deepStrictEqual((await provider.keys()).sort(), [...KEYS].sort()); + }); + + await check(`v3 ${mode}: purging what keys() lists removes it`, async () => { + const matched = (await provider.keys()).filter((key) => key.startsWith('/api/articles')); + assert.strictEqual(matched.length, 2, 'pattern matched nothing'); + await provider.delMany(matched); + assert.deepStrictEqual((await provider.keys()).sort(), ['/api/categories?locale=fr']); + for (const key of matched) assert.ok((await provider.get(key)) == null, `${key} survived`); + }); + + await check(`v3 ${mode}: clear() removes every entry`, async () => { + await client.flushdb(); + for (const key of KEYS) await provider.set(key, { key }, MAX_AGE); + await provider.clear(); + assert.deepStrictEqual(await client.keys('*'), []); + }); + } + + await check('v3 with redis sets: prune() untracks expired entries', async () => { + const provider = v3({}); + await client.flushdb(); + await provider.set('/api/short', { short: true }, 50); + await provider.set('/api/long', { long: true }, MAX_AGE); + await new Promise((resolve) => setTimeout(resolve, 150)); + await provider.prune(); + const listed = await provider.keys(); + assert.deepStrictEqual(listed, ['/api/long'], `keys() returned ${JSON.stringify(listed)}`); + const tracked = await client.smembers('namespace:keyv'); + assert.deepStrictEqual(tracked, ['keyv:/api/long'], `tracking set holds ${JSON.stringify(tracked)}`); + assert.deepStrictEqual(await provider.get('/api/long'), { long: true }); + }); + + await check('v3 with redis sets: writes start a background prune', async () => { + await client.flushdb(); + await client.sadd('namespace:keyv', 'keyv:/api/expired'); + const provider = v3({}); + await provider.set('/api/written', { written: true }, MAX_AGE); + for (let i = 0; i < 50 && (await client.scard('namespace:keyv')) > 1; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.deepStrictEqual(await client.smembers('namespace:keyv'), ['keyv:/api/written']); + }); + + await check('v3 with redis sets: prune() covers a set larger than one batch', async () => { + const provider = v3({}); + await client.flushdb(); + const live = Array.from({ length: 1500 }, (_, i) => `/api/live?page=${i}&`); + const dead = Array.from({ length: 1500 }, (_, i) => `keyv:/api/dead?page=${i}&`); + for (const key of live) await provider.set(key, { key }, MAX_AGE); + await client.sadd('namespace:keyv', ...dead); + await provider.prune(); + assert.deepStrictEqual((await provider.keys()).sort(), [...live].sort()); + }); + + // As `racing` above, on node-redis' sRem: a write lands between the prune's + // check and its SREM, and only the re-check keeps it tracked. + const racingNode = (onSrem, failExists) => + new Proxy(node, { + get(target, prop) { + if (prop === 'sRem') { + return async (...args) => { + const hook = onSrem.shift(); + if (hook) await hook(); + return target.sRem(...args); + }; + } + if (prop === 'exists' && failExists()) { + return async () => { + throw new Error('connection lost'); + }; + } + const value = target[prop]; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + await check('v3 with redis sets: prune() re-tracks an entry written while it ran', async () => { + await client.flushdb(); + const hooks = []; + const provider = v3({}, racingNode(hooks, () => false)); + await provider.set('/api/raced', { stale: true }, 50); + await new Promise((resolve) => setTimeout(resolve, 150)); + hooks.push(() => provider.set('/api/raced', { fresh: true }, MAX_AGE)); + await provider.prune(); + assert.strictEqual(hooks.length, 0, 'the write was never injected - the prune issued no SREM'); + assert.deepStrictEqual(await provider.get('/api/raced'), { fresh: true }); + assert.deepStrictEqual(await provider.keys(), ['/api/raced'], 'a live entry was left untracked'); + }); + + await check('v3 with redis sets: a failed re-check leaves nothing live untracked', async () => { + await client.flushdb(); + const hooks = []; + let failing = false; + const provider = v3({}, racingNode(hooks, () => failing)); + await provider.set('/api/raced', { stale: true }, 50); + await new Promise((resolve) => setTimeout(resolve, 150)); + hooks.push(async () => { + await provider.set('/api/raced', { fresh: true }, MAX_AGE); + // Every EXISTS from here on - the re-check - fails. + failing = true; + }); + await provider.prune(); + assert.strictEqual(hooks.length, 0, 'the write was never injected - the prune issued no SREM'); + assert.deepStrictEqual(await provider.keys(), ['/api/raced'], 'a live entry was left untracked'); + }); + + // A purge racing responses being cached for the same keys, each from its own + // connection with a few milliseconds of jitter per command. A standalone + // server takes each write and each delete as one MULTI, so they cannot + // interleave; this guards that every live entry stays reachable by keys(). + const jitteredNode = async () => { + const own = createClient({ url: REDIS_URL }); + await own.connect(); + for (const command of ['set', 'sAdd', 'sRem', 'unlink', 'exists', 'sScan']) { + const send = own[command].bind(own); + own[command] = async (...args) => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 3)); + return send(...args); + }; + } + const multi = own.multi.bind(own); + own.multi = () => { + const queued = multi(); + const exec = queued.exec.bind(queued); + queued.exec = async (...args) => { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 3)); + return exec(...args); + }; + return queued; + }; + return own; + }; + + const unseenByKeys = async (provider) => { + const listed = new Set(await provider.keys()); + return (await client.keys('keyv:*')) + .map((key) => key.slice('keyv:'.length)) + .filter((key) => !listed.has(key)); + }; + + await check('v3: delMany() racing set() never leaves a live entry keys() cannot see', async () => { + await client.flushdb(); + const provider = v3({}); + const keys = Array.from({ length: 200 }, (_, i) => `/api/articles?page=${i}&`); + for (let round = 0; round < 20; round++) { + await Promise.all(keys.map((key) => provider.set(key, 'old', MAX_AGE))); + await Promise.all([ + provider.delMany(keys), + ...keys.map((key) => provider.set(key, 'new', MAX_AGE)), + ]); + } + const unseen = await unseenByKeys(provider); + assert.strictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); + }); + + await check('v3: del() racing set() never leaves a live entry keys() cannot see', async () => { + await client.flushdb(); + const clients = [await jitteredNode(), await jitteredNode()]; + const [deleter, writer] = clients.map((own) => v3({}, own)); + const provider = v3({}); + try { + const keys = Array.from({ length: 200 }, (_, i) => `/api/articles?page=${i}&`); + for (let round = 0; round < 20; round++) { + await Promise.all(keys.map((key) => provider.set(key, 'old', MAX_AGE))); + await Promise.all(keys.flatMap((key) => [deleter.del(key), writer.set(key, 'new', MAX_AGE)])); + } + } finally { + await Promise.all(clients.map((own) => own.close())); + } + const unseen = await unseenByKeys(provider); + assert.strictEqual(unseen.length, 0, `${unseen.length} live entries missing from keys(), e.g. ${unseen[0]}`); + }); + + await node.close(); await client.flushdb(); await client.quit();