Files
cfw-autumn/server/experiments/benchmarkV2CacheRedis.ts
2026-04-22 21:01:57 +01:00

441 lines
13 KiB
TypeScript

import { AppEnv } from "@autumn/shared";
import type { Redis } from "ioredis";
import {
initDrizzle,
prodTestCustomerId,
prodTestEntityId,
prodTestOrgId,
} from "./experimentEnv";
// ---------------------------------------------------------------------------
// EDIT ME — hardcode the customer / feature you want to diagnose
// ---------------------------------------------------------------------------
const CONFIG = {
orgId: prodTestOrgId as string,
env: AppEnv.Live as AppEnv,
customerId: prodTestCustomerId as string,
entityId: prodTestEntityId as string | undefined, // or undefined
featureId: undefined as string | undefined, // set to isolate to one feature
iterations: 5,
runLua: true, // lua writes back entitlement hashes even for amount=0 — disable if paranoid
forceRepopulate: true, // DEL the cached subject before priming, so it's rebuilt from the DB
};
// ---------------------------------------------------------------------------
const { resolveRedisV2 } = await import("../src/external/redis/resolveRedisV2");
const { warmupRedisV2 } = await import("../src/external/redis/initRedisV2");
const { buildFullSubjectKey } = await import(
"../src/internal/customers/cache/fullSubject/builders/buildFullSubjectKey"
);
const { buildSharedFullSubjectBalanceKey } = await import(
"../src/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey"
);
const { buildDeductFromSubjectBalancesKeys } = await import(
"../src/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys"
);
const { getOrInitFullSubjectViewEpoch } = await import(
"../src/internal/customers/cache/fullSubject/actions/invalidate/getOrInitFullSubjectViewEpoch"
);
const { getCachedFeatureBalancesBatch } = await import(
"../src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances"
);
const { getOrSetCachedFullSubject } = await import(
"../src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject"
);
const { sanitizeCachedFullSubject } = await import(
"../src/internal/customers/cache/fullSubject/sanitize/index"
);
const { AGGREGATED_BALANCE_FIELD } = await import(
"../src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig"
);
type Step = { name: string; ms: number; extra?: string };
type Iter = { index: number; steps: Step[] };
const nowMs = () => performance.now();
const time = async <T>(
name: string,
fn: () => Promise<T>,
): Promise<{ step: Step; value: T }> => {
const start = nowMs();
const value = await fn();
const ms = nowMs() - start;
return { step: { name, ms }, value };
};
const makeCtx = ({ redisV2, db }: { redisV2: Redis; db: unknown }) =>
({
org: { id: CONFIG.orgId },
env: CONFIG.env,
redisV2,
db,
dbGeneral: db,
features: [],
skipCache: false,
isPublic: false,
extraLogs: {},
logger: {
debug: (...a: unknown[]) => console.debug(...a),
info: (...a: unknown[]) => console.info(...a),
warn: (...a: unknown[]) => console.warn(...a),
error: (...a: unknown[]) => console.error(...a),
},
}) as unknown as Parameters<typeof getOrInitFullSubjectViewEpoch>[0]["ctx"];
const pct = (sorted: number[], p: number): number => {
if (sorted.length === 0) return 0;
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
return sorted[idx];
};
const summarize = (all: Iter[]) => {
const byStep = new Map<string, number[]>();
for (const iter of all) {
for (const step of iter.steps) {
const arr = byStep.get(step.name) ?? [];
arr.push(step.ms);
byStep.set(step.name, arr);
}
}
console.log("\n=== Summary (ms) ===");
console.log(
"step".padEnd(48),
"min".padStart(8),
"p50".padStart(8),
"p95".padStart(8),
"max".padStart(8),
);
for (const [name, arr] of byStep) {
const sorted = [...arr].sort((a, b) => a - b);
console.log(
name.padEnd(48),
sorted[0].toFixed(2).padStart(8),
pct(sorted, 50).toFixed(2).padStart(8),
pct(sorted, 95).toFixed(2).padStart(8),
sorted[sorted.length - 1].toFixed(2).padStart(8),
);
}
};
const runIteration = async ({
i,
redisV2,
ctx,
}: {
i: number;
redisV2: Redis;
// biome-ignore lint/suspicious/noExplicitAny: stub ctx
ctx: any;
}): Promise<Iter> => {
const steps: Step[] = [];
const subjectKey = buildFullSubjectKey({
orgId: CONFIG.orgId,
env: CONFIG.env,
customerId: CONFIG.customerId,
entityId: CONFIG.entityId,
});
// A1. GET subject
const { step: s1, value: cachedRaw } = await time(
"GET full_subject",
() => redisV2.get(subjectKey),
);
steps.push({ ...s1, extra: cachedRaw ? `${cachedRaw.length}b` : "null" });
if (!cachedRaw) {
console.log(` iter ${i}: no cached subject — stopping this iteration`);
return { index: i, steps };
}
// parse
const { step: sParse, value: cached } = await time(
"JSON.parse + sanitize",
async () =>
sanitizeCachedFullSubject({
cachedFullSubject: JSON.parse(cachedRaw),
}),
);
steps.push(sParse);
if (i === 1) {
const sizeOf = (v: unknown) => JSON.stringify(v ?? null).length;
const cachedAny = cached as unknown as Record<string, unknown>;
const entries = Object.keys(cachedAny)
.map((k) => ({ k, bytes: sizeOf(cachedAny[k]) }))
.sort((a, b) => b.bytes - a.bytes);
const total = entries.reduce((a, e) => a + e.bytes, 0);
console.log(` blob anatomy (total ${(total / 1024).toFixed(1)}kb):`);
for (const e of entries.slice(0, 8)) {
const pct = ((e.bytes / total) * 100).toFixed(1);
console.log(
` ${(e.bytes / 1024).toFixed(1).padStart(8)}kb ${pct.padStart(5)}% ${e.k}`,
);
}
}
// A2. epoch
const { step: s2 } = await time("getOrInitFullSubjectViewEpoch", () =>
getOrInitFullSubjectViewEpoch({ ctx, customerId: CONFIG.customerId }),
);
steps.push(s2);
const meteredFeatures = CONFIG.featureId
? cached.meteredFeatures.filter((f: string) => f === CONFIG.featureId)
: cached.meteredFeatures;
const includeAggregated = !CONFIG.entityId;
// A3. batch hmget pipeline
const { step: s3, value: batchOutcome } = await time(
`getCachedFeatureBalancesBatch (${meteredFeatures.length} features)`,
() =>
getCachedFeatureBalancesBatch({
ctx,
customerId: CONFIG.customerId,
featureIds: meteredFeatures,
customerEntitlementIdsByFeatureId:
cached.customerEntitlementIdsByFeatureId,
includeAggregated,
}),
);
steps.push({ ...s3, extra: batchOutcome.kind });
// A4. sequential per-feature HMGET with per-feature timing (slow-key hunt)
const perFeature: Array<{ featureId: string; ms: number; bytes: number }> =
[];
const { step: s4 } = await time(
`sequential HMGET per feature (${meteredFeatures.length} calls)`,
async () => {
for (const featureId of meteredFeatures) {
const ids =
cached.customerEntitlementIdsByFeatureId[featureId] ?? [];
const fields = includeAggregated
? [...ids, AGGREGATED_BALANCE_FIELD]
: ids;
const balanceKey = buildSharedFullSubjectBalanceKey({
orgId: CONFIG.orgId,
env: CONFIG.env,
customerId: CONFIG.customerId,
featureId,
});
const t = nowMs();
let bytes = 0;
if (fields.length === 0) {
await redisV2.exists(balanceKey);
} else {
const vals = (await redisV2.hmget(
balanceKey,
...fields,
)) as (string | null)[];
bytes = vals.reduce((a, v) => a + (v?.length ?? 0), 0);
}
perFeature.push({ featureId, ms: nowMs() - t, bytes });
}
},
);
steps.push(s4);
// Only print on first iteration to keep output tidy
if (i === 1) {
const sorted = [...perFeature].sort((a, b) => b.ms - a.ms);
console.log(" top-5 slowest features (seq HMGET):");
for (const f of sorted.slice(0, 5)) {
console.log(
` ${f.ms.toFixed(2).padStart(7)}ms ${(f.bytes / 1024).toFixed(1).padStart(6)}kb ${f.featureId}`,
);
}
}
// C. dry lua invocation (amount_to_deduct=0)
if (CONFIG.runLua && meteredFeatures.length > 0) {
const customerEntitlementDeductions: Array<{
customer_entitlement_id: string;
credit_cost: number;
feature_id: string;
entity_feature_id: string | null;
usage_allowed: boolean;
min_balance: number;
max_balance: number;
}> = [];
for (const featureId of meteredFeatures) {
const ids = cached.customerEntitlementIdsByFeatureId[featureId] ?? [];
for (const id of ids) {
customerEntitlementDeductions.push({
customer_entitlement_id: id,
credit_cost: 0,
feature_id: featureId,
entity_feature_id: null,
usage_allowed: false,
min_balance: 0,
max_balance: 0,
});
}
}
if (customerEntitlementDeductions.length === 0) {
console.log(" (skipping lua — no customer entitlements)");
} else {
const routingKey = subjectKey;
const { keys, balanceKeyIndexByFeatureId } =
buildDeductFromSubjectBalancesKeys({
orgId: CONFIG.orgId,
env: CONFIG.env,
customerId: CONFIG.customerId,
routingKey,
lockReceiptKey: null,
customerEntitlementDeductions,
fallbackFeatureId:
CONFIG.featureId ?? meteredFeatures[0] ?? "unknown",
});
const luaParams = {
org_id: CONFIG.orgId,
env: CONFIG.env,
customer_id: CONFIG.customerId,
customer_entitlement_deductions: customerEntitlementDeductions,
balance_key_index_by_feature_id: balanceKeyIndexByFeatureId,
spend_limit_by_feature_id: null,
usage_based_cus_ent_ids_by_feature_id: null,
amount_to_deduct: 0,
target_balance: null,
target_entity_id: CONFIG.entityId ?? null,
rollovers: null,
skip_additional_balance: false,
alter_granted_balance: false,
overage_behaviour: "cap",
feature_id: CONFIG.featureId ?? meteredFeatures[0],
lock: null,
unwind_value: null,
debug: true,
};
const { step: sLua, value: luaRaw } = await time(
`deductFromSubjectBalances lua (${keys.length} keys, ${customerEntitlementDeductions.length} ents)`,
() =>
// biome-ignore lint/suspicious/noExplicitAny: ioredis custom command typed via module augmentation not available here
(redisV2 as any).deductFromSubjectBalances(
keys.length,
...keys,
JSON.stringify(luaParams),
),
);
steps.push(sLua);
try {
const luaResult = JSON.parse(luaRaw as string);
if (luaResult.error) {
console.log(` lua error: ${luaResult.error}`);
}
if (luaResult.logs?.length) {
console.log(` lua logs (${luaResult.logs.length} lines):`);
for (const line of luaResult.logs) console.log(` ${line}`);
}
} catch {
console.log(" (lua result unparseable)");
}
}
}
return { index: i, steps };
};
const main = async () => {
console.log("=== V2 cache Redis benchmark ===");
console.log("config:", {
orgId: CONFIG.orgId,
env: CONFIG.env,
customerId: CONFIG.customerId,
entityId: CONFIG.entityId,
featureId: CONFIG.featureId,
iterations: CONFIG.iterations,
runLua: CONFIG.runLua,
});
const { db } = initDrizzle();
const redisV2 = resolveRedisV2();
await warmupRedisV2();
const ctx = makeCtx({ redisV2, db });
// Prime the cache if cold, so the iteration benchmarks always measure a hit path.
const subjectKey = buildFullSubjectKey({
orgId: CONFIG.orgId,
env: CONFIG.env,
customerId: CONFIG.customerId,
entityId: CONFIG.entityId,
});
if (CONFIG.forceRepopulate) {
// Grab meteredFeatures from the existing subject so we know which
// balance hashes to drop. All keys share the {customerId} hash tag,
// so a single DEL across them is slot-safe.
const staleRaw = await redisV2.get(subjectKey);
const balanceKeys: string[] = [];
if (staleRaw) {
try {
const stale = JSON.parse(staleRaw) as {
meteredFeatures?: string[];
};
for (const featureId of stale.meteredFeatures ?? []) {
balanceKeys.push(
buildSharedFullSubjectBalanceKey({
orgId: CONFIG.orgId,
env: CONFIG.env,
customerId: CONFIG.customerId,
featureId,
}),
);
}
} catch {
console.warn(" (could not parse stale subject to extract balance keys)");
}
}
const toDel = [subjectKey, ...balanceKeys];
const deleted = await redisV2.del(...toDel);
console.log(
`\n--- forceRepopulate: DEL ${toDel.length} keys (subject + ${balanceKeys.length} balance hashes) → ${deleted} removed ---`,
);
}
const existing = await redisV2.get(subjectKey);
if (!existing) {
console.log("\n--- priming cache (cold) ---");
const t0 = nowMs();
try {
await getOrSetCachedFullSubject({
ctx,
customerId: CONFIG.customerId,
entityId: CONFIG.entityId,
source: "benchmarkV2CacheRedis",
});
console.log(` primed in ${(nowMs() - t0).toFixed(2)}ms`);
} catch (err) {
console.error(" prime failed:", err);
console.error(
" (cache is cold and cannot be populated from this script — aborting)",
);
process.exit(1);
}
} else {
console.log(`\ncache already warm (${existing.length}b)`);
}
const all: Iter[] = [];
for (let i = 1; i <= CONFIG.iterations; i++) {
console.log(`\n--- iteration ${i} ---`);
const iter = await runIteration({ i, redisV2, ctx });
for (const step of iter.steps) {
const extra = step.extra ? ` (${step.extra})` : "";
console.log(` ${step.ms.toFixed(2).padStart(8)}ms ${step.name}${extra}`);
}
all.push(iter);
}
summarize(all);
process.exit(0);
};
await main();