From 87180b10815f09ffc171d3820b0fbdae49c8506f Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 16 Aug 2026 14:19:05 -0700 Subject: [PATCH] fix: metering (#3591) --- .../services/metering/MeteringService.test.ts | 27 +++++ .../services/metering/MeteringService.ts | 20 +++- src/backend/services/metering/types.ts | 7 ++ src/gui/src/UI/Dashboard/TabHome.js | 5 +- src/gui/src/UI/Dashboard/TabUsage.js | 2 +- src/gui/src/UI/Dashboard/credits.js | 2 + src/gui/src/UI/Dashboard/usageBudget.js | 61 ++++++----- src/gui/src/UI/Dashboard/usageBudget.test.js | 100 +++++++++++++----- 8 files changed, 166 insertions(+), 58 deletions(-) diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index b3d410757..78fa36fe8 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -1432,6 +1432,33 @@ describe('MeteringService', () => { expect(allowed.remaining).toBe(0); }); + it('folds the legacy baseline in exactly once under concurrent increments', async () => { + const sub = await target.getActorSubscription(actor); + const month = `${new Date().getUTCFullYear()}-${String(new Date().getUTCMonth() + 1).padStart(2, '0')}`; + + // Legacy spend with no split recorded — one page load then fires + // many metered requests at once, all seeing the field absent. + await server.stores.meteringBuffer.incr({ + key: `${METRICS_PREFIX}:actor:${actor.user.uuid}:${month}`, + pathAndAmountMap: { total: 10_000 }, + }); + + await Promise.all( + Array.from({ length: 8 }, () => + target.incrementUsage(actor, 'kv:read', 1, 1_000), + ), + ); + + const { usage } = + await target.getActorCurrentMonthUsageDetails(actor); + expect(usage.total).toBe(18_000); + // Without the claim, every concurrent settle re-adds the ~10_000 + // baseline and the month reads as nearly exhausted. + expect(usage.allowanceUsed).toBe(18_000); + const allowed = await target.getAllowedUsage(actor); + expect(allowed.remaining).toBe(sub.monthUsageAllowance - 18_000); + }); + it('counts consumed credits from prior months against the credit pool only', async () => { // Simulate a prior-month overage: consumed credits exist but the // current month has no usage (monthly usage keys roll over). diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index 2fe763e8b..1948cdf7d 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -1746,7 +1746,10 @@ export class MeteringService extends PuterService { * A month record without `allowanceUsed` predates the split; its first * settled increment folds the fallback baseline into the write, so the * record answers directly from then on and no balance moves on the deploy - * that introduced the field. + * that introduced the field. Writing the baseline is guarded by a claim + * counter (the `monthlyChargesApplied` pattern): concurrent increments — + * one page load meters many responses at once — all see the field absent, + * and without the claim each would add the baseline again. * * Returns the month's allowance-charged spend as of after this settle — * `usageRecord` itself predates the write, so callers deciding on the @@ -1776,8 +1779,19 @@ export class MeteringService extends PuterService { const usedBefore = usageRecord.allowanceUsed ?? Math.min(totalBefore, Math.max(0, monthUsageAllowance || 0)); - const baseline = - usageRecord.allowanceUsed === undefined ? usedBefore : 0; + + let baseline = 0; + if (usageRecord.allowanceUsed === undefined && usedBefore > 0) { + const { res } = await this.stores.meteringBuffer.incr({ + key: actorUsageKey, + pathAndAmountMap: { allowanceUsedBaselined: 1 }, + }); + const claim = (res as unknown as UsageByType) + .allowanceUsedBaselined; + if (claim === 1) { + baseline = usedBefore; + } + } const headroom = Math.max(0, monthUsageAllowance - usedBefore); const allowanceCharge = Math.min(incrementCost, headroom); diff --git a/src/backend/services/metering/types.ts b/src/backend/services/metering/types.ts index 6dc136e48..34d8c712e 100644 --- a/src/backend/services/metering/types.ts +++ b/src/backend/services/metering/types.ts @@ -51,6 +51,13 @@ export type UsageByType = { * against the allowance, capped at the allowance. */ allowanceUsed?: number; + /** + * Claim counter for folding the pre-split baseline into `allowanceUsed` on + * a record that predates it. 1 for the settle that claimed the fold; higher + * for concurrent settles that raced and lost and must not add the baseline + * again. + */ + allowanceUsedBaselined?: number; /** * Claim counter for the month's recurring charges — see * `MONTHLY_CHARGE_CLAIM`. Absent until the first read or write of the diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 4d8f21caa..1f20c83e1 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -544,10 +544,7 @@ const TabHome = { // Load monthly usage data try { const res = await puter.auth.getMonthlyUsage(); - const budget = usageBudget( - res.usage?.total ?? 0, - res.allowanceInfo?.remaining ?? 0, - ); + const budget = usageBudget(res.usage, res.allowanceInfo); // The server reports credits (already scaled) or raw amounts // (no multiplier configured), and says which via the unit flag. const inCredits = usageIsCredits(res.allowanceInfo); diff --git a/src/gui/src/UI/Dashboard/TabUsage.js b/src/gui/src/UI/Dashboard/TabUsage.js index f280460ba..f107635be 100644 --- a/src/gui/src/UI/Dashboard/TabUsage.js +++ b/src/gui/src/UI/Dashboard/TabUsage.js @@ -279,7 +279,7 @@ function renderUsageTable () { async function update_usage_details ($el_window) { const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(res => { - const budget = usageBudget(res.usage?.total ?? 0, res.allowanceInfo?.remaining ?? 0); + const budget = usageBudget(res.usage, res.allowanceInfo); // The server reports credits (already scaled) or raw amounts (no // multiplier configured), and says which via the unit flag. const inCredits = usageIsCredits(res.allowanceInfo); diff --git a/src/gui/src/UI/Dashboard/credits.js b/src/gui/src/UI/Dashboard/credits.js index 0c1ce92ff..431ac4a33 100644 --- a/src/gui/src/UI/Dashboard/credits.js +++ b/src/gui/src/UI/Dashboard/credits.js @@ -33,12 +33,14 @@ export const usageIsCredits = (allowanceInfo) => * Credits as the raw number users see: whole credits once the amount has any * size, decimals only while fractions are all there is to show. Never renders * a nonzero amount as "0" — a cost that exists shows as at least "<0.01". + * Negative amounts (net usage with unspent top-up) keep their sign. * * @param {number} credits * @returns {string} */ export const formatCredits = (credits) => { const value = Number.isFinite(credits) ? credits : 0; + if (value < 0) return `-${formatCredits(-value)}`; if (value <= 0) return '0'; if (value >= 100) { return Math.round(value).toLocaleString('en-US'); diff --git a/src/gui/src/UI/Dashboard/usageBudget.js b/src/gui/src/UI/Dashboard/usageBudget.js index 5d2d80e53..d9cb26eea 100644 --- a/src/gui/src/UI/Dashboard/usageBudget.js +++ b/src/gui/src/UI/Dashboard/usageBudget.js @@ -19,40 +19,53 @@ /** * @typedef {Object} UsageBudget - * @property {number} used - Month-to-date spend, in the server's units. - * @property {number} capacity - The whole budget: spend plus what is left. - * @property {number} percent - `used` as a whole-number share of `capacity`, 0-100. - * @property {number} barPercent - The same share unrounded, for a bar width. + * @property {number} used - Allowance-charged spend net of unspent top-up, in + * the server's units. Negative when top-up credit exceeds the spend. + * @property {number} capacity - The monthly plan allowance. + * @property {number} percent - `used` as a whole-number share of `capacity`. + * Negative when `used` is. + * @property {number} barPercent - The share clamped to 0-100, for a bar width. */ /** - * The three numbers the usage cards show, from the two the server reports. + * The numbers the usage cards show, anchored to the monthly plan. * - * `remaining` is already netted: what is left of the monthly allowance plus - * what is left of any purchased credit, with overage past the allowance - * charged to the credit. So spend plus remaining IS the budget, and deriving - * capacity that way can't contradict either input — "$3.00 of $12.00, 25%" - * always adds up, whatever the mix. + * Capacity is the plan's monthly allowance, always — the bar answers "how much + * of my plan have I used", so its denominator must not move with purchases. + * `used` is the month's allowance-charged spend (`usage.allowanceUsed`; records + * from before the split was tracked fall back to the month total, capped at the + * allowance), minus whatever top-up credit is still unspent. Unspent credit + * therefore reads as headroom — spend a little with a large credit balance and + * the share is negative — rather than inflating the plan's capacity. * - * The share is taken against that whole budget rather than the monthly - * allowance alone. Measured against the allowance, held credit subtracted from - * spend, so an account that had bought credit and barely spent it read as a - * negative percentage of its own plan. - * - * @param {number} totalUsage - Month-to-date spend. - * @param {number} remaining - Server-netted budget left. + * @param {Object | null | undefined} usage - `usage` from `getMonthlyUsage()`. + * @param {Object | null | undefined} allowanceInfo - Its `allowanceInfo` + * sibling. * @returns {UsageBudget} */ -export const usageBudget = (totalUsage, remaining) => { - const used = Number.isFinite(totalUsage) ? Math.max(0, totalUsage) : 0; - const left = Number.isFinite(remaining) ? Math.max(0, remaining) : 0; - const capacity = used + left; +export const usageBudget = (usage, allowanceInfo) => { + const capacity = Number.isFinite(allowanceInfo?.monthUsageAllowance) + ? Math.max(0, allowanceInfo.monthUsageAllowance) + : 0; + const total = Number.isFinite(usage?.total) ? Math.max(0, usage.total) : 0; + const allowanceUsed = Number.isFinite(usage?.allowanceUsed) + ? Math.max(0, usage.allowanceUsed) + : Math.min(total, capacity); + const addons = allowanceInfo?.addons ?? {}; + const purchased = Number.isFinite(addons.purchasedCredits) + ? addons.purchasedCredits + : 0; + const consumed = Number.isFinite(addons.consumedPurchaseCredits) + ? addons.consumedPurchaseCredits + : 0; + const creditRemaining = Math.max(0, purchased - consumed); + + const used = allowanceUsed - creditRemaining; const share = capacity ? (used / capacity) * 100 : 0; - const barPercent = Math.max(0, Math.min(100, share)); return { used, capacity, - percent: Math.round(barPercent), - barPercent, + percent: Math.round(share), + barPercent: Math.max(0, Math.min(100, share)), }; }; diff --git a/src/gui/src/UI/Dashboard/usageBudget.test.js b/src/gui/src/UI/Dashboard/usageBudget.test.js index 9060078cc..ec77c3bee 100644 --- a/src/gui/src/UI/Dashboard/usageBudget.test.js +++ b/src/gui/src/UI/Dashboard/usageBudget.test.js @@ -1,48 +1,96 @@ import { describe, expect, it } from 'vitest'; import { usageBudget } from './usageBudget.js'; +const info = (allowance, addons = {}) => ({ + monthUsageAllowance: allowance, + addons, +}); + describe('usageBudget', () => { - it('reports the plan alone when no credit has been bought', () => { - // $0.50 spent of a $9.00 allowance. - const budget = usageBudget(50_000_000, 850_000_000); + it('anchors the bar to the monthly allowance', () => { + // $0.50 of a $9.00 allowance spent, no top-up. + const budget = usageBudget( + { total: 50_000_000, allowanceUsed: 50_000_000 }, + info(900_000_000), + ); expect(budget.capacity).toBe(900_000_000); + expect(budget.used).toBe(50_000_000); expect(budget.percent).toBe(6); }); - it('never reports a negative share for an account holding credit', () => { - // $0.50 spent, $9.00 allowance, $10.00 of purchased credit untouched. - // The cards used to subtract held credit from spend, which rendered - // this account at roughly -105% of its own plan. - const budget = usageBudget(50_000_000, 1_850_000_000); - expect(budget.percent).toBeGreaterThanOrEqual(0); - expect(budget.percent).toBe(3); - expect(budget.capacity).toBe(1_900_000_000); + it('keeps the capacity at the plan when top-up credit exists', () => { + // $12.00 of a $95.00 allowance spent, $5.00 top-up untouched: the + // credit reads as headroom against the plan, never as more plan. + const budget = usageBudget( + { total: 1_200_000_000, allowanceUsed: 1_200_000_000 }, + info(9_500_000_000, { + purchasedCredits: 500_000_000, + consumedPurchaseCredits: 0, + }), + ); + expect(budget.capacity).toBe(9_500_000_000); + expect(budget.used).toBe(700_000_000); + expect(budget.percent).toBe(7); }); - it('keeps used, capacity and share consistent once credit is being spent', () => { - // $12.00 spent: the $9.00 allowance plus $3.00 of a $10.00 top-up, - // leaving $7.00. Capacity has to stay the full $19.00 — reading it as - // allowance-plus-what's-left implied $4.00 remaining while the server - // said $7.00. - const budget = usageBudget(1_200_000_000, 700_000_000); - expect(budget.capacity).toBe(1_900_000_000); - expect(budget.used + 700_000_000).toBe(budget.capacity); - expect(budget.percent).toBe(63); + it('goes negative when unspent top-up exceeds the spend', () => { + // $0.50 spent, $10.00 credit untouched, $9.00 allowance. + const budget = usageBudget( + { total: 50_000_000, allowanceUsed: 50_000_000 }, + info(900_000_000, { + purchasedCredits: 1_000_000_000, + consumedPurchaseCredits: 0, + }), + ); + expect(budget.used).toBe(-950_000_000); + expect(budget.percent).toBe(-106); + expect(budget.barPercent).toBe(0); }); - it('reads a spent budget as full rather than overflowing the bar', () => { - const budget = usageBudget(900_000_000, 0); + it('ignores credit already consumed — only what is left offsets usage', () => { + // Allowance exhausted, $200 of top-up bought and fully spent: the + // month reads as full, not as 200 dollars into the negatives. + const budget = usageBudget( + { total: 29_500_000_000, allowanceUsed: 9_500_000_000 }, + info(9_500_000_000, { + purchasedCredits: 20_000_000_000, + consumedPurchaseCredits: 20_000_000_000, + }), + ); + expect(budget.used).toBe(9_500_000_000); + expect(budget.percent).toBe(100); + expect(budget.barPercent).toBe(100); + }); + + it('counts only allowance-charged spend against the plan', () => { + // $9.11 of allowance used this month; the total also carries spend + // that purchased credit already paid for. + const budget = usageBudget( + { total: 1_500_000_000, allowanceUsed: 911_000_000 }, + info(9_500_000_000), + ); + expect(budget.used).toBe(911_000_000); + expect(budget.percent).toBe(10); + }); + + it('falls back to the capped total for records without the split', () => { + // Legacy record: no allowanceUsed. Everything up to the allowance + // counts, and an overshot total still reads as a full plan. + const budget = usageBudget({ total: 1_000_000_000 }, info(900_000_000)); + expect(budget.used).toBe(900_000_000); expect(budget.percent).toBe(100); expect(budget.barPercent).toBe(100); }); it('answers zero for an account with no budget at all', () => { - const budget = usageBudget(0, 0); - expect(budget).toMatchObject({ capacity: 0, percent: 0 }); + const budget = usageBudget({ total: 0 }, info(0)); + expect(budget).toMatchObject({ capacity: 0, used: 0, percent: 0 }); }); - it('treats missing numbers as zero rather than rendering NaN', () => { + it('treats missing objects and numbers as zero rather than rendering NaN', () => { expect(usageBudget(undefined, undefined).percent).toBe(0); - expect(usageBudget(NaN, 100).capacity).toBe(100); + expect(usageBudget(null, info(NaN)).capacity).toBe(0); + expect(usageBudget({ total: NaN }, info(100)).capacity).toBe(100); + expect(usageBudget({ total: NaN }, info(100)).percent).toBe(0); }); });