88 lines
3.2 KiB
TypeScript
88 lines
3.2 KiB
TypeScript
/**
|
|
* Feature-flags eval-cache (two-tier). Mirrors subscriptionCache pattern.
|
|
*
|
|
* L1 = in-process Map for sub-microsecond reads on the render hot-path.
|
|
* L2 = SharedStore (Redis or in-memory adapter) for cross-replica reuse.
|
|
* Pub/sub channel `shared-store:<ns>:ch:cache:feature-flags:invalidate`
|
|
* fans out flag-mutation events — every replica flushes its L1 on
|
|
* receipt so the next eval re-reads from DB.
|
|
*
|
|
* Bootstrap: call `subscribeFeatureFlagCache()` exactly once during web
|
|
* boot (idempotent). On flag mutation (admin save) the action handler
|
|
* calls `getFeatureFlagCache().invalidateFlag(flagName)` which publishes
|
|
* a sentinel — peer L1s flush wholesale (cheap, mutations are rare).
|
|
*/
|
|
|
|
import {
|
|
FEATURE_FLAGS_INVALIDATION_CHANNEL,
|
|
createFlagEvalCache,
|
|
type EvaluateFlagResult,
|
|
type FlagEvalCache,
|
|
} from "@toolctrl/feature-flags";
|
|
import { createTwoTierCache, type TwoTierCache } from "@toolctrl/shared-store";
|
|
import { getSharedStore } from "~/shared-store.server";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
|
|
const log = createScopedLogger("feature-flags-cache");
|
|
|
|
const TTL_SECONDS_DEFAULT = 60; // spec: per-ctx eval result cached 60s.
|
|
|
|
let tierSingleton: TwoTierCache<EvaluateFlagResult> | null = null;
|
|
let cacheSingleton: FlagEvalCache | null = null;
|
|
let subscribeDisposer: (() => Promise<void>) | null = null;
|
|
|
|
function pubsubEnabledFromEnv(): boolean {
|
|
const raw = process.env.CACHE_INVALIDATION_ENABLED;
|
|
if (raw === undefined || raw === null || raw === "") return true;
|
|
return raw.toLowerCase() !== "false" && raw !== "0";
|
|
}
|
|
|
|
function ttlSecondsFromEnv(): number {
|
|
const raw = process.env.FEATURE_FLAGS_CACHE_TTL_SECONDS;
|
|
if (!raw) return TTL_SECONDS_DEFAULT;
|
|
const parsed = Number(raw);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) return TTL_SECONDS_DEFAULT;
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function getTier(): TwoTierCache<EvaluateFlagResult> {
|
|
if (tierSingleton) return tierSingleton;
|
|
tierSingleton = createTwoTierCache<EvaluateFlagResult>({
|
|
store: getSharedStore(),
|
|
namespace: "feature-flags",
|
|
ttlSeconds: ttlSecondsFromEnv(),
|
|
pubsubEnabled: pubsubEnabledFromEnv(),
|
|
logger: { warn: (obj, msg) => log.warn(obj, msg), error: (obj, msg) => log.error(obj, msg) },
|
|
});
|
|
return tierSingleton;
|
|
}
|
|
|
|
export function getFeatureFlagCache(): FlagEvalCache {
|
|
if (cacheSingleton) return cacheSingleton;
|
|
cacheSingleton = createFlagEvalCache(getTier());
|
|
return cacheSingleton;
|
|
}
|
|
|
|
/**
|
|
* Start the subscriber for this process. Idempotent. The handler
|
|
* flushes the entire L1 on any invalidation receipt, which is the
|
|
* correct trade-off because flag mutations are rare and L1 is small.
|
|
*/
|
|
export async function subscribeFeatureFlagCache(): Promise<() => Promise<void>> {
|
|
if (subscribeDisposer) return subscribeDisposer;
|
|
const tier = getTier();
|
|
subscribeDisposer = await tier.subscribe(() => {
|
|
// Any invalidation drops everything — see cache.ts comment.
|
|
tier.flushLocal();
|
|
});
|
|
log.info({ namespace: "feature-flags", channel: FEATURE_FLAGS_INVALIDATION_CHANNEL }, "feature-flag cache subscriber started");
|
|
return subscribeDisposer;
|
|
}
|
|
|
|
/** Test seam. */
|
|
export function __resetFeatureFlagCacheForTests(): void {
|
|
tierSingleton = null;
|
|
cacheSingleton = null;
|
|
subscribeDisposer = null;
|
|
}
|