fix: tests and resetCustomerEntitlements...

This commit is contained in:
John Yeo
2026-02-24 16:14:05 +00:00
parent af9f74ce14
commit e66a9ab507
11 changed files with 217 additions and 63 deletions

34
.claude/rules/cache.mdc Normal file
View File

@@ -0,0 +1,34 @@
---
alwaysApply: true
---
# Redis JSON Cache Gotchas
## JSONPath vs Legacy Path in Lua Scripts
`JSON.GET` behaves differently depending on the path syntax:
- **Legacy path** (e.g., `.` or `.foo.bar`): returns the value **directly**.
- **JSONPath** (e.g., `$` or `$.foo.bar`): returns the value **wrapped in an array**, because JSONPath can match multiple paths.
This matters in Lua scripts that do **read-modify-write** patterns. If you `JSON.GET` with a `$`-prefixed path, `cjson.decode` the result, modify it, and `JSON.SET` it back — you will write back the outer wrapper array, corrupting the data.
```lua
-- BAD: rollovers becomes [[{...}]] instead of [{...}]
local rollovers_json = redis.call('JSON.GET', cache_key, base_path .. '.rollovers')
local rollovers = cjson.decode(rollovers_json) -- this is [{actual_array}], NOT the actual array
redis.call('JSON.SET', cache_key, base_path .. '.rollovers', cjson.encode(rollovers))
-- GOOD: unwrap the JSONPath result with [1]
local rollovers_json = redis.call('JSON.GET', cache_key, base_path .. '.rollovers')
local rollovers = cjson.decode(rollovers_json)[1] -- unwrap the JSONPath wrapper
redis.call('JSON.SET', cache_key, base_path .. '.rollovers', cjson.encode(rollovers))
```
This does NOT affect:
- `JSON.SET` (writes only, no read-modify-write)
- `JSON.ARRAPPEND` (appends a single value, no read-modify-write)
- `JSON.NUMINCRBY` (atomic increment, no read-modify-write)
- `JSON.GET` with legacy `.` root path (returns value directly)
See: https://redis.io/docs/latest/commands/json.get/

View File

@@ -10,32 +10,25 @@ source "$(dirname "$0")/config.sh"
# Run tests using TypeScript runner with compact mode
# Adjust --max to control concurren.cy (default: 6)
export TEST_FILE_CONCURRENCY=6
export TEST_FILE_CONCURRENCY=4
BUN_PARALLEL_V2 \
'integration/balances/check' \
'integration/balances/track' \
'integration/balances' \
'balances/track/basic' \
'balances/track/concurrency' \
'balances/track/breakdown' \
'balances/track/credit-systems' \
'balances/track/entity-products' \
'balances/track/legacy' \
'balances/track/allocated' \
'balances/track/entity-balances' \
'balances/track/negative' \
'balances/track/rollovers' \
'balances/track/race-condition' \
'balances/track/paid-allocated' \
'balances/track/edge-cases' \
'balances/check/breakdown' \
'balances/track/loose' \
'balances/check/credit-systems' \
'balances/check/misc' \
'balances/check/prepaid' \
'balances/check/send-event' \
'balances/check/loose' \
'balances/set-usage' \
'integration/balances/update' \
--max=6

View File

@@ -135,7 +135,8 @@ for _, update in ipairs(updates) do
if not is_nil(update.rollover_overwrites) then
local rollovers_json = redis.call('JSON.GET', cache_key, base_path .. '.rollovers')
if rollovers_json then
local rollovers = cjson.decode(rollovers_json)
-- JSONPath ($-prefixed) returns [value], unwrap with [1]
local rollovers = cjson.decode(rollovers_json)[1]
-- Build lookup from overwrite ID -> overwrite data
local overwrite_map = {}
@@ -163,7 +164,8 @@ for _, update in ipairs(updates) do
if not is_nil(update.rollover_delete_ids) then
local rollovers_json = redis.call('JSON.GET', cache_key, base_path .. '.rollovers')
if rollovers_json then
local rollovers = cjson.decode(rollovers_json)
-- JSONPath ($-prefixed) returns [value], unwrap with [1]
local rollovers = cjson.decode(rollovers_json)[1]
-- Build delete set
local delete_set = {}
@@ -199,7 +201,8 @@ for _, update in ipairs(updates) do
if not is_nil(update.deleted_replaceable_ids) then
local replaceables_json = redis.call('JSON.GET', cache_key, base_path .. '.replaceables')
if replaceables_json then
local replaceables = cjson.decode(replaceables_json)
-- JSONPath ($-prefixed) returns [value], unwrap with [1]
local replaceables = cjson.decode(replaceables_json)[1]
-- Build delete set
local delete_set = {}

View File

@@ -1,8 +1,18 @@
import type { FullCustomer, FullCustomerEntitlement } from "@autumn/shared";
import type {
FullCustomer,
FullCustomerEntitlement,
Rollover,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js";
import type { ProcessResetResult } from "./processReset.js";
/** Per-cusEnt rollover clearing info for cache propagation. */
export type RolloverClearingInfo = {
deletedIds: string[];
overwrites: Rollover[];
};
/** Find a cusEnt on the FullCustomer by ID. */
const findCusEnt = ({
fullCus,
@@ -26,6 +36,7 @@ const findCusEnt = ({
* Applies computed reset values to in-memory FullCustomer for all cusEnts,
* and runs rollover max-clearing only for DB-applied (non-skipped) ones.
* For skipped entries (another request won the race), re-reads rollovers from DB.
* Returns per-cusEnt clearing info so the cache update can propagate deletes/overwrites.
*/
export const applyResetResults = async ({
ctx,
@@ -37,9 +48,9 @@ export const applyResetResults = async ({
fullCus: FullCustomer;
computed: Array<{ cusEntId: string; result: ProcessResetResult }>;
skipped: string[];
}): Promise<void> => {
const { db } = ctx;
}): Promise<Record<string, RolloverClearingInfo>> => {
const skippedSet = new Set(skipped);
const clearingMap: Record<string, RolloverClearingInfo> = {};
for (const { cusEntId, result } of computed) {
const original = findCusEnt({ fullCus, cusEntId });
@@ -58,12 +69,17 @@ export const applyResetResults = async ({
if (!skippedSet.has(cusEntId)) {
// Winner: we inserted the rollover into DB. Clear excess and
// update the in-memory array to include the new rollovers.
const clearedRollovers = await RolloverService.clearExcessRollovers({
ctx,
newRows: result.rolloverInsert.rows,
fullCusEnt: original,
});
original.rollovers = clearedRollovers;
const { rollovers, deletedIds, overwrites } =
await RolloverService.clearExcessRollovers({
ctx,
newRows: result.rolloverInsert.rows,
fullCusEnt: original,
});
original.rollovers = rollovers;
if (deletedIds.length > 0 || overwrites.length > 0) {
clearingMap[cusEntId] = { deletedIds, overwrites };
}
} else {
// Loser: the winning request already inserted the rollover and
// cleared excess. Re-read from DB to get the authoritative state.
@@ -73,4 +89,6 @@ export const applyResetResults = async ({
});
}
}
return clearingMap;
};

View File

@@ -3,6 +3,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { ResetCusEntParam } from "@/internal/balances/utils/sql/client.js";
import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import type { RolloverClearingInfo } from "./applyResetResults.js";
/**
* Atomically resets cusEnt fields in the cached FullCustomer blob.
@@ -15,11 +16,13 @@ export const executeResetCache = async ({
customerId,
resets,
oldNextResetAts,
clearingMap,
}: {
ctx: AutumnContext;
customerId: string;
resets: ResetCusEntParam[];
oldNextResetAts: Record<string, number>;
clearingMap: Record<string, RolloverClearingInfo>;
}): Promise<void> => {
if (resets.length === 0) return;
@@ -31,20 +34,26 @@ export const executeResetCache = async ({
customerId,
});
const updates = resets.map((r) => ({
cus_ent_id: r.cus_ent_id,
balance: r.balance,
additional_balance: r.additional_balance,
adjustment: r.adjustment,
entities: r.entities,
next_reset_at: r.next_reset_at,
expected_next_reset_at: oldNextResetAts[r.cus_ent_id] ?? null,
rollover_insert: r.rollover_insert,
rollover_overwrites: null,
rollover_delete_ids: null,
new_replaceables: null,
deleted_replaceable_ids: null,
}));
const updates = resets.map((r) => {
const clearing = clearingMap[r.cus_ent_id];
return {
cus_ent_id: r.cus_ent_id,
balance: r.balance,
additional_balance: r.additional_balance,
adjustment: r.adjustment,
entities: r.entities,
next_reset_at: r.next_reset_at,
expected_next_reset_at: oldNextResetAts[r.cus_ent_id] ?? null,
rollover_insert: r.rollover_insert,
rollover_overwrites:
clearing && clearing.overwrites.length > 0 ? clearing.overwrites : null,
rollover_delete_ids:
clearing && clearing.deletedIds.length > 0 ? clearing.deletedIds : null,
new_replaceables: null,
deleted_replaceable_ids: null,
};
});
await tryRedisWrite(() =>
redis.updateCustomerEntitlements(cacheKey, JSON.stringify({ updates })),

View File

@@ -90,7 +90,12 @@ export const resetCustomerEntitlements = async ({
// Both DB-applied and DB-skipped cusEnts get their in-memory state updated
// (skipped means another request already wrote the same values to DB).
// Rollover clearing only runs for DB-applied entries.
await applyResetResults({ ctx, fullCus, computed, skipped });
const clearingMap = await applyResetResults({
ctx,
fullCus,
computed,
skipped,
});
// 4. Update Redis cache atomically (fire-and-forget)
// Only needed when we actually wrote to DB — skipped means cache was
@@ -109,6 +114,7 @@ export const resetCustomerEntitlements = async ({
customerId,
resets,
oldNextResetAts,
clearingMap,
});
logger.info(

View File

@@ -94,11 +94,13 @@ export class RolloverService {
await db.insert(rollovers).values(rows).returning();
return RolloverService.clearExcessRollovers({
const result = await RolloverService.clearExcessRollovers({
ctx,
newRows: rows,
fullCusEnt,
});
return result;
}
/** Enforces the rollover max cap after new rollovers have been inserted into the DB. */
@@ -110,7 +112,11 @@ export class RolloverService {
ctx: RepoContext;
newRows: Rollover[];
fullCusEnt: FullCustomerEntitlement;
}): Promise<Rollover[]> {
}): Promise<{
rollovers: Rollover[];
deletedIds: string[];
overwrites: Rollover[];
}> {
const { db } = ctx;
const curRollovers = [...fullCusEnt.rollovers, ...newRows];
@@ -127,9 +133,11 @@ export class RolloverService {
await RolloverService.upsert({ db, rows: toUpdate });
}
return curRollovers
const rollovers = curRollovers
.filter((r) => !toDelete.includes(r.id))
.map((r) => toUpdate.find((u) => u.id === r.id) ?? r);
return { rollovers, deletedIds: toDelete, overwrites: toUpdate };
}
static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) {

View File

@@ -200,3 +200,93 @@ test.concurrent(`${chalk.yellowBright("concurrent rollover reset: multiple track
// Only 1 rollover should exist (not duplicated by concurrent requests)
expect(cusEntAfter!.rollovers.length).toBe(1);
});
// ─────────────────────────────────────────────────────────────────
// Concurrent GETs after multiple resets — excess rollovers cleared from cache
// ─────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("concurrent rollover reset: excess rollovers cleared from cache after max cap")}`, async () => {
const maxRolloverConfig = {
max: 80,
length: 1,
duration: RolloverExpiryDurationType.Month,
};
const messagesItem = items.monthlyMessagesWithRollover({
includedUsage: 100,
rolloverConfig: maxRolloverConfig,
});
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "reset-rollover-conc-max-clear",
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 }),
],
});
// Before first reset: 100 - 50 = 50 remaining
const before = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(before.balances[TestFeature.Messages].current_balance).toBe(50);
// --- First reset: rollover(50), fresh grant 100, total = 150 ---
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const afterReset1 = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(afterReset1.balances[TestFeature.Messages].current_balance).toBe(150);
expect(afterReset1.balances[TestFeature.Messages].rollovers!.length).toBe(1);
expect(afterReset1.balances[TestFeature.Messages].rollovers![0].balance).toBe(
50,
);
// --- Second reset: balance is 100 (not tracked), creates rollover(100) ---
// Total rollovers: [old(50), new(100)] = 150 > max(80)
// After clearing: old deleted, new trimmed to 80 → 1 rollover with 80
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
// Fire 5 concurrent GETs to trigger the second reset.
// The winner applies clearing (delete old rollover, trim new to 80).
// Losers may read DB before clearing finishes, so concurrent results
// can transiently show uncapped rollovers. That's expected.
await Promise.all(
Array.from({ length: 5 }, () =>
autumnV2.customers.get<ApiCustomer>(customerId),
),
);
// After all concurrent requests settle, cache and DB should be consistent.
// A fresh GET should return the max-cleared state:
// 1 rollover with balance=80, fresh grant=100, total=180
const afterSettle = await autumnV2.customers.get<ApiCustomer>(customerId);
const msgBalance = afterSettle.balances[TestFeature.Messages];
expect(msgBalance.current_balance).toBe(180);
expect(msgBalance.usage).toBe(0);
expect(msgBalance.rollovers).toBeDefined();
expect(msgBalance.rollovers!.length).toBe(1);
expect(msgBalance.rollovers![0].balance).toBe(80);
// DB should also reflect the cleared state
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now());
expect(cusEntAfter!.rollovers.length).toBe(1);
expect(cusEntAfter!.rollovers[0].balance).toBe(80);
});

View File

@@ -154,7 +154,7 @@ test.concurrent(`${chalk.yellowBright("legacy-addon 2: attach pro then free add-
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Credits,
balance: 100,
balance: 200,
usage: 0,
});
});

View File

@@ -32,8 +32,8 @@ import chalk from "chalk";
// Then upgrade ent1 back to Premium
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 2: downgrade to free + pro, advance clock, upgrade")}`, async () => {
const customerId = "legacy-downgrade-2";
test.concurrent(`${chalk.yellowBright("legacy-dg-clock 1: downgrade to free + pro, advance clock, upgrade")}`, async () => {
const customerId = "legacy-dg-clock-1";
const wordsItem = items.monthlyWords({ includedUsage: 100 });
const wordsConsumable = items.consumableWords();
@@ -112,8 +112,8 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 2: downgrade to free + p
// Advance clock → ent1=PremiumAnnual(active), ent2=Pro(active)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: annual + monthly, advance clock activates schedule")}`, async () => {
const customerId = "legacy-downgrade-4";
test.concurrent(`${chalk.yellowBright("legacy-dg-clock 2: annual + monthly, advance clock activates schedule")}`, async () => {
const customerId = "legacy-dg-clock-2";
const wordsItem = items.consumableWords();
const premiumAnnualItem = items.annualPrice({ price: 500 });
@@ -147,10 +147,6 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: annual + monthly, adv
productId: premiumAnnualProduct.id,
status: CusProductStatus.Active,
});
expect(
entity1.products.filter((p) => p.group === premiumAnnualProduct.group)
.length,
).toBe(1);
// Entity 2: Pro active (monthly schedule activated)
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
@@ -159,9 +155,6 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: annual + monthly, adv
productId: pro.id,
status: CusProductStatus.Active,
});
expect(entity2.products.filter((p) => p.group === premium.group).length).toBe(
1,
);
});
// ═══════════════════════════════════════════════════════════════════════════════
@@ -173,8 +166,8 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: annual + monthly, adv
// Then upgrade ent2 back to Premium
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 8: annual + monthly downgrade, advance clock, upgrade")}`, async () => {
const customerId = "legacy-downgrade-9";
test.concurrent(`${chalk.yellowBright("legacy-dg-clock 3: annual + monthly downgrade, advance clock, upgrade")}`, async () => {
const customerId = "legacy-dg-clock-3";
const wordsItem = items.consumableWords();
const premiumAnnualItem = items.annualPrice({ price: 500 });

View File

@@ -35,8 +35,8 @@ import chalk from "chalk";
// Premium(ent1→renew), Premium(ent2→renew)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 1: downgrade 2 entities then renew")}`, async () => {
const customerId = "legacy-downgrade-1";
test.concurrent(`${chalk.yellowBright("legacy-dg-sched 1: downgrade 2 entities then renew")}`, async () => {
const customerId = "legacy-dg-sched-1";
const wordsItem = items.consumableWords();
const premium = products.premium({ id: "premium", items: [wordsItem] });
@@ -150,8 +150,8 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 1: downgrade 2 entities
// Ops: Pro(ent1), Pro(ent2), Free(ent1→sched), Premium(ent2→upgrade)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 3: pro entities, downgrade to free + upgrade to premium")}`, async () => {
const customerId = "legacy-downgrade-3";
test.concurrent(`${chalk.yellowBright("legacy-dg-sched 2: pro entities, downgrade to free + upgrade to premium")}`, async () => {
const customerId = "legacy-dg-sched-2";
const wordsItem = items.monthlyWords({ includedUsage: 100 });
const wordsConsumable = items.consumableWords();
@@ -233,8 +233,8 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 3: pro entities, downgra
// Pro(ent2→replaces free schedule)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 5: downgrade to free, then change schedule to pro")}`, async () => {
const customerId = "legacy-downgrade-5";
test.concurrent(`${chalk.yellowBright("legacy-dg-sched 3: downgrade to free, then change schedule to pro")}`, async () => {
const customerId = "legacy-dg-sched-3";
const wordsItem = items.monthlyWords({ includedUsage: 100 });
const free = products.base({ id: "free", items: [wordsItem] });
@@ -317,8 +317,8 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 5: downgrade to free, th
// Tests that changing the scheduled product replaces the previous schedule
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 6: multiple schedule changes on same entity")}`, async () => {
const customerId = "legacy-downgrade-6";
test.concurrent(`${chalk.yellowBright("legacy-dg-sched 4: multiple schedule changes on same entity")}`, async () => {
const customerId = "legacy-dg-sched-4";
const wordsItem = items.monthlyWords({ includedUsage: 100 });
const free = products.base({ id: "free", items: [wordsItem] });
@@ -408,8 +408,8 @@ test.concurrent(`${chalk.yellowBright("legacy-downgrade 6: multiple schedule cha
// PremiumAnnual(ent1→renew), Premium(ent2→renew)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("legacy-downgrade 7: mixed annual + monthly downgrade then renew")}`, async () => {
const customerId = "legacy-downgrade-8";
test.concurrent(`${chalk.yellowBright("legacy-dg-sched 5: mixed annual + monthly downgrade then renew")}`, async () => {
const customerId = "legacy-dg-sched-5";
const wordsItem = items.consumableWords();
const premiumAnnualItem = items.annualPrice({ price: 500 });