From 569afa23c55ce8254001e442742e7404bb60cd66 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 18 Sep 2026 15:41:49 -0700 Subject: [PATCH 1/3] improvement(knowledge): cache admitted usage checks on the search path --- .../knowledge/search/route.provenance.test.ts | 5 +- .../lib/billing/core/ingestion-usage-gate.ts | 69 ------------ ...-gate.test.ts => usage-gate-cache.test.ts} | 65 +++++++++-- apps/sim/lib/billing/core/usage-gate-cache.ts | 106 ++++++++++++++++++ .../lib/knowledge/application/search.test.ts | 5 +- apps/sim/lib/knowledge/application/search.ts | 4 +- .../documents/document-indexing-usage.test.ts | 4 +- .../document-processing-source.test.ts | 4 +- apps/sim/lib/knowledge/documents/service.ts | 2 +- 9 files changed, 179 insertions(+), 85 deletions(-) delete mode 100644 apps/sim/lib/billing/core/ingestion-usage-gate.ts rename apps/sim/lib/billing/core/{ingestion-usage-gate.test.ts => usage-gate-cache.test.ts} (58%) create mode 100644 apps/sim/lib/billing/core/usage-gate-cache.ts diff --git a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts index f295359c749..5dd6d6c8a22 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts @@ -31,7 +31,10 @@ vi.mock('@sim/platform-authz/workspace', () => ({ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveBillingAttribution: mocks.resolveBilling, resolveSystemBillingAttribution: mocks.resolveBilling, - checkAttributedUsageLimits: mocks.checkUsage, +})) + +vi.mock('@/lib/billing/core/usage-gate-cache', () => ({ + checkSearchUsageLimits: mocks.checkUsage, })) /** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ diff --git a/apps/sim/lib/billing/core/ingestion-usage-gate.ts b/apps/sim/lib/billing/core/ingestion-usage-gate.ts deleted file mode 100644 index 2f6f1c5f5f8..00000000000 --- a/apps/sim/lib/billing/core/ingestion-usage-gate.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { LRUCache } from 'lru-cache' -import { - type AttributedUsageLimitsResult, - type BillingAttributionSnapshot, - checkAttributedUsageLimits, -} from '@/lib/billing/core/billing-attribution' -import { coalesceLocally } from '@/lib/concurrency/singleflight' - -/** - * How long a usage-gate answer stays usable on the ingestion path. - * - * The gate sums the payer's usage ledger for the billing period, which grows - * with every indexed document, so a bulk sync that re-checks per document - * reads the whole period's ledger tens of thousands of times. Staleness fails - * in the harmless direction: a payer at their limit keeps indexing for at most - * this long, and a payer whose limit was just raised waits at most this long. - * Nothing on this path has a person waiting for the answer. - */ -export const INGESTION_USAGE_GATE_TTL_MS = 60 * 1000 - -/** Recent gate answers, with `LRUCache` supplying the TTL and the size bound. */ -const gateCache = new LRUCache({ - max: 10_000, - ttl: INGESTION_USAGE_GATE_TTL_MS, -}) - -/** - * The gate depends on who pays, for which period, and which member acts: the - * payer pool and the per-member cap are both part of the answer. - */ -function gateKey(attribution: BillingAttributionSnapshot): string { - return [ - attribution.billingEntity.type, - attribution.billingEntity.id, - attribution.billingPeriod.start, - attribution.billingPeriod.end, - attribution.billedAccountUserId, - attribution.actorUserId, - ].join(':') -} - -/** - * {@link checkAttributedUsageLimits} for background ingestion, with bounded - * staleness. Interactive callers (uploads, search, the settings surfaces) keep - * reading the gate fresh so a limit change is visible at once. - * - * `coalesceLocally` collapses the concurrent misses of a batch onto one ledger - * read and bounds a hung read at its settle deadline. The cache write stays on - * the value this caller received, so a producer that timed out and later - * resolved cannot overwrite a fresher answer. - */ -export async function checkIngestionUsageLimits( - attribution: BillingAttributionSnapshot -): Promise { - const key = gateKey(attribution) - const cached = gateCache.get(key) - if (cached !== undefined) return cached - - const result = await coalesceLocally(`ingestion-usage-gate:${key}`, () => - checkAttributedUsageLimits(attribution) - ) - gateCache.set(key, result) - return result -} - -/** Drops every cached gate answer. Test seam; never called in production code. */ -export function resetIngestionUsageGateCache(): void { - gateCache.clear() -} diff --git a/apps/sim/lib/billing/core/ingestion-usage-gate.test.ts b/apps/sim/lib/billing/core/usage-gate-cache.test.ts similarity index 58% rename from apps/sim/lib/billing/core/ingestion-usage-gate.test.ts rename to apps/sim/lib/billing/core/usage-gate-cache.test.ts index dbecf11abf0..9e992a27725 100644 --- a/apps/sim/lib/billing/core/ingestion-usage-gate.test.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.test.ts @@ -13,9 +13,10 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { checkIngestionUsageLimits, - INGESTION_USAGE_GATE_TTL_MS, - resetIngestionUsageGateCache, -} from '@/lib/billing/core/ingestion-usage-gate' + checkSearchUsageLimits, + resetUsageGateCache, + USAGE_GATE_TTL_MS, +} from '@/lib/billing/core/usage-gate-cache' const ATTRIBUTION: BillingAttributionSnapshot = { actorUserId: 'member-1', @@ -31,9 +32,19 @@ const ATTRIBUTION: BillingAttributionSnapshot = { payerSubscription: null, } +const SUBSCRIPTION: BillingAttributionSnapshot['payerSubscription'] = { + id: 'sub-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 5, + periodStart: '2026-09-01T00:00:00.000Z', + periodEnd: '2026-10-01T00:00:00.000Z', +} + describe('checkIngestionUsageLimits', () => { beforeEach(() => { - resetIngestionUsageGateCache() + resetUsageGateCache() mockCheck.mockReset().mockResolvedValue({ isExceeded: false }) }) afterEach(() => vi.restoreAllMocks()) @@ -68,13 +79,13 @@ describe('checkIngestionUsageLimits', () => { /** `lru-cache` reads `performance.now()` and debounces it behind a real 1 ms timer. */ const start = performance.now() - vi.spyOn(performance, 'now').mockReturnValue(start + INGESTION_USAGE_GATE_TTL_MS + 1) + vi.spyOn(performance, 'now').mockReturnValue(start + USAGE_GATE_TTL_MS + 1) await sleep(5) expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) expect(mockCheck).toHaveBeenCalledTimes(2) }) - it('separates answers by actor, period and payer', async () => { + it('separates answers by actor, period, payer and plan', async () => { await checkIngestionUsageLimits(ATTRIBUTION) await checkIngestionUsageLimits({ ...ATTRIBUTION, actorUserId: 'member-2' }) await checkIngestionUsageLimits({ @@ -86,7 +97,12 @@ describe('checkIngestionUsageLimits', () => { billedAccountUserId: 'owner-2', billingEntity: { type: 'user', id: 'owner-2' }, }) - expect(mockCheck).toHaveBeenCalledTimes(4) + await checkIngestionUsageLimits({ ...ATTRIBUTION, payerSubscription: SUBSCRIPTION }) + await checkIngestionUsageLimits({ + ...ATTRIBUTION, + payerSubscription: { ...SUBSCRIPTION, plan: 'enterprise' }, + }) + expect(mockCheck).toHaveBeenCalledTimes(6) }) it('does not cache a failed read', async () => { @@ -96,3 +112,38 @@ describe('checkIngestionUsageLimits', () => { expect(mockCheck).toHaveBeenCalledTimes(2) }) }) + +describe('checkSearchUsageLimits', () => { + beforeEach(() => { + resetUsageGateCache() + mockCheck.mockReset().mockResolvedValue({ isExceeded: false }) + }) + + it('reuses an admission across workspaces of the same payer', async () => { + await checkSearchUsageLimits(ATTRIBUTION) + await checkSearchUsageLimits({ ...ATTRIBUTION, workspaceId: 'workspace-9' }) + expect(mockCheck).toHaveBeenCalledTimes(1) + }) + + it('re-reads a refusal, so a raised limit applies on the next search', async () => { + mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' }) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(true) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) + + it('does not serve a refusal cached by ingestion', async () => { + mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' }) + expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) + + it('does not cache a failed read', async () => { + mockCheck.mockRejectedValueOnce(new Error('ledger unavailable')) + await expect(checkSearchUsageLimits(ATTRIBUTION)).rejects.toThrow('ledger unavailable') + await checkSearchUsageLimits(ATTRIBUTION) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/billing/core/usage-gate-cache.ts b/apps/sim/lib/billing/core/usage-gate-cache.ts new file mode 100644 index 00000000000..bf173ca9641 --- /dev/null +++ b/apps/sim/lib/billing/core/usage-gate-cache.ts @@ -0,0 +1,106 @@ +import { LRUCache } from 'lru-cache' +import { + type AttributedUsageLimitsResult, + type BillingAttributionSnapshot, + checkAttributedUsageLimits, +} from '@/lib/billing/core/billing-attribution' +import { coalesceLocally } from '@/lib/concurrency/singleflight' + +/** + * How long a usage-gate answer stays usable on the high-frequency paths. + * + * The gate sums the payer's usage ledger for the billing period, which grows + * with the payer's activity, so a large organization scans its whole period on + * every uncached call. Bulk ingestion re-checks per document and knowledge + * search checks per query. Staleness is bounded by this TTL and fails in the + * harmless direction: a payer who crosses their limit keeps going for at most + * this long, which charges nobody wrongly. + */ +export const USAGE_GATE_TTL_MS = 60 * 1000 + +/** + * Recent gate answers, admitted and refused, with `LRUCache` supplying the TTL + * and the size bound. Each entry point decides which of them it may serve. + */ +const gateCache = new LRUCache({ + max: 10_000, + ttl: USAGE_GATE_TTL_MS, +}) + +/** + * The gate depends on who pays, for which period, under which plan, and which + * member acts: the payer pool, its limit and the per-member cap are all part of + * the answer. The workspace is not, so every workspace of one payer shares an + * entry. + */ +function gateKey(attribution: BillingAttributionSnapshot): string { + const subscription = attribution.payerSubscription + return [ + attribution.billingEntity.type, + attribution.billingEntity.id, + attribution.billingPeriod.start, + attribution.billingPeriod.end, + attribution.billedAccountUserId, + attribution.actorUserId, + subscription?.id ?? '', + subscription?.plan ?? '', + subscription?.status ?? '', + subscription?.seats ?? '', + ].join(':') +} + +/** + * Serves a cached answer the caller accepts, otherwise reads the gate. + * + * `coalesceLocally` collapses concurrent misses onto one ledger read and bounds + * a hung read at its settle deadline. A failed read throws without writing, so + * an outage is never recorded as an answer. The write stays on the value this + * caller received, so a producer that timed out and later resolved cannot + * overwrite a fresher answer. + * + * There is deliberately no invalidator: usage and limit changes land in other + * processes (execution workers, Stripe webhooks), so the TTL is the real bound. + */ +async function checkUsageLimitsThroughCache( + attribution: BillingAttributionSnapshot, + serveCachedRefusal: boolean +): Promise { + const key = gateKey(attribution) + const cached = gateCache.get(key) + if (cached !== undefined && (serveCachedRefusal || !cached.isExceeded)) return cached + + const result = await coalesceLocally(`usage-gate:${key}`, () => + checkAttributedUsageLimits(attribution) + ) + gateCache.set(key, result) + return result +} + +/** + * {@link checkAttributedUsageLimits} for background ingestion. Serves admitted + * and refused answers alike: nothing on this path has a person waiting for a + * raised limit to apply, so a refused payer waits at most the TTL. + */ +export function checkIngestionUsageLimits( + attribution: BillingAttributionSnapshot +): Promise { + return checkUsageLimitsThroughCache(attribution, true) +} + +/** + * {@link checkAttributedUsageLimits} for knowledge search. Serves only a cached + * admission: a refusal is always re-read, so a payer who just raised their limit + * or upgraded is never held behind a cached block while they wait on a search. + * Every other interactive caller (uploads, execution admission, the settings + * surfaces) keeps reading the gate fresh. + */ +export function checkSearchUsageLimits( + attribution: BillingAttributionSnapshot +): Promise { + return checkUsageLimitsThroughCache(attribution, false) +} + +/** Drops every cached gate answer. Test seam; never called in production code. */ +export function resetUsageGateCache(): void { + gateCache.clear() +} diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index e42e26018c4..7b3ddd7a709 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -56,7 +56,10 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveBillingAttribution: mocks.resolveBilling, resolveSystemBillingAttribution: mocks.resolveBilling, resolveOrganizationBillingAttribution: mocks.resolveBilling, - checkAttributedUsageLimits: mocks.checkUsage, +})) + +vi.mock('@/lib/billing/core/usage-gate-cache', () => ({ + checkSearchUsageLimits: mocks.checkUsage, })) /** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 5b186cc1f22..174b7009320 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -3,9 +3,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type BillingAttributionSnapshot, - checkAttributedUsageLimits, toBillingContext, } from '@/lib/billing/core/billing-attribution' +import { checkSearchUsageLimits } from '@/lib/billing/core/usage-gate-cache' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -297,7 +297,7 @@ const searchKnowledgeUseCase = defineAuthorizedKnowledgeUseCase({ : undefined if (shouldMeter && billingAttribution) { const usage = await measureSearchStage('usage_admission', () => - checkAttributedUsageLimits(billingAttribution) + checkSearchUsageLimits(billingAttribution) ) if (usage.isExceeded) { throw new KnowledgeUsageLimitExceededError( diff --git a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts index 437de7bf205..a6a351c5cbf 100644 --- a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts +++ b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts @@ -65,13 +65,13 @@ vi.mock('@/providers/utils', () => ({ })) import * as billingAttribution from '@/lib/billing/core/billing-attribution' -import { resetIngestionUsageGateCache } from '@/lib/billing/core/ingestion-usage-gate' +import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache' import * as embeddingClient from '@/lib/embeddings/client' import { processDocumentAsync } from '@/lib/knowledge/documents/service' const mockEmbeddingCapacity = vi.fn() beforeEach(() => { - resetIngestionUsageGateCache() + resetUsageGateCache() vi.spyOn(billingAttribution, 'checkAttributedUsageLimits').mockImplementation( mockCheckAttributedUsageLimits ) diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 2dc0061bc2d..92b64b9424d 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -74,7 +74,7 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) import * as billingAttribution from '@/lib/billing/core/billing-attribution' -import { resetIngestionUsageGateCache } from '@/lib/billing/core/ingestion-usage-gate' +import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache' import { env } from '@/lib/core/config/env' import { markInsideTriggerRun, @@ -102,7 +102,7 @@ import { MAX_PROCESSING_ATTEMPTS } from '@/lib/knowledge/documents/types' const mockEmbeddingCapacity = vi.fn() beforeEach(() => { - resetIngestionUsageGateCache() + resetUsageGateCache() vi.spyOn(billingAttribution, 'checkAttributedUsageLimits').mockImplementation( mockCheckAttributedUsageLimits ) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 16531d6fa01..7b1bbef023b 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -36,7 +36,7 @@ import { type BillingAttributionSnapshot, toBillingContext, } from '@/lib/billing/core/billing-attribution' -import { checkIngestionUsageLimits } from '@/lib/billing/core/ingestion-usage-gate' +import { checkIngestionUsageLimits } from '@/lib/billing/core/usage-gate-cache' import { recordUsage } from '@/lib/billing/core/usage-log' import { applyStorageUsageDeltasInTx, From 9d6e7e772f8fa2c5acad25da85d3046d89746263 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 18 Sep 2026 15:49:27 -0700 Subject: [PATCH 2/3] fix(knowledge): keep search-path refusals out of the shared usage gate cache --- .../lib/billing/core/usage-gate-cache.test.ts | 7 +++++++ apps/sim/lib/billing/core/usage-gate-cache.ts | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/billing/core/usage-gate-cache.test.ts b/apps/sim/lib/billing/core/usage-gate-cache.test.ts index 9e992a27725..fd2e4af9785 100644 --- a/apps/sim/lib/billing/core/usage-gate-cache.test.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.test.ts @@ -140,6 +140,13 @@ describe('checkSearchUsageLimits', () => { expect(mockCheck).toHaveBeenCalledTimes(2) }) + it('never stores a refusal for ingestion to serve', async () => { + mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' }) + expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(true) + expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false) + expect(mockCheck).toHaveBeenCalledTimes(2) + }) + it('does not cache a failed read', async () => { mockCheck.mockRejectedValueOnce(new Error('ledger unavailable')) await expect(checkSearchUsageLimits(ATTRIBUTION)).rejects.toThrow('ledger unavailable') diff --git a/apps/sim/lib/billing/core/usage-gate-cache.ts b/apps/sim/lib/billing/core/usage-gate-cache.ts index bf173ca9641..ad267a7c47c 100644 --- a/apps/sim/lib/billing/core/usage-gate-cache.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.ts @@ -52,27 +52,32 @@ function gateKey(attribution: BillingAttributionSnapshot): string { /** * Serves a cached answer the caller accepts, otherwise reads the gate. * + * `cacheRefusals` governs both directions: a caller that must re-read refusals + * also never stores one, so a refusal read on the search path never reaches + * ingestion. The usage read fails closed (a ledger error comes back as + * exceeded), which makes that the only way a search-path outage stays out of + * the cache. A read that throws writes nothing. + * * `coalesceLocally` collapses concurrent misses onto one ledger read and bounds - * a hung read at its settle deadline. A failed read throws without writing, so - * an outage is never recorded as an answer. The write stays on the value this - * caller received, so a producer that timed out and later resolved cannot - * overwrite a fresher answer. + * a hung read at its settle deadline. The write stays on the value this caller + * received, so a producer that timed out and later resolved cannot overwrite a + * fresher answer. * * There is deliberately no invalidator: usage and limit changes land in other * processes (execution workers, Stripe webhooks), so the TTL is the real bound. */ async function checkUsageLimitsThroughCache( attribution: BillingAttributionSnapshot, - serveCachedRefusal: boolean + cacheRefusals: boolean ): Promise { const key = gateKey(attribution) const cached = gateCache.get(key) - if (cached !== undefined && (serveCachedRefusal || !cached.isExceeded)) return cached + if (cached !== undefined && (cacheRefusals || !cached.isExceeded)) return cached const result = await coalesceLocally(`usage-gate:${key}`, () => checkAttributedUsageLimits(attribution) ) - gateCache.set(key, result) + if (cacheRefusals || !result.isExceeded) gateCache.set(key, result) return result } From f59978fc7bd336091d225b3a35f42ce98df0056d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 18 Sep 2026 15:55:55 -0700 Subject: [PATCH 3/3] fix(knowledge): key the usage gate cache by billing period source --- apps/sim/lib/billing/core/usage-gate-cache.test.ts | 8 ++++++-- apps/sim/lib/billing/core/usage-gate-cache.ts | 9 +++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/billing/core/usage-gate-cache.test.ts b/apps/sim/lib/billing/core/usage-gate-cache.test.ts index fd2e4af9785..ba5d0e48f98 100644 --- a/apps/sim/lib/billing/core/usage-gate-cache.test.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.test.ts @@ -85,7 +85,7 @@ describe('checkIngestionUsageLimits', () => { expect(mockCheck).toHaveBeenCalledTimes(2) }) - it('separates answers by actor, period, payer and plan', async () => { + it('separates answers by actor, period, period source, payer and plan', async () => { await checkIngestionUsageLimits(ATTRIBUTION) await checkIngestionUsageLimits({ ...ATTRIBUTION, actorUserId: 'member-2' }) await checkIngestionUsageLimits({ @@ -97,12 +97,16 @@ describe('checkIngestionUsageLimits', () => { billedAccountUserId: 'owner-2', billingEntity: { type: 'user', id: 'owner-2' }, }) + await checkIngestionUsageLimits({ + ...ATTRIBUTION, + billingPeriod: { ...ATTRIBUTION.billingPeriod, source: 'reporting' }, + }) await checkIngestionUsageLimits({ ...ATTRIBUTION, payerSubscription: SUBSCRIPTION }) await checkIngestionUsageLimits({ ...ATTRIBUTION, payerSubscription: { ...SUBSCRIPTION, plan: 'enterprise' }, }) - expect(mockCheck).toHaveBeenCalledTimes(6) + expect(mockCheck).toHaveBeenCalledTimes(7) }) it('does not cache a failed read', async () => { diff --git a/apps/sim/lib/billing/core/usage-gate-cache.ts b/apps/sim/lib/billing/core/usage-gate-cache.ts index ad267a7c47c..982e5a406d9 100644 --- a/apps/sim/lib/billing/core/usage-gate-cache.ts +++ b/apps/sim/lib/billing/core/usage-gate-cache.ts @@ -28,10 +28,10 @@ const gateCache = new LRUCache({ }) /** - * The gate depends on who pays, for which period, under which plan, and which - * member acts: the payer pool, its limit and the per-member cap are all part of - * the answer. The workspace is not, so every workspace of one payer shares an - * entry. + * The gate depends on who pays, for which period (and how that period was + * derived), under which plan, and which member acts: the payer pool, its limit + * and the per-member cap are all part of the answer. The workspace is not, so + * every workspace of one payer shares an entry. */ function gateKey(attribution: BillingAttributionSnapshot): string { const subscription = attribution.payerSubscription @@ -40,6 +40,7 @@ function gateKey(attribution: BillingAttributionSnapshot): string { attribution.billingEntity.id, attribution.billingPeriod.start, attribution.billingPeriod.end, + attribution.billingPeriod.source ?? '', attribution.billedAccountUserId, attribution.actorUserId, subscription?.id ?? '',