Skip to content

Commit 6310517

Browse files
committed
Merge branch 'fix/billing.update-write-on-cache-miss'
2 parents 300c96c + 815c056 commit 6310517

File tree

2 files changed

+222
-1
lines changed

2 files changed

+222
-1
lines changed

server/src/internal/customers/cusProducts/actions/updateDbAndCache.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import type { InsertCustomerProduct } from "@autumn/shared";
22
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
3+
import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js";
34
import { CusProductService } from "../CusProductService.js";
45
import { updateCachedCustomerProduct } from "./cache/updateCachedCustomerProduct.js";
56

67
/**
78
* Updates a customer product in both Postgres and the Redis FullCustomer cache.
9+
*
10+
* If the Lua atomic patch finds a cache_miss (e.g. because a concurrent Stripe
11+
* webhook deleted the key mid-flight), we fall back to a full DB fetch and
12+
* re-populate the cache before returning. This ensures the cache is warm and
13+
* correct before the 200 response goes out, preventing stale reads immediately
14+
* after the update.
815
*/
916
export const updateCustomerProductDbAndCache = async ({
1017
ctx,
@@ -23,10 +30,21 @@ export const updateCustomerProductDbAndCache = async ({
2330
updates,
2431
});
2532

26-
await updateCachedCustomerProduct({
33+
const result = await updateCachedCustomerProduct({
2734
ctx,
2835
customerId,
2936
cusProductId,
3037
updates,
3138
});
39+
40+
if (result?.error === "cache_miss") {
41+
ctx.logger.info(
42+
`[updateCustomerProductDbAndCache] cache_miss for cusProduct ${cusProductId}, rebuilding cache from DB`,
43+
);
44+
await getOrSetCachedFullCustomer({
45+
ctx,
46+
customerId,
47+
source: "updateDbAndCache:cache_miss_fallback",
48+
});
49+
}
3250
};
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { expect, test } from "bun:test";
2+
import type {
3+
ApiCustomerV3,
4+
UpdateSubscriptionV1ParamsInput,
5+
} from "@autumn/shared";
6+
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb";
7+
import { TestFeature } from "@tests/setup/v2Features.js";
8+
import { items } from "@tests/utils/fixtures/items.js";
9+
import { products } from "@tests/utils/fixtures/products.js";
10+
import { timeout } from "@tests/utils/genUtils.js";
11+
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
12+
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
13+
import chalk from "chalk";
14+
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
15+
16+
/**
17+
* Regression test for: billing.update + recalculate_balances returns 200
18+
* but Autumn dashboard still shows old balance.
19+
*
20+
* Root cause (from Axiom, 2026-03-30 22:08–22:09 UTC):
21+
*
22+
* billing.update took ~20s. During that window, Stripe webhooks
23+
* (customer.subscription.updated, invoice.created, invoice.paid) fired and
24+
* deleted the Redis FullCustomer cache key. Concurrently, balances.check and
25+
* entities.get calls re-populated the cache with the stale pre-update value.
26+
* When billing.update finally called updateCachedCustomerProduct, the Lua
27+
* script found the key present but holding stale data, patched only the
28+
* cusProduct fields (not the entitlement balances), and returned ok:true.
29+
* refreshCacheMiddleware then deleted that key. The net result: 200 returned,
30+
* cache empty, next dashboard poll re-populated from the DB correctly — but
31+
* any read in the gap between the stale re-population and the middleware
32+
* delete served the old 250/250 balance.
33+
*
34+
* This test replicates the exact concurrent structure 1:1:
35+
* - billing.update runs for ~20s
36+
* - Concurrently: cache is deleted (webhook), then immediately re-populated
37+
* with stale data by a balances.check-equivalent GET
38+
* - After 200: assert both cached and DB reads show the new balance
39+
*/
40+
41+
test.concurrent(`${chalk.yellowBright("cache-race: concurrent webhook delete + stale re-population during billing.update leaves correct balance after 200")}`, async () => {
42+
const customerId = "billing-update-cache-race";
43+
const initialQuantity = 250;
44+
const updatedQuantity = 1500;
45+
46+
const product = products.base({
47+
id: "ai-credits-plan",
48+
items: [
49+
items.prepaid({
50+
featureId: TestFeature.Messages,
51+
billingUnits: 1,
52+
price: 1,
53+
includedUsage: 0,
54+
}),
55+
],
56+
});
57+
58+
const { autumnV1, autumnV2_1 } = await initScenario({
59+
customerId,
60+
setup: [
61+
s.customer({ paymentMethod: "success" }),
62+
s.products({ list: [product] }),
63+
],
64+
actions: [
65+
s.billing.attach({
66+
productId: product.id,
67+
options: [
68+
{ feature_id: TestFeature.Messages, quantity: initialQuantity },
69+
],
70+
}),
71+
],
72+
});
73+
74+
// Confirm correct initial state in both cache and DB before the race.
75+
await expectFeatureCachedAndDb({
76+
autumn: autumnV1,
77+
customerId,
78+
featureId: TestFeature.Messages,
79+
balance: initialQuantity,
80+
usage: 0,
81+
});
82+
83+
await Promise.all([
84+
// Leg 1: the long-running billing.update (~20s in prod).
85+
autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
86+
customer_id: customerId,
87+
plan_id: product.id,
88+
feature_quantities: [
89+
{ feature_id: TestFeature.Messages, quantity: updatedQuantity },
90+
],
91+
recalculate_balances: { enabled: true },
92+
}),
93+
94+
// Leg 2: concurrent webhook + stale reader, mirroring the Axiom timeline.
95+
(async () => {
96+
// Wait for billing.update to be in-flight (Stripe processes subscription
97+
// update and fires webhooks roughly 2–3s after the call starts).
98+
await timeout(3000);
99+
100+
// Simulate customer.subscription.updated webhook deleting the cache.
101+
await deleteCachedFullCustomer({
102+
customerId,
103+
ctx,
104+
source: "simulated-stripe-webhook",
105+
skipGuard: true,
106+
});
107+
108+
// Simulate concurrent balances.check / entities.get reads that
109+
// re-populate the cache with the pre-update stale value while
110+
// billing.update is still running (this is what Kyle's dashboard
111+
// was doing — polling GET /customers mid-flight).
112+
await autumnV1.customers.get<ApiCustomerV3>(customerId);
113+
await autumnV1.customers.get<ApiCustomerV3>(customerId);
114+
})(),
115+
]);
116+
117+
// THE ASSERTION THAT CATCHES THE BUG:
118+
// Both reads must reflect the new balance immediately after billing.update
119+
// returns 200 — not the stale pre-update value re-populated by the concurrent
120+
// reads during the race window.
121+
const customerCached =
122+
await autumnV1.customers.get<ApiCustomerV3>(customerId);
123+
const customerDb = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
124+
skip_cache: "true",
125+
});
126+
127+
console.log(
128+
`[cache-race] cached balance: ${customerCached.features[TestFeature.Messages]?.balance} | db balance: ${customerDb.features[TestFeature.Messages]?.balance} | expected: ${updatedQuantity}`,
129+
);
130+
131+
await expectFeatureCachedAndDb({
132+
autumn: autumnV1,
133+
customerId,
134+
featureId: TestFeature.Messages,
135+
balance: updatedQuantity,
136+
usage: 0,
137+
});
138+
});
139+
140+
/**
141+
* Control: same product/quantity update without the concurrent race.
142+
* Ensures the fix doesn't break the happy path.
143+
*/
144+
test.concurrent(`${chalk.yellowBright("cache-race: billing.update with recalculate_balances reflects new balance in cache (no race)")}`, async () => {
145+
const customerId = "billing-update-no-cache-race";
146+
const initialQuantity = 250;
147+
const updatedQuantity = 1500;
148+
149+
const product = products.base({
150+
id: "ai-credits-plan",
151+
items: [
152+
items.prepaid({
153+
featureId: TestFeature.Messages,
154+
billingUnits: 1,
155+
price: 1,
156+
includedUsage: 0,
157+
}),
158+
],
159+
});
160+
161+
const { autumnV1, autumnV2_1 } = await initScenario({
162+
customerId,
163+
setup: [
164+
s.customer({ paymentMethod: "success" }),
165+
s.products({ list: [product] }),
166+
],
167+
actions: [
168+
s.billing.attach({
169+
productId: product.id,
170+
options: [
171+
{ feature_id: TestFeature.Messages, quantity: initialQuantity },
172+
],
173+
}),
174+
],
175+
});
176+
177+
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1ParamsInput>({
178+
customer_id: customerId,
179+
plan_id: product.id,
180+
feature_quantities: [
181+
{ feature_id: TestFeature.Messages, quantity: updatedQuantity },
182+
],
183+
recalculate_balances: { enabled: true },
184+
});
185+
186+
const customerCached =
187+
await autumnV1.customers.get<ApiCustomerV3>(customerId);
188+
const customerDb = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
189+
skip_cache: "true",
190+
});
191+
192+
console.log(
193+
`[no-race] cached balance: ${customerCached.features[TestFeature.Messages]?.balance} | db balance: ${customerDb.features[TestFeature.Messages]?.balance} | expected: ${updatedQuantity}`,
194+
);
195+
196+
await expectFeatureCachedAndDb({
197+
autumn: autumnV1,
198+
customerId,
199+
featureId: TestFeature.Messages,
200+
balance: updatedQuantity,
201+
usage: 0,
202+
});
203+
});

0 commit comments

Comments
 (0)