fix: cubic comments

This commit is contained in:
John Yeo
2026-02-23 16:04:57 +00:00
parent 292d7e16d8
commit 5a14c19bae
7 changed files with 231 additions and 224 deletions

View File

@@ -364,8 +364,9 @@ async function runTestFile({
// A file is considered failed if:
// 1. Any individual test failed, OR
// 2. The process exited non-zero (e.g. module import error), OR
// 3. Zero tests were found (likely a silent import failure)
const isFailed = hasFailures || processExitedNonZero || hasNoTests;
// 3. Zero tests were found AND process exited non-zero (likely a silent import failure)
// Note: Empty files that run successfully (exit 0) are treated as passed/skipped
const isFailed = hasFailures || processExitedNonZero;
const finalResult: TestFileResult = {
file,

View File

@@ -73,6 +73,12 @@ BEGIN
WHERE ce.id = ent_id
FOR UPDATE;
-- Skip if the row doesn't exist (stale ID from a deleted cusEnt)
IF NOT FOUND THEN
skipped_ids := skipped_ids || to_jsonb(ent_id);
CONTINUE;
END IF;
-- Optimistic lock: skip if next_reset_at already equals the new value
IF db_next_reset_at IS NOT DISTINCT FROM new_next_reset_at THEN
skipped_ids := skipped_ids || to_jsonb(ent_id);

View File

@@ -2,11 +2,10 @@ import { CusProductStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { BatchResetCusEntsPayload } from "@/queue/workflows.js";
import { CusService } from "../../CusService.js";
import { resetCustomerEntitlements } from "./resetCustomerEntitlements.js";
/**
* SQS worker handler: fetches cusEnts by ID, groups by customer,
* fetches each FullCustomer, and runs the lazy reset logic.
* SQS worker handler: fetches each FullCustomer via CusService.getFull,
* which triggers the lazy reset internally.
*/
export const batchResetCustomerEntitlements = async ({
ctx,
@@ -25,18 +24,13 @@ export const batchResetCustomerEntitlements = async ({
const batch = resets.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (reset) => {
const fullCus = await CusService.getFull({
batch.map((reset) =>
CusService.getFull({
ctx,
idOrInternalId: reset.internalCustomerId,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
});
await resetCustomerEntitlements({
ctx,
fullCus,
});
}),
}),
),
);
}
};

View File

@@ -1,4 +1,5 @@
import type { FullCustomer } from "@autumn/shared";
import * as Sentry from "@sentry/bun";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import {
type ResetCusEntParam,
@@ -53,55 +54,63 @@ export const resetCustomerEntitlements = async ({
if (cusEntsNeedingReset.length === 0) return false;
logger.info(
`[resetCustomerEntitlements] customer=${customerId}, cusEnts needing reset: ${cusEntsNeedingReset.length}`,
);
try {
logger.info(
`[resetCustomerEntitlements] customer=${customerId}, cusEnts needing reset: ${cusEntsNeedingReset.length}`,
);
// 1. Compute all resets (pure computation, no DB writes)
const computed: Array<{
cusEntId: string;
result: ProcessResetResult;
}> = [];
// 1. Compute all resets (pure computation, no DB writes)
const computed: Array<{
cusEntId: string;
result: ProcessResetResult;
}> = [];
for (const cusEnt of cusEntsNeedingReset) {
const result = await processReset({ cusEnt, ctx });
if (!result) continue;
computed.push({ cusEntId: cusEnt.id, result });
}
for (const cusEnt of cusEntsNeedingReset) {
const result = await processReset({ cusEnt, ctx });
if (!result) continue;
computed.push({ cusEntId: cusEnt.id, result });
}
if (computed.length === 0) return false;
if (computed.length === 0) return false;
// 2. Execute atomic DB writes via Postgres function
const resets = computed.map(({ cusEntId, result }) =>
toResetParam({ cusEntId, result }),
);
// 2. Execute atomic DB writes via Postgres function
const resets = computed.map(({ cusEntId, result }) =>
toResetParam({ cusEntId, result }),
);
const { applied, skipped } = await resetCusEnts({ ctx, resets });
logger.info(
`[resetCustomerEntitlements] customer=${customerId}, applied: ${Object.keys(applied).length}, skipped: ${skipped.length}`,
);
// 3. Apply computed reset values to in-memory FullCustomer.
// 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 });
// 4. Update Redis cache atomically (fire-and-forget)
// Only needed when we actually wrote to DB — skipped means cache was
// already updated by the winning request.
if (Object.keys(applied).length > 0) {
await executeResetCache({
ctx,
customerId,
resets,
});
const { applied, skipped } = await resetCusEnts({ ctx, resets });
logger.info(
`[resetCustomerEntitlements] customer=${customerId}, Redis cache updated`,
`[resetCustomerEntitlements] customer=${customerId}, applied: ${Object.keys(applied).length}, skipped: ${skipped.length}`,
);
}
return true;
// 3. Apply computed reset values to in-memory FullCustomer.
// 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 });
// 4. Update Redis cache atomically (fire-and-forget)
// Only needed when we actually wrote to DB — skipped means cache was
// already updated by the winning request.
if (Object.keys(applied).length > 0) {
await executeResetCache({
ctx,
customerId,
resets,
});
logger.info(
`[resetCustomerEntitlements] customer=${customerId}, Redis cache updated`,
);
}
return true;
} catch (error) {
logger.error(
`[resetCustomerEntitlements] customer=${customerId}, failed: ${error}`,
);
Sentry.captureException(error);
return false;
}
};

View File

@@ -23,6 +23,8 @@ export const triggerBatchResetCustomerEntitlements = async ({
now,
});
if (cusEntsNeedingReset.length === 0) continue;
resets.push({
internalCustomerId: fullCus.internal_id,
customerId: fullCus.id ?? "",

View File

@@ -1,205 +1,200 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { expect, test } from "bun:test";
import {
ApiVersion,
type CheckResponseV2,
ResetInterval,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
/**
* Sleep until a specific epoch time in milliseconds
*/
function sleepUntil(epochMs: number): Promise<void> {
const delay = epochMs - Date.now();
if (delay <= 0) {
return Promise.resolve();
}
if (delay <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, delay));
}
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [messagesFeature],
});
const testCase = "check-loose-expiry";
describe(`${chalk.yellowBright(`${testCase}: expiring loose entitlement check`)}`, () => {
const customerBasic = `${testCase}-basic`;
const customerProductMix = `${testCase}-prod`;
const customerResetMix = `${testCase}-reset`;
test.concurrent(chalk.yellowBright(`${testCase}-basic: expiring loose entitlement should be allowed before expiry, then denied after`), async () => {
const customerId = `${testCase}-basic`;
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({ id: "free", items: [messagesItem] });
const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 });
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
// Setup products
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
// Setup customers only
await initCustomerV3({
ctx,
customerId: customerBasic,
withTestClock: false,
});
await initCustomerV3({
ctx,
customerId: customerProductMix,
withTestClock: false,
});
await initCustomerV3({
ctx,
customerId: customerResetMix,
withTestClock: false,
});
const { ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [],
});
test("basic: expiring loose entitlement should be allowed before expiry, then denied after", async () => {
const expiresAt = Date.now() + 3000;
const autumnV1 = new AutumnInt({
version: ApiVersion.V1_2,
secretKey: ctx.orgSecretKey,
});
const autumnV2 = new AutumnInt({
version: ApiVersion.V2_0,
secretKey: ctx.orgSecretKey,
});
// Create expiring loose entitlement
await autumnV1.balances.create({
customer_id: customerBasic,
const expiresAt = Date.now() + 3000;
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 500,
expires_at: expiresAt,
});
const resBefore = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resBefore).toMatchObject({
allowed: true,
customer_id: customerId,
balance: {
plan_id: null,
feature_id: TestFeature.Messages,
granted_balance: 500,
expires_at: expiresAt,
});
// Check before expiry
const resBefore = (await autumnV2.check({
customer_id: customerBasic,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resBefore.allowed).toBe(true);
expect(resBefore.customer_id).toBe(customerBasic);
expect(resBefore.balance).toBeDefined();
expect(resBefore.balance?.plan_id).toBeNull();
expect(resBefore.balance?.feature_id).toBe(TestFeature.Messages);
expect(resBefore.balance?.granted_balance).toBe(500);
expect(resBefore.balance?.current_balance).toBe(500);
expect(resBefore.balance?.usage).toBe(0);
expect(resBefore.balance?.unlimited).toBe(false);
// Wait until expiry
await sleepUntil(expiresAt + 1000);
// Check after expiry
const resAfter = (await autumnV2.check({
customer_id: customerBasic,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resAfter.allowed).toBe(false);
expect(resAfter.balance).toBeNull();
current_balance: 500,
usage: 0,
unlimited: false,
},
});
test("product-mix: should combine product and expiring loose ent, then only product after expiry", async () => {
const expiresAt = Date.now() + 3000;
await sleepUntil(expiresAt + 1000);
// Attach product with 100 messages
await autumnV1.attach({
customer_id: customerProductMix,
product_id: freeProd.id,
});
const resAfter = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
// Create expiring loose entitlement with 200 messages
await autumnV1.balances.create({
customer_id: customerProductMix,
feature_id: TestFeature.Messages,
granted_balance: 200,
expires_at: expiresAt,
});
expect(resAfter).toMatchObject({
allowed: false,
balance: null,
});
});
// Check before expiry
const resBefore = (await autumnV2.check({
customer_id: customerProductMix,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
test.concurrent(chalk.yellowBright(`${testCase}-product-mix: should combine product and expiring loose ent, then only product after expiry`), async () => {
const customerId = `${testCase}-product-mix`;
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({ id: "free", items: [messagesItem] });
expect(resBefore.allowed).toBe(true);
expect(resBefore.balance?.granted_balance).toBe(300); // 100 from product + 200 from loose
expect(resBefore.balance?.current_balance).toBe(300);
// Wait until expiry
await sleepUntil(expiresAt + 1000);
// Check after expiry
const resAfter = (await autumnV2.check({
customer_id: customerProductMix,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resAfter.allowed).toBe(true);
expect(resAfter.balance?.granted_balance).toBe(100); // Only product balance remains
expect(resAfter.balance?.current_balance).toBe(100);
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
test("reset-mix: should combine expiring and resetting loose ents, then only resetting after expiry", async () => {
const expiresAt = Date.now() + 3000;
const autumnV2 = new AutumnInt({
version: ApiVersion.V2_0,
secretKey: ctx.orgSecretKey,
});
// Create expiring loose entitlement
await autumnV1.balances.create({
customer_id: customerResetMix,
feature_id: TestFeature.Messages,
granted_balance: 200,
expires_at: expiresAt,
});
const expiresAt = Date.now() + 3000;
// Create resetting loose entitlement (no expiry)
await autumnV1.balances.create({
customer_id: customerResetMix,
feature_id: TestFeature.Messages,
granted_balance: 100,
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 200,
expires_at: expiresAt,
});
const resBefore = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resBefore).toMatchObject({
allowed: true,
balance: {
granted_balance: 300, // 100 from product + 200 from loose
current_balance: 300,
},
});
await sleepUntil(expiresAt + 1000);
const resAfter = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resAfter).toMatchObject({
allowed: true,
balance: {
granted_balance: 100, // Only product balance remains
current_balance: 100,
},
});
});
test.concurrent(chalk.yellowBright(`${testCase}-reset-mix: should combine expiring and resetting loose ents, then only resetting after expiry`), async () => {
const customerId = `${testCase}-reset-mix`;
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeProd = products.base({ id: "free", items: [messagesItem] });
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [],
});
const autumnV2 = new AutumnInt({
version: ApiVersion.V2_0,
secretKey: ctx.orgSecretKey,
});
const expiresAt = Date.now() + 3000;
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 200,
expires_at: expiresAt,
});
await autumnV1.balances.create({
customer_id: customerId,
feature_id: TestFeature.Messages,
granted_balance: 100,
reset: {
interval: ResetInterval.Month,
},
});
const resBefore = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resBefore).toMatchObject({
allowed: true,
balance: {
granted_balance: 300, // 200 expiring + 100 resetting
current_balance: 300,
},
});
await sleepUntil(expiresAt + 1000);
const resAfter = (await autumnV2.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resAfter).toMatchObject({
allowed: true,
balance: {
granted_balance: 100, // Only resetting balance remains
current_balance: 100,
reset: {
interval: ResetInterval.Month,
},
});
// Check before expiry
const resBefore = (await autumnV2.check({
customer_id: customerResetMix,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resBefore.allowed).toBe(true);
expect(resBefore.balance?.granted_balance).toBe(300); // 200 expiring + 100 resetting
expect(resBefore.balance?.current_balance).toBe(300);
// Wait until expiry
await sleepUntil(expiresAt + 1000);
// Check after expiry
const resAfter = (await autumnV2.check({
customer_id: customerResetMix,
feature_id: TestFeature.Messages,
})) as unknown as CheckResponseV2;
expect(resAfter.allowed).toBe(true);
expect(resAfter.balance?.granted_balance).toBe(100); // Only resetting balance remains
expect(resAfter.balance?.current_balance).toBe(100);
expect(resAfter.balance?.reset).toBeDefined();
expect(resAfter.balance?.reset?.interval).toBe(ResetInterval.Month);
},
});
});