Skip to content

Commit 60b4fb6

Browse files
committed
fix(aws-marketplace): count rows while a long run can still raise them, flush a license at its expiry, and keep charged usage when its organization is deleted
1 parent 87f5256 commit 60b4fb6

6 files changed

Lines changed: 86 additions & 35 deletions

File tree

‎apps/sim/lib/billing/aws-marketplace/metering.integration.ts‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,12 +329,12 @@ describe('AWS Marketplace usage metering', () => {
329329
expect((await reportsFor(owner.organizationId)).map((row) => row.quantity)).toEqual([4])
330330
})
331331

332-
it('keeps counting a period that ended within a day, and leaves an older one alone', async () => {
332+
it('keeps counting a period while a long run can still raise its rows, and leaves a settled one alone', async () => {
333333
const owner = await createOrganization()
334334
await insertLicense(owner.organizationId, { organizationLinkedAt: new Date(NOW - 70 * DAY_MS) })
335-
const justEnded = { start: new Date(NOW - 31 * DAY_MS), end: minutesAgo(120) }
335+
const justEnded = { start: new Date(NOW - 31 * DAY_MS), end: new Date(NOW - 3 * DAY_MS) }
336336
const longEnded = { start: new Date(NOW - 62 * DAY_MS), end: new Date(NOW - 31 * DAY_MS) }
337-
await logUsage(owner, { at: new Date(NOW - 3 * HOUR_MS), cost: 0.04, period: justEnded })
337+
await logUsage(owner, { at: new Date(NOW - 4 * DAY_MS), cost: 0.04, period: justEnded })
338338
await logUsage(owner, { at: new Date(NOW - 35 * DAY_MS), cost: 9, period: longEnded })
339339

340340
await meterAwsMarketplaceUsage(NOW)
@@ -520,4 +520,35 @@ describe('AWS Marketplace usage metering', () => {
520520
}),
521521
])
522522
})
523+
524+
it('flushes a license at its expiry before any check marks it inactive', async () => {
525+
const owner = await createOrganization()
526+
const expiresAt = minutesAgo(30)
527+
const { licenseArn } = await insertLicense(owner.organizationId, { expiresAt })
528+
await logUsage(owner, { at: minutesAgo(35), cost: 0.03 })
529+
530+
await meterAwsMarketplaceUsage(NOW)
531+
532+
expect(sentRecords(licenseArn)).toEqual([
533+
expect.objectContaining({ Quantity: 3, Timestamp: floorHour(expiresAt.getTime()) }),
534+
])
535+
})
536+
537+
it('still sends usage already charged after its organization is deleted', async () => {
538+
const owner = await createOrganization()
539+
const { id, licenseArn } = await insertLicense(owner.organizationId)
540+
await logUsage(owner, { at: minutesAgo(10), cost: 0.02 })
541+
await meterAwsMarketplaceUsage(NOW)
542+
543+
await db.delete(organization).where(eq(organization.id, owner.organizationId))
544+
await meterAwsMarketplaceUsage(NEXT_HOUR)
545+
546+
expect(sentRecords(licenseArn)).toEqual([expect.objectContaining({ Quantity: 2 })])
547+
expect(
548+
await db
549+
.select({ status: awsMarketplaceUsageReport.status })
550+
.from(awsMarketplaceUsageReport)
551+
.where(eq(awsMarketplaceUsageReport.licenseId, id))
552+
).toEqual([{ status: 'reported' }])
553+
})
523554
})

‎apps/sim/lib/billing/aws-marketplace/metering.ts‎

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import { getMeteringClient } from '@/lib/billing/aws-marketplace/client'
4040
import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config'
4141
import type { AwsMarketplaceUsagePricing } from '@/lib/billing/aws-marketplace/products'
42+
import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
4243
import { toDecimal, toFixedString } from '@/lib/billing/utils/decimal'
4344
import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
4445
import type { DbTransaction } from '@/lib/db/types'
@@ -53,10 +54,10 @@ const MONTH_GRACE_MS = 6 * HOUR_MS
5354
/** AWS takes a license's last usage for about an hour after it ends. */
5455
const AFTER_END_MS = HOUR_MS
5556
/**
56-
* A billing period stops changing once it ends, since a request that outlives it records the rest
57-
* in the next period; a day of margin covers a request still finishing as the period turns.
57+
* How long a usage row's cost can keep growing: a request tops up its row in place while it runs,
58+
* and no run outlasts the longest execution timeout. A day of margin covers late cost callbacks.
5859
*/
59-
const PERIOD_SETTLE_MS = 24 * HOUR_MS
60+
const ROW_SETTLE_MS = MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS * 1000 + 24 * HOUR_MS
6061
/** BatchMeterUsage takes at most 25 records. */
6162
const RECORDS_PER_CALL = 25
6263
/** Rows sent per run, oldest first; any beyond it go in the next run. */
@@ -97,7 +98,7 @@ interface MeteredLicense {
9798
/** A ledger row awaiting AWS, with the license it bills. */
9899
interface PendingReport {
99100
id: string
100-
organizationId: string
101+
organizationId: string | null
101102
dimension: string
102103
hourStart: Date
103104
quantity: number
@@ -170,30 +171,43 @@ async function listMeteredLicenses(
170171
})
171172
}
172173

174+
/**
175+
* When a license stopped being in force, or null while it still is: the earlier of its expiry, once
176+
* passed, and the moment a check first found it inactive.
177+
*/
178+
function licenseEnd(license: MeteredLicense, now: number): number | null {
179+
const expiry = license.expiresAt?.getTime()
180+
const ends = [
181+
...(expiry !== undefined && expiry <= now ? [expiry] : []),
182+
...(license.status === 'active' ? [] : [license.endedAt?.getTime() ?? now]),
183+
]
184+
return ends.length > 0 ? Math.min(...ends) : null
185+
}
186+
173187
/**
174188
* The license an organization's newly counted usage is charged to, and the hour it is charged in.
175189
* A license still in force wins, the earliest-linked first, so overlapping licenses during a
176-
* renewal never both bill. With none in force, a license that ended within the hour AWS allows
177-
* takes the usage at the hour it ended, until that record has gone to AWS.
190+
* renewal never both bill. With none in force, the license that ended most recently, within the
191+
* hour AWS allows, takes the usage at the hour it ended. That record is sent as soon as the end is
192+
* known, because AWS counts its hour from the unsubscribe itself, which a check only finds later:
193+
* usage that lands after the send, from work still finishing once access is gone, is not billed.
178194
*/
179195
function chargeTarget(
180196
licenses: MeteredLicense[],
181197
now: number
182198
): { license: MeteredLicense; hourStart: number } | null {
183-
const inForce = licenses.find(
184-
(license) =>
185-
license.status === 'active' && (!license.expiresAt || license.expiresAt.getTime() > now)
186-
)
187-
if (inForce) return { license: inForce, hourStart: floorHour(now) }
188-
const ended = licenses
189-
.filter((license) => license.endedAt && license.endedAt.getTime() >= now - AFTER_END_MS)
190-
.sort((left, right) => (right.endedAt?.getTime() ?? 0) - (left.endedAt?.getTime() ?? 0))[0]
191-
return ended?.endedAt ? { license: ended, hourStart: floorHour(ended.endedAt.getTime()) } : null
199+
let ended: { license: MeteredLicense; end: number } | null = null
200+
for (const license of licenses) {
201+
const end = licenseEnd(license, now)
202+
if (end === null) return { license, hourStart: floorHour(now) }
203+
if (end >= now - AFTER_END_MS && (!ended || end > ended.end)) ended = { license, end }
204+
}
205+
return ended ? { license: ended.license, hourStart: floorHour(ended.end) } : null
192206
}
193207

194208
/**
195-
* Counts the organization's usage in each billing period still open to change, and returns the
196-
* whole units not yet charged. Each period's tally is updated to what it now covers; a period's
209+
* Counts the organization's usage in each billing period with a row still able to change, and
210+
* returns the whole units not yet charged. Each period's tally is updated to what it now covers; a period's
197211
* first tally fixes where its count starts.
198212
*/
199213
async function countNewUnits(
@@ -221,7 +235,7 @@ async function countNewUnits(
221235
and(
222236
eq(usageLog.billingEntityType, 'organization'),
223237
eq(usageLog.billingEntityId, organizationId),
224-
gt(usageLog.billingPeriodEnd, new Date(now - PERIOD_SETTLE_MS))
238+
gt(usageLog.createdAt, new Date(now - ROW_SETTLE_MS))
225239
)
226240
)
227241

@@ -391,9 +405,13 @@ function recordKey(dimension: string | undefined, timestamp: Date | undefined):
391405
*/
392406
async function freezeForSending(rows: PendingReport[]): Promise<PendingReport[]> {
393407
return db.transaction(async (tx) => {
394-
const scopes = new Map(rows.map((row) => [`${row.organizationId}:${row.dimension}`, row]))
408+
const scopes = new Map<string, { organizationId: string; dimension: string }>()
409+
for (const { organizationId, dimension } of rows) {
410+
if (organizationId)
411+
scopes.set(`${organizationId}:${dimension}`, { organizationId, dimension })
412+
}
395413
const ordered = [...scopes].sort(([left], [right]) => compareStrings(left, right))
396-
for (const [, row] of ordered) await lockUsage(tx, row.organizationId, row.dimension)
414+
for (const [, scope] of ordered) await lockUsage(tx, scope.organizationId, scope.dimension)
397415
const frozen = await tx
398416
.update(awsMarketplaceUsageReport)
399417
.set({ sentAt: sql`coalesce(${awsMarketplaceUsageReport.sentAt}, now())` })
@@ -483,8 +501,8 @@ async function sendBatch(
483501
}
484502

485503
/**
486-
* Sends every pending row whose hour has closed, or whose license has ended so no more usage can
487-
* join it, oldest first and grouped by license.
504+
* Sends every pending row whose hour has closed, or whose license has ended or expired so no more
505+
* usage can join it, oldest first and grouped by license.
488506
*/
489507
async function sendPendingReports(now: number, summary: AwsMarketplaceMeteringSummary) {
490508
summary.rejected += await rejectExpiredReports(now)
@@ -508,7 +526,8 @@ async function sendPendingReports(now: number, summary: AwsMarketplaceMeteringSu
508526
eq(awsMarketplaceUsageReport.status, 'pending'),
509527
or(
510528
lt(awsMarketplaceUsageReport.hourStart, new Date(floorHour(now))),
511-
eq(awsMarketplaceLicense.status, 'inactive')
529+
eq(awsMarketplaceLicense.status, 'inactive'),
530+
lte(awsMarketplaceLicense.expiresAt, new Date(now))
512531
)
513532
)
514533
)

‎packages/db/migrations/0456_aws_marketplace_usage_metering.sql‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
CREATE TYPE "public"."aws_marketplace_usage_report_status" AS ENUM('pending', 'reported', 'rejected');--> statement-breakpoint
22
CREATE TABLE "aws_marketplace_usage_report" (
33
"id" text PRIMARY KEY NOT NULL,
4-
"organization_id" text NOT NULL,
4+
"organization_id" text,
55
"license_id" text NOT NULL,
66
"dimension" text NOT NULL,
77
"hour_start" timestamp NOT NULL,
@@ -28,7 +28,7 @@ CREATE TABLE "aws_marketplace_usage_tally" (
2828
--> statement-breakpoint
2929
ALTER TABLE "aws_marketplace_license" ADD COLUMN "organization_linked_at" timestamp;--> statement-breakpoint
3030
ALTER TABLE "aws_marketplace_license" ADD COLUMN "ended_at" timestamp;--> statement-breakpoint
31-
ALTER TABLE "aws_marketplace_usage_report" ADD CONSTRAINT "aws_marketplace_usage_report_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
31+
ALTER TABLE "aws_marketplace_usage_report" ADD CONSTRAINT "aws_marketplace_usage_report_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
3232
ALTER TABLE "aws_marketplace_usage_report" ADD CONSTRAINT "aws_marketplace_usage_report_license_id_aws_marketplace_license_id_fk" FOREIGN KEY ("license_id") REFERENCES "public"."aws_marketplace_license"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
3333
ALTER TABLE "aws_marketplace_usage_tally" ADD CONSTRAINT "aws_marketplace_usage_tally_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
3434
CREATE UNIQUE INDEX "aws_marketplace_usage_report_license_dimension_hour_unique" ON "aws_marketplace_usage_report" USING btree ("license_id","dimension","hour_start");--> statement-breakpoint

‎packages/db/migrations/meta/0456_snapshot.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"id": "7e636cc9-1659-46ab-927e-da45dec1efc2",
2+
"id": "b8d5fbd7-96f6-4c95-bb70-f2562c0c83de",
33
"prevId": "47f71127-867a-4c55-8869-0e00cd2c4429",
44
"version": "7",
55
"dialect": "postgresql",
@@ -1457,7 +1457,7 @@
14571457
"name": "organization_id",
14581458
"type": "text",
14591459
"primaryKey": false,
1460-
"notNull": true
1460+
"notNull": false
14611461
},
14621462
"license_id": {
14631463
"name": "license_id",
@@ -1602,7 +1602,7 @@
16021602
"tableTo": "organization",
16031603
"columnsFrom": ["organization_id"],
16041604
"columnsTo": ["id"],
1605-
"onDelete": "cascade",
1605+
"onDelete": "set null",
16061606
"onUpdate": "no action"
16071607
},
16081608
"aws_marketplace_usage_report_license_id_aws_marketplace_license_id_fk": {

‎packages/db/migrations/meta/_journal.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3190,7 +3190,7 @@
31903190
{
31913191
"idx": 456,
31923192
"version": "7",
3193-
"when": 1794463676028,
3193+
"when": 1794503347845,
31943194
"tag": "0456_aws_marketplace_usage_metering",
31953195
"breakpoints": true
31963196
}

‎packages/db/schema.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2040,9 +2040,10 @@ export const awsMarketplaceUsageReport = pgTable(
20402040
'aws_marketplace_usage_report',
20412041
{
20422042
id: text('id').primaryKey(),
2043-
organizationId: text('organization_id')
2044-
.notNull()
2045-
.references(() => organization.id, { onDelete: 'cascade' }),
2043+
/** Kept null when the organization is deleted, so usage already charged is still sent. */
2044+
organizationId: text('organization_id').references(() => organization.id, {
2045+
onDelete: 'set null',
2046+
}),
20462047
licenseId: text('license_id')
20472048
.notNull()
20482049
.references(() => awsMarketplaceLicense.id, { onDelete: 'cascade' }),

0 commit comments

Comments
 (0)