mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user