Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { AdminModule } from './admin/admin.module'
import { ClickHouseModule } from './clickhouse/clickhouse.module'
import { BoxTelemetryModule } from './box-telemetry/box-telemetry.module'
import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module'
import { UsageModule } from './usage/usage.module'

@Module({
imports: [
Expand Down Expand Up @@ -199,6 +200,7 @@ import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module'
ClickHouseModule,
BoxTelemetryModule,
BoxliteRestModule,
UsageModule,
OpenFeatureModule.forRoot({
contextFactory: (request: ExecutionContext) => {
const req = request.switchToHttp().getRequest()
Expand Down
77 changes: 77 additions & 0 deletions apps/api/src/migrations/pre-deploy/1782700000000-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { MigrationInterface, QueryRunner } from 'typeorm'

export class Migration1782700000000 implements MigrationInterface {
name = 'Migration1782700000000'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "box_usage_period" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"boxId" character varying NOT NULL,
"organizationId" character varying NOT NULL,
"region" character varying,
"startAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"endAt" TIMESTAMP WITH TIME ZONE,
"kind" character varying NOT NULL,
"cpu" double precision NOT NULL,
"gpu" double precision NOT NULL,
"mem" double precision NOT NULL,
"disk" double precision NOT NULL,
"actualCpuSeconds" double precision,
"actualRssAvgBytes" bigint,
"actualRssPeakBytes" bigint,
"sampleCount" integer,
"boxClass" character varying NOT NULL DEFAULT 'small',
"regionType" character varying NOT NULL DEFAULT 'shared',
CONSTRAINT "box_usage_period_id_pk" PRIMARY KEY ("id"),
CONSTRAINT "box_usage_period_end_after_start" CHECK ("endAt" IS NULL OR "endAt" >= "startAt")
)`,
)
await queryRunner.query(`CREATE INDEX "box_usage_period_box_end_idx" ON "box_usage_period" ("boxId", "endAt")`)
await queryRunner.query(
`CREATE INDEX "box_usage_period_org_start_idx" ON "box_usage_period" ("organizationId", "startAt")`,
)
await queryRunner.query(
`CREATE UNIQUE INDEX "box_usage_period_one_open_per_box_idx" ON "box_usage_period" ("boxId") WHERE "endAt" IS NULL`,
)

await queryRunner.query(
`CREATE TABLE "box_usage_period_archive" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"sourcePeriodId" uuid NOT NULL,
"boxId" character varying NOT NULL,
"organizationId" character varying NOT NULL,
"region" character varying,
"startAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"endAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"kind" character varying NOT NULL,
"cpu" double precision NOT NULL,
"gpu" double precision NOT NULL,
"mem" double precision NOT NULL,
"disk" double precision NOT NULL,
"actualCpuSeconds" double precision,
"actualRssAvgBytes" bigint,
"actualRssPeakBytes" bigint,
"sampleCount" integer,
"boxClass" character varying NOT NULL DEFAULT 'small',
"regionType" character varying NOT NULL DEFAULT 'shared',
CONSTRAINT "box_usage_period_archive_id_pk" PRIMARY KEY ("id"),
CONSTRAINT "box_usage_period_archive_end_after_start" CHECK ("endAt" >= "startAt")
)`,
)
await queryRunner.query(
`CREATE UNIQUE INDEX "box_usage_period_archive_source_period_idx" ON "box_usage_period_archive" ("sourcePeriodId")`,
)
await queryRunner.query(
`CREATE INDEX "box_usage_period_archive_org_start_idx" ON "box_usage_period_archive" ("organizationId", "startAt")`,
)
await queryRunner.query(
`CREATE INDEX "box_usage_period_archive_box_start_idx" ON "box_usage_period_archive" ("boxId", "startAt")`,
)
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "box_usage_period_archive"`)
await queryRunner.query(`DROP TABLE "box_usage_period"`)
}
}
97 changes: 97 additions & 0 deletions apps/api/src/usage/entities/usage-period-archive.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright BoxLite AI, 2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
import { BoxClass } from '../../box/enums/box-class.enum'
import { RegionType } from '../../region/enums/region-type.enum'
import type { UsagePeriodKind } from '../metering/usage-period-math'
import { UsagePeriod } from './usage-period.entity'

@Entity('box_usage_period_archive')
@Index('box_usage_period_archive_source_period_idx', ['sourcePeriodId'], { unique: true })
@Index('box_usage_period_archive_org_start_idx', ['organizationId', 'startAt'])
@Index('box_usage_period_archive_box_start_idx', ['boxId', 'startAt'])
@Check('box_usage_period_archive_end_after_start', '"endAt" >= "startAt"')
export class UsagePeriodArchive {
@PrimaryGeneratedColumn('uuid')
id: string

@Column()
sourcePeriodId: string

@Column()
boxId: string

@Column()
organizationId: string

@Column({ nullable: true })
region: string | null

@Column({ type: 'timestamp with time zone' })
startAt: Date

@Column({ type: 'timestamp with time zone' })
endAt: Date

@Column({ type: 'character varying' })
kind: UsagePeriodKind

@Column({ type: 'float' })
cpu: number

@Column({ type: 'float' })
gpu: number

@Column({ type: 'float' })
mem: number

@Column({ type: 'float' })
disk: number

@Column({ type: 'double precision', nullable: true })
actualCpuSeconds: number | null

@Column({ type: 'bigint', nullable: true })
actualRssAvgBytes: string | null

@Column({ type: 'bigint', nullable: true })
actualRssPeakBytes: string | null

@Column({ type: 'int', nullable: true })
sampleCount: number | null

@Column({ type: 'character varying', default: BoxClass.SMALL })
boxClass: BoxClass = BoxClass.SMALL

@Column({ type: 'character varying', default: RegionType.SHARED })
regionType: string = RegionType.SHARED

static fromUsagePeriod(period: UsagePeriod): UsagePeriodArchive {
if (!period.endAt) {
throw new Error(`usage period ${period.id} cannot be archived while open`)
}

const archived = new UsagePeriodArchive()
archived.sourcePeriodId = period.id
archived.boxId = period.boxId
archived.organizationId = period.organizationId
archived.region = period.region
archived.startAt = period.startAt
archived.endAt = period.endAt
archived.kind = period.kind
archived.cpu = period.cpu
archived.gpu = period.gpu
archived.mem = period.mem
archived.disk = period.disk
archived.actualCpuSeconds = period.actualCpuSeconds
archived.actualRssAvgBytes = period.actualRssAvgBytes
archived.actualRssPeakBytes = period.actualRssPeakBytes
archived.sampleCount = period.sampleCount
archived.boxClass = period.boxClass
archived.regionType = period.regionType
return archived
}
}
74 changes: 74 additions & 0 deletions apps/api/src/usage/entities/usage-period.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright BoxLite AI, 2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
import { BoxClass } from '../../box/enums/box-class.enum'
import { RegionType } from '../../region/enums/region-type.enum'
import type { UsagePeriodKind } from '../metering/usage-period-math'

@Entity('box_usage_period')
@Index('box_usage_period_box_end_idx', ['boxId', 'endAt'])
@Index('box_usage_period_org_start_idx', ['organizationId', 'startAt'])
@Index('box_usage_period_one_open_per_box_idx', ['boxId'], { unique: true, where: '"endAt" IS NULL' })
@Check('box_usage_period_end_after_start', '"endAt" IS NULL OR "endAt" >= "startAt"')
export class UsagePeriod {
@PrimaryGeneratedColumn('uuid')
id: string

@Column()
boxId: string

@Column()
organizationId: string

@Column({ nullable: true })
region: string | null

@Column({ type: 'timestamp with time zone' })
startAt: Date

@Column({ type: 'timestamp with time zone', nullable: true })
endAt: Date | null

@Column({ type: 'character varying' })
kind: UsagePeriodKind

@Column({ type: 'float' })
cpu: number

@Column({ type: 'float' })
gpu: number

@Column({ type: 'float' })
mem: number

@Column({ type: 'float' })
disk: number

@Column({ type: 'double precision', nullable: true })
actualCpuSeconds: number | null

@Column({ type: 'bigint', nullable: true })
actualRssAvgBytes: string | null

@Column({ type: 'bigint', nullable: true })
actualRssPeakBytes: string | null

@Column({ type: 'int', nullable: true })
sampleCount: number | null

@Column({ type: 'character varying', default: BoxClass.SMALL })
boxClass: BoxClass = BoxClass.SMALL

@Column({ type: 'character varying', default: RegionType.SHARED })
regionType: string = RegionType.SHARED

static fromUsagePeriod(period: UsagePeriod): UsagePeriod {
const next = new UsagePeriod()
Object.assign(next, period)
delete (next as Partial<UsagePeriod>).id
return next
}
}
131 changes: 131 additions & 0 deletions apps/api/src/usage/metering/usage-period-math.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright BoxLite AI, 2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { BoxDesiredState } from '../../box/enums/box-desired-state.enum'
import { BoxState } from '../../box/enums/box-state.enum'
import {
aggregateUsagePeriods,
planUsageTransition,
usagePeriodKind,
UsagePeriodLike,
} from './usage-period-math'

describe('usagePeriodKind', () => {
it('maps STARTED to running compute plus disk', () => {
expect(usagePeriodKind(BoxState.STARTED, BoxDesiredState.STARTED)).toBe('running')
})

it('keeps hot resize as running when desired state is STARTED', () => {
expect(usagePeriodKind(BoxState.RESIZING, BoxDesiredState.STARTED)).toBe('running')
})

it('treats stopped and stopped resize as disk-only', () => {
expect(usagePeriodKind(BoxState.STOPPED, BoxDesiredState.STOPPED)).toBe('stopped')
expect(usagePeriodKind(BoxState.RESIZING, BoxDesiredState.STOPPED)).toBe('stopped')
})

it('maps destroy states to gone', () => {
expect(usagePeriodKind(BoxState.DESTROYING, BoxDesiredState.DESTROYED)).toBe('gone')
expect(usagePeriodKind(BoxState.DESTROYED, BoxDesiredState.DESTROYED)).toBe('gone')
})
})

describe('planUsageTransition', () => {
it('opens a running period when there is no open period and the box starts', () => {
expect(planUsageTransition(null, BoxState.STARTED, BoxDesiredState.STARTED)).toEqual({
closeOpen: false,
openKind: 'running',
})
})

it('closes running and opens stopped when a box stops', () => {
expect(planUsageTransition('running', BoxState.STOPPED, BoxDesiredState.STOPPED)).toEqual({
closeOpen: true,
openKind: 'stopped',
})
})

it('does not fragment same-kind transitions', () => {
expect(planUsageTransition('running', BoxState.RESIZING, BoxDesiredState.STARTED)).toEqual({
closeOpen: false,
openKind: null,
})
})

it('closes the open period and opens nothing when a box is destroyed', () => {
expect(planUsageTransition('stopped', BoxState.DESTROYED, BoxDesiredState.DESTROYED)).toEqual({
closeOpen: true,
openKind: null,
})
})
})

describe('aggregateUsagePeriods', () => {
const from = new Date('2026-07-08T00:00:00Z')
const to = new Date('2026-07-08T00:01:00Z')

it('bills cpu, memory, disk, and gpu for running periods', () => {
const periods: UsagePeriodLike[] = [
{
startAt: new Date('2026-07-08T00:00:10Z'),
endAt: new Date('2026-07-08T00:00:20Z'),
kind: 'running',
cpu: 2,
mem: 4,
disk: 10,
gpu: 1,
},
]

expect(aggregateUsagePeriods(periods, from, to)).toEqual({
cpuSeconds: 20,
memGibSeconds: 40,
diskGibSeconds: 100,
gpuSeconds: 10,
})
})

it('bills only disk for stopped periods', () => {
const periods: UsagePeriodLike[] = [
{
startAt: new Date('2026-07-08T00:00:10Z'),
endAt: new Date('2026-07-08T00:00:20Z'),
kind: 'stopped',
cpu: 2,
mem: 4,
disk: 10,
gpu: 1,
},
]

expect(aggregateUsagePeriods(periods, from, to)).toEqual({
cpuSeconds: 0,
memGibSeconds: 0,
diskGibSeconds: 100,
gpuSeconds: 0,
})
})

it('clips open and overlapping periods to the requested range', () => {
const periods: UsagePeriodLike[] = [
{
startAt: new Date('2026-07-07T23:59:50Z'),
endAt: null,
kind: 'running',
cpu: 1,
mem: 1,
disk: 1,
gpu: 0,
},
]

expect(aggregateUsagePeriods(periods, from, to)).toEqual({
cpuSeconds: 60,
memGibSeconds: 60,
diskGibSeconds: 60,
gpuSeconds: 0,
})
})
})
Loading
Loading