fix: use entitlement index for json path
This commit is contained in:
51
server/perf/redis-bench/artillery.yml
Normal file
51
server/perf/redis-bench/artillery.yml
Normal file
@@ -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
|
||||||
25
server/perf/redis-bench/processor.mjs
Normal file
25
server/perf/redis-bench/processor.mjs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
157
server/perf/redis-bench/setup.ts
Normal file
157
server/perf/redis-bench/setup.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
params:
|
params:
|
||||||
cache_key: string
|
cache_key: string
|
||||||
customer_entitlement_ids: array of customer_entitlement IDs
|
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:
|
Returns: context table with:
|
||||||
customer_entitlements: { [cus_ent_id]: { base_path, balance, adjustment, entities } }
|
customer_entitlements: { [cus_ent_id]: { base_path, balance, adjustment, entities } }
|
||||||
@@ -25,11 +27,16 @@
|
|||||||
]]
|
]]
|
||||||
local function init_context(params)
|
local function init_context(params)
|
||||||
local logs = {}
|
local logs = {}
|
||||||
|
local has_pathidx = params.has_pathidx
|
||||||
|
local pathidx_key = params.pathidx_key
|
||||||
|
|
||||||
local context = {
|
local context = {
|
||||||
customer_entitlements = {},
|
customer_entitlements = {},
|
||||||
rollovers = {},
|
rollovers = {},
|
||||||
|
cache_key = params.cache_key,
|
||||||
full_customer = params.full_customer,
|
full_customer = params.full_customer,
|
||||||
|
pathidx_key = pathidx_key,
|
||||||
|
has_pathidx = has_pathidx,
|
||||||
mutation_logs = {},
|
mutation_logs = {},
|
||||||
pending_writes = {},
|
pending_writes = {},
|
||||||
logs = logs,
|
logs = logs,
|
||||||
@@ -41,65 +48,90 @@ local function init_context(params)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, ent_id in ipairs(params.customer_entitlement_ids or {}) do
|
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
|
if has_pathidx then
|
||||||
local base_path
|
local result = get_customer_entitlement_via_index({
|
||||||
local is_loose = (cp_idx == nil) -- Loose entitlement if no customer_product index
|
pathidx_key = pathidx_key,
|
||||||
|
cache_key = params.cache_key,
|
||||||
if is_loose then
|
cus_ent_id = ent_id,
|
||||||
-- Loose entitlement: path is $.extra_customer_entitlements[idx]
|
})
|
||||||
local ece_idx_0 = ce_idx - 1
|
if result then
|
||||||
base_path = '$.extra_customer_entitlements[' .. ece_idx_0 .. ']'
|
base_path = result.base_path
|
||||||
else
|
has_entity_scope = result.has_entity_scope
|
||||||
-- Product entitlement: path is $.customer_products[cp_idx].customer_entitlements[ce_idx]
|
is_loose = result.is_loose
|
||||||
local cp_idx_0 = cp_idx - 1
|
local sub = result.sub
|
||||||
local ce_idx_0 = ce_idx - 1
|
adjustment = safe_number(sub.adjustment or 0)
|
||||||
base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_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
|
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
|
if cus_ent then
|
||||||
local has_entity_scope = not is_nil(entitlement)
|
is_loose = (cp_idx == nil)
|
||||||
and not is_nil(entitlement.entity_feature_id)
|
|
||||||
|
|
||||||
|
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 = {
|
local ent_data = {
|
||||||
base_path = base_path,
|
base_path = base_path,
|
||||||
has_entity_scope = has_entity_scope,
|
has_entity_scope = has_entity_scope,
|
||||||
adjustment = cus_ent.adjustment or 0,
|
adjustment = adjustment,
|
||||||
unlimited = cus_ent.unlimited,
|
unlimited = unlimited,
|
||||||
is_loose = is_loose,
|
is_loose = is_loose,
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_entity_scope then
|
if has_entity_scope then
|
||||||
ent_data.balance = 0 -- Not used for entity-scoped
|
ent_data.balance = 0
|
||||||
ent_data.entities = read_current_entities(params.cache_key, base_path)
|
ent_data.entities = cus_ent_entities or {}
|
||||||
else
|
else
|
||||||
ent_data.balance = read_current_balance(params.cache_key, base_path)
|
ent_data.balance = cus_ent_balance or 0
|
||||||
ent_data.entities = nil
|
ent_data.entities = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
context.customer_entitlements[ent_id] = ent_data
|
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
|
if cus_ent_rollovers and type(cus_ent_rollovers) == 'table' then
|
||||||
for r_idx, rollover in ipairs(cus_ent_rollovers) do
|
for r_idx, rollover in ipairs(cus_ent_rollovers) do
|
||||||
if rollover and rollover.id then
|
if rollover and rollover.id then
|
||||||
local r_idx_0 = r_idx - 1
|
local r_idx_0 = r_idx - 1
|
||||||
local rollover_path = base_path .. '.rollovers[' .. r_idx_0 .. ']'
|
local rollover_path = base_path .. '.rollovers[' .. r_idx_0 .. ']'
|
||||||
|
|
||||||
-- Read fresh rollover data from Redis
|
context.rollovers[rollover.id] = {
|
||||||
local rollover_data = read_rollover_data(params.cache_key, rollover_path)
|
base_path = rollover_path,
|
||||||
|
cus_ent_id = ent_id,
|
||||||
if rollover_data then
|
balance = safe_number(rollover.balance or 0),
|
||||||
context.rollovers[rollover.id] = {
|
usage = safe_number(rollover.usage or 0),
|
||||||
base_path = rollover_path,
|
entities = safe_table(rollover.entities),
|
||||||
cus_ent_id = ent_id,
|
}
|
||||||
balance = rollover_data.balance,
|
|
||||||
usage = rollover_data.usage,
|
|
||||||
entities = rollover_data.entities,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,17 +10,22 @@
|
|||||||
3. Pass 2: Allow negative if usage_allowed
|
3. Pass 2: Allow negative if usage_allowed
|
||||||
|
|
||||||
Helper functions are prepended via string interpolation from:
|
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)
|
- 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)
|
- deductFromRollovers.lua (deduct_from_rollovers)
|
||||||
- deductFromMainBalance.lua (calculate_change, deduct_from_main_balance)
|
- deductFromMainBalance.lua (calculate_change, deduct_from_main_balance)
|
||||||
- getTotalBalance.lua (get_total_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:
|
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 }],
|
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,
|
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,
|
usage_based_cus_ent_ids_by_feature_id: { [feature_id]: string[] } | null,
|
||||||
@@ -51,6 +56,11 @@
|
|||||||
local cache_key = KEYS[1]
|
local cache_key = KEYS[1]
|
||||||
local params = cjson.decode(ARGV[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
|
-- Extract parameters
|
||||||
local sorted_entitlements = params.sorted_entitlements or {}
|
local sorted_entitlements = params.sorted_entitlements or {}
|
||||||
local spend_limit_by_feature_id = params.spend_limit_by_feature_id
|
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
|
-- Compute overage_behavior_is_allow once
|
||||||
local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow'
|
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('[]')
|
local empty_logs = cjson.decode('[]')
|
||||||
|
|
||||||
|
-- Check if customer exists
|
||||||
local key_exists = redis.call('EXISTS', cache_key)
|
local key_exists = redis.call('EXISTS', cache_key)
|
||||||
if key_exists == 0 then
|
if key_exists == 0 then
|
||||||
return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 })
|
return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Get FullCustomer structure (for finding entitlement indices only)
|
-- Build path index key and check existence (fast path vs fallback)
|
||||||
local full_customer_json = redis.call('JSON.GET', cache_key, '.')
|
local pathidx_key = build_path_index_key(org_id, env, customer_id)
|
||||||
if not full_customer_json then
|
local has_pathidx = redis.call('EXISTS', pathidx_key) == 1
|
||||||
return cjson.encode({ error = 'CUSTOMER_NOT_FOUND', updates = {}, rollover_updates = {}, mutation_logs = empty_logs, remaining = 0 })
|
|
||||||
|
-- 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
|
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
|
-- Initialize context with in-memory state from Redis
|
||||||
local customer_entitlement_ids = {}
|
local customer_entitlement_ids = {}
|
||||||
for _, ent_obj in ipairs(sorted_entitlements) do
|
for _, ent_obj in ipairs(sorted_entitlements) do
|
||||||
@@ -107,6 +123,8 @@ local context = init_context({
|
|||||||
cache_key = cache_key,
|
cache_key = cache_key,
|
||||||
customer_entitlement_ids = customer_entitlement_ids,
|
customer_entitlement_ids = customer_entitlement_ids,
|
||||||
full_customer = full_customer,
|
full_customer = full_customer,
|
||||||
|
pathidx_key = pathidx_key,
|
||||||
|
has_pathidx = has_pathidx,
|
||||||
})
|
})
|
||||||
|
|
||||||
local unwind_modified_cus_ent_ids = {}
|
local unwind_modified_cus_ent_ids = {}
|
||||||
@@ -229,7 +247,7 @@ then
|
|||||||
hashed_key = lock.hashed_key or cjson.null,
|
hashed_key = lock.hashed_key or cjson.null,
|
||||||
status = 'pending',
|
status = 'pending',
|
||||||
region = lock.region or cjson.null,
|
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,
|
feature_id = feature_id or cjson.null,
|
||||||
entity_id = target_entity_id or cjson.null,
|
entity_id = target_entity_id or cjson.null,
|
||||||
expires_at = lock.expires_at or cjson.null,
|
expires_at = lock.expires_at or cjson.null,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- LUA UTILITIES
|
-- 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)
|
local function safe_table(val)
|
||||||
if val == nil or val == cjson.null or type(val) ~= 'table' then
|
if val == nil or val == cjson.null or type(val) ~= 'table' then
|
||||||
return {}
|
return {}
|
||||||
@@ -13,9 +10,6 @@ local function safe_table(val)
|
|||||||
return val
|
return val
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- HELPER: Safe number getter
|
|
||||||
-- ============================================================================
|
|
||||||
local function safe_number(val)
|
local function safe_number(val)
|
||||||
if val == nil or val == cjson.null then
|
if val == nil or val == cjson.null then
|
||||||
return 0
|
return 0
|
||||||
@@ -23,56 +17,10 @@ local function safe_number(val)
|
|||||||
return tonumber(val) or 0
|
return tonumber(val) or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- HELPER: Check if value is nil or cjson.null
|
|
||||||
-- ============================================================================
|
|
||||||
local function is_nil(val)
|
local function is_nil(val)
|
||||||
return val == nil or val == cjson.null
|
return val == nil or val == cjson.null
|
||||||
end
|
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 function sorted_keys(tbl)
|
||||||
local keys = {}
|
local keys = {}
|
||||||
for k in pairs(tbl) do
|
for k in pairs(tbl) do
|
||||||
|
|||||||
@@ -6,8 +6,39 @@
|
|||||||
local function build_ent_data_from_full_customer(params)
|
local function build_ent_data_from_full_customer(params)
|
||||||
local full_customer = params.full_customer
|
local full_customer = params.full_customer
|
||||||
local cus_ent_id = params.cus_ent_id
|
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
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,6 +77,8 @@ local function get_available_overage_from_spend_limit(params)
|
|||||||
ent_data = build_ent_data_from_full_customer({
|
ent_data = build_ent_data_from_full_customer({
|
||||||
full_customer = context.full_customer,
|
full_customer = context.full_customer,
|
||||||
cus_ent_id = cus_ent_id,
|
cus_ent_id = cus_ent_id,
|
||||||
|
cache_key = context.cache_key,
|
||||||
|
pathidx_key = context.pathidx_key,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
100
server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua
Normal file
100
server/src/_luaScriptsV2/fullCustomer/fullCustomerUtils.lua
Normal file
@@ -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
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
--[[
|
--[[
|
||||||
Shared key builder functions for FullCustomer cache keys.
|
Shared key builder functions for FullCustomer cache keys.
|
||||||
|
FULL_CUSTOMER_CACHE_VERSION is injected by luaScriptsV2.ts at load time.
|
||||||
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).
|
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local FULL_CUSTOMER_CACHE_VERSION = "__FULL_CUSTOMER_CACHE_VERSION__"
|
local FULL_CUSTOMER_CACHE_VERSION = "__FULL_CUSTOMER_CACHE_VERSION__"
|
||||||
|
|||||||
@@ -18,8 +18,15 @@ const UPDATE_DIR = join(__dirname, "updateCustomerEntitlements");
|
|||||||
// HELPER MODULES
|
// HELPER MODULES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const FULL_CUSTOMER_DIR = join(__dirname, "fullCustomer");
|
||||||
|
|
||||||
const LUA_UTILS = readFileSync(join(DEDUCT_DIR, "luaUtils.lua"), "utf-8");
|
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(
|
const READ_BALANCES = readFileSync(
|
||||||
join(DEDUCT_DIR, "readBalances.lua"),
|
join(DEDUCT_DIR, "readBalances.lua"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -75,6 +82,15 @@ const LOCK_UNWIND_UTILS = readFileSync(
|
|||||||
"utf-8",
|
"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
|
// MAIN SCRIPT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -90,7 +106,9 @@ const mainScript = readFileSync(
|
|||||||
* Composed from helper modules via string interpolation.
|
* Composed from helper modules via string interpolation.
|
||||||
* Supports both positive deductions and negative refunds.
|
* 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}
|
${READ_BALANCES}
|
||||||
${CONTEXT_UTILS}
|
${CONTEXT_UTILS}
|
||||||
${GET_TOTAL_BALANCE}
|
${GET_TOTAL_BALANCE}
|
||||||
@@ -119,15 +137,6 @@ ${LOCK_RECEIPT_UTILS}
|
|||||||
${LOCK_STATE_UTILS}
|
${LOCK_STATE_UTILS}
|
||||||
${claimLockReceiptMainScript}`;
|
${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
|
// DELETE FULL CUSTOMER CACHE SCRIPTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -178,6 +187,7 @@ const resetMainScript = readFileSync(
|
|||||||
* @deprecated Use UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT instead.
|
* @deprecated Use UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT instead.
|
||||||
*/
|
*/
|
||||||
export const RESET_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS}
|
export const RESET_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS}
|
||||||
|
${FULL_CUSTOMER_UTILS}
|
||||||
${resetMainScript}`;
|
${resetMainScript}`;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -195,6 +205,7 @@ const updateMainScript = readFileSync(
|
|||||||
* "apply absolute values to customer entitlements in the Redis cache."
|
* "apply absolute values to customer entitlements in the Redis cache."
|
||||||
*/
|
*/
|
||||||
export const UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS}
|
export const UPDATE_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS}
|
||||||
|
${FULL_CUSTOMER_UTILS}
|
||||||
${updateMainScript}`;
|
${updateMainScript}`;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -213,6 +224,7 @@ const adjustBalanceMainScript = readFileSync(
|
|||||||
* FullCustomer via JSON.NUMINCRBY. Safe with concurrent deductions.
|
* FullCustomer via JSON.NUMINCRBY. Safe with concurrent deductions.
|
||||||
*/
|
*/
|
||||||
export const ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT = `${LUA_UTILS}
|
export const ADJUST_CUSTOMER_ENTITLEMENT_BALANCE_SCRIPT = `${LUA_UTILS}
|
||||||
|
${FULL_CUSTOMER_UTILS}
|
||||||
${adjustBalanceMainScript}`;
|
${adjustBalanceMainScript}`;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export const executeRedisDeduction = async ({
|
|||||||
|
|
||||||
// Call Lua script to deduct from FullCustomer in Redis
|
// Call Lua script to deduct from FullCustomer in Redis
|
||||||
const luaParams = {
|
const luaParams = {
|
||||||
|
org_id: org.id,
|
||||||
|
env,
|
||||||
|
customer_id: customerId,
|
||||||
sorted_entitlements: customerEntitlementDeductions,
|
sorted_entitlements: customerEntitlementDeductions,
|
||||||
spend_limit_by_feature_id: spendLimitByFeatureId ?? null,
|
spend_limit_by_feature_id: spendLimitByFeatureId ?? null,
|
||||||
usage_based_cus_ent_ids_by_feature_id:
|
usage_based_cus_ent_ids_by_feature_id:
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { FullCustomer } from "@autumn/shared";
|
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.
|
* 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 = ({
|
export const buildPathIndex = ({
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
@@ -19,12 +24,12 @@ export const buildPathIndex = ({
|
|||||||
ceIdx++
|
ceIdx++
|
||||||
) {
|
) {
|
||||||
const customerEntitlement = customerProduct.customer_entitlements[ceIdx];
|
const customerEntitlement = customerProduct.customer_entitlements[ceIdx];
|
||||||
const path = `$.customer_products[${cpIdx}].customer_entitlements[${ceIdx}]`;
|
|
||||||
const entityFeatureId =
|
const entityFeatureId =
|
||||||
customerEntitlement.entitlement?.entity_feature_id ?? null;
|
customerEntitlement.entitlement?.entity_feature_id ?? null;
|
||||||
|
|
||||||
entries[`ent:${customerEntitlement.id}`] = JSON.stringify({
|
entries[`cus_ent:${customerEntitlement.id}`] = JSON.stringify({
|
||||||
p: path,
|
cp: cpIdx,
|
||||||
|
ce: ceIdx,
|
||||||
ef: entityFeatureId,
|
ef: entityFeatureId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,12 +43,11 @@ export const buildPathIndex = ({
|
|||||||
) {
|
) {
|
||||||
const extraCustomerEntitlement =
|
const extraCustomerEntitlement =
|
||||||
fullCustomer.extra_customer_entitlements[eceIdx];
|
fullCustomer.extra_customer_entitlements[eceIdx];
|
||||||
const path = `$.extra_customer_entitlements[${eceIdx}]`;
|
|
||||||
const entityFeatureId =
|
const entityFeatureId =
|
||||||
extraCustomerEntitlement.entitlement?.entity_feature_id ?? null;
|
extraCustomerEntitlement.entitlement?.entity_feature_id ?? null;
|
||||||
|
|
||||||
entries[`ent:${extraCustomerEntitlement.id}`] = JSON.stringify({
|
entries[`cus_ent:${extraCustomerEntitlement.id}`] = JSON.stringify({
|
||||||
p: path,
|
ece: eceIdx,
|
||||||
ef: entityFeatureId,
|
ef: entityFeatureId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,488 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { TestFeature } from "@tests/setup/v2Features";
|
import { AppEnv, type FullCustomer } from "@autumn/shared";
|
||||||
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
|
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
|
||||||
import { items } from "@tests/utils/fixtures/items";
|
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
||||||
import { products } from "@tests/utils/fixtures/products";
|
import { customers } from "@tests/utils/fixtures/db/customers";
|
||||||
import { timeout } from "@tests/utils/genUtils";
|
import { entities as entityFixtures } from "@tests/utils/fixtures/db/entities";
|
||||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
|
||||||
import chalk from "chalk";
|
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 ENTITY_COUNT = 100;
|
||||||
const customerId = "temp-legacy-checkout-annual-adjustable-prepaid";
|
const CUS_ENTS_PER_PRODUCT = 10;
|
||||||
const includedCallMinutes = 100;
|
const ORG_ID = "org_perf_test";
|
||||||
const checkoutQuantityInUnits = 300;
|
const ENV = AppEnv.Sandbox;
|
||||||
|
const CUSTOMER_ID = "cus_perf_large";
|
||||||
|
|
||||||
const monthlyPrepaidCallMinutes = items.prepaid({
|
const buildLargeFullCustomer = (): FullCustomer => {
|
||||||
featureId: TestFeature.Messages,
|
const entitiesList = Array.from({ length: ENTITY_COUNT }, (_, i) =>
|
||||||
includedUsage: includedCallMinutes,
|
entityFixtures.create({
|
||||||
billingUnits: 100,
|
id: `entity_${i}`,
|
||||||
price: 13,
|
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({
|
const fullCustomer: FullCustomer = {
|
||||||
id: "small-business",
|
...customers.create({ customerProducts: cusProducts }),
|
||||||
items: [monthlyPrepaidCallMinutes],
|
id: CUSTOMER_ID,
|
||||||
});
|
org_id: ORG_ID,
|
||||||
|
env: ENV,
|
||||||
|
entities: entitiesList,
|
||||||
|
extra_customer_entitlements: [],
|
||||||
|
};
|
||||||
|
|
||||||
const { autumnV1 } = await initScenario({
|
return fullCustomer;
|
||||||
customerId,
|
};
|
||||||
setup: [
|
|
||||||
s.customer({ testClock: true }),
|
|
||||||
s.products({ list: [smallBusiness] }),
|
|
||||||
],
|
|
||||||
actions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await autumnV1.attach({
|
const cacheKey = buildFullCustomerCacheKey({
|
||||||
customer_id: customerId,
|
orgId: ORG_ID,
|
||||||
product_id: smallBusiness.id,
|
env: ENV,
|
||||||
// options: [
|
customerId: CUSTOMER_ID,
|
||||||
// {
|
});
|
||||||
// feature_id: TestFeature.Messages,
|
|
||||||
// adjustable: true,
|
const pathIdxKey = buildPathIndexKey({
|
||||||
// },
|
orgId: ORG_ID,
|
||||||
// ],
|
env: ENV,
|
||||||
});
|
customerId: CUSTOMER_ID,
|
||||||
|
});
|
||||||
expect(result.checkout_url).toBeDefined();
|
|
||||||
expect(result.checkout_url).toContain("checkout.stripe.com");
|
const formatStats = (
|
||||||
|
timings: number[],
|
||||||
await completeStripeCheckoutForm({
|
): { min: number; avg: number; p95: number; max: number } => {
|
||||||
url: result.checkout_url,
|
const sorted = [...timings].sort((a, b) => a - b);
|
||||||
// overrideQuantity: checkoutQuantityInUnits / 100,
|
const sum = sorted.reduce((acc, t) => acc + t, 0);
|
||||||
});
|
const p95Index = Math.floor(sorted.length * 0.95);
|
||||||
|
return {
|
||||||
await timeout(12000);
|
min: sorted[0],
|
||||||
|
avg: sum / sorted.length,
|
||||||
|
p95: sorted[p95Index],
|
||||||
|
max: sorted[sorted.length - 1],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const printStats = (label: string, stats: ReturnType<typeof formatStats>) => {
|
||||||
|
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<string, unknown>)[`__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<string, unknown>)[`__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<typeof formatStats>;
|
||||||
|
serverStats: ReturnType<typeof formatStats> | null;
|
||||||
|
};
|
||||||
|
const g = globalThis as Record<string, unknown>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user