Skip to content

fix(api): keep open usage periods in step with their boxes - #1104

Open
ltstriker wants to merge 1 commit into
mainfrom
fix/usage_failure_lt_v2.0
Open

fix(api): keep open usage periods in step with their boxes#1104
ltstriker wants to merge 1 commit into
mainfrom
fix/usage_failure_lt_v2.0

Conversation

@ltstriker

@ltstriker ltstriker commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1091. Base branch is fix/usage_failure_lt, not main, so the
diff here is only the six usage files. Merge #1091 first; GitHub will then
retarget this PR to main on its own. Reviewing it against main before that
shows #1091's fourteen files as well.

The dependency is branch-level, not code-level: nothing here calls anything
#1091 adds, and the two touch disjoint files. It is stacked because it was
written on top of that branch, so rebasing it onto main today would be a
clean no-op — it just has not been done, to keep the diff reviewable.

Problem

A box's open usage period is maintained by in-process events, which are
fire-and-forget. A handler that throws, or whose process dies mid-transition,
leaves the box and its period disagreeing and nothing notices. Two gaps let that
disagreement persist indefinitely:

The daily roll-over preserved drift instead of correcting it. It copied the
closing period's resources into the replacement, so a period charging no cpu for
a running box was re-copied every day, forever. A disk resize that landed while
the box was stopped never reached the ledger at all.

Nothing ever scanned from the box side. The roll-over walks the period
table, so a box that never got a period is invisible to it — there is no row to
find. Its one-day cutoff also lets a wrong period bill for a further day.

Approach

expectedOpenPeriod(box) becomes the single source of truth for the
state → period rule — full compute while running, disk alone once stopped,
nothing otherwise — and the event handler, the roll-over and the new reconcile
pass all answer the question the same way.

Roll-over re-derives resources from the box rather than copying them
forward. A box that is gone or terminal yields no shape and so is not reopened,
which is what stops a deleted box from accruing.

Reconcile pass (every 5 min) scans from the box side, which is why both are
needed — their blind spots are opposite. The roll-over is the only thing that
can see a period whose box row was deleted outright; this is the only thing that
can see a box that never got a period.

  • Sharded by runner so each scan rides box_runnerid_idx rather than reading
    the box table end to end. Measured on 20k boxes across 50 runners: bitmap
    index scan, ~400 rows rechecked, ~1.7 ms. The trailing runnerId IS NULL
    shard is not optional — a box that reached DESTROYED or ARCHIVED has had its
    runnerId cleared, and those are exactly the periods that must be closed.
  • Two-minute grace window before a box is eligible. The per-box lock's TTL is
    60 s, so a handler can legitimately take a full minute to reach the ledger;
    reconciling inside that window would race it and collide on the
    one-open-period index. Anything under 60 s is provably too short.
  • Each candidate is re-checked under the per-box lock against
    expectedOpenPeriod, so one the event handler fixed in the meantime is left
    alone. The SQL is a deliberately wide filter, not the authority.
  • Repairs are counted on usage_period_drift_repaired{kind=missing|orphan|stale_shape}.

Decisions worth challenging

  • Billing deliberately diverges from quota. BOX_STATES_CONSUMING_COMPUTE
    counts CREATING and STARTING because the runner has already pinned the
    resources; billing does not charge for a box the tenant cannot use yet.
    Divergence here is a pricing decision, not a bug — expected-usage-period.spec.ts
    asserts it so nobody "fixes" it by accident.
  • Corrections start now and are never backdated. The window a box spent
    mis-billed cannot be reconstructed (its updatedAt has moved on for unrelated
    reasons), and guessing it would replace a known gap with an invented charge.
  • The resource comparison is qualified by state. A bare p.cpu <> b.cpu is
    permanently true for every stopped box — a stopped period should charge no
    cpu — and those false positives would fill each page and starve real drift out
    of the batch forever.
  • A float tolerance is required, not cosmetic. cpu/gpu/mem/disk are double
    precision on both sides; without RESOURCE_EPSILON the pass would rewrite an
    already-correct period on every run, fragmenting the ledger into unbillable
    slivers.
  • ARCHIVING bills nothing because no code in this repository assigns it.
    Pricing a state the product does not have yet would be inventing a rule.

No database migration, no API surface change, no client regeneration.

Verification

Ran against a real Postgres 16 + Redis (DB_*/REDIS_* pointed at a disposable
database; the integration spec builds its schema by running the migrations, so
it exercises the DDL that ships, and skips when no database is reachable).

Result
usage suites (unit + integration) 3 suites / 82 tests pass
full api suite 58 suites / 321 tests pass
tsc -p api/tsconfig.spec.json --noEmit exit 0
eslint api/src/usage --max-warnings=0 clean
prettier --check clean

Two-side verified. With the roll-over hunk reverted and everything else
intact, three roll-over assertions fail on the bug itself — a running box's
rolled-over period comes back cpu: 0, gpu: 0, mem: 0 instead of 2/1/4:

● the daily roll-over on its own › carries the resources the box has now,
  not the ones the closing period held
    - ObjectContaining { "cpu": 2, "gpu": 1, "mem": 4, }
    + BoxUsagePeriod  { "cpu": 0, "gpu": 0, "mem": 0, ... }

Restoring the hunk turns all three green. A full revert of every production file
is red too, but only as a compile error, since the reconcile pass is new surface
its specs cannot load without — so the roll-over hunk is the isolation that
carries real signal. Stated plainly: the reconcile tests demonstrate new
behaviour, they do not reproduce a prior bug.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved usage billing across box states, including compute, disk-only, inactive, and terminal states.
    • Added automatic reconciliation to repair missing, orphaned, or outdated usage periods.
    • Updated rollover processing to reflect current resource usage and avoid terminal boxes.
    • Added tolerance for minor resource-value differences when validating usage periods.
  • Tests

    • Expanded coverage for billing rules, reconciliation, rollover behavior, resource drift, and idempotency.

@ltstriker
ltstriker requested a review from a team as a code owner July 31, 2026 08:42
@boxlite-agent

boxlite-agent Bot commented Jul 31, 2026

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

claude exited 1

stdout:
{"is_error":true,"duration_api_ms":0,"num_turns":1,"stop_reason":"stop_sequence","session_id":"73e4b3a6-5fff-4701-9c6a-77c0e252e331","total_cost_usd":0,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":403,"result":"Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access","type":"result","duration_ms":299,"uuid":"7427f16a-9160-4fca-9859-0f04b2856317"}

stderr:
<empty>

powered by BoxLite

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 24a7702a-26a6-494c-b416-1d0706387628

📥 Commits

Reviewing files that changed from the base of the PR and between ba64941 and 3a17efd.

📒 Files selected for processing (4)
  • apps/api/src/usage/services/expected-usage-period.spec.ts
  • apps/api/src/usage/services/expected-usage-period.ts
  • apps/api/src/usage/services/usage.service.metrics.spec.ts
  • apps/api/src/usage/services/usage.service.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/api/src/usage/services/expected-usage-period.spec.ts
  • apps/api/src/usage/services/usage.service.ts
  • apps/api/src/usage/services/expected-usage-period.ts

📝 Walkthrough

Walkthrough

Usage billing now derives open-period shapes from box state. Rollover refreshes resources from current box data. A locked reconciliation job repairs missing, stale, and orphaned periods across runner shards. Tests cover lifecycle states, drift, locking, grace windows, metrics, and idempotency.

Changes

Usage period reconciliation

Layer / File(s) Summary
State-based billing shapes
apps/api/src/usage/services/expected-usage-period.ts, apps/api/src/usage/services/expected-usage-period.spec.ts
Defines billing shapes for each BoxState. Adds tolerant resource-shape comparison and exhaustive tests.
Usage period lifecycle and rollover
apps/api/src/usage/services/usage.service.ts, apps/api/src/usage/services/usage.service.spec.ts
Creates periods from expected state-based shapes, supports transactional writes, and refreshes rollover resources from current box state.
Reconciliation workflow and integration coverage
apps/api/src/usage/services/usage.service.ts, apps/api/src/usage/usage.module.ts, apps/api/src/usage/usage.module.spec.ts, apps/api/src/usage/services/usage.service.integration.spec.ts, apps/api/src/usage/services/usage.service.metrics.spec.ts
Adds runner-aware reconciliation with distributed and per-box locks, drift metrics, transactional repairs, and coverage for state transitions, drift, grace windows, terminal states, rollover, metrics, and idempotency.

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

Sequence Diagram(s)

sequenceDiagram
  participant UsageService
  participant RunnerRepository
  participant BoxRepository
  participant UsagePeriodRepository
  UsageService->>RunnerRepository: scan runner shards
  UsageService->>BoxRepository: load current box state
  UsageService->>UsagePeriodRepository: identify missing, stale, or orphaned periods
  UsageService->>UsagePeriodRepository: repair periods under locks and transactions
Loading

Possibly related issues

  • boxlite-ai/boxlite#1207: Both changes modify UsageService rollover and period-creation logic.

Possibly related PRs

Suggested reviewers: dorianzheng

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the problem, approach, decisions, and verification, but it omits the required Call graph and Changes sections. Add the required Summary, Call graph with Before and After hops, Changes, and How to verify sections using the repository template.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: synchronizing open usage periods with box state.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 fix/usage_failure_lt_v2.0

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.

@DorianZheng

Copy link
Copy Markdown
Member

End to end, in one picture

BEFORE
  box ──state event──✗ lost
        └─► open period: missing, or charging the wrong thing
              └─► roll-over scans the PERIOD table
                    · a box with no period has no row to find
                    · reopens by copying the closing period's figures
                    └─► wrong forever

AFTER
  box ──state event──✗ lost
        └─► open period: missing, or charging the wrong thing
              └─► reconcile scans the BOX table   (every 5 min)
                    · box LEFT JOIN its open period
                    · re-derives the shape from the box
                    └─► correct within 5 min

The two passes stay side by side because their blind spots are opposite:

period whose box row was deleted box that never got a period
roll-over (scans periods) sees it blind
reconcile (scans boxes) blind sees it

expectedOpenPeriod(box) is what makes them agree: the event handler, the
roll-over and the reconcile pass all ask it the same question instead of each
carrying its own copy of the state → period rule.

@DorianZheng

Copy link
Copy Markdown
Member

Design challenge — duplication, performance, style

Bare line numbers are apps/api/src/usage/services/usage.service.ts at 7dcddd3.

Duplication

1. The two passes disagree about which boxes are in scope.
:223 — the roll-over skips the warm-pool org. reconcileUsagePeriods does not skip it.
Warm-pool boxes sit in STARTED under that org (box-warm-pool.service.ts:102-106), so reconcile
will open periods for boxes the roll-over deliberately leaves alone. This PR unified the
state → shape rule; the which-boxes rule is still written in one place only and the two passes
now mean different things by it. Pick one answer, apply it to both.

2. The state rule is written twice.
TS at expected-usage-period.ts:66, SQL at :364-383. The PR says so and pins it with
it.each(Object.values(BoxState)). But that test varies the state at one fixed resource shape.
It cannot catch SQL that is narrower than the TS rule — and narrower means drift stays
invisible forever with no test failing. Wider only costs work. Worth stating that invariant.
Fix that also shrinks the API: move the predicate builder into expected-usage-period.ts. Then
BOX_STATES_WITH_OPEN_PERIOD / _WITHOUT_ / _BILLING_DISK_ONLY become private and the module
goes from 8 exports to 3.

3. "Agrees with the box" means three different things.
SQL compares 6 fields (:364-383), sameShape compares 4 (expected-usage-period.ts:80), and
org/region are compared by hand at :425-426. Let sameShape own all six.

4. Two ways to close a period, inside one method.
:417 calls closeUsagePeriod; :432-434 does it inline. :417 also re-runs a SELECT for the
row already loaded at :409.

5. createUsagePeriod's optional entityManager (:176-192) is the flag this PR just deleted.
Same two-workflows-in-one-function shape as the old diskOnly = false. Make the manager required
and pass this.boxUsagePeriodRepository.manager from openUsagePeriodFor; the ternary at :192
disappears with it.

Performance

6. Eight of the nine selected columns are never read.
Only candidate.box_id is used. box_state, box_cpu, box_gpu, box_mem, box_disk,
box_org, box_region, period_id have zero readers — the re-fetch at :406 supersedes all of
them. SELECT b.id is enough, and DriftCandidate (:56-66) can lose eight fields.

7. The lock TTL equals the cron interval — no margin.
:299 holds the lock for 300s; the cron is every 5 minutes. A pass that reaches 300s loses its
lock exactly as the next one fires, so two passes overlap. Compare the roll-over: 60s lock, 100
items total, per minute.

8. The cap is per shard, not per pass.
RECONCILE_BATCH_SIZE (:40) is 100 per runner. At 50 runners one pass can perform 5,100
repairs, each a Redis SETNX + 2 SELECTs + a transaction, all sequential (:319-324). Nothing
bounds that against the TTL above. This is the incident case: a deploy that drops events drifts
thousands of boxes at once.

9. One query per runner even when nothing is wrong.
Steady state is (runners + 1) queries every 5 minutes, all returning zero rows. The description
compares sharding against a full table scan — it does not compare it against a single query with
runnerId IN (...) OR runnerId IS NULL, which is still index-friendly at one round trip. The
1.7 ms figure is one measurement at 50 runners, not a bound.

10. getDriftCounter() (:50) builds a meter and a counter on every increment.
The empty meter name matches otel.decorator.ts:12 — fine. But that file caches its
instruments in a Map (otel.decorator.ts:162-171) and this does not, so it deviates from the
repo's own pattern rather than following it.

Style

11. box can be null, and the compiler cannot see that it isn't.
:406 findOne may return null. :425 box.organizationId and :436 createUsagePeriod(box, …)
are safe only because expectedOpenPeriod(null) returns null and :413 returns early — reasoning
that crosses a module boundary and that TypeScript never verifies, since no strict /
strictNullChecks is set in apps/tsconfig.base.json or any apps/api/tsconfig*.json.
An if (!box) return after :406 makes it local and free.

12. repairDrift (:400-444) does five things.
Lock, fetch, decide, two different write paths, metric, log, error, unlock. The orphan path and the
shape path are two workflows sharing a preamble.

13. DriftCandidate is snake_case in a camelCase codebase (:56-66). b.id AS "boxId" keeps
the naming consistent; getRawMany does not force snake_case.

14. "provably too short" (:47) overclaims.
waitForLock (:488) loops indefinitely at 500 ms, so a handler queued behind others can exceed
60s by any margin — two minutes is a heuristic, not a proof. And since repairDrift skips when
the per-box lock is held, the grace window really only covers the gap between emit() and the
handler reaching waitForLock. That narrower claim is the true one, and the stronger one.

15. :312-315 breaks mid-sentence — "a box that / reached DESTROYED"; a rewrap artifact
prettier will not touch.

Holds up under challenge

RESOURCE_EPSILON; the exhaustiveness test forcing a decision on every new BoxState; opposite
blind spots as the reason both passes stay; not backdating corrections; state-qualifying the
resource comparison so stopped boxes don't fill every page.

If only four things change

  1. Decide the warm-pool scope once and apply it to both passes — (1)
  2. SELECT b.id only; drop eight fields from DriftCandidate(6)
  3. Predicate builder into expected-usage-period.ts, three constants private — (2)
  4. Give the pass a time budget, or raise the TTL above its worst case — (7 + 8)

@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch 6 times, most recently from f983773 to 2227901 Compare August 10, 2026 03:14
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch 2 times, most recently from a98687e to 5fa3c43 Compare August 10, 2026 13:43
Base automatically changed from fix/usage_failure_lt to main August 10, 2026 14:27
Copilot AI lite review requested due to automatic review settings August 11, 2026 02:40
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt_v2.0 branch from 7dcddd3 to 0fc7290 Compare August 11, 2026 02:40
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt_v2.0 branch from 0fc7290 to 7dcddd3 Compare August 11, 2026 02:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the API’s usage ledger so a box’s open usage period stays consistent with the box’s current state even when fire-and-forget state events are missed (process crash/throw mid-transition).

Changes:

  • Centralizes the state → billable-period rule in expectedOpenPeriod(box) and reuses it across event handling, daily roll-over, and reconciliation.
  • Updates the daily roll-over to re-derive billed resources from the current box row instead of copying forward from the closing period.
  • Adds a 5-minute reconcile cron pass that scans from the box side (sharded by runner) to repair missing/orphan/stale open periods, with integration coverage.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
apps/api/src/usage/usage.module.ts Registers Runner with TypeORM so the usage service can shard reconciliation by runner.
apps/api/src/usage/usage.module.spec.ts Updates module wiring assertions for the new Runner repository dependency.
apps/api/src/usage/services/usage.service.ts Adds expectedOpenPeriod usage, fixes roll-over drift, and introduces the reconcile cron pass + drift metrics.
apps/api/src/usage/services/usage.service.spec.ts Updates unit test scaffolding to account for the new runner repository dependency.
apps/api/src/usage/services/usage.service.integration.spec.ts Expands integration coverage to validate reconcile + updated roll-over behavior against real Postgres/Redis.
apps/api/src/usage/services/expected-usage-period.ts Introduces the shared “expected open period” rule and shape comparison helper.
apps/api/src/usage/services/expected-usage-period.spec.ts Adds unit coverage asserting state exhaustiveness and intended divergence from quota semantics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/api/src/usage/services/expected-usage-period.ts Outdated
Comment thread apps/api/src/usage/services/usage.service.ts Outdated
Copilot AI review requested due to automatic review settings August 11, 2026 02:45

@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: 5

🧹 Nitpick comments (8)
apps/api/src/usage/services/usage.service.ts (3)

400-444: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The state rule now lives in two places, which the SQL can silently narrow.

findDriftCandidates encodes the state-to-shape rule in SQL. repairDrift re-derives it through expectedOpenPeriod. A state added to expectedOpenPeriod but not to the SQL predicate produces a box that is never selected and therefore never repaired. The failure mode is silent under-billing, not an error.

The integration spec pins the two together for every BoxState, which covers the current states. Consider extracting the predicate into one builder next to expectedOpenPeriod so the SQL cannot drift on its own.

Also note that box can be null at Line 406 when the box is deleted between the scan and the repair. The expected === null branch handles that case correctly, so no guard is required today, but the dependency is implicit.

🤖 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 400 - 444,
Consolidate the state-to-shape rule used by findDriftCandidates and repairDrift
into a shared predicate or builder located beside expectedOpenPeriod, then reuse
it for the SQL filtering and repair logic so every BoxState remains covered
consistently. Preserve the existing expected === null handling for deleted boxes
and avoid adding a separate guard.

55-66: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Select only the box id, and drop the unused candidate columns.

repairDrift uses candidate.box_id only. It re-reads the box through boxRepository.findOne and re-derives the shape. The other eight columns are transferred for every candidate and never read. The DriftCandidate interface also uses snake_case fields, which does not match the rest of the codebase.

Reduce the projection to b.id and type the result as { box_id: string }[], or alias the column to boxId.

Also applies to: 343-353

🤖 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 55 - 66, Update
the drift-candidate query and DriftCandidate type used by repairDrift to select
and retain only the box identifier, using the codebase’s naming convention
(prefer an alias such as boxId, or consistently type the result as { box_id:
string }[]). Remove the unused state, resource, organization, region, and period
fields from the projection and interface.

50-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cache the counter lazily, without changing the meter name.

recordDrift calls createCounter on every repair. Cache the counter after first use. Keep metrics.getMeter(''); existing TypeScript instrumentation uses the empty meter name. Do not create the counter at module scope because AppModule loads before otelSdk.start().

🤖 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 50 - 53, Update
getDriftCounter to lazily cache and reuse the counter after its first creation,
while preserving metrics.getMeter('') and delaying initialization until the
function is called rather than creating it at module scope; ensure recordDrift
continues using this cached counter.
apps/api/src/box/services/box.service.start-reconciliation.spec.ts (2)

45-65: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add coverage for a box with no runnerId.

findStalledStartupJob returns null when box.runnerId is unset. No test covers that branch. A box in CREATING without a runner must not query the job repository and must not change state.

🤖 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/box/services/box.service.start-reconciliation.spec.ts` around
lines 45 - 65, Add a test for the start-reconciliation flow using a CREATING box
whose runnerId is unset. Assert that findStalledStartupJob returns null without
calling the job repository, and that the box remains unchanged with no
updateJobStatus or updateWhere calls.

81-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The assertion does not cover the stall window.

expect.objectContaining omits startedAt. A regression that drops the LessThan(claimedBefore) predicate, or that ignores boxSync.startConfirmationStallSeconds, still passes this test. The stall window is the core rule of this feature. Assert the startedAt bound.

💚 Add the stall-window assertion
     expect(jobFindOne).toHaveBeenCalledWith(
       expect.objectContaining({
         where: expect.objectContaining({
           runnerId: 'runner-1',
           resourceType: ResourceType.BOX,
           resourceId: box.id,
           type: JobType.CREATE_BOX,
           status: JobStatus.IN_PROGRESS,
+          startedAt: LessThan(expect.any(Date)),
         }),
       }),
     )
+
+    const claimedBefore = jobFindOne.mock.calls[0][0].where.startedAt.value as Date
+    expect(Date.now() - claimedBefore.getTime()).toBeGreaterThanOrEqual(STALL_SECONDS * 1000)

Import LessThan from typeorm.

🤖 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/box/services/box.service.start-reconciliation.spec.ts` around
lines 81 - 91, Update the job query assertion in the reconciliation test to
include the startedAt stall-window predicate, using TypeORM’s LessThan with a
bound derived from boxSync.startConfirmationStallSeconds. Import LessThan from
typeorm and assert it alongside runnerId, resourceType, resourceId, type, and
status so regressions removing or misconfiguring the bound fail.
apps/api/src/box/services/job.service.transaction.spec.ts (1)

41-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The commit-ordering assertion can be swallowed.

updateJobStatus calls handleJobCompletion(...).catch(...) and logs the error. If the expect inside this mock throws, the service catches it and the test still passes. Record the flag instead and assert it after the call.

♻️ Record the ordering, then assert
+  let committedAtCompletion: boolean | null = null
   const jobStateHandlerService = {
     handleJobCompletion: jest.fn(() => {
-      expect(transactionCommitted).toBe(true)
+      committedAtCompletion = transactionCommitted
       return Promise.resolve()
     }),
   }
   const service = new JobService(jobRepository as any, {} as any, jobStateHandlerService as any)
-  return { entityManager, jobRepository, jobStateHandlerService, service }
+  return { entityManager, jobRepository, jobStateHandlerService, service, committedAtCompletion: () => committedAtCompletion }

Then assert expect(committedAtCompletion()).toBe(true) in the first test.

🤖 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/box/services/job.service.transaction.spec.ts` around lines 41 -
46, Update the jobStateHandlerService.handleJobCompletion mock to record
transactionCommitted through a flag or committedAtCompletion helper instead of
asserting inside the callback, then assert that recorded value after
updateJobStatus completes in the first test. This keeps the commit-ordering
assertion outside the service’s caught error path.
apps/api/src/box/services/job.service.claim.spec.ts (1)

31-31: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

returningCalled is never asserted.

The helper tracks and exposes returningCalled, but no test reads it. Either assert that returning was called in the first test, or remove the tracking.

Also applies to: 44-47, 71-71

🤖 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/box/services/job.service.claim.spec.ts` at line 31, Update the
tests using the returningCalled helper to assert that returning was invoked in
the relevant first test, or remove the unused tracking variable and assignments
if that behavior is not under test. Ensure no unasserted returningCalled state
remains.
apps/api/src/box/services/job.service.ts (1)

489-511: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider one conditional UPDATE for the whole batch.

The loop issues one round trip per job. With the default limit of 10 each poll costs up to 10 sequential statements, and every poll from every runner pays this. A single statement keeps the same compare-and-swap semantics:

♻️ Batch the claim
-    for (const job of jobs) {
-      const claim = await this.jobRepository
-        .createQueryBuilder()
-        .update(Job)
-        .set({ status: JobStatus.IN_PROGRESS, startedAt: now, updatedAt: now })
-        .where('id = :id', { id: job.id })
-        .andWhere('status = :pending', { pending: JobStatus.PENDING })
-        .returning('*')
-        .execute()
-
-      const claimedRow = (claim.raw as Job[])[0]
-      if (!claim.affected || !claimedRow) {
-        this.logger.debug(`Job ${job.id} was already claimed by a concurrent poll`)
-        continue
-      }
-
-      claimedJobs.push(new JobDto(this.jobRepository.create(claimedRow)))
-    }
+    const claim = await this.jobRepository
+      .createQueryBuilder()
+      .update(Job)
+      .set({ status: JobStatus.IN_PROGRESS, startedAt: now, updatedAt: now })
+      .where('id IN (:...ids)', { ids: jobs.map((job) => job.id) })
+      .andWhere('status = :pending', { pending: JobStatus.PENDING })
+      .returning('*')
+      .execute()
+
+    for (const row of (claim.raw as Job[]) ?? []) {
+      claimedJobs.push(new JobDto(this.jobRepository.create(row)))
+    }
🤖 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/box/services/job.service.ts` around lines 489 - 511, Replace the
per-job update loop in the job polling method with one batch conditional UPDATE
that claims all eligible jobs in a single round trip, preserving the
pending-status compare-and-swap semantics and limiting claims to the requested
batch. Build JobDto instances from the returned rows, hydrating each through
this.jobRepository.create before adding them to claimedJobs; retain
concurrency-safe handling for rows not claimed.
🤖 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/box/services/box.service.ts`:
- Around line 1323-1347: Serialize stalled-startup reconciliation per box around
findStalledStartupJob and updateJobStatus, using an ownership-safe box lock so
concurrent runner callbacks cannot complete the same job or release another
operation’s lock. Ensure only the lock holder proceeds and always releases only
its own lock, preserving the existing completion-handler flow.

In `@apps/api/src/config/configuration.ts`:
- Around line 336-339: Validate BOX_SYNC_START_CONFIRMATION_STALL_SECONDS in the
configuration before assigning startConfirmationStallSeconds, falling back to 60
when the value is non-numeric, zero, or negative. Reuse an existing shared
numeric environment helper if available, and preserve the parsed positive value
for valid input.

In `@apps/api/src/usage/services/usage.service.ts`:
- Around line 293-328: Prevent overlapping reconciliation passes in
reconcileUsagePeriods by ensuring the lock remains valid for the entire
shard-and-repair loop: either extend the lock TTL beyond the worst-case runtime
or add a deadline check that stops processing before expiration. Preserve the
existing unlock behavior and shard processing for work completed within the
valid lock period.
- Around line 338-363: Update findDriftCandidates to exclude boxes whose
organization is BOX_WARM_POOL_UNASSIGNED_ORGANIZATION, matching the exclusion
used by closeAndReopenUsagePeriods. Add this predicate to the query so
repairDrift cannot create billable periods for warm-pool boxes.

In `@apps/runner/pkg/boxlite/client.go`:
- Around line 176-178: Update NewClient’s home-directory resolution so
Client.homeDir always matches the directory passed to boxlite.WithHomeDir:
either propagate the os.UserHomeDir() resolution error, or store the exact
fallback directory selected by NewRuntime. Ensure BoxSyncService can use the
populated Client.homeDir to run readStartedRecord during startup reconciliation.

---

Nitpick comments:
In `@apps/api/src/box/services/box.service.start-reconciliation.spec.ts`:
- Around line 45-65: Add a test for the start-reconciliation flow using a
CREATING box whose runnerId is unset. Assert that findStalledStartupJob returns
null without calling the job repository, and that the box remains unchanged with
no updateJobStatus or updateWhere calls.
- Around line 81-91: Update the job query assertion in the reconciliation test
to include the startedAt stall-window predicate, using TypeORM’s LessThan with a
bound derived from boxSync.startConfirmationStallSeconds. Import LessThan from
typeorm and assert it alongside runnerId, resourceType, resourceId, type, and
status so regressions removing or misconfiguring the bound fail.

In `@apps/api/src/box/services/job.service.claim.spec.ts`:
- Line 31: Update the tests using the returningCalled helper to assert that
returning was invoked in the relevant first test, or remove the unused tracking
variable and assignments if that behavior is not under test. Ensure no
unasserted returningCalled state remains.

In `@apps/api/src/box/services/job.service.transaction.spec.ts`:
- Around line 41-46: Update the jobStateHandlerService.handleJobCompletion mock
to record transactionCommitted through a flag or committedAtCompletion helper
instead of asserting inside the callback, then assert that recorded value after
updateJobStatus completes in the first test. This keeps the commit-ordering
assertion outside the service’s caught error path.

In `@apps/api/src/box/services/job.service.ts`:
- Around line 489-511: Replace the per-job update loop in the job polling method
with one batch conditional UPDATE that claims all eligible jobs in a single
round trip, preserving the pending-status compare-and-swap semantics and
limiting claims to the requested batch. Build JobDto instances from the returned
rows, hydrating each through this.jobRepository.create before adding them to
claimedJobs; retain concurrency-safe handling for rows not claimed.

In `@apps/api/src/usage/services/usage.service.ts`:
- Around line 400-444: Consolidate the state-to-shape rule used by
findDriftCandidates and repairDrift into a shared predicate or builder located
beside expectedOpenPeriod, then reuse it for the SQL filtering and repair logic
so every BoxState remains covered consistently. Preserve the existing expected
=== null handling for deleted boxes and avoid adding a separate guard.
- Around line 55-66: Update the drift-candidate query and DriftCandidate type
used by repairDrift to select and retain only the box identifier, using the
codebase’s naming convention (prefer an alias such as boxId, or consistently
type the result as { box_id: string }[]). Remove the unused state, resource,
organization, region, and period fields from the projection and interface.
- Around line 50-53: Update getDriftCounter to lazily cache and reuse the
counter after its first creation, while preserving metrics.getMeter('') and
delaying initialization until the function is called rather than creating it at
module scope; ensure recordDrift continues using this cached counter.
🪄 Autofix

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: febbaac1-d922-4178-9ae9-325de286285c

📥 Commits

Reviewing files that changed from the base of the PR and between 996912c and 7dcddd3.

📒 Files selected for processing (20)
  • apps/api/src/box/services/box.service.spec.ts
  • apps/api/src/box/services/box.service.start-reconciliation.spec.ts
  • apps/api/src/box/services/box.service.ts
  • apps/api/src/box/services/job.service.claim.spec.ts
  • apps/api/src/box/services/job.service.transaction.spec.ts
  • apps/api/src/box/services/job.service.ts
  • apps/api/src/config/configuration.ts
  • apps/api/src/usage/services/expected-usage-period.spec.ts
  • apps/api/src/usage/services/expected-usage-period.ts
  • apps/api/src/usage/services/usage.service.integration.spec.ts
  • apps/api/src/usage/services/usage.service.spec.ts
  • apps/api/src/usage/services/usage.service.ts
  • apps/api/src/usage/usage.module.ts
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/create_invariant_test.go
  • apps/runner/pkg/services/box_sync.go
  • apps/runner/pkg/services/box_sync_test.go
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/runtime/layout.rs
  • src/boxlite/tests/container_start_record.rs

Comment thread apps/api/src/box/services/box.service.ts
Comment thread apps/api/src/config/configuration.ts
Comment thread apps/api/src/usage/services/usage.service.ts
Comment thread apps/api/src/usage/services/usage.service.ts
Comment thread apps/runner/pkg/boxlite/client.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

apps/api/src/usage/services/usage.service.ts:53

  • getDriftCounter() currently calls createCounter() every time drift is recorded, which can register duplicate instruments and add unnecessary overhead on hot paths. Cache the Counter instance so repeated repairs just call add() on the same instrument.
const getDriftCounter = () =>
  metrics.getMeter('').createCounter('usage_period_drift_repaired', {
    description: 'Open usage periods brought back in step with the box they bill for',
  })

apps/api/src/usage/services/usage.service.ts:283

  • The PR description says the diff here should only include the usage files (stacked on #1091), but this PR also contains non-usage changes (BoxLite Rust runtime/tests, runner BoxSync, JobService concurrency). Please retarget/rebase or update the PR description/scope so reviewers know what they are approving.
  /**
   * Brings open periods back in step with the boxes they bill for.
   *
   * The ledger is maintained by in-process events, which are fire-and-forget: a
   * handler that dies, throws, or loses its process leaves the box and its period

Comment thread src/boxlite/src/runtime/layout.rs Outdated
Copilot AI review requested due to automatic review settings August 11, 2026 03:19
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt_v2.0 branch from 7dcddd3 to 0fc7290 Compare August 11, 2026 03:19
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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/services/usage.service.integration.spec.ts (1)

477-494: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The parity test pins only one direction.

it.each always starts from a box with no open period. It therefore checks that the SQL predicate creates the shape expectedOpenPeriod returns. It never checks the opposite direction: a box that already holds an open period whose state maps to null. The test at lines 443-456 shows that STARTING diverges in exactly that direction. Consider adding a second it.each that seeds an open period first and asserts closure when expectedOpenPeriod returns null, with STARTING and other in-flight states excluded explicitly.

🤖 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.integration.spec.ts` around lines
477 - 494, Extend the parity coverage near the existing “agrees with
expectedOpenPeriod” test with a second it.each that seeds an open period before
reconciliation, then asserts it is closed when expectedOpenPeriod({ ...box,
state }) returns null. Exclude STARTING and the other in-flight states
explicitly, while preserving the current test’s coverage for creating open
periods from an initially closed box.
🤖 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/services/usage.service.integration.spec.ts`:
- Around line 477-494: Extend the parity coverage near the existing “agrees with
expectedOpenPeriod” test with a second it.each that seeds an open period before
reconciliation, then asserts it is closed when expectedOpenPeriod({ ...box,
state }) returns null. Exclude STARTING and the other in-flight states
explicitly, while preserving the current test’s coverage for creating open
periods from an initially closed box.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a9cc6dbe-314a-4210-9e3b-96bf2f4ddf45

📥 Commits

Reviewing files that changed from the base of the PR and between ae47ad7 and 0fc7290.

📒 Files selected for processing (7)
  • apps/api/src/usage/services/expected-usage-period.spec.ts
  • apps/api/src/usage/services/expected-usage-period.ts
  • apps/api/src/usage/services/usage.service.integration.spec.ts
  • apps/api/src/usage/services/usage.service.spec.ts
  • apps/api/src/usage/services/usage.service.ts
  • apps/api/src/usage/usage.module.spec.ts
  • apps/api/src/usage/usage.module.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/api/src/usage/services/usage.service.spec.ts
  • apps/api/src/usage/usage.module.ts
  • apps/api/src/usage/services/expected-usage-period.spec.ts
  • apps/api/src/usage/services/expected-usage-period.ts
  • apps/api/src/usage/services/usage.service.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (3)

apps/api/src/usage/services/usage.service.ts:451

  • recordDrift() logs a WARN per repaired box. With up to RECONCILE_BATCH_SIZE repairs per shard and many shards per run, this can generate very high log volume during catch-up, impacting logging/alerting noise. Consider logging per-run summaries at WARN/INFO and keeping per-box detail at DEBUG.
  private recordDrift(kind: 'missing' | 'orphan' | 'stale_shape', boxId: string): void {
    getDriftCounter().add(1, { kind })
    this.logger.warn(`Repaired ${kind} usage period drift for box ${boxId}`)
  }

apps/api/src/usage/services/usage.service.ts:302

  • The cron-level Redis lock for reconcileUsagePeriods() is set to 300s, but the work per run scales with the number of runner shards (up to RECONCILE_BATCH_SIZE repairs per shard). If the run exceeds 300s, the lock can expire mid-run and another instance can start overlapping reconciliation, increasing contention and potentially duplicating repairs.

This issue also appears on line 448 of the same file.

  async reconcileUsagePeriods() {
    const lockKey = 'reconcile-usage-periods'
    if (!(await this.redisLockProvider.lock(lockKey, 300))) {
      return

apps/api/src/usage/services/usage.service.ts:404

  • The per-box lock helper is named aquireLock (missing the second 'c'), and the new reconciliation path calls it. This typo makes the locking API harder to scan/search and easy to retype incorrectly; consider renaming to acquireLock and updating call sites + the helper definition.
  private async repairDrift(candidate: DriftCandidate): Promise<void> {
    if (!(await this.aquireLock(candidate.box_id))) {
      return

Copilot AI review requested due to automatic review settings August 11, 2026 03:27
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt_v2.0 branch from 0fc7290 to ba64941 Compare August 11, 2026 03:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

apps/api/src/usage/services/usage.service.ts:54

  • getDriftCounter() creates a new OpenTelemetry counter on every call. In OTel, instruments are expected to be created once and reused; repeatedly creating them can add unnecessary overhead and may lead to duplicate-instrument behavior depending on the SDK/exporter. Memoize the counter instance and only call createCounter the first time.
const getDriftCounter = () =>
  metrics.getMeter('').createCounter('usage_period_drift_repaired', {
    description: 'Open usage periods brought back in step with the box they bill for',
  })

apps/api/src/usage/services/expected-usage-period.ts:51

  • This comment says cpu/gpu/mem/disk are "double precision on both sides", but the box resource columns are currently int (see Box entity) while usage periods are stored as floats. The rationale for RESOURCE_EPSILON still makes sense (JS numbers + float storage for periods), but the wording is misleading; tightening it will prevent future confusion about which side can introduce rounding noise.
// cpu/gpu/mem/disk are double precision on both sides, so a figure that has
// round-tripped through Postgres can differ from the box's by float noise.
// Without a tolerance the reconcile pass would rewrite an already-correct
// period on every run, fragmenting the ledger into unbillable slivers.

Comment thread apps/api/src/usage/services/usage.service.ts
DorianZheng
DorianZheng previously approved these changes Aug 12, 2026
The ledger is maintained by fire-and-forget in-process events, so a
handler that throws or loses its process leaves a box and its open
period disagreeing, with nothing to notice. Two gaps followed:

- The daily roll-over copied the closing period's resources into the
  new one, so drift was re-copied every day: a period charging no cpu
  for a running box stayed wrong forever, and a disk resize that landed
  while the box was stopped never reached the ledger at all.
- Nothing scanned from the box side. The roll-over walks the period
  table, so a box that never got a period was invisible to it.

`expectedOpenPeriod` becomes the single source of truth for the
state -> period rule, shared by the event handler, the roll-over and a
new reconcile pass. The roll-over now re-derives resources from the box
instead of copying them forward. The reconcile pass scans boxes per
runner shard every five minutes, re-checks each candidate under the
per-box lock, and repairs missing, stale and orphaned periods, counting
each repair on `usage_period_drift_repaired`.

It deliberately does not reuse `BOX_STATES_CONSUMING_COMPUTE`: quota
counts CREATING and STARTING because the runner has pinned the
resources, while billing does not charge for a box the tenant cannot
use yet. Corrections start now and are never backdated — the window a
box spent mis-billed cannot be reconstructed, and guessing it would
replace a known gap with an invented charge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 12, 2026 08:56
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt_v2.0 branch from ba64941 to 3a17efd Compare August 12, 2026 08:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (2)

apps/api/src/usage/services/usage.service.ts:462

  • In the drift repair transaction, open.endAt is set with new Date(), then createUsagePeriod sets startAt with a separate new Date(). That can introduce a small gap/overlap between periods (unlike the roll-over path which uses a single closeTime). Capture one timestamp and reuse it for both the close and the reopened period’s startAt to keep the ledger contiguous.
          open.endAt = new Date()
          await transactionalEntityManager.save(open)
        }
        signal.throwIfAborted()
        await this.createUsagePeriod(box, expected, transactionalEntityManager)

apps/api/src/usage/services/expected-usage-period.ts:39

  • This comment says RESTORING is a state “no writer in this repository ever assigns”, but the runner adapter converts runner state BoxStateRestoring into BoxState.RESTORING (apps/api/src/box/runner-adapter/runnerAdapter.v0.ts:126–148). Please adjust the comment so it only claims “no writer” for the states that are truly unassigned (e.g. ARCHIVING/RESIZING) while keeping RESTORING in the “bills nothing” group.
// Every remaining state is in neither list and bills nothing, the same answer a
// terminal box gets: CREATING, STARTING and UNKNOWN, plus RESTORING, RESIZING
// and ARCHIVING, which no writer in this repository ever assigns.

@ltstriker
ltstriker requested a review from DorianZheng August 12, 2026 09:04
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.

3 participants