Skip to content

feat(usage): add box usage metering ledger (Phase 1 Usage)#969

Open
law-chain-hot wants to merge 2 commits into
mainfrom
codex/billing-v3-pr1-usage
Open

feat(usage): add box usage metering ledger (Phase 1 Usage)#969
law-chain-hot wants to merge 2 commits into
mainfrom
codex/billing-v3-pr1-usage

Conversation

@law-chain-hot

@law-chain-hot law-chain-hot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add box_usage_period and box_usage_period_archive as the usage ledger
  • convert Box lifecycle events into running and stopped usage periods
  • use Redis locks to serialize per-Box updates and deduplicate cron execution across API replicas
  • roll open periods every 24 hours and archive closed periods every 5 seconds
  • keep rating, wallet, payment, and invoice behavior out of PR1
  • keep Dashboard, read APIs, and billing presentation out of PR1

Architecture

BoxRepository emits lifecycle events
                |
                v
+--------------------------------------+
| UsageService                         |
| - per-Box Redis lock                 |
| - open / close usage period          |
| - 24h rollover                       |
| - archive closed periods             |
+-------------------+------------------+
                    |
          +---------+----------+
          |                    |
          v                    v
+-------------------+  +--------------------------+
| box_usage_period  |  | box_usage_period_archive |
| active + recent   |  | immutable closed history |
+-------------------+  +--------------------------+

Implementation notes

The lifecycle writer is intentionally small: Box state events open or close periods, Redis locks serialize writers, rollover bounds open-period duration, and archiving keeps the active table lightweight.

Review order

  1. apps/api/src/usage/services/usage.service.ts: lifecycle, locks, rollover, archive
  2. apps/api/src/usage/entities/: ledger schema
  3. apps/api/src/migrations/pre-deploy/1782700000000-migration.ts: physical tables and indexes
  4. apps/api/src/usage/usage.service.spec.ts: lifecycle and persistence behavior

Validation

  • Usage API tests: 1 suite, 9 tests passed
  • API production build passed
  • targeted Usage lint and formatting passed

Supersedes #947.

Summary by CodeRabbit

  • New Features

    • Added usage tracking for boxes, recording CPU, GPU, memory, disk, region, and classification details over time.
    • Automatically opens, closes, rolls over, and archives usage periods based on box lifecycle events.
    • Added safeguards to prevent duplicate active usage periods and support reliable background processing.
  • Tests

    • Added coverage for usage tracking, rollover, archiving, locking, error handling, and metadata.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds active and archived usage-period entities, database migration, lifecycle event handling, scheduled rollover and archiving jobs, NestJS module wiring, and comprehensive service tests.

Changes

Usage tracking

Layer / File(s) Summary
Usage period schema and persistence
apps/api/src/migrations/..., apps/api/src/usage/entities/*
Creates active and archive tables, indexes, usage-period fields, classification defaults, and entity factories.
Usage module wiring
apps/api/src/usage/usage.module.ts, apps/api/src/app.module.ts
Registers repositories and UsageService, exports UsageModule, and adds it to the application module.
Lifecycle event processing
apps/api/src/usage/services/usage.service.ts
Processes desired-state and state events with per-box locks, period closure or creation, region-type lookup, and shutdown draining.
Rollover and archiving jobs
apps/api/src/usage/services/usage.service.ts
Adds scheduled rollover of aged periods and transactional archiving of closed periods under global locks.
Service behavior validation
apps/api/src/usage/usage.service.spec.ts
Tests lifecycle transitions, rollover, archiving, locking, transaction ordering, failures, and TypeORM metadata.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: G4614

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a usage metering ledger for Phase 1.
Description check ✅ Passed The description is substantive and aligned with the template, though it omits an explicit Changes section and a clear How to verify section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/billing-v3-pr1-usage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread apps/scripts/metering-local-e2e.mjs Fixed
@law-chain-hot law-chain-hot force-pushed the codex/billing-v3-pr1-usage branch from 16e5c5e to 2febc70 Compare July 10, 2026 09:32
@law-chain-hot law-chain-hot changed the title feat(usage): add box usage metering ledger feat(usage): add box usage metering ledger (Phase 1 Usage) Jul 10, 2026
@law-chain-hot law-chain-hot marked this pull request as ready for review July 10, 2026 10:57
@law-chain-hot law-chain-hot requested a review from a team as a code owner July 10, 2026 10:57
@boxlite-agent

boxlite-agent Bot commented Jul 10, 2026

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

review model timed out after 35 minutes without a /publish callback
repo: boxlite-ai/boxlite
pr: 969
head: ac41b659e06352dbdbcde209af2e938e4e1dce9b
box: pr-review-boxlite-969-mrev5wfu
last stage: model started
stage detail: claude
stage updated: 2026-07-10T11:40:13.796Z

powered by BoxLite

@boxlite-agent boxlite-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📦 BoxLite review — 1 issue

Comment on lines +77 to +100
case BoxState.STARTED: {
await this.closeUsagePeriod(event.box.id)
await this.createUsagePeriod(event)
break
}
case BoxState.STOPPING:
await this.closeUsagePeriod(event.box.id)
await this.createUsagePeriod(event, true)
break
// Safeguard if STOPPING state is skipped.
case BoxState.STOPPED: {
const cpuUsagePeriod = await this.usagePeriodRepository.findOne({
where: {
boxId: event.box.id,
endAt: IsNull(),
cpu: Not(0),
},
})
if (cpuUsagePeriod) {
await this.closeUsagePeriod(event.box.id)
await this.createUsagePeriod(event, true)
}
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 close+reopen not transactional in event handlers
unlike closeAndReopenUsagePeriods (line 187) which wraps close+create in one DB transaction, handleBoxStateUpdate calls closeUsagePeriod then createUsagePeriod as two separate saves; if the second save throws (DB blip), the box is left with zero open usage periods and no automatic recovery — cron rollover only rewrites existing open rows (endAt IsNull), so it can't detect a box that lost its open row, silently under-billing until the box's next STATE_UPDATED/DESIRED_STATE_UPDATED event, which may never come while the box just keeps running.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
apps/api/src/usage/entities/usage-period.entity.ts (1)

48-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type regionType as RegionType instead of string.

boxClass is typed as the BoxClass enum, but regionType is typed as string despite importing and using RegionType. This inconsistency reduces type safety — callers could assign arbitrary strings. The same issue exists in UsagePeriodArchive.

♻️ Proposed fix
- `@Column`({ type: 'character varying', default: RegionType.SHARED })
- regionType: string = RegionType.SHARED
+ `@Column`({ type: 'character varying', default: RegionType.SHARED })
+ regionType: RegionType = RegionType.SHARED
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/entities/usage-period.entity.ts` around lines 48 - 49,
Change the regionType property in both UsagePeriod and UsagePeriodArchive from
string to RegionType, preserving the existing RegionType.SHARED default and
column configuration.
apps/api/src/migrations/pre-deploy/1782700000000-migration.ts (1)

29-45: 🚀 Performance & Scalability | 🔵 Trivial

Consider adding indexes to the archive table.

The active table has indexes on ("boxId", "endAt") and a partial unique index, but box_usage_period_archive has none. Since organizationId is explicitly described as a redundant column to "optimize billing queries," the archive table — which accumulates closed periods and will grow unbounded — would benefit from indexes on commonly queried columns (e.g., organizationId, boxId, startAt/endAt). Without them, billing queries scanning archived periods will degrade as the table grows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/migrations/pre-deploy/1782700000000-migration.ts` around lines
29 - 45, Add indexes to the archive table created in the migration, prioritizing
billing query access through organizationId and common box/time lookups using
boxId with endAt and/or startAt. Define the indexes alongside the CREATE TABLE
migration and ensure the down migration removes them when dropping
box_usage_period_archive.
apps/api/src/usage/usage.service.spec.ts (1)

216-324: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for the STOPPED safeguard and remaining terminal states.

The service's handleBoxStateUpdate includes a safeguard for when STOPPING is skipped (checking for open CPU periods on STOPPED), and handles ERROR, ARCHIVED, and DESTROYING states by closing the period. None of these paths are tested — only DESTROYED is exercised indirectly via the archive test. The STOPPED safeguard is particularly important as it's a recovery path for unexpected state transitions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/usage.service.spec.ts` around lines 216 - 324, Add tests
for handleBoxStateUpdate covering a direct STOPPED transition that closes any
open CPU period, plus ERROR, ARCHIVED, and DESTROYING transitions that close the
active period. Assert the resulting period has an endAt timestamp and no open
CPU allocation, and verify the STOPPED recovery path works when STOPPING was
skipped.
apps/api/src/usage/services/usage.service.ts (1)

227-248: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Archiving up to 5000 rows every 5s in one transaction.

A single transaction that reads, deletes, and re-inserts up to 5000 rows on a 5-second cadence can hold row/index locks long enough to contend with lifecycle-event writes and bloat WAL/undo. Consider a smaller batch size with the loop draining across runs, and ensure endAt (the archive selection predicate) and startAt (ordering) are indexed on the active table to keep the find cheap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/services/usage.service.ts` around lines 227 - 248, Reduce
the batch size used by the usage-period archival transaction and keep each run
bounded so subsequent executions drain remaining rows; update the transaction
around the archive logic in the usage-period service accordingly. Add or verify
indexes on UsagePeriod.endAt for the selection predicate and UsagePeriod.startAt
for ordering to keep archival queries efficient.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/usage/entities/usage-period-archive.entity.ts`:
- Line 56: Replace the unsafe cast in the usage-period archive mapping with an
explicit null check for usagePeriod.endAt, throwing a clear error when it is
null before assigning usagePeriodEntity.endAt. Keep the archive entity’s
non-null Date contract intact and reference the surrounding archive creation
logic for the guard placement.

In `@apps/api/src/usage/services/usage.service.ts`:
- Around line 217-252: archiveUsagePeriods can leave the Redis lock held when
its transaction fails. Wrap the transaction and related work in a try/finally
block within archiveUsagePeriods, ensuring redisLockProvider.unlock(lockKey)
always executes after a successful lock acquisition.
- Around line 43-49: Bound the wait in UsageService.onApplicationShutdown so
shutdown cannot hang indefinitely on stuck activeJobs. Add a deadline or maximum
wait duration, continue polling while jobs remain only until that limit, then
log that shutdown is proceeding with unfinished jobs and return.
- Around line 153-215: Ensure the global Redis lock acquired in
closeAndReopenUsagePeriods is always released by wrapping all work after lock
acquisition, including usage-period lookup and iteration, in a try/finally
block. Move unlock('close-and-reopen-usage-periods') into that finally block,
while preserving the per-usage-period cleanup and lock release behavior.
- Around line 175-206: After acquiring the lock in the usage-period processing
loop, refetch the current open period for the box instead of using the pre-lock
usagePeriod snapshot. Update and save it only when it still exists with a null
endAt; otherwise skip rollover, and use the freshly loaded period when creating
the next period inside the transaction.

In `@apps/api/src/usage/usage.service.spec.ts`:
- Around line 133-149: Update FakeTransactionManager.save to distinguish
UsagePeriodArchive from UsagePeriod for both single entities and arrays; route
single archives and archive arrays through archivedRows, while retaining
active-table handling for UsagePeriod values. Use an explicit entity-type check
rather than the current !Array.isArray(value) condition, and preserve archive ID
assignment and transaction tracking.

---

Nitpick comments:
In `@apps/api/src/migrations/pre-deploy/1782700000000-migration.ts`:
- Around line 29-45: Add indexes to the archive table created in the migration,
prioritizing billing query access through organizationId and common box/time
lookups using boxId with endAt and/or startAt. Define the indexes alongside the
CREATE TABLE migration and ensure the down migration removes them when dropping
box_usage_period_archive.

In `@apps/api/src/usage/entities/usage-period.entity.ts`:
- Around line 48-49: Change the regionType property in both UsagePeriod and
UsagePeriodArchive from string to RegionType, preserving the existing
RegionType.SHARED default and column configuration.

In `@apps/api/src/usage/services/usage.service.ts`:
- Around line 227-248: Reduce the batch size used by the usage-period archival
transaction and keep each run bounded so subsequent executions drain remaining
rows; update the transaction around the archive logic in the usage-period
service accordingly. Add or verify indexes on UsagePeriod.endAt for the
selection predicate and UsagePeriod.startAt for ordering to keep archival
queries efficient.

In `@apps/api/src/usage/usage.service.spec.ts`:
- Around line 216-324: Add tests for handleBoxStateUpdate covering a direct
STOPPED transition that closes any open CPU period, plus ERROR, ARCHIVED, and
DESTROYING transitions that close the active period. Assert the resulting period
has an endAt timestamp and no open CPU allocation, and verify the STOPPED
recovery path works when STOPPING was skipped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3de64576-9f77-4d10-80cf-b05cdec48cf6

📥 Commits

Reviewing files that changed from the base of the PR and between 08de7ba and 2febc70.

📒 Files selected for processing (7)
  • apps/api/src/app.module.ts
  • apps/api/src/migrations/pre-deploy/1782700000000-migration.ts
  • apps/api/src/usage/entities/usage-period-archive.entity.ts
  • apps/api/src/usage/entities/usage-period.entity.ts
  • apps/api/src/usage/services/usage.service.ts
  • apps/api/src/usage/usage.module.ts
  • apps/api/src/usage/usage.service.spec.ts

usagePeriodEntity.boxId = usagePeriod.boxId
usagePeriodEntity.organizationId = usagePeriod.organizationId
usagePeriodEntity.startAt = usagePeriod.startAt
usagePeriodEntity.endAt = usagePeriod.endAt as Date

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Unsafe as Date cast on endAt masks null values.

usagePeriod.endAt is Date | null, but the archive column is NOT NULL. The as Date cast silences the compiler; if an open period is accidentally passed, it will fail at the DB level with a constraint violation rather than being caught at compile time. Consider a runtime guard or explicit error.

🛡️ Proposed fix
  public static fromUsagePeriod(usagePeriod: UsagePeriod) {
+   if (!usagePeriod.endAt) {
+     throw new Error('Cannot archive a usage period with null endAt')
+   }
    const usagePeriodEntity = new UsagePeriodArchive()
    usagePeriodEntity.boxId = usagePeriod.boxId
    usagePeriodEntity.organizationId = usagePeriod.organizationId
    usagePeriodEntity.startAt = usagePeriod.startAt
-   usagePeriodEntity.endAt = usagePeriod.endAt as Date
+   usagePeriodEntity.endAt = usagePeriod.endAt
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
usagePeriodEntity.endAt = usagePeriod.endAt as Date
if (!usagePeriod.endAt) {
throw new Error('Cannot archive a usage period with null endAt')
}
usagePeriodEntity.endAt = usagePeriod.endAt
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/entities/usage-period-archive.entity.ts` at line 56,
Replace the unsafe cast in the usage-period archive mapping with an explicit
null check for usagePeriod.endAt, throwing a clear error when it is null before
assigning usagePeriodEntity.endAt. Keep the archive entity’s non-null Date
contract intact and reference the surrounding archive creation logic for the
guard placement.

Comment on lines +43 to +49
async onApplicationShutdown() {
// Wait for all active jobs to finish.
while (this.activeJobs.size > 0) {
this.logger.log(`Waiting for ${this.activeJobs.size} active jobs to finish`)
await sleep(1000)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add a bounded deadline to the shutdown wait.

onApplicationShutdown loops until activeJobs drains with no upper bound. A single stuck job (hung DB/Redis call) will block graceful shutdown indefinitely until the orchestrator force-kills the pod, defeating in-flight completion and delaying rollout.

🛡️ Proposed bounded wait
   async onApplicationShutdown() {
-    // Wait for all active jobs to finish.
-    while (this.activeJobs.size > 0) {
-      this.logger.log(`Waiting for ${this.activeJobs.size} active jobs to finish`)
-      await sleep(1000)
-    }
+    // Wait for all active jobs to finish, up to a bounded deadline.
+    const deadline = Date.now() + 30_000
+    while (this.activeJobs.size > 0 && Date.now() < deadline) {
+      this.logger.log(`Waiting for ${this.activeJobs.size} active jobs to finish`)
+      await sleep(1000)
+    }
+    if (this.activeJobs.size > 0) {
+      this.logger.warn(`Shutdown proceeding with ${this.activeJobs.size} unfinished jobs`)
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async onApplicationShutdown() {
// Wait for all active jobs to finish.
while (this.activeJobs.size > 0) {
this.logger.log(`Waiting for ${this.activeJobs.size} active jobs to finish`)
await sleep(1000)
}
}
async onApplicationShutdown() {
// Wait for all active jobs to finish, up to a bounded deadline.
const deadline = Date.now() + 30_000
while (this.activeJobs.size > 0 && Date.now() < deadline) {
this.logger.log(`Waiting for ${this.activeJobs.size} active jobs to finish`)
await sleep(1000)
}
if (this.activeJobs.size > 0) {
this.logger.warn(`Shutdown proceeding with ${this.activeJobs.size} unfinished jobs`)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/services/usage.service.ts` around lines 43 - 49, Bound the
wait in UsageService.onApplicationShutdown so shutdown cannot hang indefinitely
on stuck activeJobs. Add a deadline or maximum wait duration, continue polling
while jobs remain only until that limit, then log that shutdown is proceeding
with unfinished jobs and return.

Comment on lines +153 to +215
@Cron(CronExpression.EVERY_MINUTE, { name: 'close-and-reopen-usage-periods' })
@TrackJobExecution()
@LogExecution('close-and-reopen-usage-periods')
@WithInstrumentation()
async closeAndReopenUsagePeriods() {
if (!(await this.redisLockProvider.lock('close-and-reopen-usage-periods', 60))) {
return
}

const usagePeriods = await this.usagePeriodRepository.find({
where: {
endAt: IsNull(),
// 1 day ago
startAt: LessThan(new Date(Date.now() - 1000 * 60 * 60 * 24)),
organizationId: Not(BOX_WARM_POOL_UNASSIGNED_ORGANIZATION),
},
order: {
startAt: 'ASC',
},
take: 100,
})

for (const usagePeriod of usagePeriods) {
if (!(await this.aquireLock(usagePeriod.boxId))) {
continue
}

try {
const box = await this.boxRepository.findOne({
where: {
id: usagePeriod.boxId,
},
})

await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
const closeTime = new Date()
usagePeriod.endAt = closeTime
await transactionalEntityManager.save(usagePeriod)

if (
box &&
(box.state === BoxState.STARTED || box.state === BoxState.STOPPED || box.state === BoxState.STOPPING)
) {
const newUsagePeriod = UsagePeriod.fromUsagePeriod(usagePeriod)
newUsagePeriod.startAt = closeTime
newUsagePeriod.endAt = null
if (box.state === BoxState.STOPPED) {
newUsagePeriod.cpu = 0
newUsagePeriod.gpu = 0
newUsagePeriod.mem = 0
}
await transactionalEntityManager.save(newUsagePeriod)
}
})
} catch (error) {
this.logger.error(`Error closing and reopening usage period ${usagePeriod.boxId}`, error)
} finally {
await this.releaseLock(usagePeriod.boxId)
}
}

await this.redisLockProvider.unlock('close-and-reopen-usage-periods')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Global Redis lock is not released on error paths.

The close-and-reopen-usage-periods lock is only unlocked at Line 214. If find (Line 162) throws, or releaseLock in the per-iteration finally (Line 210) rejects and propagates, Line 214 is skipped and the lock is held until its 60s TTL expires, stalling this job across all replicas for up to a minute. Wrap the body in try/finally.

🔒 Guard the global unlock
     if (!(await this.redisLockProvider.lock('close-and-reopen-usage-periods', 60))) {
       return
     }
-
-    const usagePeriods = await this.usagePeriodRepository.find({
-      ...
-    })
-    ...
-    await this.redisLockProvider.unlock('close-and-reopen-usage-periods')
+    try {
+      const usagePeriods = await this.usagePeriodRepository.find({
+        ...
+      })
+      // ... loop ...
+    } finally {
+      await this.redisLockProvider.unlock('close-and-reopen-usage-periods')
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Cron(CronExpression.EVERY_MINUTE, { name: 'close-and-reopen-usage-periods' })
@TrackJobExecution()
@LogExecution('close-and-reopen-usage-periods')
@WithInstrumentation()
async closeAndReopenUsagePeriods() {
if (!(await this.redisLockProvider.lock('close-and-reopen-usage-periods', 60))) {
return
}
const usagePeriods = await this.usagePeriodRepository.find({
where: {
endAt: IsNull(),
// 1 day ago
startAt: LessThan(new Date(Date.now() - 1000 * 60 * 60 * 24)),
organizationId: Not(BOX_WARM_POOL_UNASSIGNED_ORGANIZATION),
},
order: {
startAt: 'ASC',
},
take: 100,
})
for (const usagePeriod of usagePeriods) {
if (!(await this.aquireLock(usagePeriod.boxId))) {
continue
}
try {
const box = await this.boxRepository.findOne({
where: {
id: usagePeriod.boxId,
},
})
await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
const closeTime = new Date()
usagePeriod.endAt = closeTime
await transactionalEntityManager.save(usagePeriod)
if (
box &&
(box.state === BoxState.STARTED || box.state === BoxState.STOPPED || box.state === BoxState.STOPPING)
) {
const newUsagePeriod = UsagePeriod.fromUsagePeriod(usagePeriod)
newUsagePeriod.startAt = closeTime
newUsagePeriod.endAt = null
if (box.state === BoxState.STOPPED) {
newUsagePeriod.cpu = 0
newUsagePeriod.gpu = 0
newUsagePeriod.mem = 0
}
await transactionalEntityManager.save(newUsagePeriod)
}
})
} catch (error) {
this.logger.error(`Error closing and reopening usage period ${usagePeriod.boxId}`, error)
} finally {
await this.releaseLock(usagePeriod.boxId)
}
}
await this.redisLockProvider.unlock('close-and-reopen-usage-periods')
}
`@Cron`(CronExpression.EVERY_MINUTE, { name: 'close-and-reopen-usage-periods' })
`@TrackJobExecution`()
`@LogExecution`('close-and-reopen-usage-periods')
`@WithInstrumentation`()
async closeAndReopenUsagePeriods() {
if (!(await this.redisLockProvider.lock('close-and-reopen-usage-periods', 60))) {
return
}
try {
const usagePeriods = await this.usagePeriodRepository.find({
where: {
endAt: IsNull(),
// 1 day ago
startAt: LessThan(new Date(Date.now() - 1000 * 60 * 60 * 24)),
organizationId: Not(BOX_WARM_POOL_UNASSIGNED_ORGANIZATION),
},
order: {
startAt: 'ASC',
},
take: 100,
})
for (const usagePeriod of usagePeriods) {
if (!(await this.aquireLock(usagePeriod.boxId))) {
continue
}
try {
const box = await this.boxRepository.findOne({
where: {
id: usagePeriod.boxId,
},
})
await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
const closeTime = new Date()
usagePeriod.endAt = closeTime
await transactionalEntityManager.save(usagePeriod)
if (
box &&
(box.state === BoxState.STARTED || box.state === BoxState.STOPPED || box.state === BoxState.STOPPING)
) {
const newUsagePeriod = UsagePeriod.fromUsagePeriod(usagePeriod)
newUsagePeriod.startAt = closeTime
newUsagePeriod.endAt = null
if (box.state === BoxState.STOPPED) {
newUsagePeriod.cpu = 0
newUsagePeriod.gpu = 0
newUsagePeriod.mem = 0
}
await transactionalEntityManager.save(newUsagePeriod)
}
})
} catch (error) {
this.logger.error(`Error closing and reopening usage period ${usagePeriod.boxId}`, error)
} finally {
await this.releaseLock(usagePeriod.boxId)
}
}
} finally {
await this.redisLockProvider.unlock('close-and-reopen-usage-periods')
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/services/usage.service.ts` around lines 153 - 215, Ensure
the global Redis lock acquired in closeAndReopenUsagePeriods is always released
by wrapping all work after lock acquisition, including usage-period lookup and
iteration, in a try/finally block. Move unlock('close-and-reopen-usage-periods')
into that finally block, while preserving the per-usage-period cleanup and lock
release behavior.

Comment on lines +175 to +206
for (const usagePeriod of usagePeriods) {
if (!(await this.aquireLock(usagePeriod.boxId))) {
continue
}

try {
const box = await this.boxRepository.findOne({
where: {
id: usagePeriod.boxId,
},
})

await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
const closeTime = new Date()
usagePeriod.endAt = closeTime
await transactionalEntityManager.save(usagePeriod)

if (
box &&
(box.state === BoxState.STARTED || box.state === BoxState.STOPPED || box.state === BoxState.STOPPING)
) {
const newUsagePeriod = UsagePeriod.fromUsagePeriod(usagePeriod)
newUsagePeriod.startAt = closeTime
newUsagePeriod.endAt = null
if (box.state === BoxState.STOPPED) {
newUsagePeriod.cpu = 0
newUsagePeriod.gpu = 0
newUsagePeriod.mem = 0
}
await transactionalEntityManager.save(newUsagePeriod)
}
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm both cron and event handlers serialize on the same box lock key
rg -nP "usage-period-\$\{boxId\}|usage-period-" apps/api/src/usage/services/usage.service.ts
# Check whether any DB constraint prevents two open (endAt IS NULL) periods per box
fd -e ts . apps/api/src/migrations apps/api/src/usage/entities --exec rg -nP "endAt|IsNull|UNIQUE|Index" {}

Repository: boxlite-ai/boxlite

Length of output: 6478


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant service section and surrounding helpers
sed -n '130,290p' apps/api/src/usage/services/usage.service.ts

# Find other code paths that manipulate usage periods or use the same lock
rg -n "UsagePeriod\.fromUsagePeriod|usage-period-\$\{boxId\}|aquireLock|releaseLock|endAt = null|box_usage_period_one_open_per_box_idx" apps/api/src -g'*.ts'

Repository: boxlite-ai/boxlite

Length of output: 6259


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the event handlers and the initial query that populates usagePeriods
sed -n '1,140p' apps/api/src/usage/services/usage.service.ts

# Inspect the usage-period entity for relevant constraints/columns
sed -n '1,120p' apps/api/src/usage/entities/usage-period.entity.ts

Repository: boxlite-ai/boxlite

Length of output: 7263


Refetch the open usage period inside the box lock

The partial unique index blocks duplicate open rows, but this cron still works from a pre-lock snapshot. If another handler closes the period first, this save can overwrite endAt on an already-closed row. Reload the current open period after acquiring the lock and only roll it forward if it is still open.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/services/usage.service.ts` around lines 175 - 206, After
acquiring the lock in the usage-period processing loop, refetch the current open
period for the box instead of using the pre-lock usagePeriod snapshot. Update
and save it only when it still exists with a null endAt; otherwise skip
rollover, and use the freshly loaded period when creating the next period inside
the transaction.

Comment on lines +217 to +252
@Cron(CronExpression.EVERY_5_SECONDS, { name: 'archive-usage-periods' })
@TrackJobExecution()
@LogExecution('archive-usage-periods')
@WithInstrumentation()
async archiveUsagePeriods() {
const lockKey = 'archive-usage-periods'
if (!(await this.redisLockProvider.lock(lockKey, 60))) {
return
}

await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
const usagePeriods = await transactionalEntityManager.find(UsagePeriod, {
where: {
endAt: Not(IsNull()),
},
order: {
startAt: 'ASC',
},
take: 5000,
})

if (usagePeriods.length === 0) {
return
}

this.logger.debug(`Found ${usagePeriods.length} usage periods to archive`)

await transactionalEntityManager.delete(
UsagePeriod,
usagePeriods.map((usagePeriod) => usagePeriod.id),
)
await transactionalEntityManager.save(usagePeriods.map(UsagePeriodArchive.fromUsagePeriod))
})

await this.redisLockProvider.unlock(lockKey)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Global lock leaks if the archive transaction throws.

Same pattern as closeAndReopenUsagePeriods: if the transaction (Lines 227-249) rejects, unlock(lockKey) at Line 251 never runs and the lock is held for the full 60s TTL. Since this job runs every 5s, one failure suppresses ~12 subsequent runs across replicas. Wrap in try/finally.

🔒 Guard the global unlock
     const lockKey = 'archive-usage-periods'
     if (!(await this.redisLockProvider.lock(lockKey, 60))) {
       return
     }
-
-    await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
-      ...
-    })
-
-    await this.redisLockProvider.unlock(lockKey)
+    try {
+      await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
+        ...
+      })
+    } finally {
+      await this.redisLockProvider.unlock(lockKey)
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Cron(CronExpression.EVERY_5_SECONDS, { name: 'archive-usage-periods' })
@TrackJobExecution()
@LogExecution('archive-usage-periods')
@WithInstrumentation()
async archiveUsagePeriods() {
const lockKey = 'archive-usage-periods'
if (!(await this.redisLockProvider.lock(lockKey, 60))) {
return
}
await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
const usagePeriods = await transactionalEntityManager.find(UsagePeriod, {
where: {
endAt: Not(IsNull()),
},
order: {
startAt: 'ASC',
},
take: 5000,
})
if (usagePeriods.length === 0) {
return
}
this.logger.debug(`Found ${usagePeriods.length} usage periods to archive`)
await transactionalEntityManager.delete(
UsagePeriod,
usagePeriods.map((usagePeriod) => usagePeriod.id),
)
await transactionalEntityManager.save(usagePeriods.map(UsagePeriodArchive.fromUsagePeriod))
})
await this.redisLockProvider.unlock(lockKey)
}
`@Cron`(CronExpression.EVERY_5_SECONDS, { name: 'archive-usage-periods' })
`@TrackJobExecution`()
`@LogExecution`('archive-usage-periods')
`@WithInstrumentation`()
async archiveUsagePeriods() {
const lockKey = 'archive-usage-periods'
if (!(await this.redisLockProvider.lock(lockKey, 60))) {
return
}
try {
await this.usagePeriodRepository.manager.transaction(async (transactionalEntityManager) => {
const usagePeriods = await transactionalEntityManager.find(UsagePeriod, {
where: {
endAt: Not(IsNull()),
},
order: {
startAt: 'ASC',
},
take: 5000,
})
if (usagePeriods.length === 0) {
return
}
this.logger.debug(`Found ${usagePeriods.length} usage periods to archive`)
await transactionalEntityManager.delete(
UsagePeriod,
usagePeriods.map((usagePeriod) => usagePeriod.id),
)
await transactionalEntityManager.save(usagePeriods.map(UsagePeriodArchive.fromUsagePeriod))
})
} finally {
await this.redisLockProvider.unlock(lockKey)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/services/usage.service.ts` around lines 217 - 252,
archiveUsagePeriods can leave the Redis lock held when its transaction fails.
Wrap the transaction and related work in a try/finally block within
archiveUsagePeriods, ensuring redisLockProvider.unlock(lockKey) always executes
after a successful lock acquisition.

Comment on lines +133 to +149
async save(
entityOrInput: typeof UsagePeriod | typeof UsagePeriodArchive | UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
input?: UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
): Promise<UsagePeriod | UsagePeriod[] | UsagePeriodArchive[]> {
this.periods.transactionOperations.push('save')
const value = input ?? (entityOrInput as UsagePeriod | UsagePeriod[] | UsagePeriodArchive[])
if (!Array.isArray(value) || value[0] instanceof UsagePeriod) {
return this.periods.save(value as UsagePeriod | UsagePeriod[])
}

const rows = value as UsagePeriodArchive[]
rows.forEach((row) => {
row.id = row.id ?? `archive-${this.periods.archivedRows.length + 1}`
this.periods.archivedRows.push(row)
})
return rows
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

FakeTransactionManager.save misroutes single UsagePeriodArchive instances to the active table.

The condition !Array.isArray(value) || value[0] instanceof UsagePeriod routes any non-array value to this.periods.save() (the active rows array). A single UsagePeriodArchive passed without array-wrapping would be silently saved to the active table instead of archivedRows. This is currently masked because the service passes arrays, but it's a latent trap if the service ever saves a single archive row.

🛡️ Proposed fix: route by entity type instead of array membership
   async save(
     entityOrInput: typeof UsagePeriod | typeof UsagePeriodArchive | UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
     input?: UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
   ): Promise<UsagePeriod | UsagePeriod[] | UsagePeriodArchive[]> {
     this.periods.transactionOperations.push('save')
     const value = input ?? (entityOrInput as UsagePeriod | UsagePeriod[] | UsagePeriodArchive[])
-    if (!Array.isArray(value) || value[0] instanceof UsagePeriod) {
+    const first = Array.isArray(value) ? value[0] : value
+    if (first instanceof UsagePeriodArchive) {
+      const rows = Array.isArray(value) ? (value as UsagePeriodArchive[]) : [value as UsagePeriodArchive]
+      rows.forEach((row) => {
+        row.id = row.id ?? `archive-${this.periods.archivedRows.length + 1}`
+        this.periods.archivedRows.push(row)
+      })
+      return rows
+    }
-      return this.periods.save(value as UsagePeriod | UsagePeriod[])
-    }
-
-    const rows = value as UsagePeriodArchive[]
-    rows.forEach((row) => {
-      row.id = row.id ?? `archive-${this.periods.archivedRows.length + 1}`
-      this.periods.archivedRows.push(row)
-    })
-    return rows
+    return this.periods.save(value as UsagePeriod | UsagePeriod[])
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async save(
entityOrInput: typeof UsagePeriod | typeof UsagePeriodArchive | UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
input?: UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
): Promise<UsagePeriod | UsagePeriod[] | UsagePeriodArchive[]> {
this.periods.transactionOperations.push('save')
const value = input ?? (entityOrInput as UsagePeriod | UsagePeriod[] | UsagePeriodArchive[])
if (!Array.isArray(value) || value[0] instanceof UsagePeriod) {
return this.periods.save(value as UsagePeriod | UsagePeriod[])
}
const rows = value as UsagePeriodArchive[]
rows.forEach((row) => {
row.id = row.id ?? `archive-${this.periods.archivedRows.length + 1}`
this.periods.archivedRows.push(row)
})
return rows
}
async save(
entityOrInput: typeof UsagePeriod | typeof UsagePeriodArchive | UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
input?: UsagePeriod | UsagePeriod[] | UsagePeriodArchive[],
): Promise<UsagePeriod | UsagePeriod[] | UsagePeriodArchive[]> {
this.periods.transactionOperations.push('save')
const value = input ?? (entityOrInput as UsagePeriod | UsagePeriod[] | UsagePeriodArchive[])
const first = Array.isArray(value) ? value[0] : value
if (first instanceof UsagePeriodArchive) {
const rows = Array.isArray(value) ? (value as UsagePeriodArchive[]) : [value as UsagePeriodArchive]
rows.forEach((row) => {
row.id = row.id ?? `archive-${this.periods.archivedRows.length + 1}`
this.periods.archivedRows.push(row)
})
return rows
}
return this.periods.save(value as UsagePeriod | UsagePeriod[])
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/usage.service.spec.ts` around lines 133 - 149, Update
FakeTransactionManager.save to distinguish UsagePeriodArchive from UsagePeriod
for both single entities and arrays; route single archives and archive arrays
through archivedRows, while retaining active-table handling for UsagePeriod
values. Use an explicit entity-type check rather than the current
!Array.isArray(value) condition, and preserve archive ID assignment and
transaction tracking.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2febc7052c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +88 to +93
const cpuUsagePeriod = await this.usagePeriodRepository.findOne({
where: {
boxId: event.box.id,
endAt: IsNull(),
cpu: Not(0),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh disk-only usage on STOPPED resize events

When a stopped box is cold-resized, BoxService.resize updates disk and emits a STATE_UPDATED event back to STOPPED (see apps/api/src/box/services/box.service.ts around the V0 disk: newDisk, state: previousState update). If the box already has an open disk-only usage period, this branch ignores the event because it only matches open periods with cpu != 0, so the ledger keeps charging the old disk size until some later start/stop/destroy/rollover event. The STOPPED handler needs to also close/reopen when the open disk-only period's metered allocation no longer matches the updated box.

Useful? React with 👍 / 👎.

Comment on lines +78 to +84
await this.closeUsagePeriod(event.box.id)
await this.createUsagePeriod(event)
break
}
case BoxState.STOPPING:
await this.closeUsagePeriod(event.box.id)
await this.createUsagePeriod(event, true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make lifecycle period rotation atomic

For STARTED/STOPPING transitions that already have an open period, the old period is committed closed before the replacement period is inserted. If createUsagePeriod then fails or the process exits between these two awaits, the box is left with no open usage row and future usage is silently unmetered until another lifecycle event happens. The close and create operations should be performed in one DB transaction, as the daily rollover path already does.

Useful? React with 👍 / 👎.

import { RegionType } from '../../region/enums/region-type.enum'

@Injectable()
export class UsageService implements TrackableJobExecutions, OnApplicationShutdown {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed usage periods for boxes already running at deploy

After this module is enabled on an existing environment, boxes that are already STARTED or STOPPED will not emit a new state event, and the daily rollover job only processes rows that already exist in box_usage_period. Those boxes therefore have no open ledger row from deploy time until their next lifecycle transition, losing running or disk-only usage for that interval. Add a bootstrap/backfill path that creates open periods for current non-terminal boxes when the service starts.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/api/src/usage/entities/box-usage-period.entity.ts (1)

48-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Type regionType as RegionType instead of string.

boxClass is typed as the BoxClass enum while regionType is typed as string, despite both using character varying columns and enum defaults. Typing regionType as RegionType would provide compile-time safety consistent with boxClass.

♻️ Proposed type fix
-  `@Column`({ type: 'character varying', default: RegionType.SHARED })
-  regionType: string = RegionType.SHARED
+  `@Column`({ type: 'character varying', default: RegionType.SHARED })
+  regionType: RegionType = RegionType.SHARED
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/usage/entities/box-usage-period.entity.ts` around lines 48 - 49,
Change the regionType property in the box usage period entity from string to
RegionType, preserving its existing default and database column configuration so
it has compile-time enum safety consistent with boxClass.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/src/usage/entities/box-usage-period.entity.ts`:
- Around line 48-49: Change the regionType property in the box usage period
entity from string to RegionType, preserving its existing default and database
column configuration so it has compile-time enum safety consistent with
boxClass.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 361fda75-c1c3-49f9-9263-0576747590fc

📥 Commits

Reviewing files that changed from the base of the PR and between 2febc70 and ac41b65.

📒 Files selected for processing (5)
  • apps/api/src/usage/entities/box-usage-period-archive.entity.ts
  • apps/api/src/usage/entities/box-usage-period.entity.ts
  • apps/api/src/usage/services/usage.service.ts
  • apps/api/src/usage/usage.module.ts
  • apps/api/src/usage/usage.service.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/api/src/usage/entities/box-usage-period-archive.entity.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/api/src/usage/usage.module.ts
  • apps/api/src/usage/services/usage.service.ts
  • apps/api/src/usage/usage.service.spec.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants