Skip to content

Commit d22b656

Browse files
authored
fix: metering issues with addons (#3588)
1 parent 19cd5f4 commit d22b656

4 files changed

Lines changed: 280 additions & 45 deletions

File tree

extensions/metering.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ const scaleUsageByType = (
7070
): Record<string, unknown> => {
7171
const out: Record<string, unknown> = {};
7272
for (const [key, value] of Object.entries(usage)) {
73-
if (key === 'total') {
74-
out.total = toCredits(Number(value) || 0, multiplier);
73+
// `allowanceUsed` is the month's allowance-charged spend — monetary,
74+
// scalar, scaled like the total.
75+
if (key === 'total' || key === 'allowanceUsed') {
76+
out[key] = toCredits(Number(value) || 0, multiplier);
7577
} else if (
7678
value &&
7779
typeof value === 'object' &&

src/backend/services/metering/MeteringService.test.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,30 @@ describe('MeteringService', () => {
453453
expect(addons.consumedPurchaseCredits).toBe(1_000_000);
454454
});
455455
});
456+
457+
it('charges the allowance first and records the split on the month record', async () => {
458+
const overActor: Actor = { user: makeUser() };
459+
const sub = await target.getActorSubscription(overActor);
460+
await target.updateAddonCredit(overActor.user.uuid!, 5_000_000);
461+
462+
// One increment that straddles the boundary: the allowance part
463+
// lands in `allowanceUsed`, only the rest draws down credit.
464+
await target.incrementUsage(
465+
overActor,
466+
'kv:read',
467+
1,
468+
sub.monthUsageAllowance + 1_000_000,
469+
);
470+
471+
await waitFor(async () => {
472+
const { usage } =
473+
await target.getActorCurrentMonthUsageDetails(overActor);
474+
expect(usage.allowanceUsed).toBe(sub.monthUsageAllowance);
475+
expect(usage.total).toBe(sub.monthUsageAllowance + 1_000_000);
476+
const addons = await target.getActorAddons(overActor);
477+
expect(addons.consumedPurchaseCredits).toBe(1_000_000);
478+
});
479+
});
456480
});
457481

458482
// ── overuse alarm ────────────────────────────────────────────────
@@ -806,7 +830,9 @@ describe('MeteringService', () => {
806830
}
807831
const incrSpy = vi.spyOn(server.stores.meteringBuffer, 'incr');
808832
await target.flushBufferedUsages();
809-
expect(incrSpy).toHaveBeenCalledOnce();
833+
// One usage write for all ten buffered events, plus the settle
834+
// write that records the allowance/credit split.
835+
expect(incrSpy).toHaveBeenCalledTimes(2);
810836
incrSpy.mockRestore();
811837

812838
const { usage } =
@@ -894,7 +920,8 @@ describe('MeteringService', () => {
894920
]);
895921
}
896922
await target.flushBufferedUsages();
897-
expect(spy).toHaveBeenCalledTimes(concurrency * 3);
923+
// Per bucket: the usage write plus the allowance settle.
924+
expect(spy).toHaveBeenCalledTimes(concurrency * 3 * 2);
898925
expect(peak).toBeLessThanOrEqual(concurrency);
899926
} finally {
900927
spy.mockRestore();
@@ -924,7 +951,8 @@ describe('MeteringService', () => {
924951
target.flushBufferedUsages(),
925952
target.flushBufferedUsages(),
926953
]);
927-
expect(started).toBe(1);
954+
// One cycle ran (usage write + allowance settle), not three.
955+
expect(started).toBe(2);
928956
} finally {
929957
spy.mockRestore();
930958
}
@@ -1191,6 +1219,32 @@ describe('MeteringService', () => {
11911219
expect(result.total).toBe(100);
11921220
});
11931221

1222+
it('re-anchors allowanceUsed so the adjusted total is what the allowance is billed', async () => {
1223+
const sub = await target.getActorSubscription(actor);
1224+
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
1225+
1226+
// Overspend so the month holds allowance + credit-charged spend.
1227+
await target.incrementUsage(
1228+
actor,
1229+
'kv:read',
1230+
1,
1231+
sub.monthUsageAllowance + 5_000_000,
1232+
);
1233+
expect(await target.getRemainingUsage(actor)).toBe(0);
1234+
1235+
// Support sets the month back down: the new total is billed to
1236+
// the allowance in full and the rest of it reopens.
1237+
const result = await target.setActorCurrentMonthUsageTotal(
1238+
actor,
1239+
1_000,
1240+
);
1241+
expect(result.total).toBe(1_000);
1242+
expect(result.allowanceUsed).toBe(1_000);
1243+
expect(await target.getRemainingUsage(actor)).toBe(
1244+
sub.monthUsageAllowance - 1_000,
1245+
);
1246+
});
1247+
11941248
it('rejects a negative total', async () => {
11951249
await expect(
11961250
target.setActorCurrentMonthUsageTotal(actor, -1),
@@ -1308,6 +1362,76 @@ describe('MeteringService', () => {
13081362
expect(allowed.remaining).toBe(4_000_000);
13091363
});
13101364

1365+
it('keeps the allowance and credit pools separate across a mid-month upgrade', async () => {
1366+
const freeSub = await target.getActorSubscription(actor);
1367+
const freeAllowance = freeSub.monthUsageAllowance;
1368+
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
1369+
1370+
// Exhaust the free allowance, then draw 2_000_000 from credit.
1371+
await target.incrementUsage(actor, 'kv:read', 1, freeAllowance);
1372+
await target.incrementUsage(actor, 'kv:read', 1, 2_000_000);
1373+
await waitFor(async () => {
1374+
const addons = await target.getActorAddons(actor);
1375+
expect(addons.consumedPurchaseCredits).toBe(2_000_000);
1376+
});
1377+
1378+
// Upgrade mid-month to ten times the allowance. The allowance
1379+
// pool reopens (freeAllowance of 10x used); the credit pool is
1380+
// exactly where it was (2 of 5 consumed).
1381+
const paid = {
1382+
id: 'upgrade-paid',
1383+
monthUsageAllowance: freeAllowance * 10,
1384+
monthlyStorageAllowance: 1024 * 1024 * 1024,
1385+
};
1386+
target.registerPolicy(paid);
1387+
target.registerSubscriptionResolver(async () => 'upgrade-paid');
1388+
target.invalidateActorSubscription(actor.user.uuid!);
1389+
1390+
const allowed = await target.getAllowedUsage(actor);
1391+
expect(allowed.remaining).toBe(freeAllowance * 9 + 3_000_000);
1392+
expect(allowed.addons.consumedPurchaseCredits).toBe(2_000_000);
1393+
1394+
// Further spend consumes the reopened allowance, not credit.
1395+
await target.incrementUsage(actor, 'kv:read', 1, freeAllowance * 9);
1396+
const addons = await target.getActorAddons(actor);
1397+
expect(addons.consumedPurchaseCredits).toBe(2_000_000);
1398+
expect((await target.getAllowedUsage(actor)).remaining).toBe(
1399+
3_000_000,
1400+
);
1401+
1402+
// Only once the new allowance is full does credit drain again.
1403+
await target.incrementUsage(actor, 'kv:read', 1, 1_000_000);
1404+
await waitFor(async () => {
1405+
const after = await target.getActorAddons(actor);
1406+
expect(after.consumedPurchaseCredits).toBe(3_000_000);
1407+
});
1408+
});
1409+
1410+
it('falls back to the pre-split reading for month records without allowanceUsed', async () => {
1411+
const sub = await target.getActorSubscription(actor);
1412+
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
1413+
await server.stores.kv.incr({
1414+
key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`,
1415+
pathAndAmountMap: { consumedPurchaseCredits: 5_000_000 },
1416+
});
1417+
1418+
// A legacy month record: total spans allowance + credit overage,
1419+
// no allowanceUsed split recorded.
1420+
const month = `${new Date().getUTCFullYear()}-${String(new Date().getUTCMonth() + 1).padStart(2, '0')}`;
1421+
await server.stores.meteringBuffer.incr({
1422+
key: `${METRICS_PREFIX}:actor:${actor.user.uuid}:${month}`,
1423+
pathAndAmountMap: {
1424+
total: sub.monthUsageAllowance + 5_000_000,
1425+
},
1426+
});
1427+
1428+
// Pre-split behavior: the whole total counts against the
1429+
// allowance (capped at it), so nothing changes at the deploy
1430+
// that introduced the field.
1431+
const allowed = await target.getAllowedUsage(actor);
1432+
expect(allowed.remaining).toBe(0);
1433+
});
1434+
13111435
it('counts consumed credits from prior months against the credit pool only', async () => {
13121436
// Simulate a prior-month overage: consumed credits exist but the
13131437
// current month has no usage (monthly usage keys roll over).
@@ -1894,8 +2018,9 @@ describe('MeteringService', () => {
18942018
const incr = vi.spyOn(server.stores.meteringBuffer, 'incr');
18952019
const usage = await target.getActorCurrentMonthUsageDetails(actor);
18962020

1897-
// Four charges across two listeners, settling as a single write.
1898-
expect(incr).toHaveBeenCalledTimes(1);
2021+
// Four charges across two listeners fold into a single usage
2022+
// write; the second call is the allowance settle.
2023+
expect(incr).toHaveBeenCalledTimes(2);
18992024
expect(incr.mock.calls[0]![0].pathAndAmountMap).toEqual({
19002025
total: 600,
19012026
'workers:monthly.units': 5,

0 commit comments

Comments
 (0)