fix(cache): namespace cache keys per environment via CACHE_KEY_NAMESPACE (#3586)

Preview deployments run against a scratch database but shared the production cache instance. Cache keys carried no environment segment, so a preview page load could populate a key production then served - dev-shaped data answering production reads, and preview traffic evicting production entries.

Keys are now prefixed with '<CACHE_KEY_NAMESPACE>:'. Production leaves it unset and is a STRUCTURAL no-op: applyCacheKeyPrefix returns the same object, prefixCacheKey is the identity, keys byte-identical. Non-production environments set it explicitly.

Derived from an explicit namespace rather than the IS_PREVIEW boolean because IS_PREVIEW is overloaded - it also gates an auth path, and one standing non-production deployment sets it while running against the production database. Keying the cache namespace off it would have put production-data and scratch-data entries in the same keyspace.

Single choke point: the key table itself is passed through applyCacheKeyPrefix, so every consumer inherits it. Wrapping client methods was rejected - the client exposes the full node-redis surface, so any method missed by a wrapper would go silently unprefixed, which is the original bug in an invisible form.

Also covers two previously-unprefixed free-form minters, adds regression coverage for queryCacheRaw (a mutant that survived the earlier revision), and rejects array leaves in the key table at compile time. custody-sweep's payout mutex is deliberately left shared - it guards a real external payment, so cross-environment sharing is protective and namespacing it would permit a second payout.

Verified live on this PR's own preview before merge: the container carries the namespace, prefixed keys appear on all three cache shards, and a production key and its preview twin coexist for the same entity id with production's copy untouched.

Note: previews now start against a cold cache namespace instead of inheriting production's warm entries.
This commit is contained in:
Zachary Lowden
2026-08-04 13:32:42 -05:00
committed by GitHub
parent 7edabd9322
commit aaf209dc21
9 changed files with 787 additions and 13 deletions
@@ -0,0 +1,252 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// The prefix is resolved at MODULE-EVAL time (the key table is a module-level const), so every
// case re-imports the module under a fresh `process.env` — the same pattern the app uses for
// `isPreview` in src/env/__tests__/other.test.ts.
//
// 🔴 Every expected value below is a HAND-WRITTEN LITERAL, never derived from the key table or
// from the prefix constant. The production assertions are what pins "production keys do not
// move": if a prefix ever leaked into a namespace-less environment, these go red.
describe('cache key prefix', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
/**
* Load the module graph with a clean environment plus whatever this case sets.
*
* Both modules are pulled so a case can reach `applyCacheKeyPrefix` (which the client
* re-exports only the results of) under the same module-eval environment.
*/
const load = async (vars: Record<string, string> = {}) => {
const { CACHE_KEY_NAMESPACE: _ns, IS_PREVIEW: _preview, ...clean } = process.env;
process.env = { ...clean, ...vars };
const [client, prefix] = await Promise.all([
import('../client'),
import('../cache-key-prefix'),
]);
return { ...client, ...prefix };
};
describe('production (CACHE_KEY_NAMESPACE unset)', () => {
it('leaves every cache key byte-identical', async () => {
const { REDIS_KEYS } = await load();
expect(REDIS_KEYS.CACHES.USER_COSMETICS).toBe('packed:caches:user-cosmetics');
expect(REDIS_KEYS.CACHES.TAGGED_CACHE).toBe('packed:caches:tagged-cache');
expect(REDIS_KEYS.TRPC.BASE).toBe('packed:trpc');
expect(REDIS_KEYS.TRPC.LIMIT.KEYS).toBe('packed:trpc:limit:keys');
expect(REDIS_KEYS.TAG).toBe('tag');
expect(REDIS_KEYS.CACHE_LOCKS).toBe('cache-lock');
});
it('builds the same full entry key as before', async () => {
const { REDIS_KEYS } = await load();
expect(`${REDIS_KEYS.CACHES.USER_COSMETICS}:123`).toBe('packed:caches:user-cosmetics:123');
expect(`${REDIS_KEYS.CACHE_LOCKS}:some-lock`).toBe('cache-lock:some-lock');
});
it('exposes an empty prefix and an identity prefixCacheKey', async () => {
const { CACHE_KEY_PREFIX, CACHE_KEY_NAMESPACE, prefixCacheKey } = await load();
expect(CACHE_KEY_NAMESPACE).toBe('');
expect(CACHE_KEY_PREFIX).toBe('');
expect(prefixCacheKey('getTags:v1:abc123')).toBe('getTags:v1:abc123');
});
it('returns the key table as the SAME OBJECT — a structural no-op, not a rebuild', async () => {
// The identity assertion is the strongest form of "production keys do not move": it fails
// even if a rebuild produced byte-identical strings, catching a refactor that drops the
// early return.
const mod = await load();
const { REDIS_KEYS, applyCacheKeyPrefix } = mod;
const table = { A: 'a', NESTED: { B: 'b' } } as const;
expect(applyCacheKeyPrefix(table)).toBe(table);
expect(REDIS_KEYS.CACHES).toBe(REDIS_KEYS.CACHES);
});
it('treats an empty / whitespace-only namespace as unset', async () => {
const blank = await load({ CACHE_KEY_NAMESPACE: ' ' });
expect(blank.CACHE_KEY_PREFIX).toBe('');
expect(blank.REDIS_KEYS.CACHES.USER_COSMETICS).toBe('packed:caches:user-cosmetics');
vi.resetModules();
const empty = await load({ CACHE_KEY_NAMESPACE: '' });
expect(empty.CACHE_KEY_PREFIX).toBe('');
expect(empty.REDIS_KEYS.CACHES.USER_COSMETICS).toBe('packed:caches:user-cosmetics');
});
});
describe('namespaced deployments', () => {
it('prefixes every cache key with the "preview" namespace', async () => {
const { REDIS_KEYS, CACHE_KEY_PREFIX } = await load({ CACHE_KEY_NAMESPACE: 'preview' });
expect(CACHE_KEY_PREFIX).toBe('preview:');
expect(REDIS_KEYS.CACHES.USER_COSMETICS).toBe('preview:packed:caches:user-cosmetics');
expect(REDIS_KEYS.CACHES.TAGGED_CACHE).toBe('preview:packed:caches:tagged-cache');
expect(REDIS_KEYS.TRPC.BASE).toBe('preview:packed:trpc');
expect(REDIS_KEYS.TRPC.LIMIT.KEYS).toBe('preview:packed:trpc:limit:keys');
expect(REDIS_KEYS.TAG).toBe('preview:tag');
expect(REDIS_KEYS.CACHE_LOCKS).toBe('preview:cache-lock');
});
it('prefixes DIFFERENTLY under the "next" namespace', async () => {
// The whole point of an explicit namespace: two non-production deployments that both set
// IS_PREVIEW=true, but run against different databases, must not share a keyspace.
const { REDIS_KEYS, CACHE_KEY_PREFIX } = await load({ CACHE_KEY_NAMESPACE: 'next' });
expect(CACHE_KEY_PREFIX).toBe('next:');
expect(REDIS_KEYS.CACHES.USER_COSMETICS).toBe('next:packed:caches:user-cosmetics');
expect(REDIS_KEYS.TAG).toBe('next:tag');
expect(REDIS_KEYS.CACHE_LOCKS).toBe('next:cache-lock');
});
it('builds a full entry key that cannot collide with production or another namespace', async () => {
const preview = await load({ CACHE_KEY_NAMESPACE: 'preview' });
expect(`${preview.REDIS_KEYS.CACHES.USER_COSMETICS}:123`).toBe(
'preview:packed:caches:user-cosmetics:123'
);
expect(`${preview.REDIS_KEYS.CACHE_LOCKS}:some-lock`).toBe('preview:cache-lock:some-lock');
vi.resetModules();
const next = await load({ CACHE_KEY_NAMESPACE: 'next' });
expect(`${next.REDIS_KEYS.CACHES.USER_COSMETICS}:123`).toBe(
'next:packed:caches:user-cosmetics:123'
);
expect(`${next.REDIS_KEYS.CACHE_LOCKS}:some-lock`).toBe('next:cache-lock:some-lock');
});
it('prefixes an on-the-fly key via prefixCacheKey', async () => {
const { prefixCacheKey } = await load({ CACHE_KEY_NAMESPACE: 'preview' });
expect(prefixCacheKey('getTags:v1:abc123')).toBe('preview:getTags:v1:abc123');
});
it('prefixes each key exactly once (no double-prefixing of nested tables)', async () => {
const { REDIS_KEYS } = await load({ CACHE_KEY_NAMESPACE: 'preview' });
// TRPC.LIMIT.BASE is a nested leaf whose literal already contains TRPC.BASE's text — a
// deep-map bug that prefixed parents and children separately would produce
// `preview:preview:…` here.
expect(REDIS_KEYS.TRPC.LIMIT.BASE).toBe('preview:packed:trpc:limit');
expect(REDIS_KEYS.TRPC.LIMIT.BASE.match(/preview:/g)).toHaveLength(1);
});
it('trims surrounding whitespace out of the namespace', async () => {
const { CACHE_KEY_PREFIX, REDIS_KEYS } = await load({ CACHE_KEY_NAMESPACE: ' preview ' });
expect(CACHE_KEY_PREFIX).toBe('preview:');
expect(REDIS_KEYS.TAG).toBe('preview:tag');
});
it('appends the separator itself, so the value is configured without one', async () => {
// Pins that `CACHE_KEY_NAMESPACE=preview` and a hypothetical `=preview:` cannot silently
// produce two different keyspaces from the same intent.
const { CACHE_KEY_PREFIX } = await load({ CACHE_KEY_NAMESPACE: 'preview' });
expect(CACHE_KEY_PREFIX).toBe('preview:');
});
});
describe('decoupling from IS_PREVIEW', () => {
it('does NOT prefix when IS_PREVIEW=true but no namespace is set', async () => {
// 🔴 The core of this change. A deployment that sets IS_PREVIEW=true may be running against
// the PRODUCTION database, so the flag cannot be the source of the cache namespace.
const { REDIS_KEYS, CACHE_KEY_PREFIX } = await load({ IS_PREVIEW: 'true' });
expect(CACHE_KEY_PREFIX).toBe('');
expect(REDIS_KEYS.CACHES.USER_COSMETICS).toBe('packed:caches:user-cosmetics');
expect(REDIS_KEYS.TAG).toBe('tag');
});
it('prefixes when a namespace is set and IS_PREVIEW is absent', async () => {
// The converse: the namespace alone drives the keyspace. A deployment need not claim to be
// a "preview" to get its own namespace.
const { REDIS_KEYS, CACHE_KEY_PREFIX } = await load({ CACHE_KEY_NAMESPACE: 'next' });
expect(CACHE_KEY_PREFIX).toBe('next:');
expect(REDIS_KEYS.CACHES.USER_COSMETICS).toBe('next:packed:caches:user-cosmetics');
});
it('logs a loud error when IS_PREVIEW=true carries no namespace', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await load({ IS_PREVIEW: 'true' });
expect(spy).toHaveBeenCalledTimes(1);
const message = String(spy.mock.calls[0]?.[0]);
expect(message).toContain('CACHE_KEY_NAMESPACE');
expect(message).toContain('IS_PREVIEW=true');
});
it('does NOT log when IS_PREVIEW=true and a namespace IS set', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await load({ IS_PREVIEW: 'true', CACHE_KEY_NAMESPACE: 'preview' });
expect(spy).not.toHaveBeenCalled();
});
it('does NOT log in production (no IS_PREVIEW, no namespace)', async () => {
// Guards against the warning becoming per-process noise on every production boot.
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await load();
expect(spy).not.toHaveBeenCalled();
});
it('does not throw or fail module evaluation on the misconfiguration', async () => {
// 🔴 Log-only by design: a deployment sets IS_PREVIEW=true today without a namespace, and
// throwing here would fail its boot.
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await expect(load({ IS_PREVIEW: 'true' })).resolves.toBeDefined();
expect(spy).toHaveBeenCalled();
});
});
describe('table shape', () => {
it('preserves an array leaf instead of rebuilding it as an object', async () => {
// `Object.entries`-based rebuilding turns ['a','b'] into {0:'a',1:'b'}. No array leaf
// exists in REDIS_KEYS today, so this is latent — and it would be invisible in production,
// where the function returns early. The type constraint rejects an array leaf at compile
// time; this pins the runtime behaviour for a caller that casts past it.
const { applyCacheKeyPrefix } = await load({ CACHE_KEY_NAMESPACE: 'preview' });
const table = { LIST: ['alpha', 'beta'], LEAF: 'solo' } as unknown as Record<string, string>;
const out = applyCacheKeyPrefix(table) as unknown as {
LIST: string[];
LEAF: string;
};
expect(Array.isArray(out.LIST)).toBe(true);
expect(out.LIST).toEqual(['preview:alpha', 'preview:beta']);
expect(out.LEAF).toBe('preview:solo');
});
});
describe('system keyspace', () => {
// sysRedis is explicitly out of scope: non-production deployments get their own system
// instance, and the system client must keep addressing the keys it always has.
it('never prefixes REDIS_SYS_KEYS, in any namespace', async () => {
const prod = await load();
expect(prod.REDIS_SYS_KEYS.DEVICE.ACCOUNTS).toBe('device:accounts');
vi.resetModules();
const preview = await load({ CACHE_KEY_NAMESPACE: 'preview' });
expect(preview.REDIS_SYS_KEYS.DEVICE.ACCOUNTS).toBe('device:accounts');
vi.resetModules();
const next = await load({ CACHE_KEY_NAMESPACE: 'next' });
expect(next.REDIS_SYS_KEYS.DEVICE.ACCOUNTS).toBe('device:accounts');
});
});
});
@@ -0,0 +1,125 @@
// Environment-scoped prefix for CACHE redis keys.
//
// WHY: several deployments share ONE cache instance. Cache keys carried no environment segment,
// so a page load on a non-production deployment could populate a key that production then served
// — foreign-shaped data answering production reads, and non-production traffic evicting
// production entries.
//
// 🔴 THE NAMESPACE IS EXPLICIT, NOT DERIVED FROM `IS_PREVIEW`. An earlier revision keyed this off
// `IS_PREVIEW === 'true'`. That is wrong, because `IS_PREVIEW` does not mean what a cache
// namespace needs it to mean:
//
// * It is not one environment. At least two distinct deployment classes set it, and they run
// against DIFFERENT databases — one against a scratch database, one against the PRODUCTION
// database. Deriving the namespace from the flag would place both in the same `preview:`
// namespace on different data, which is the exact bug this module exists to prevent, merely
// relocated. For the deployment that runs against the production database it would also be a
// net regression: its cache co-tenants are production today (consistent, same database), and
// would become scratch-database deployments instead.
// * It is overloaded. `IS_PREVIEW` also gates the auth path (see
// src/server/auth/get-server-auth-session.ts) and page-level access (src/server/auth/
// route-guard.ts). Flipping it to fix a cache problem would change live login behaviour.
//
// So the cache namespace gets its own variable and the two concerns are decoupled. Set
// `CACHE_KEY_NAMESPACE` per deployment:
//
// unset / empty → NO prefix. Production.
// 'preview' → ephemeral per-PR deployments (they share one scratch database)
// 'next' → the standing non-production deployment
//
// 🔴 PRODUCTION MUST BE THE EMPTY PREFIX, and structurally so. `applyCacheKeyPrefix` /
// `prefixCacheKey` return their argument UNCHANGED (same object identity for the key table) when
// the namespace is unset — production is a no-op, not a concatenation that happens to add
// nothing. A non-empty prefix in production would re-key the entire cache and cold-start it.
//
// WHY `process.env` DIRECTLY AND NOT THE PACKAGE ENV SCHEMA (./env): this value is needed at
// MODULE-EVAL time, because the key table (`REDIS_KEYS`) is a module-level const that is built
// long before any client is constructed — so there is no client-construction hook to inject it
// through. `loadRedisEnv()` is deliberately lazy so that a bare import of this package never
// touches process.env and never throws (build, scripts and tests all import it); calling it at
// module scope would break that invariant. Reading one optional string cannot throw and keeps it.
//
// This is CACHE-ONLY. The system client (REDIS_SYS_*/`REDIS_SYS_KEYS`) is untouched.
const RAW_NAMESPACE = process.env.CACHE_KEY_NAMESPACE?.trim() ?? '';
/**
* The configured cache namespace, or `''` in production. Exported for diagnostics; callers should
* prefer `CACHE_KEY_PREFIX` / `prefixCacheKey`, which carry the separator.
*/
export const CACHE_KEY_NAMESPACE = RAW_NAMESPACE;
/**
* The prefix applied to every cache key — `'<namespace>:'`, or `''` in production (see above).
*
* The separator lives here rather than in the deployment's value so a namespace can never be
* configured without it (`CACHE_KEY_NAMESPACE=preview` and `=preview:` would otherwise produce
* two different keyspaces).
*/
export const CACHE_KEY_PREFIX = RAW_NAMESPACE ? `${RAW_NAMESPACE}:` : '';
// Misconfiguration guard. A deployment that announces itself as non-production but sets no cache
// namespace is silently sharing production's keyspace — the damaging direction, and invisible
// from the outside until production serves foreign data.
//
// 🔴 DELIBERATELY LOG-ONLY, NEVER THROW. This runs at module eval, on the import path of every
// process that touches redis, so throwing here would fail boot — and at least one deployment sets
// `IS_PREVIEW=true` today and will not carry `CACHE_KEY_NAMESPACE` until its configuration is
// updated separately. Taking that deployment down to enforce a cache-hygiene invariant is a worse
// outcome than the mis-namespacing this warns about.
if (!RAW_NAMESPACE && process.env.IS_PREVIEW === 'true') {
// eslint-disable-next-line no-console
console.error(
'🔴 CACHE_KEY_NAMESPACE IS UNSET on a deployment with IS_PREVIEW=true. This deployment is ' +
"sharing PRODUCTION's cache keyspace: it can read production cache entries, overwrite " +
'them with its own data, and evict them. Set CACHE_KEY_NAMESPACE (e.g. "preview" or ' +
'"next") on this deployment. See packages/civitai-redis/src/cache-key-prefix.ts.'
);
}
/**
* A nested table of cache-key literals. String leaves only — see `applyCacheKeyPrefix`.
*/
export type CacheKeyTable = { readonly [key: string]: string | CacheKeyTable };
/**
* Prefix a single cache key (or key glob) that was built on the fly rather than derived from
* the `REDIS_KEYS` table.
*
* Do NOT call this on a key already derived from `REDIS_KEYS`: those carry the prefix from the
* table itself, and prefixing again yields `preview:preview:…`.
*/
export function prefixCacheKey<T extends string>(key: T): T {
return (CACHE_KEY_PREFIX ? `${CACHE_KEY_PREFIX}${key}` : key) as T;
}
/**
* Deep-copy a nested key table, prefixing every string leaf. Returns the input UNCHANGED when
* the namespace is unset (production), so the production key table is literally the same object.
*
* The return type is the input type, which keeps the literal-string types of the `as const` key
* table intact — `RedisKeyTemplateCache` and every `${REDIS_KEYS.X}:${id}` template type are
* unaffected by this change. Only the runtime values differ, and only when a namespace is set.
*
* 🔴 The `CacheKeyTable` constraint is load-bearing: it rejects an ARRAY leaf at compile time.
* The rebuild below walks objects generically, and an array reached through the object branch
* would come back as a plain object (`{0: …, 1: …}`), silently changing the table's shape. That
* failure would be invisible in production — where this function returns early — and would only
* appear in a namespaced environment. `prefixDeep` also preserves arrays at runtime as a second
* line of defence, since the constraint can be bypassed with a cast.
*/
export function applyCacheKeyPrefix<T extends CacheKeyTable>(keys: T): T {
if (!CACHE_KEY_PREFIX) return keys;
return prefixDeep(keys) as T;
}
function prefixDeep(value: unknown): unknown {
if (typeof value === 'string') return `${CACHE_KEY_PREFIX}${value}`;
// Before the object branch: `Object.entries` on an array would rebuild it as a plain object.
if (Array.isArray(value)) return value.map(prefixDeep);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = prefixDeep(v);
return out;
}
return value;
}
+21 -2
View File
@@ -22,7 +22,9 @@ import {
resetClusterDeadlineHits,
} from './cluster-deadline-hits';
import { compressPacked, decompressPacked } from './packed-compression';
import { applyCacheKeyPrefix } from './cache-key-prefix';
export { CACHE_KEY_NAMESPACE, CACHE_KEY_PREFIX, prefixCacheKey } from './cache-key-prefix';
export type { RedisConfig } from './env';
export type RedisLogFn = (message: string, ...args: unknown[]) => void;
/** Resolves whether enhanced cluster failover is enabled — injected app policy (Flipt). */
@@ -2027,8 +2029,14 @@ export const REDIS_SYS_KEYS = {
},
} as const;
// Cached data
export const REDIS_KEYS = {
// Cached data.
//
// The literal table. Every cache key in the app is built from one of these leaves, so applying
// the environment prefix here (see `REDIS_KEYS` below) is the single place that scopes the whole
// cache keyspace. Declared separately from the export so the `as const` literal types survive —
// `RedisKeyStringsCache` / `RedisKeyTemplateCache` are derived from these literals and are
// unchanged by the prefixing.
const REDIS_KEYS_UNPREFIXED = {
BLOCKS: {
REGISTRY: 'packed:caches:block-registry',
TOKEN_RATE_LIMIT: 'blocks:token-rate-limit',
@@ -2268,6 +2276,17 @@ export const REDIS_KEYS = {
},
} as const;
/**
* The cache key table, environment-scoped.
*
* Identical to `REDIS_KEYS_UNPREFIXED` in production (same object, byte-identical keys); on a
* deployment that sets `CACHE_KEY_NAMESPACE` every leaf carries the `<namespace>:` prefix, so a
* non-production deployment cannot read or overwrite a production cache entry. See
* ./cache-key-prefix for the full rationale — in particular why the namespace is explicit rather
* than derived from `IS_PREVIEW`.
*/
export const REDIS_KEYS = applyCacheKeyPrefix(REDIS_KEYS_UNPREFIXED);
// These are used as subkeys after a dynamic key, such as `user:13:stuff`
// we should probably be flipping all redis keys to have any dynamic keys come at the end
export const REDIS_SUB_KEYS = {
+9
View File
@@ -9,6 +9,15 @@ const SWEEP_THRESHOLD = 5; // Only sweep if balance > $5
const SWEEP_BUFFER = 1; // Leave $1 in custody
/** Prevent duplicate sweeps within this window (seconds) */
const SWEEP_DEDUP_TTL = 3600; // 1 hour
// 🔴 DELIBERATELY NOT ENVIRONMENT-NAMESPACED (unlike every other on-the-fly redis key — see
// packages/civitai-redis/src/cache-key-prefix.ts). This is not a cache entry; it is the mutual
// exclusion guard around a REAL payout, and the payout is made against the payment provider by
// API credentials, not against a database. Every environment that holds those credentials can
// therefore move real money. Sharing this one key across environments is PROTECTIVE: it means a
// non-production deployment that runs this job is suppressed by production's recent sweep instead
// of issuing a second one. Namespacing it would give each environment an independent key and
// remove that suppression — a strictly worse failure than the cache co-tenancy the namespace
// exists to fix. Read and write are both unprefixed, so this is self-consistent.
const SWEEP_DEDUP_KEY = 'custody-sweep:last-payout' as RedisKeyTemplateCache;
export const custodySweepJob = createJob(
+7 -1
View File
@@ -4,6 +4,10 @@ import { env } from '~/env/server';
import { dbWrite } from '~/server/db/client';
import { merchBuzzCreditedEmail, merchClaimInviteEmail } from '~/server/email/templates';
import { logToAxiom } from '~/server/logging/client';
// From the package rather than the `~/server/redis/client` shim on purpose: it is a pure helper
// with no client state, and importing it through the shim would force every suite that mocks the
// shim to add it to its mock factory.
import { prefixCacheKey } from '@civitai/redis';
import { redis } from '~/server/redis/client';
import { setCustomerCivitaiUserId } from '~/server/http/shopify/shopify.caller';
import { createBuzzTransaction } from '~/server/services/buzz.service';
@@ -75,7 +79,9 @@ function verifyOrderKey(key: string): string | null {
const CLAIM_RATE_WINDOW_SECONDS = 600; // 10 min
const CLAIM_RATE_MAX = 20;
async function withinClaimRateLimit(userId: number) {
const key = `merch:claim-rate:${userId}`;
// Minted from a literal rather than derived from REDIS_KEYS, so it does not inherit the
// environment namespace from the key table — apply it explicitly. No-op in production.
const key = prefixCacheKey(`merch:claim-rate:${userId}`);
try {
const count = await redis.incrBy(key as never, 1);
if (count === 1) await redis.expire(key as never, CLAIM_RATE_WINDOW_SECONDS);
@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as RedisClientModule from '@civitai/redis/client';
import type * as StringHelpers from '~/utils/string-helpers';
/**
* NAMESPACED half of the environment-scoped cache-key prefixing coverage (production half is
* cache-helpers-key-prefix-prod.test.ts — the two cannot share a file because a `vi.mock`
* factory is evaluated once per file, so `CACHE_KEY_NAMESPACE` cannot be flipped between cases).
*
* Pins that on a namespaced deployment every cache key and every `tag:<name>` set moves into that
* namespace, that a tag bust still finds its members there, and that a production entry sitting
* under the unprefixed key is neither read nor overwritten. Expected keys are hand-written
* literals; `hashifyObject` is stubbed to a constant.
*/
// Must run before the `vi.mock` factory below imports the key table — the prefix is resolved at
// module-eval time. Note IS_PREVIEW is NOT set: the namespace is the only input that matters.
vi.hoisted(() => {
process.env.CACHE_KEY_NAMESPACE = 'preview';
delete process.env.IS_PREVIEW;
});
const { store, sets, fakeRedis } = vi.hoisted(() => {
const store = new Map<string, unknown>();
const sets = new Map<string, Set<string>>();
return {
store,
sets,
fakeRedis: {
packed: {
get: async (key: string) => (store.has(key) ? store.get(key) : null),
set: async (key: string, value: unknown) => void store.set(key, value),
},
sAdd: async (key: string, member: string) => {
const set = sets.get(key) ?? new Set<string>();
set.add(member);
sets.set(key, set);
return 1;
},
sMembers: async (key: string) => [...(sets.get(key) ?? [])],
del: async (key: string) => {
const hit = store.delete(key);
const setHit = sets.delete(key);
return hit || setHit ? 1 : 0;
},
setNxKeepTtlWithEx: async () => true,
},
};
});
vi.mock('~/server/redis/client', async () => {
const pkg = await vi.importActual<typeof RedisClientModule>('@civitai/redis/client');
return { ...pkg, redis: fakeRedis, sysRedis: fakeRedis };
});
vi.mock('~/utils/string-helpers', async () => {
const actual = await vi.importActual<typeof StringHelpers>('~/utils/string-helpers');
return { ...actual, hashifyObject: () => 'HASH' };
});
vi.mock('~/server/redis/fail-open-log', () => ({ logSysRedisFailOpen: vi.fn() }));
vi.mock('~/server/prom/client', () => ({
cacheHitCounter: { inc: vi.fn() },
cacheMissCounter: { inc: vi.fn() },
cacheRevalidateCounter: { inc: vi.fn() },
cacheFailOpenDegradedCounter: { inc: vi.fn() },
cacheFailOpenOriginFetchCounter: { inc: vi.fn() },
}));
vi.mock('~/server/logging/client', () => ({ logToAxiom: vi.fn().mockResolvedValue(undefined) }));
describe('cache-helpers key prefixing — namespaced', () => {
const rows = [{ id: 1, name: 'anime' }];
const sql = {} as never;
let queryRaw: ReturnType<typeof vi.fn>;
let db: never;
let executor: ReturnType<typeof vi.fn>;
beforeEach(() => {
store.clear();
sets.clear();
queryRaw = vi.fn().mockResolvedValue(rows);
db = { $queryRaw: queryRaw } as never;
executor = vi.fn().mockResolvedValue(rows);
});
it('exposes the configured prefix', async () => {
const { CACHE_KEY_PREFIX } = await import('~/server/redis/client');
expect(CACHE_KEY_PREFIX).toBe('preview:');
});
it('prefixes the derived key table', async () => {
const { REDIS_KEYS } = await import('~/server/redis/client');
expect(REDIS_KEYS.CACHES.USER_COSMETICS).toBe('preview:packed:caches:user-cosmetics');
expect(`${REDIS_KEYS.CACHES.USER_COSMETICS}:123`).toBe(
'preview:packed:caches:user-cosmetics:123'
);
expect(REDIS_KEYS.TAG).toBe('preview:tag');
});
it('leaves the system keyspace unprefixed', async () => {
const { REDIS_SYS_KEYS } = await import('~/server/redis/client');
expect(REDIS_SYS_KEYS.DEVICE.ACCOUNTS).toBe('device:accounts');
});
it('writes queryCache entries and tag sets under prefixed keys', async () => {
const { queryCache } = await import('~/server/utils/cache-helpers');
await queryCache(db, 'getTags', 'v1')(sql, { ttl: 60, tag: 'tags' });
expect(store.get('preview:getTags:v1:HASH')).toEqual(rows);
expect([...(sets.get('preview:tag:tags') ?? [])]).toEqual(['preview:getTags:v1:HASH']);
// The production keys must be untouched by a namespaced write.
expect(store.has('getTags:v1:HASH')).toBe(false);
expect(sets.has('tag:tags')).toBe(false);
});
// 🔴 queryCacheRaw is a SEPARATE key minter from queryCache — it has its own `prefixCacheKey`
// call, and without these two cases deleting that call leaves every other test in this suite
// green (a surviving mutant). It is the path getAllImages runs on, so it is also the highest
// traffic of the two.
it('writes queryCacheRaw entries and tag sets under prefixed keys', async () => {
const { queryCacheRaw } = await import('~/server/utils/cache-helpers');
await queryCacheRaw(executor as never, 'getImagesRaw', 'v3')(sql, { ttl: 60, tag: 'images' });
expect(store.get('preview:getImagesRaw:v3:HASH')).toEqual(rows);
expect([...(sets.get('preview:tag:images') ?? [])]).toEqual(['preview:getImagesRaw:v3:HASH']);
expect(store.has('getImagesRaw:v3:HASH')).toBe(false);
expect(sets.has('tag:images')).toBe(false);
});
it('does not let queryCacheRaw read a production entry under the unprefixed key', async () => {
const { queryCacheRaw } = await import('~/server/utils/cache-helpers');
store.set('getImagesRaw:v3:HASH', [{ id: 99, name: 'production-only' }]);
const result = await queryCacheRaw(executor as never, 'getImagesRaw', 'v3')(sql, { ttl: 60 });
expect(executor).toHaveBeenCalledTimes(1);
expect(result).toEqual(rows);
expect(store.get('getImagesRaw:v3:HASH')).toEqual([{ id: 99, name: 'production-only' }]);
});
it('busts a tagged entry by tag', async () => {
const { queryCache, bustCacheTag } = await import('~/server/utils/cache-helpers');
const cacheable = queryCache(db, 'getTags', 'v1');
await cacheable(sql, { ttl: 60, tag: 'tags' });
expect(queryRaw).toHaveBeenCalledTimes(1);
// Positive control: the entry and its tag set must actually EXIST under the prefixed keys
// before the bust. Without this the "gone after bust" assertions below pass vacuously on
// code that never prefixed anything.
expect(store.has('preview:getTags:v1:HASH')).toBe(true);
expect(sets.has('preview:tag:tags')).toBe(true);
await cacheable(sql, { ttl: 60, tag: 'tags' });
expect(queryRaw).toHaveBeenCalledTimes(1);
await bustCacheTag('tags');
expect(store.has('preview:getTags:v1:HASH')).toBe(false);
expect(sets.has('preview:tag:tags')).toBe(false);
await cacheable(sql, { ttl: 60, tag: 'tags' });
expect(queryRaw).toHaveBeenCalledTimes(2);
});
it('does not read a production entry written under the unprefixed key', async () => {
const { queryCache } = await import('~/server/utils/cache-helpers');
// Pre-seed the production key, as production would have.
store.set('getTags:v1:HASH', [{ id: 99, name: 'production-only' }]);
const result = await queryCache(db, 'getTags', 'v1')(sql, { ttl: 60 });
// The preview misses and fetches from its own database instead of serving production's value.
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(result).toEqual(rows);
expect(store.get('getTags:v1:HASH')).toEqual([{ id: 99, name: 'production-only' }]);
});
});
@@ -0,0 +1,165 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type * as RedisClientModule from '@civitai/redis/client';
import type * as StringHelpers from '~/utils/string-helpers';
/**
* PRODUCTION half of the environment-scoped cache-key prefixing coverage (the namespaced half is
* cache-helpers-key-prefix-preview.test.ts — the two cannot share a file because a `vi.mock`
* factory is evaluated once per file, so `CACHE_KEY_NAMESPACE` cannot be flipped between cases).
*
* 🔴 This file is the one that pins "production keys do not move". `queryCache` / `queryCacheRaw`
* entries and the `tag:<name>` sets that invalidate them must come out byte-identical to what
* they were before environment prefixing existed. Expected keys are hand-written literals;
* `hashifyObject` is stubbed to a constant so the whole key is a literal rather than a value
* recomputed from the code under test.
*
* Note IS_PREVIEW is set to 'true' here on purpose: it pins that the flag ALONE does not move a
* single production key. That is the regression this revision of the change exists to prevent —
* a deployment can set IS_PREVIEW=true and still run against the production database.
*/
// Guarantee the production shape regardless of the ambient environment. Hoisted so it runs
// before the `vi.mock` factory below imports the key table (the prefix is resolved at
// module-eval time).
vi.hoisted(() => {
delete process.env.CACHE_KEY_NAMESPACE;
process.env.IS_PREVIEW = 'true';
});
const { store, sets, fakeRedis } = vi.hoisted(() => {
const store = new Map<string, unknown>();
const sets = new Map<string, Set<string>>();
return {
store,
sets,
fakeRedis: {
packed: {
get: async (key: string) => (store.has(key) ? store.get(key) : null),
set: async (key: string, value: unknown) => void store.set(key, value),
},
sAdd: async (key: string, member: string) => {
const set = sets.get(key) ?? new Set<string>();
set.add(member);
sets.set(key, set);
return 1;
},
sMembers: async (key: string) => [...(sets.get(key) ?? [])],
del: async (key: string) => {
const hit = store.delete(key);
const setHit = sets.delete(key);
return hit || setHit ? 1 : 0;
},
setNxKeepTtlWithEx: async () => true,
},
};
});
// Real REDIS_KEYS / prefixCacheKey from the package, live clients swapped for the fake above.
vi.mock('~/server/redis/client', async () => {
const pkg = await vi.importActual<typeof RedisClientModule>('@civitai/redis/client');
return { ...pkg, redis: fakeRedis, sysRedis: fakeRedis };
});
vi.mock('~/utils/string-helpers', async () => {
const actual = await vi.importActual<typeof StringHelpers>('~/utils/string-helpers');
return { ...actual, hashifyObject: () => 'HASH' };
});
vi.mock('~/server/redis/fail-open-log', () => ({ logSysRedisFailOpen: vi.fn() }));
vi.mock('~/server/prom/client', () => ({
cacheHitCounter: { inc: vi.fn() },
cacheMissCounter: { inc: vi.fn() },
cacheRevalidateCounter: { inc: vi.fn() },
cacheFailOpenDegradedCounter: { inc: vi.fn() },
cacheFailOpenOriginFetchCounter: { inc: vi.fn() },
}));
vi.mock('~/server/logging/client', () => ({ logToAxiom: vi.fn().mockResolvedValue(undefined) }));
describe('cache-helpers key prefixing — production', () => {
const rows = [{ id: 1, name: 'anime' }];
// `sql` stands in for a Prisma.Sql — hashifyObject is stubbed, so its contents don't matter.
const sql = {} as never;
let queryRaw: ReturnType<typeof vi.fn>;
let db: never;
let executor: ReturnType<typeof vi.fn>;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeAll(() => {
// IS_PREVIEW=true with no namespace is exactly the combination the package's misconfiguration
// guard warns about, and the key table is imported lazily by the cases below — so the warning
// lands mid-suite. Capture it rather than let it print, and assert on it instead.
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
beforeEach(() => {
store.clear();
sets.clear();
queryRaw = vi.fn().mockResolvedValue(rows);
db = { $queryRaw: queryRaw } as never;
executor = vi.fn().mockResolvedValue(rows);
});
it('exposes an empty prefix', async () => {
const { CACHE_KEY_PREFIX } = await import('~/server/redis/client');
expect(CACHE_KEY_PREFIX).toBe('');
});
it('warns that IS_PREVIEW=true carries no cache namespace', async () => {
// Proves the guard is reachable through the app's real import graph, not just the package's.
await import('~/server/redis/client');
const messages = consoleError.mock.calls.map((call) => String(call[0]));
expect(messages.some((m) => m.includes('CACHE_KEY_NAMESPACE'))).toBe(true);
});
it('leaves the derived key table byte-identical', async () => {
const { REDIS_KEYS } = await import('~/server/redis/client');
expect(REDIS_KEYS.CACHES.USER_COSMETICS).toBe('packed:caches:user-cosmetics');
expect(`${REDIS_KEYS.CACHES.USER_COSMETICS}:123`).toBe('packed:caches:user-cosmetics:123');
expect(REDIS_KEYS.TAG).toBe('tag');
});
it('writes queryCache entries and tag sets under the unprefixed keys', async () => {
const { queryCache } = await import('~/server/utils/cache-helpers');
await queryCache(db, 'getTags', 'v1')(sql, { ttl: 60, tag: 'tags' });
expect(store.get('getTags:v1:HASH')).toEqual(rows);
expect([...(sets.get('tag:tags') ?? [])]).toEqual(['getTags:v1:HASH']);
});
it('writes queryCacheRaw entries and tag sets under the unprefixed keys', async () => {
// The sibling minter — pinned separately so a prefix leaking into only one of the two paths
// is still caught.
const { queryCacheRaw } = await import('~/server/utils/cache-helpers');
await queryCacheRaw(executor as never, 'getImagesRaw', 'v3')(sql, { ttl: 60, tag: 'images' });
expect(store.get('getImagesRaw:v3:HASH')).toEqual(rows);
expect([...(sets.get('tag:images') ?? [])]).toEqual(['getImagesRaw:v3:HASH']);
});
it('busts a tagged entry by tag', async () => {
const { queryCache, bustCacheTag } = await import('~/server/utils/cache-helpers');
const cacheable = queryCache(db, 'getTags', 'v1');
await cacheable(sql, { ttl: 60, tag: 'tags' });
expect(queryRaw).toHaveBeenCalledTimes(1);
// Positive control: the entry and its tag set exist under the unprefixed keys before the bust.
expect(store.has('getTags:v1:HASH')).toBe(true);
expect(sets.has('tag:tags')).toBe(true);
// Served from cache — the origin is not hit again.
await cacheable(sql, { ttl: 60, tag: 'tags' });
expect(queryRaw).toHaveBeenCalledTimes(1);
await bustCacheTag('tags');
expect(store.has('getTags:v1:HASH')).toBe(false);
expect(sets.has('tag:tags')).toBe(false);
// Cold again — the bust really removed the entry.
await cacheable(sql, { ttl: 60, tag: 'tags' });
expect(queryRaw).toHaveBeenCalledTimes(2);
});
});
+16 -9
View File
@@ -1,6 +1,9 @@
import type { Prisma, PrismaClient } from '@prisma/client';
import { chunk } from 'lodash-es';
import { createCacheBuilders } from '@civitai/redis';
// `prefixCacheKey` comes from the package rather than the `~/server/redis/client` shim on
// purpose: it is a pure helper with no client state, and importing it through the shim would
// force every suite that mocks the shim to add it to its mock factory.
import { createCacheBuilders, prefixCacheKey } from '@civitai/redis';
import { CacheTTL } from '~/server/common/constants';
import { logToAxiom } from '~/server/logging/client';
import {
@@ -38,10 +41,13 @@ export function queryCache(db: PrismaClient, key: string, version?: string) {
return async function <T extends object[]>(query: Prisma.Sql, options?: cachedQueryOptions) {
if (options?.ttl === 0) return db.$queryRaw<T>(query);
// this typing is not quite right, as we're creating redis keys on the fly here
const cacheKey = [key, version, hashifyObject(query).toString()]
.filter(isDefined)
.join(':') as RedisKeyTemplateCache;
// this typing is not quite right, as we're creating redis keys on the fly here.
// These keys are minted from a free-form `key` rather than derived from REDIS_KEYS, so they
// don't inherit the environment prefix from the key table — apply it explicitly. Without
// this, previews would share these entries with production. No-op in production.
const cacheKey = prefixCacheKey(
[key, version, hashifyObject(query).toString()].filter(isDefined).join(':')
) as RedisKeyTemplateCache;
const cachedData = await redis.packed.get<T>(cacheKey);
if (cachedData && options?.ttl !== 0) {
cacheHitCounter.inc({ cache_name: key, cache_type: 'queryCache' });
@@ -67,10 +73,11 @@ export function queryCacheRaw(executor: RawQueryExecutor, key: string, version?:
return async function <T extends object[]>(query: Prisma.Sql, options?: cachedQueryOptions) {
if (options?.ttl === 0) return (await executor<T[number]>(query)) as unknown as T;
// this typing is not quite right, as we're creating redis keys on the fly here
const cacheKey = [key, version, hashifyObject(query).toString()]
.filter(isDefined)
.join(':') as RedisKeyTemplateCache;
// this typing is not quite right, as we're creating redis keys on the fly here.
// Same on-the-fly minting as queryCache above — apply the environment prefix explicitly.
const cacheKey = prefixCacheKey(
[key, version, hashifyObject(query).toString()].filter(isDefined).join(':')
) as RedisKeyTemplateCache;
const cachedData = await redis.packed.get<T>(cacheKey);
if (cachedData && options?.ttl !== 0) {
cacheHitCounter.inc({ cache_name: key, cache_type: 'queryCache' });
+9 -1
View File
@@ -1,5 +1,9 @@
import type { NextApiRequest } from 'next';
import requestIp from 'request-ip';
// From the package rather than the `~/server/redis/client` shim on purpose: it is a pure helper
// with no client state, and importing it through the shim would force every suite that mocks the
// shim to add it to its mock factory.
import { prefixCacheKey } from '@civitai/redis';
import { redis } from '~/server/redis/client';
/**
@@ -123,6 +127,10 @@ export async function checkPublicApiRateLimit({
const authed = typeof userId === 'number';
const max = authed ? PUBLIC_API_RATE_LIMIT_AUTH_MAX : PUBLIC_API_RATE_LIMIT_UNAUTH_MAX;
const bucket = authed ? `user:${userId}` : `ip:${resolveClientIp(req)}`;
const key = `${KEY_PREFIX}:${family}:${bucket}`;
// Minted from a literal rather than derived from REDIS_KEYS, so it does not inherit the
// environment namespace from the key table — apply it explicitly. Without this a non-production
// deployment burns PRODUCTION users' rate-limit budget and can 429 real traffic. No-op in
// production.
const key = prefixCacheKey(`${KEY_PREFIX}:${family}:${bucket}`);
return checkFixedWindow(key, max, PUBLIC_API_RATE_LIMIT_WINDOW_SECONDS);
}