356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
/**
|
|
* Benchmark: sanitizeCachedFullSubject + sanitizeCachedSubjectBalance
|
|
*
|
|
* Simulates a customer with 1000 customer products, each with 3 customer
|
|
* entitlements (3000 SubjectBalance blobs total), and measures how long
|
|
* sanitization takes.
|
|
*
|
|
* Run with: bun run experiments/sanitizeBenchmark.ts
|
|
*/
|
|
|
|
import { AppEnv } from "@autumn/shared";
|
|
import { CollectionMethod, CusProductStatus } from "@shared/models/cusProductModels/cusProductEnums.js";
|
|
import { FeatureType } from "@shared/models/featureModels/featureEnums.js";
|
|
import { AllowanceType } from "@shared/models/productModels/entModels/entModels.js";
|
|
import { EntInterval } from "@shared/models/productModels/intervals/entitlementInterval.js";
|
|
import type { CachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js";
|
|
import { sanitizeCachedFullSubject } from "@/internal/customers/cache/fullSubject/sanitize/sanitizeCachedFullSubject.js";
|
|
import { sanitizeCachedSubjectBalance } from "@/internal/customers/cache/fullSubject/sanitize/sanitizeCachedSubjectBalance.js";
|
|
|
|
const NUM_CUSTOMER_PRODUCTS = 1000;
|
|
const CUS_ENTS_PER_PRODUCT = 3;
|
|
const TOTAL_CUS_ENTS = NUM_CUSTOMER_PRODUCTS * CUS_ENTS_PER_PRODUCT;
|
|
const WARMUP_ITERATIONS = 100;
|
|
const BENCHMARK_ITERATIONS = 1000;
|
|
|
|
const now = Date.now();
|
|
|
|
const buildSubjectBalance = ({ index }: { index: number }) => ({
|
|
id: `cus_ent_${index}`,
|
|
customer_product_id: `cp_${Math.floor(index / CUS_ENTS_PER_PRODUCT)}`,
|
|
entitlement_id: `ent_${index}`,
|
|
internal_customer_id: "cus_int_1",
|
|
internal_entity_id: null,
|
|
internal_feature_id: `feat_int_${index}`,
|
|
feature_id: `feature_${index}`,
|
|
unlimited: false,
|
|
balance: 100 + index,
|
|
adjustment: 0,
|
|
additional_balance: 0,
|
|
usage_allowed: true,
|
|
next_reset_at: now + 86400000,
|
|
expires_at: null,
|
|
external_id: null,
|
|
cache_version: 1,
|
|
created_at: now,
|
|
customer_id: "cus_1",
|
|
rollovers: index % 5 === 0
|
|
? [
|
|
{
|
|
id: `rollover_${index}`,
|
|
cus_ent_id: `cus_ent_${index}`,
|
|
balance: 50,
|
|
usage: 10,
|
|
expires_at: now + 172800000,
|
|
entities: {},
|
|
},
|
|
]
|
|
: [],
|
|
entities: index % 3 === 0
|
|
? {
|
|
entity_a: { id: "entity_a", balance: 50, adjustment: 0 },
|
|
entity_b: { id: "entity_b", balance: 30, adjustment: 0 },
|
|
}
|
|
: null,
|
|
entitlement: {
|
|
id: `ent_${index}`,
|
|
created_at: now,
|
|
internal_feature_id: `feat_int_${index}`,
|
|
internal_product_id: `prod_int_${Math.floor(index / CUS_ENTS_PER_PRODUCT)}`,
|
|
is_custom: false,
|
|
allowance_type: AllowanceType.Fixed,
|
|
allowance: 1000,
|
|
interval: EntInterval.Month,
|
|
interval_count: 1,
|
|
carry_from_previous: false,
|
|
entity_feature_id: null,
|
|
org_id: "org_1",
|
|
feature_id: `feature_${index}`,
|
|
usage_limit: null,
|
|
rollover: null,
|
|
feature: {
|
|
internal_id: `feat_int_${index}`,
|
|
org_id: "org_1",
|
|
created_at: now,
|
|
env: AppEnv.Sandbox,
|
|
id: `feature_${index}`,
|
|
name: `Feature ${index}`,
|
|
type: FeatureType.Metered,
|
|
config: { usage_type: "single" },
|
|
display: null,
|
|
archived: false,
|
|
event_names: ["track_event"],
|
|
},
|
|
},
|
|
customerPrice: null,
|
|
customerProductOptions: null,
|
|
customerProductQuantity: 1,
|
|
isEntityLevel: false,
|
|
});
|
|
|
|
const buildCachedFullSubject = (): CachedFullSubject => {
|
|
const customerProducts = Array.from(
|
|
{ length: NUM_CUSTOMER_PRODUCTS },
|
|
(_, index) => ({
|
|
id: `cp_${index}`,
|
|
internal_product_id: `prod_int_${index}`,
|
|
product_id: `prod_${index}`,
|
|
internal_customer_id: "cus_int_1",
|
|
customer_id: "cus_1",
|
|
internal_entity_id: null,
|
|
entity_id: null,
|
|
created_at: now,
|
|
status: CusProductStatus.Active,
|
|
canceled: false,
|
|
starts_at: now,
|
|
trial_ends_at: null,
|
|
canceled_at: null,
|
|
ended_at: null,
|
|
options: [
|
|
{
|
|
feature_id: `feature_${index * CUS_ENTS_PER_PRODUCT}`,
|
|
quantity: 1,
|
|
upcoming_quantity: null,
|
|
adjustable_quantity: false,
|
|
internal_feature_id: `feat_int_${index * CUS_ENTS_PER_PRODUCT}`,
|
|
},
|
|
],
|
|
free_trial_id: null,
|
|
collection_method: CollectionMethod.ChargeAutomatically,
|
|
subscription_ids: ["sub_1"],
|
|
scheduled_ids: null,
|
|
processor: null,
|
|
quantity: 1,
|
|
api_semver: null,
|
|
is_custom: false,
|
|
billing_version: "v1" as const,
|
|
external_id: null,
|
|
}),
|
|
);
|
|
|
|
const entitlements = Array.from(
|
|
{ length: TOTAL_CUS_ENTS },
|
|
(_, index) => ({
|
|
id: `ent_${index}`,
|
|
created_at: now,
|
|
internal_feature_id: `feat_int_${index}`,
|
|
internal_product_id: `prod_int_${Math.floor(index / CUS_ENTS_PER_PRODUCT)}`,
|
|
is_custom: false,
|
|
allowance_type: AllowanceType.Fixed,
|
|
allowance: 1000,
|
|
interval: EntInterval.Month,
|
|
interval_count: 1,
|
|
carry_from_previous: false,
|
|
entity_feature_id: null,
|
|
org_id: "org_1",
|
|
feature_id: `feature_${index}`,
|
|
usage_limit: null,
|
|
rollover: null,
|
|
feature: {
|
|
internal_id: `feat_int_${index}`,
|
|
org_id: "org_1",
|
|
created_at: now,
|
|
env: AppEnv.Sandbox,
|
|
id: `feature_${index}`,
|
|
name: `Feature ${index}`,
|
|
type: FeatureType.Metered,
|
|
config: { usage_type: "single" },
|
|
display: null,
|
|
archived: false,
|
|
event_names: ["track_event"],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const products = Array.from(
|
|
{ length: NUM_CUSTOMER_PRODUCTS },
|
|
(_, index) => ({
|
|
internal_id: `prod_int_${index}`,
|
|
id: `prod_${index}`,
|
|
name: `Product ${index}`,
|
|
description: null,
|
|
is_add_on: false,
|
|
is_default: false,
|
|
version: 1,
|
|
group: "",
|
|
env: AppEnv.Sandbox,
|
|
org_id: "org_1",
|
|
created_at: now,
|
|
processor: null,
|
|
base_variant_id: null,
|
|
archived: false,
|
|
}),
|
|
);
|
|
|
|
const customerEntitlementIdsByFeatureId: Record<string, string[]> = {};
|
|
for (let i = 0; i < TOTAL_CUS_ENTS; i++) {
|
|
customerEntitlementIdsByFeatureId[`feature_${i}`] = [`cus_ent_${i}`];
|
|
}
|
|
|
|
return {
|
|
subjectType: "customer",
|
|
customerId: "cus_1",
|
|
internalCustomerId: "cus_int_1",
|
|
customer: {
|
|
id: "cus_1",
|
|
internal_id: "cus_int_1",
|
|
org_id: "org_1",
|
|
env: AppEnv.Live,
|
|
created_at: now,
|
|
name: "Benchmark Customer",
|
|
email: "bench@test.com",
|
|
fingerprint: null,
|
|
processor: null,
|
|
processors: null,
|
|
metadata: {},
|
|
send_email_receipts: false,
|
|
auto_topups: [],
|
|
spend_limits: [],
|
|
usage_alerts: [],
|
|
overage_allowed: [],
|
|
},
|
|
customer_products: customerProducts,
|
|
products,
|
|
entitlements,
|
|
prices: [],
|
|
free_trials: [],
|
|
subscriptions: [],
|
|
invoices: [],
|
|
flags: {},
|
|
_cachedAt: now,
|
|
meteredFeatures: Array.from({ length: TOTAL_CUS_ENTS }, (_, i) => `feature_${i}`),
|
|
customerEntitlementIdsByFeatureId,
|
|
subjectViewEpoch: 1,
|
|
} as unknown as CachedFullSubject;
|
|
};
|
|
|
|
const subjectBalances = Array.from(
|
|
{ length: TOTAL_CUS_ENTS },
|
|
(_, index) => buildSubjectBalance({ index }),
|
|
);
|
|
|
|
const cachedFullSubject = buildCachedFullSubject();
|
|
|
|
const formatNs = (nanoseconds: bigint): string => {
|
|
const microseconds = Number(nanoseconds) / 1000;
|
|
if (microseconds < 1000) return `${microseconds.toFixed(1)}us`;
|
|
return `${(microseconds / 1000).toFixed(3)}ms`;
|
|
};
|
|
|
|
console.log("=== Sanitize Benchmark ===");
|
|
console.log(`Customer products: ${NUM_CUSTOMER_PRODUCTS}`);
|
|
console.log(`CusEnts per product: ${CUS_ENTS_PER_PRODUCT}`);
|
|
console.log(`Total SubjectBalance blobs: ${TOTAL_CUS_ENTS}`);
|
|
console.log(`Warmup iterations: ${WARMUP_ITERATIONS}`);
|
|
console.log(`Benchmark iterations: ${BENCHMARK_ITERATIONS}`);
|
|
console.log();
|
|
|
|
// --- Benchmark: sanitizeCachedSubjectBalance (single) ---
|
|
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
|
|
sanitizeCachedSubjectBalance({ subjectBalance: subjectBalances[0] as any });
|
|
}
|
|
|
|
let singleTotal = 0n;
|
|
for (let i = 0; i < BENCHMARK_ITERATIONS; i++) {
|
|
const balance = subjectBalances[i % subjectBalances.length];
|
|
const start = Bun.nanoseconds();
|
|
sanitizeCachedSubjectBalance({ subjectBalance: balance as any });
|
|
singleTotal += BigInt(Math.round(Bun.nanoseconds() - start));
|
|
}
|
|
const singleAvg = singleTotal / BigInt(BENCHMARK_ITERATIONS);
|
|
console.log(`sanitizeCachedSubjectBalance (1 balance):`);
|
|
console.log(` avg: ${formatNs(singleAvg)}`);
|
|
console.log(` total for ${BENCHMARK_ITERATIONS} calls: ${formatNs(singleTotal)}`);
|
|
console.log();
|
|
|
|
// --- Benchmark: sanitizeCachedSubjectBalance (all 3000) ---
|
|
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
|
|
for (const balance of subjectBalances) {
|
|
sanitizeCachedSubjectBalance({ subjectBalance: balance as any });
|
|
}
|
|
}
|
|
|
|
let batchTotal = 0n;
|
|
for (let i = 0; i < 10; i++) {
|
|
const start = Bun.nanoseconds();
|
|
for (const balance of subjectBalances) {
|
|
sanitizeCachedSubjectBalance({ subjectBalance: balance as any });
|
|
}
|
|
batchTotal += BigInt(Math.round(Bun.nanoseconds() - start));
|
|
}
|
|
const batchAvg = batchTotal / 10n;
|
|
console.log(`sanitizeCachedSubjectBalance (all ${TOTAL_CUS_ENTS} balances):`);
|
|
console.log(` avg per full batch: ${formatNs(batchAvg)}`);
|
|
console.log(` avg per balance: ${formatNs(batchAvg / BigInt(TOTAL_CUS_ENTS))}`);
|
|
console.log();
|
|
|
|
// --- Benchmark: sanitizeCachedFullSubject ---
|
|
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
|
|
sanitizeCachedFullSubject({ cachedFullSubject: cachedFullSubject });
|
|
}
|
|
|
|
let shellTotal = 0n;
|
|
for (let i = 0; i < BENCHMARK_ITERATIONS; i++) {
|
|
const start = Bun.nanoseconds();
|
|
sanitizeCachedFullSubject({ cachedFullSubject: cachedFullSubject });
|
|
shellTotal += BigInt(Math.round(Bun.nanoseconds() - start));
|
|
}
|
|
const shellAvg = shellTotal / BigInt(BENCHMARK_ITERATIONS);
|
|
console.log(`sanitizeCachedFullSubject (${NUM_CUSTOMER_PRODUCTS} products, ${TOTAL_CUS_ENTS} entitlements):`);
|
|
console.log(` avg: ${formatNs(shellAvg)}`);
|
|
console.log(` total for ${BENCHMARK_ITERATIONS} calls: ${formatNs(shellTotal)}`);
|
|
console.log();
|
|
|
|
// --- Combined: full cache read sanitization ---
|
|
for (let i = 0; i < WARMUP_ITERATIONS; i++) {
|
|
sanitizeCachedFullSubject({ cachedFullSubject: cachedFullSubject });
|
|
for (const balance of subjectBalances) {
|
|
sanitizeCachedSubjectBalance({ subjectBalance: balance as any });
|
|
}
|
|
}
|
|
|
|
let combinedTotal = 0n;
|
|
for (let i = 0; i < 10; i++) {
|
|
const start = Bun.nanoseconds();
|
|
sanitizeCachedFullSubject({ cachedFullSubject: cachedFullSubject });
|
|
for (const balance of subjectBalances) {
|
|
sanitizeCachedSubjectBalance({ subjectBalance: balance as any });
|
|
}
|
|
combinedTotal += BigInt(Math.round(Bun.nanoseconds() - start));
|
|
}
|
|
const combinedAvg = combinedTotal / 10n;
|
|
console.log(`Combined (shell + all ${TOTAL_CUS_ENTS} balances):`);
|
|
console.log(` avg: ${formatNs(combinedAvg)}`);
|
|
console.log();
|
|
|
|
// --- JSON.parse baseline for comparison ---
|
|
const serializedBalances = subjectBalances.map((balance) => JSON.stringify(balance));
|
|
const serializedShell = JSON.stringify(cachedFullSubject);
|
|
|
|
let parseTotal = 0n;
|
|
for (let i = 0; i < 10; i++) {
|
|
const start = Bun.nanoseconds();
|
|
JSON.parse(serializedShell);
|
|
for (const json of serializedBalances) {
|
|
JSON.parse(json);
|
|
}
|
|
parseTotal += BigInt(Math.round(Bun.nanoseconds() - start));
|
|
}
|
|
const parseAvg = parseTotal / 10n;
|
|
console.log(`Baseline: JSON.parse (shell + ${TOTAL_CUS_ENTS} balances):`);
|
|
console.log(` avg: ${formatNs(parseAvg)}`);
|
|
console.log();
|
|
|
|
const overheadPct = Number(combinedAvg) / Number(parseAvg) * 100;
|
|
console.log(`Sanitize overhead vs JSON.parse: ${overheadPct.toFixed(1)}%`);
|