From f12f21a9ad37b245f9159dbdfda65d1f8a9632d6 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 24 Mar 2026 13:24:52 +0000 Subject: [PATCH] fix: use entitlement index for json path --- server/perf/redis-bench/artillery.yml | 51 ++ server/perf/redis-bench/processor.mjs | 25 + server/perf/redis-bench/setup.ts | 157 ++++++ .../contextUtils.lua | 106 ++-- .../deductFromCustomerEntitlements.lua | 62 +- .../luaUtils.lua | 54 +- .../spendLimitUtils.lua | 35 +- .../fullCustomer/fullCustomerUtils.lua | 100 ++++ .../_luaScriptsV2/fullCustomerKeyBuilders.lua | 4 +- server/src/_luaScriptsV2/luaScriptsV2.ts | 32 +- .../utils/deduction/executeRedisDeduction.ts | 3 + .../cache/pathIndex/buildPathIndex.ts | 18 +- server/tests/_temp/temp.test.ts | 528 ++++++++++++++++-- 13 files changed, 994 insertions(+), 181 deletions(-) create mode 100644 server/perf/redis-bench/artillery.yml create mode 100644 server/perf/redis-bench/processor.mjs create mode 100644 server/perf/redis-bench/setup.ts create mode 100644 server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua diff --git a/server/perf/redis-bench/artillery.yml b/server/perf/redis-bench/artillery.yml new file mode 100644 index 000000000..f9d689816 --- /dev/null +++ b/server/perf/redis-bench/artillery.yml @@ -0,0 +1,51 @@ +# Redis benchmark load test — stresses check/track for a single large customer +# to verify path index optimization holds under concurrent load. +# +# Prerequisites: +# 1. Run setup: cd server && bun perf/redis-bench/setup.ts +# 2. Start server: bun dev +# 3. Run: npx artillery run perf/redis-bench/artillery.yml + +config: + target: "http://localhost:8080" + processor: "./processor.mjs" + + phases: + - duration: 30 + arrivalRate: 5 + rampTo: 50 + name: "Ramp" + + - duration: 120 + arrivalRate: 50 + name: "Sustained" + + - duration: 30 + arrivalRate: 50 + rampTo: 100 + name: "Spike" + + defaults: + headers: + Authorization: "Bearer {{ $processEnvironment.UNIT_TEST_AUTUMN_SECRET_KEY }}" + Content-Type: "application/json" + +scenarios: + - name: "Check + Track (large customer, random entity)" + weight: 1 + beforeScenario: "setContext" + flow: + - post: + url: "/v1/check" + json: + customer_id: "{{ customerId }}" + feature_id: "{{ featureId }}" + entity_id: "{{ entityId }}" + + - post: + url: "/v1/track" + json: + customer_id: "{{ customerId }}" + feature_id: "{{ featureId }}" + entity_id: "{{ entityId }}" + value: 1 diff --git a/server/perf/redis-bench/processor.mjs b/server/perf/redis-bench/processor.mjs new file mode 100644 index 000000000..6db61adc3 --- /dev/null +++ b/server/perf/redis-bench/processor.mjs @@ -0,0 +1,25 @@ +/** + * Artillery processor for the Redis benchmark load test. + * + * Always targets the same large customer (the point is to stress Redis + * with the worst-case key size). Randomises the entity to spread load + * across different path index entries. + */ + +const CUSTOMER_ID = "redis-bench-large-cus"; +const ENTITY_COUNT = 100; +const CUS_ENTS_PER_PRODUCT = 10; + +/** + * Called before each virtual user scenario. + * Sets customerId, featureId, and a random entityId. + */ +export function setContext(ctx, _events, done) { + const entityIdx = Math.floor(Math.random() * ENTITY_COUNT); + const featureIdx = Math.floor(Math.random() * CUS_ENTS_PER_PRODUCT); + + ctx.vars.customerId = CUSTOMER_ID; + ctx.vars.featureId = `feature_${featureIdx}`; + ctx.vars.entityId = `entity_${entityIdx}`; + done(); +} diff --git a/server/perf/redis-bench/setup.ts b/server/perf/redis-bench/setup.ts new file mode 100644 index 000000000..0a16a9f6e --- /dev/null +++ b/server/perf/redis-bench/setup.ts @@ -0,0 +1,157 @@ +/** + * Redis benchmark setup — seeds a large FullCustomer (100 entities x 10 cusEnts) + * directly into Redis and creates a minimal customer via the Autumn API so that + * check/track endpoints can resolve it. + * + * Run: cd server && bun perf/redis-bench/setup.ts + */ + +import { loadLocalEnv } from "../../src/utils/envUtils.js"; +loadLocalEnv(); + +import { AppEnv, ApiVersion, type FullCustomer } from "@autumn/shared"; +import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements.js"; +import { customerProducts } from "@tests/utils/fixtures/db/customerProducts.js"; +import { customers } from "@tests/utils/fixtures/db/customers.js"; +import { entities as entityFixtures } from "@tests/utils/fixtures/db/entities.js"; +import { buildPathIndex } from "@/internal/customers/cache/pathIndex/buildPathIndex.js"; +import { + buildFullCustomerCacheKey, + FULL_CUSTOMER_CACHE_TTL_SECONDS, +} from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; +import { redis } from "@/external/redis/initRedis.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +const ENTITY_COUNT = 100; +const CUS_ENTS_PER_PRODUCT = 10; +const ORG_ID = process.env.TESTS_ORG_ID || "org_perf_test"; +const ENV = AppEnv.Sandbox; +const CUSTOMER_ID = "redis-bench-large-cus"; +const FEATURE_ID = "feature_0"; + +async function main() { + console.log("=== Redis Benchmark Setup ===\n"); + + // 1. Build large FullCustomer + console.log( + `Building FullCustomer: ${ENTITY_COUNT} entities x ${CUS_ENTS_PER_PRODUCT} cusEnts = ${ENTITY_COUNT * CUS_ENTS_PER_PRODUCT} total cusEnts`, + ); + + const entitiesList = Array.from({ length: ENTITY_COUNT }, (_, i) => + entityFixtures.create({ + id: `entity_${i}`, + featureId: `entity_feature_${i}`, + }), + ); + + const cusProducts = entitiesList.map((entity, entityIdx) => { + const cusEnts = Array.from({ length: CUS_ENTS_PER_PRODUCT }, (_, ceIdx) => + customerEntitlements.create({ + id: `cus_ent_${entityIdx}_${ceIdx}`, + featureId: `feature_${ceIdx}`, + featureName: `Feature ${ceIdx}`, + allowance: 1000, + balance: 500, + customerProductId: `cus_prod_entity_${entityIdx}`, + entityFeatureId: entity.feature_id, + entities: { + [entity.id!]: { id: entity.id!, balance: 500, adjustment: 0 }, + }, + }), + ); + + return customerProducts.create({ + id: `cus_prod_entity_${entityIdx}`, + productId: `prod_entity_${entityIdx}`, + customerEntitlements: cusEnts, + internalEntityId: entity.internal_id, + entityId: entity.id!, + }); + }); + + const fullCustomer: FullCustomer = { + ...customers.create({ customerProducts: cusProducts }), + id: CUSTOMER_ID, + org_id: ORG_ID, + env: ENV, + entities: entitiesList, + extra_customer_entitlements: [], + }; + + const serialized = JSON.stringify(fullCustomer); + console.log( + ` Serialized size: ${(serialized.length / 1024 / 1024).toFixed(2)} MB`, + ); + + // 2. Write to Redis + const cacheKey = buildFullCustomerCacheKey({ + orgId: ORG_ID, + env: ENV, + customerId: CUSTOMER_ID, + }); + + const pathIndexEntries = buildPathIndex({ fullCustomer }); + const pathIndexJson = JSON.stringify(pathIndexEntries); + console.log( + ` Path index: ${Object.keys(pathIndexEntries).length} entries, ${(pathIndexJson.length / 1024).toFixed(2)} KB`, + ); + + const result = await redis.setFullCustomerCache( + cacheKey, + ORG_ID, + ENV, + CUSTOMER_ID, + String(Date.now()), + String(FULL_CUSTOMER_CACHE_TTL_SECONDS), + serialized, + "true", + pathIndexJson, + ); + + console.log(` Redis setFullCustomerCache result: ${result}`); + + // 3. Create customer via Autumn API (so check/track endpoints work) + const secretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY; + if (secretKey) { + console.log("\nCreating customer via Autumn API..."); + const autumn = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey, + }); + + try { + await autumn.customers.delete(CUSTOMER_ID); + } catch { + // May not exist yet + } + + try { + await autumn.customers.create({ + id: CUSTOMER_ID, + name: "Redis Bench Large Customer", + email: "redis-bench@test.local", + }); + console.log(` Created customer: ${CUSTOMER_ID}`); + } catch (error) { + console.warn(` Could not create customer via API: ${error}`); + } + } else { + console.log( + "\n Skipping Autumn API customer creation (UNIT_TEST_AUTUMN_SECRET_KEY not set)", + ); + } + + console.log(`\n=== Setup complete ===`); + console.log(` Customer ID: ${CUSTOMER_ID}`); + console.log(` Feature ID: ${FEATURE_ID}`); + console.log(` Entity IDs: entity_0 through entity_${ENTITY_COUNT - 1}`); +} + +main() + .catch((error) => { + console.error("Setup failed:", error); + process.exit(1); + }) + .finally(() => { + process.exit(0); + }); diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua index c96f7ec1c..1eb4e8357 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua @@ -13,7 +13,9 @@ params: cache_key: string customer_entitlement_ids: array of customer_entitlement IDs - full_customer: decoded FullCustomer object + full_customer: decoded FullCustomer object (nil when path index is available) + pathidx_key: string (path index Redis Hash key) + has_pathidx: boolean (true when path index exists) Returns: context table with: customer_entitlements: { [cus_ent_id]: { base_path, balance, adjustment, entities } } @@ -25,11 +27,16 @@ ]] local function init_context(params) local logs = {} + local has_pathidx = params.has_pathidx + local pathidx_key = params.pathidx_key local context = { customer_entitlements = {}, rollovers = {}, + cache_key = params.cache_key, full_customer = params.full_customer, + pathidx_key = pathidx_key, + has_pathidx = has_pathidx, mutation_logs = {}, pending_writes = {}, logs = logs, @@ -41,65 +48,90 @@ local function init_context(params) } for _, ent_id in ipairs(params.customer_entitlement_ids or {}) do - local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(params.full_customer, ent_id) + local base_path + local has_entity_scope + local is_loose + local adjustment + local unlimited + local cus_ent_rollovers + local cus_ent_balance + local cus_ent_entities - if cus_ent then - local base_path - local is_loose = (cp_idx == nil) -- Loose entitlement if no customer_product index - - if is_loose then - -- Loose entitlement: path is $.extra_customer_entitlements[idx] - local ece_idx_0 = ce_idx - 1 - base_path = '$.extra_customer_entitlements[' .. ece_idx_0 .. ']' - else - -- Product entitlement: path is $.customer_products[cp_idx].customer_entitlements[ce_idx] - local cp_idx_0 = cp_idx - 1 - local ce_idx_0 = ce_idx - 1 - base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']' + if has_pathidx then + local result = get_customer_entitlement_via_index({ + pathidx_key = pathidx_key, + cache_key = params.cache_key, + cus_ent_id = ent_id, + }) + if result then + base_path = result.base_path + has_entity_scope = result.has_entity_scope + is_loose = result.is_loose + local sub = result.sub + adjustment = safe_number(sub.adjustment or 0) + unlimited = sub.unlimited + cus_ent_rollovers = sub.rollovers + cus_ent_balance = safe_number(sub.balance or 0) + cus_ent_entities = safe_table(sub.entities) end + else + -- Fallback path: decode full customer + nested loop search + local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(params.full_customer, ent_id) - local entitlement = cus_ent.entitlement - local has_entity_scope = not is_nil(entitlement) - and not is_nil(entitlement.entity_feature_id) + if cus_ent then + is_loose = (cp_idx == nil) + if is_loose then + local ece_idx_0 = ce_idx - 1 + base_path = '$.extra_customer_entitlements[' .. ece_idx_0 .. ']' + else + local cp_idx_0 = cp_idx - 1 + local ce_idx_0 = ce_idx - 1 + base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']' + end + + local entitlement = cus_ent.entitlement + has_entity_scope = not is_nil(entitlement) and not is_nil(entitlement.entity_feature_id) + adjustment = cus_ent.adjustment or 0 + unlimited = cus_ent.unlimited + cus_ent_rollovers = cus_ent.rollovers + cus_ent_balance = safe_number(cus_ent.balance or 0) + cus_ent_entities = safe_table(cus_ent.entities) + end + end + + if base_path then local ent_data = { base_path = base_path, has_entity_scope = has_entity_scope, - adjustment = cus_ent.adjustment or 0, - unlimited = cus_ent.unlimited, + adjustment = adjustment, + unlimited = unlimited, is_loose = is_loose, } if has_entity_scope then - ent_data.balance = 0 -- Not used for entity-scoped - ent_data.entities = read_current_entities(params.cache_key, base_path) + ent_data.balance = 0 + ent_data.entities = cus_ent_entities or {} else - ent_data.balance = read_current_balance(params.cache_key, base_path) + ent_data.balance = cus_ent_balance or 0 ent_data.entities = nil end context.customer_entitlements[ent_id] = ent_data - -- Build rollover index for this customer_entitlement - local cus_ent_rollovers = cus_ent.rollovers if cus_ent_rollovers and type(cus_ent_rollovers) == 'table' then for r_idx, rollover in ipairs(cus_ent_rollovers) do if rollover and rollover.id then local r_idx_0 = r_idx - 1 local rollover_path = base_path .. '.rollovers[' .. r_idx_0 .. ']' - -- Read fresh rollover data from Redis - local rollover_data = read_rollover_data(params.cache_key, rollover_path) - - if rollover_data then - context.rollovers[rollover.id] = { - base_path = rollover_path, - cus_ent_id = ent_id, - balance = rollover_data.balance, - usage = rollover_data.usage, - entities = rollover_data.entities, - } - end + context.rollovers[rollover.id] = { + base_path = rollover_path, + cus_ent_id = ent_id, + balance = safe_number(rollover.balance or 0), + usage = safe_number(rollover.usage or 0), + entities = safe_table(rollover.entities), + } end end end diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua index 724672a35..18ad670f4 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua @@ -10,17 +10,22 @@ 3. Pass 2: Allow negative if usage_allowed Helper functions are prepended via string interpolation from: - - luaUtils.lua (safe_table, safe_number, find_entitlement, build_entity_path, sorted_keys, is_nil) + - fullCustomerKeyBuilders.lua (build_path_index_key, etc.) + - luaUtils.lua (safe_table, safe_number, sorted_keys, is_nil) + - fullCustomerUtils.lua (find_entitlement, find_entitlement_from_index, build_entity_path, etc.) - readBalances.lua (read_current_balance, read_current_entity_balance, read_current_entities, read_rollover_data) - - contextUtils.lua (init_context, update_in_memory_customer_entitlement, queue_balance_update, apply_pending_writes) + - contextUtils.lua (init_context, queue_balance_update, apply_pending_writes) - deductFromRollovers.lua (deduct_from_rollovers) - deductFromMainBalance.lua (calculate_change, deduct_from_main_balance) - getTotalBalance.lua (get_total_balance) - KEYS[1] = FullCustomer cache key + KEYS[1] = FullCustomer cache key (used for cluster slot routing) ARGV[1] = JSON params: { + org_id: string, + env: string, + customer_id: string, sorted_entitlements: [{ customer_entitlement_id, credit_cost, feature_id, entity_feature_id, usage_allowed, min_balance, max_balance }], spend_limit_by_feature_id: { [feature_id]: { feature_id, enabled, overage_limit } } | null, usage_based_cus_ent_ids_by_feature_id: { [feature_id]: string[] } | null, @@ -51,6 +56,11 @@ local cache_key = KEYS[1] local params = cjson.decode(ARGV[1]) +-- Extract org/env/customer for path index key construction +local org_id = params.org_id +local env = params.env +local customer_id = params.customer_id + -- Extract parameters local sorted_entitlements = params.sorted_entitlements or {} local spend_limit_by_feature_id = params.spend_limit_by_feature_id @@ -70,33 +80,39 @@ local lock_receipt_key = params.lock_receipt_key -- Compute overage_behavior_is_allow once local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow' --- Check if customer exists (just check the key exists) local empty_logs = cjson.decode('[]') +-- Check if customer exists local key_exists = redis.call('EXISTS', cache_key) if key_exists == 0 then return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 }) end --- Get FullCustomer structure (for finding entitlement indices only) -local full_customer_json = redis.call('JSON.GET', cache_key, '.') -if not full_customer_json then - return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 }) +-- Build path index key and check existence (fast path vs fallback) +local pathidx_key = build_path_index_key(org_id, env, customer_id) +local has_pathidx = redis.call('EXISTS', pathidx_key) == 1 + +-- Only decode full customer if path index is NOT available (fallback) +local full_customer = nil +if not has_pathidx then + local full_customer_json = redis.call('JSON.GET', cache_key, '.') + if not full_customer_json then + return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 }) + end + + full_customer = cjson.decode(full_customer_json) + + if not full_customer.customer_products then + return cjson.encode({ + error = 'NO_CUSTOMER_PRODUCTS', + updates = {}, + rollover_updates = {}, + mutation_logs = empty_logs, + remaining = 0 + }) + end end -local full_customer = cjson.decode(full_customer_json) - -if not full_customer.customer_products then - return cjson.encode({ - error = 'NO_CUSTOMER_PRODUCTS', - updates = {}, - rollover_updates = {}, - mutation_logs = empty_logs, - remaining = 0 - }) -end - --- Track updates for return value -- Initialize context with in-memory state from Redis local customer_entitlement_ids = {} for _, ent_obj in ipairs(sorted_entitlements) do @@ -107,6 +123,8 @@ local context = init_context({ cache_key = cache_key, customer_entitlement_ids = customer_entitlement_ids, full_customer = full_customer, + pathidx_key = pathidx_key, + has_pathidx = has_pathidx, }) local unwind_modified_cus_ent_ids = {} @@ -229,7 +247,7 @@ then hashed_key = lock.hashed_key or cjson.null, status = 'pending', region = lock.region or cjson.null, - customer_id = full_customer.id or cjson.null, + customer_id = customer_id or cjson.null, feature_id = feature_id or cjson.null, entity_id = target_entity_id or cjson.null, expires_at = lock.expires_at or cjson.null, diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua index 780cc2ca9..a31c43a19 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/luaUtils.lua @@ -1,11 +1,8 @@ -- ============================================================================ -- LUA UTILITIES --- Common helper functions for Lua scripts +-- Generic helper functions (no FullCustomer-specific logic) -- ============================================================================ --- ============================================================================ --- HELPER: Safe table getter (handles cjson.null) --- ============================================================================ local function safe_table(val) if val == nil or val == cjson.null or type(val) ~= 'table' then return {} @@ -13,9 +10,6 @@ local function safe_table(val) return val end --- ============================================================================ --- HELPER: Safe number getter --- ============================================================================ local function safe_number(val) if val == nil or val == cjson.null then return 0 @@ -23,56 +17,10 @@ local function safe_number(val) return tonumber(val) or 0 end --- ============================================================================ --- HELPER: Check if value is nil or cjson.null --- ============================================================================ local function is_nil(val) return val == nil or val == cjson.null end --- ============================================================================ --- HELPER: Find entitlement in FullCustomer by ID --- Returns: cus_ent table, cus_product table (or nil for loose), cus_ent_index, cus_product_index (or nil for loose) --- For loose entitlements: cus_product=nil and cus_product_index=nil --- ============================================================================ -local function find_entitlement(full_customer, ent_id) - -- Search in customer_products first - if full_customer.customer_products then - for cp_idx, cus_product in ipairs(full_customer.customer_products) do - if cus_product.customer_entitlements then - for ce_idx, cus_ent in ipairs(cus_product.customer_entitlements) do - if cus_ent.id == ent_id then - return cus_ent, cus_product, ce_idx, cp_idx - end - end - end - end - end - - -- Search in extra_customer_entitlements (loose entitlements) - if full_customer.extra_customer_entitlements then - for ece_idx, cus_ent in ipairs(full_customer.extra_customer_entitlements) do - if cus_ent.id == ent_id then - -- Return nil for cus_product and cus_product_index to indicate loose entitlement - return cus_ent, nil, ece_idx, nil - end - end - end - - return nil, nil, nil, nil -end - --- ============================================================================ --- HELPER: Build entity path (consistent across all operations) --- ============================================================================ -local function build_entity_path(base_path, entity_id) - -- Use bracket notation for entity access since entity IDs are object keys - return base_path .. '["entities"]["' .. entity_id .. '"]' -end - --- ============================================================================ --- HELPER: Get sorted keys from table (for consistent entity iteration) --- ============================================================================ local function sorted_keys(tbl) local keys = {} for k in pairs(tbl) do diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/spendLimitUtils.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/spendLimitUtils.lua index 8d5a4eed0..ace4031d3 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/spendLimitUtils.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/spendLimitUtils.lua @@ -6,8 +6,39 @@ local function build_ent_data_from_full_customer(params) local full_customer = params.full_customer local cus_ent_id = params.cus_ent_id + local cache_key = params.cache_key + local pathidx_key = params.pathidx_key - if is_nil(full_customer) or is_nil(cus_ent_id) then + if is_nil(cus_ent_id) then + return nil + end + + -- Fast path: single JSON.GET on the sub-object via path index + if not is_nil(pathidx_key) then + local result = get_customer_entitlement_via_index({ + pathidx_key = pathidx_key, + cache_key = cache_key, + cus_ent_id = cus_ent_id, + }) + if is_nil(result) then return nil end + + if result.has_entity_scope then + return { + has_entity_scope = true, + balance = 0, + entities = safe_table(result.sub.entities), + } + else + return { + has_entity_scope = false, + balance = safe_number(result.sub.balance or 0), + entities = {}, + } + end + end + + -- Fallback path: decode from full customer + if is_nil(full_customer) then return nil end @@ -46,6 +77,8 @@ local function get_available_overage_from_spend_limit(params) ent_data = build_ent_data_from_full_customer({ full_customer = context.full_customer, cus_ent_id = cus_ent_id, + cache_key = context.cache_key, + pathidx_key = context.pathidx_key, }) end diff --git a/server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua b/server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua new file mode 100644 index 000000000..aa65bd6c3 --- /dev/null +++ b/server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua @@ -0,0 +1,100 @@ +-- ============================================================================ +-- FULL CUSTOMER UTILITIES +-- Functions for navigating the FullCustomer JSON structure in Redis +-- ============================================================================ + +-- ============================================================================ +-- Path builders: construct JSON paths from array indices +-- ============================================================================ +local function build_customer_entitlement_base_path(cp_idx, ce_idx) + return '$.customer_products[' .. cp_idx .. '].customer_entitlements[' .. ce_idx .. ']' +end + +local function build_extra_customer_entitlement_base_path(ece_idx) + return '$.extra_customer_entitlements[' .. ece_idx .. ']' +end + +-- ============================================================================ +-- Build entity path (consistent across all operations) +-- ============================================================================ +local function build_entity_path(base_path, entity_id) + return base_path .. '["entities"]["' .. entity_id .. '"]' +end + +-- ============================================================================ +-- Find entitlement in decoded FullCustomer by ID (fallback path) +-- Returns: cus_ent table, cus_product table (or nil for loose), cus_ent_index, cus_product_index (or nil for loose) +-- ============================================================================ +local function find_entitlement(full_customer, ent_id) + if full_customer.customer_products then + for cp_idx, cus_product in ipairs(full_customer.customer_products) do + if cus_product.customer_entitlements then + for ce_idx, cus_ent in ipairs(cus_product.customer_entitlements) do + if cus_ent.id == ent_id then + return cus_ent, cus_product, ce_idx, cp_idx + end + end + end + end + end + + if full_customer.extra_customer_entitlements then + for ece_idx, cus_ent in ipairs(full_customer.extra_customer_entitlements) do + if cus_ent.id == ent_id then + return cus_ent, nil, ece_idx, nil + end + end + end + + return nil, nil, nil, nil +end + +-- ============================================================================ +-- Find entitlement via the path index Hash (fast path) +-- Returns: { base_path, entity_feature_id } or nil +-- ============================================================================ +local function find_entitlement_from_index(pathidx_key, cus_ent_id) + local raw = redis.call('HGET', pathidx_key, 'cus_ent:' .. cus_ent_id) + if not raw then return nil end + local entry = cjson.decode(raw) + local base_path + if entry.ece then + base_path = build_extra_customer_entitlement_base_path(entry.ece) + else + base_path = build_customer_entitlement_base_path(entry.cp, entry.ce) + end + return { + base_path = base_path, + entity_feature_id = entry.ef, + } +end + +-- ============================================================================ +-- Fetch a customer entitlement sub-object via path index + single JSON.GET. +-- Combines index lookup and document read in one call to minimise tree traversals. +-- Returns: { base_path, has_entity_scope, is_loose, sub } or nil +-- ============================================================================ +local function get_customer_entitlement_via_index(params) + local pathidx_key = params.pathidx_key + local cache_key = params.cache_key + local cus_ent_id = params.cus_ent_id + + local idx_result = find_entitlement_from_index(pathidx_key, cus_ent_id) + if not idx_result then return nil end + + local base_path = idx_result.base_path + local sub_raw = redis.call('JSON.GET', cache_key, base_path) + if not sub_raw or sub_raw == cjson.null then return nil end + + local sub = cjson.decode(sub_raw) + if type(sub) == 'table' and sub[1] ~= nil and type(sub[1]) == 'table' then + sub = sub[1] + end + + return { + base_path = base_path, + has_entity_scope = not is_nil(idx_result.entity_feature_id), + is_loose = string.find(base_path, 'extra_customer_entitlements') ~= nil, + sub = sub, + } +end diff --git a/server/src/_luaScriptsV2/fullCustomerKeyBuilders.lua b/server/src/_luaScriptsV2/fullCustomerKeyBuilders.lua index 8acc0ab26..c362f1fcd 100644 --- a/server/src/_luaScriptsV2/fullCustomerKeyBuilders.lua +++ b/server/src/_luaScriptsV2/fullCustomerKeyBuilders.lua @@ -1,8 +1,6 @@ --[[ Shared key builder functions for FullCustomer cache keys. - - NOTE: FULL_CUSTOMER_CACHE_VERSION is injected by luaScriptsV2.ts at load time - (the __FULL_CUSTOMER_CACHE_VERSION__ placeholder is replaced with the real value). + FULL_CUSTOMER_CACHE_VERSION is injected by luaScriptsV2.ts at load time. ]] local FULL_CUSTOMER_CACHE_VERSION = "__FULL_CUSTOMER_CACHE_VERSION__" diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index 010d8160e..0bb431aa5 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -18,8 +18,15 @@ const UPDATE_DIR = join(__dirname, "updateCustomerEntitlements"); // HELPER MODULES // ============================================================================ +const FULL_CUSTOMER_DIR = join(__dirname, "fullCustomer"); + const LUA_UTILS = readFileSync(join(DEDUCT_DIR, "luaUtils.lua"), "utf-8"); +const FULL_CUSTOMER_UTILS = readFileSync( + join(FULL_CUSTOMER_DIR, "fullCustomerUtils.lua"), + "utf-8", +); + const READ_BALANCES = readFileSync( join(DEDUCT_DIR, "readBalances.lua"), "utf-8", @@ -75,6 +82,15 @@ const LOCK_UNWIND_UTILS = readFileSync( "utf-8", ); +// ============================================================================ +// FULL CUSTOMER KEY BUILDER LUA (version interpolated from TS config) +// ============================================================================ + +const FULL_CUSTOMER_KEY_BUILDERS = readFileSync( + join(__dirname, "fullCustomerKeyBuilders.lua"), + "utf-8", +).replaceAll("__FULL_CUSTOMER_CACHE_VERSION__", FULL_CUSTOMER_CACHE_VERSION); + // ============================================================================ // MAIN SCRIPT // ============================================================================ @@ -90,7 +106,9 @@ const mainScript = readFileSync( * Composed from helper modules via string interpolation. * Supports both positive deductions and negative refunds. */ -export const DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS} +export const DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT = `${FULL_CUSTOMER_KEY_BUILDERS} +${LUA_UTILS} +${FULL_CUSTOMER_UTILS} ${READ_BALANCES} ${CONTEXT_UTILS} ${GET_TOTAL_BALANCE} @@ -119,15 +137,6 @@ ${LOCK_RECEIPT_UTILS} ${LOCK_STATE_UTILS} ${claimLockReceiptMainScript}`; -// ============================================================================ -// FULL CUSTOMER KEY BUILDER LUA (version interpolated from TS config) -// ============================================================================ - -const FULL_CUSTOMER_KEY_BUILDERS = readFileSync( - join(__dirname, "fullCustomerKeyBuilders.lua"), - "utf-8", -).replace("__FULL_CUSTOMER_CACHE_VERSION__", FULL_CUSTOMER_CACHE_VERSION); - // ============================================================================ // DELETE FULL CUSTOMER CACHE SCRIPTS // ============================================================================ @@ -178,6 +187,7 @@ const resetMainScript = readFileSync( * @deprecated Use UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT instead. */ export const RESET_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS} +${FULL_CUSTOMER_UTILS} ${resetMainScript}`; // ============================================================================ @@ -195,6 +205,7 @@ const updateMainScript = readFileSync( * "apply absolute values to customer entitlements in the Redis cache." */ export const UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS} +${FULL_CUSTOMER_UTILS} ${updateMainScript}`; // ============================================================================ @@ -213,6 +224,7 @@ const adjustBalanceMainScript = readFileSync( * FullCustomer via JSON.NUMINCRBY. Safe with concurrent deductions. */ export const ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT = `${LUA_UTILS} +${FULL_CUSTOMER_UTILS} ${adjustBalanceMainScript}`; // ============================================================================ diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 9279ee35f..717d3d68e 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -120,6 +120,9 @@ export const executeRedisDeduction = async ({ // Call Lua script to deduct from FullCustomer in Redis const luaParams = { + org_id: org.id, + env, + customer_id: customerId, sorted_entitlements: customerEntitlementDeductions, spend_limit_by_feature_id: spendLimitByFeatureId ?? null, usage_based_cus_ent_ids_by_feature_id: diff --git a/server/src/internal/customers/cache/pathIndex/buildPathIndex.ts b/server/src/internal/customers/cache/pathIndex/buildPathIndex.ts index 61ecee1cb..0b59fa3f8 100644 --- a/server/src/internal/customers/cache/pathIndex/buildPathIndex.ts +++ b/server/src/internal/customers/cache/pathIndex/buildPathIndex.ts @@ -1,8 +1,13 @@ import type { FullCustomer } from "@autumn/shared"; /** - * Builds a Record mapping entitlement IDs to their JSON paths within + * Builds a Record mapping entitlement IDs to their array indices within * the FullCustomer cache value, ready for HSET into the path index. + * + * Entry format for product entitlements: { cp, ce, ef } + * Entry format for extra (loose) entitlements: { ece, ef } + * + * The Lua script constructs the JSON path at read time from these indices. */ export const buildPathIndex = ({ fullCustomer, @@ -19,12 +24,12 @@ export const buildPathIndex = ({ ceIdx++ ) { const customerEntitlement = customerProduct.customer_entitlements[ceIdx]; - const path = `$.customer_products[${cpIdx}].customer_entitlements[${ceIdx}]`; const entityFeatureId = customerEntitlement.entitlement?.entity_feature_id ?? null; - entries[`ent:${customerEntitlement.id}`] = JSON.stringify({ - p: path, + entries[`cus_ent:${customerEntitlement.id}`] = JSON.stringify({ + cp: cpIdx, + ce: ceIdx, ef: entityFeatureId, }); } @@ -38,12 +43,11 @@ export const buildPathIndex = ({ ) { const extraCustomerEntitlement = fullCustomer.extra_customer_entitlements[eceIdx]; - const path = `$.extra_customer_entitlements[${eceIdx}]`; const entityFeatureId = extraCustomerEntitlement.entitlement?.entity_feature_id ?? null; - entries[`ent:${extraCustomerEntitlement.id}`] = JSON.stringify({ - p: path, + entries[`cus_ent:${extraCustomerEntitlement.id}`] = JSON.stringify({ + ece: eceIdx, ef: entityFeatureId, }); } diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 90f01bace..b6c064584 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,56 +1,488 @@ -import { expect, test } from "bun:test"; -import { TestFeature } from "@tests/setup/v2Features"; -import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; -import { items } from "@tests/utils/fixtures/items"; -import { products } from "@tests/utils/fixtures/products"; -import { timeout } from "@tests/utils/genUtils"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { describe, expect, test } from "bun:test"; +import { AppEnv, type FullCustomer } from "@autumn/shared"; +import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; +import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; +import { customers } from "@tests/utils/fixtures/db/customers"; +import { entities as entityFixtures } from "@tests/utils/fixtures/db/entities"; import chalk from "chalk"; +import { redis } from "@/external/redis/initRedis.js"; +import { buildPathIndex } from "@/internal/customers/cache/pathIndex/buildPathIndex.js"; +import { buildPathIndexKey } from "@/internal/customers/cache/pathIndex/pathIndexConfig.js"; +import { + buildFullCustomerCacheKey, + FULL_CUSTOMER_CACHE_TTL_SECONDS, +} from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; -test.concurrent(`${chalk.yellowBright("temp: legacy checkout annual base + adjustable monthly prepaid")}`, async () => { - const customerId = "temp-legacy-checkout-annual-adjustable-prepaid"; - const includedCallMinutes = 100; - const checkoutQuantityInUnits = 300; +const ENTITY_COUNT = 100; +const CUS_ENTS_PER_PRODUCT = 10; +const ORG_ID = "org_perf_test"; +const ENV = AppEnv.Sandbox; +const CUSTOMER_ID = "cus_perf_large"; - const monthlyPrepaidCallMinutes = items.prepaid({ - featureId: TestFeature.Messages, - includedUsage: includedCallMinutes, - billingUnits: 100, - price: 13, +const buildLargeFullCustomer = (): FullCustomer => { + const entitiesList = Array.from({ length: ENTITY_COUNT }, (_, i) => + entityFixtures.create({ + id: `entity_${i}`, + featureId: `entity_feature_${i}`, + }), + ); + + const cusProducts = entitiesList.map((entity, entityIdx) => { + const cusEnts = Array.from({ length: CUS_ENTS_PER_PRODUCT }, (_, ceIdx) => + customerEntitlements.create({ + id: `cus_ent_${entityIdx}_${ceIdx}`, + featureId: `feature_${ceIdx}`, + featureName: `Feature ${ceIdx}`, + allowance: 1000, + balance: 500, + customerProductId: `cus_prod_entity_${entityIdx}`, + entityFeatureId: entity.feature_id, + entities: { + [entity.id!]: { id: entity.id!, balance: 500, adjustment: 0 }, + }, + }), + ); + + return customerProducts.create({ + id: `cus_prod_entity_${entityIdx}`, + productId: `prod_entity_${entityIdx}`, + customerEntitlements: cusEnts, + internalEntityId: entity.internal_id, + entityId: entity.id!, + }); }); - const smallBusiness = products.proAnnual({ - id: "small-business", - items: [monthlyPrepaidCallMinutes], - }); + const fullCustomer: FullCustomer = { + ...customers.create({ customerProducts: cusProducts }), + id: CUSTOMER_ID, + org_id: ORG_ID, + env: ENV, + entities: entitiesList, + extra_customer_entitlements: [], + }; - const { autumnV1 } = await initScenario({ - customerId, - setup: [ - s.customer({ testClock: true }), - s.products({ list: [smallBusiness] }), - ], - actions: [], - }); + return fullCustomer; +}; - const result = await autumnV1.attach({ - customer_id: customerId, - product_id: smallBusiness.id, - // options: [ - // { - // feature_id: TestFeature.Messages, - // adjustable: true, - // }, - // ], - }); - - expect(result.checkout_url).toBeDefined(); - expect(result.checkout_url).toContain("checkout.stripe.com"); - - await completeStripeCheckoutForm({ - url: result.checkout_url, - // overrideQuantity: checkoutQuantityInUnits / 100, - }); - - await timeout(12000); +const cacheKey = buildFullCustomerCacheKey({ + orgId: ORG_ID, + env: ENV, + customerId: CUSTOMER_ID, +}); + +const pathIdxKey = buildPathIndexKey({ + orgId: ORG_ID, + env: ENV, + customerId: CUSTOMER_ID, +}); + +const formatStats = ( + timings: number[], +): { min: number; avg: number; p95: number; max: number } => { + const sorted = [...timings].sort((a, b) => a - b); + const sum = sorted.reduce((acc, t) => acc + t, 0); + const p95Index = Math.floor(sorted.length * 0.95); + return { + min: sorted[0], + avg: sum / sorted.length, + p95: sorted[p95Index], + max: sorted[sorted.length - 1], + }; +}; + +const printStats = (label: string, stats: ReturnType) => { + console.log( + ` ${label}: min=${stats.min.toFixed(2)}ms avg=${stats.avg.toFixed(2)}ms p95=${stats.p95.toFixed(2)}ms max=${stats.max.toFixed(2)}ms`, + ); +}; + +// ═══════════════════════════════════════════════════════════════════ +// Block 1: Setup — seed large FullCustomer into Redis +// ═══════════════════════════════════════════════════════════════════ +describe(chalk.blueBright("setup: seed large FullCustomer"), () => { + test("seed large FullCustomer in Redis", async () => { + const fullCustomer = buildLargeFullCustomer(); + const serialized = JSON.stringify(fullCustomer); + console.log( + ` Serialized FullCustomer size: ${(serialized.length / 1024 / 1024).toFixed(2)} MB`, + ); + console.log( + ` Customer products: ${fullCustomer.customer_products.length}`, + ); + console.log( + ` Total cusEnts: ${fullCustomer.customer_products.reduce((sum, cp) => sum + cp.customer_entitlements.length, 0)}`, + ); + console.log(` Entities: ${fullCustomer.entities.length}`); + + const pathIndexEntries = buildPathIndex({ fullCustomer }); + const pathIndexJson = JSON.stringify(pathIndexEntries); + console.log( + ` Path index entries: ${Object.keys(pathIndexEntries).length}`, + ); + console.log( + ` Path index size: ${(pathIndexJson.length / 1024).toFixed(2)} KB`, + ); + + const result = await redis.setFullCustomerCache( + cacheKey, + ORG_ID, + ENV, + CUSTOMER_ID, + String(Date.now()), + String(FULL_CUSTOMER_CACHE_TTL_SECONDS), + serialized, + "true", + pathIndexJson, + ); + + expect(result).toBe("OK"); + + const exists = await redis.call("EXISTS", cacheKey); + expect(exists).toBe(1); + + const pathExists = await redis.call("EXISTS", pathIdxKey); + expect(pathExists).toBe(1); + + console.log(chalk.green(" FullCustomer seeded successfully")); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Helpers for building deduction params +// ═══════════════════════════════════════════════════════════════════ + +const buildDeductionParams = ({ + iteration, + entitlementCount = 1, +}: { + iteration: number; + entitlementCount?: number; +}) => { + const sortedEntitlements = Array.from( + { length: entitlementCount }, + (_, idx) => { + const entIdx = (iteration * entitlementCount + idx) % ENTITY_COUNT; + const ceIdx = (iteration * entitlementCount + idx) % CUS_ENTS_PER_PRODUCT; + return { + customer_entitlement_id: `cus_ent_${entIdx}_${ceIdx}`, + credit_cost: 1, + feature_id: `feature_${ceIdx}`, + entity_feature_id: `entity_feature_${entIdx}`, + usage_allowed: true, + min_balance: null, + max_balance: null, + }; + }, + ); + + const entityIdx = iteration % ENTITY_COUNT; + const ceIdx = iteration % CUS_ENTS_PER_PRODUCT; + return { + org_id: ORG_ID, + env: ENV, + customer_id: CUSTOMER_ID, + sorted_entitlements: sortedEntitlements, + spend_limit_by_feature_id: null, + usage_based_cus_ent_ids_by_feature_id: null, + amount_to_deduct: 1, + target_balance: null, + target_entity_id: `entity_${entityIdx}`, + rollovers: null, + skip_additional_balance: false, + alter_granted_balance: false, + overage_behaviour: "allow", + feature_id: `feature_${ceIdx}`, + lock: null, + unwind_value: null, + lock_receipt_key: null, + }; +}; + +type SlowlogEntry = [ + id: number, + timestamp: number, + durationMicros: number, + command: string[], + clientIp: string, + clientName: string, +]; + +/** + * Runs a batch of deductions and measures server-side execution time via SLOWLOG. + * SLOWLOG records commands that exceed the threshold (in microseconds). + * By setting the threshold to 0, every command is logged. + */ +const runSlowlogBenchmark = async ({ + iterations, + label, + entitlementCount = 1, +}: { + iterations: number; + label: string; + entitlementCount?: number; +}) => { + // Set threshold to 0 to capture ALL commands, keep enough entries + await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "0"); + await redis.call("CONFIG", "SET", "slowlog-max-len", "1024"); + await redis.call("SLOWLOG", "RESET"); + + const e2eTimings: number[] = []; + + for (let i = 0; i < iterations; i++) { + const luaParams = buildDeductionParams({ iteration: i, entitlementCount }); + const start = performance.now(); + const result = await redis.deductFromCustomerEntitlements( + cacheKey, + JSON.stringify(luaParams), + ); + e2eTimings.push(performance.now() - start); + + const parsed = JSON.parse(result); + expect(parsed.error).toBeNull(); + } + + // Collect slowlog entries for EVALSHA commands (our Lua scripts) + const rawEntries = (await redis.call( + "SLOWLOG", + "GET", + "1024", + )) as SlowlogEntry[]; + + const evalEntries = rawEntries.filter( + (entry) => + entry[3] && (entry[3][0] === "evalsha" || entry[3][0] === "EVALSHA"), + ); + + // Duration is in microseconds (entry[2]) + const serverTimings = evalEntries.map((entry) => entry[2] / 1000); + + // Restore default slowlog config + await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "10000"); + + const e2eStats = formatStats(e2eTimings); + const serverStats = + serverTimings.length > 0 ? formatStats(serverTimings) : null; + + console.log(chalk.bold(`\n ${label} (${iterations} iterations):`)); + printStats("End-to-end (TS)", e2eStats); + if (serverStats) { + printStats("Server-side (SLOWLOG)", serverStats); + const avgRtt = e2eStats.avg - serverStats.avg; + console.log(` Network RTT (avg): ~${avgRtt.toFixed(2)}ms`); + } else { + console.log(" (no SLOWLOG entries captured for EVALSHA)"); + } + + return { e2eStats, serverStats }; +}; + +// ═══════════════════════════════════════════════════════════════════ +// Block 2: Benchmark — re-runnable against seeded data +// ═══════════════════════════════════════════════════════════════════ +describe(chalk.yellowBright("benchmark: Redis operations"), () => { + const ITERATIONS = 50; + + test("benchmark JSON.GET full read (TS-side getCachedFullCustomer cost)", async () => { + // Also measure server-side via SLOWLOG + await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "0"); + await redis.call("CONFIG", "SET", "slowlog-max-len", "256"); + await redis.call("SLOWLOG", "RESET"); + + const timings: number[] = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + const raw = (await redis.call("JSON.GET", cacheKey)) as string | null; + expect(raw).toBeTruthy(); + JSON.parse(raw!); + const elapsed = performance.now() - start; + timings.push(elapsed); + } + + const rawEntries = (await redis.call( + "SLOWLOG", + "GET", + "256", + )) as SlowlogEntry[]; + const jsonGetEntries = rawEntries.filter( + (entry) => + entry[3] && (entry[3][0] === "JSON.GET" || entry[3][0] === "json.get"), + ); + const serverTimings = jsonGetEntries.map((entry) => entry[2] / 1000); + + await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "10000"); + + const e2eStats = formatStats(timings); + const serverStats = + serverTimings.length > 0 ? formatStats(serverTimings) : null; + + console.log( + chalk.cyan(`\n JSON.GET '.' + JSON.parse (${ITERATIONS} iterations):`), + ); + printStats("End-to-end (TS)", e2eStats); + if (serverStats) { + printStats("Server-side (SLOWLOG)", serverStats); + console.log( + ` Network + JSON.parse (avg): ~${(e2eStats.avg - serverStats.avg).toFixed(2)}ms`, + ); + } + }); + + const entitlementCounts = [1, 5, 10, 20]; + + for (const count of entitlementCounts) { + test(`benchmark deduction WITH path index — ${count} entitlement(s)`, async () => { + // Re-seed to reset balances + const fullCustomer = buildLargeFullCustomer(); + const pathIndexEntries = buildPathIndex({ fullCustomer }); + await redis.setFullCustomerCache( + cacheKey, + ORG_ID, + ENV, + CUSTOMER_ID, + String(Date.now()), + String(FULL_CUSTOMER_CACHE_TTL_SECONDS), + JSON.stringify(fullCustomer), + "true", + JSON.stringify(pathIndexEntries), + ); + + const exists = await redis.call("EXISTS", pathIdxKey); + expect(exists).toBe(1); + + const { e2eStats, serverStats } = await runSlowlogBenchmark({ + iterations: ITERATIONS, + label: chalk.green(`Fast path — ${count} entitlement(s)`), + entitlementCount: count, + }); + + (globalThis as Record)[`__fastPathStats_${count}`] = { + e2eStats, + serverStats, + }; + }); + } + + for (const count of entitlementCounts) { + test(`benchmark deduction WITHOUT path index — ${count} entitlement(s)`, async () => { + // Re-seed to reset balances, then delete path index + const fullCustomer = buildLargeFullCustomer(); + const pathIndexEntries = buildPathIndex({ fullCustomer }); + await redis.setFullCustomerCache( + cacheKey, + ORG_ID, + ENV, + CUSTOMER_ID, + String(Date.now()), + String(FULL_CUSTOMER_CACHE_TTL_SECONDS), + JSON.stringify(fullCustomer), + "true", + JSON.stringify(pathIndexEntries), + ); + await redis.call("DEL", pathIdxKey); + + const { e2eStats, serverStats } = await runSlowlogBenchmark({ + iterations: ITERATIONS, + label: chalk.red(`Fallback — ${count} entitlement(s)`), + entitlementCount: count, + }); + + (globalThis as Record)[`__fallbackStats_${count}`] = { + e2eStats, + serverStats, + }; + }); + } + + test("comparison table", async () => { + // Re-seed for subsequent runs + const fullCustomer = buildLargeFullCustomer(); + const pathIndexEntries = buildPathIndex({ fullCustomer }); + await redis.setFullCustomerCache( + cacheKey, + ORG_ID, + ENV, + CUSTOMER_ID, + String(Date.now()), + String(FULL_CUSTOMER_CACHE_TTL_SECONDS), + JSON.stringify(fullCustomer), + "true", + JSON.stringify(pathIndexEntries), + ); + + type Stats = { + e2eStats: ReturnType; + serverStats: ReturnType | null; + }; + const g = globalThis as Record; + + console.log(chalk.bold("\n ─── Comparison: Fast path vs Fallback ───")); + console.log(" │ Ents │ Fast (server) │ Fallback (server) │ Speedup │"); + console.log(" │──────│───────────────│───────────────────│─────────│"); + + for (const count of entitlementCounts) { + const fast = g[`__fastPathStats_${count}`] as Stats | undefined; + const slow = g[`__fallbackStats_${count}`] as Stats | undefined; + if (fast?.serverStats && slow?.serverStats) { + const speedup = slow.serverStats.avg / fast.serverStats.avg; + console.log( + ` │ ${String(count).padStart(4)} │ ${fast.serverStats.avg.toFixed(2).padStart(9)}ms │ ${slow.serverStats.avg.toFixed(2).padStart(13)}ms │ ${speedup.toFixed(1).padStart(5)}x │`, + ); + } + } + }); + + test("benchmark setFullCustomerCache", async () => { + const fullCustomer = buildLargeFullCustomer(); + const serialized = JSON.stringify(fullCustomer); + const pathIndexEntries = buildPathIndex({ fullCustomer }); + const pathIndexJson = JSON.stringify(pathIndexEntries); + + await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "0"); + await redis.call("CONFIG", "SET", "slowlog-max-len", "256"); + await redis.call("SLOWLOG", "RESET"); + + const timings: number[] = []; + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + const result = await redis.setFullCustomerCache( + cacheKey, + ORG_ID, + ENV, + CUSTOMER_ID, + String(Date.now()), + String(FULL_CUSTOMER_CACHE_TTL_SECONDS), + serialized, + "true", + pathIndexJson, + ); + const elapsed = performance.now() - start; + timings.push(elapsed); + expect(result).toBe("OK"); + } + + const rawEntries = (await redis.call( + "SLOWLOG", + "GET", + "256", + )) as SlowlogEntry[]; + const evalEntries = rawEntries.filter( + (entry) => + entry[3] && (entry[3][0] === "evalsha" || entry[3][0] === "EVALSHA"), + ); + const serverTimings = evalEntries.map((entry) => entry[2] / 1000); + + await redis.call("CONFIG", "SET", "slowlog-log-slower-than", "10000"); + + const e2eStats = formatStats(timings); + const serverStats = + serverTimings.length > 0 ? formatStats(serverTimings) : null; + + console.log( + chalk.magenta(`\n setFullCustomerCache (${ITERATIONS} iterations):`), + ); + printStats("End-to-end (TS)", e2eStats); + if (serverStats) { + printStats("Server-side (SLOWLOG)", serverStats); + } + }); });