Skip to content

Add per-minute runner billing - #707

Merged
epompeii merged 16 commits into
develfrom
claude/runner-per-minute-billing-0JOha
Mar 17, 2026
Merged

Add per-minute runner billing#707
epompeii merged 16 commits into
develfrom
claude/runner-per-minute-billing-0JOha

Conversation

@epompeii

@epompeii epompeii commented Mar 15, 2026

Copy link
Copy Markdown
Member

This changeset adds Stripe meter event based runner usage billing.

@epompeii
epompeii changed the base branch from main to devel March 15, 2026 15:30
@github-actions

github-actions Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

PR: #707
Base: devel
Head: claude/runner-per-minute-billing-0JOha
Commit: 18cf2685284d41855213a5a27ad6afc20b7b0b90


PR Review: Per-Minute Runner Job Billing via Stripe

Summary

This PR implements per-minute billing for runner jobs, adds OTEL metering for billing failures, hardens billing robustness with TOCTOU race prevention, removes cargo audit from CI (replaced by cargo deny), and updates dependencies.


Positive Highlights

  • TOCTOU prevention: The billing read+write is performed under a single write_conn! lock, preventing race conditions where concurrent heartbeats could advance last_billed_minute between read and write. Well-documented with comments.
  • Best-effort billing pattern: Stripe calls happen after releasing the DB write lock, so billing failures don't block job execution. The delta is "claimed" in the DB first — correct approach.
  • BillingState caching: Avoids repeated DB lookups for the metered plan on every heartbeat. Sensible for a value that won't change mid-job.
  • Debounced Sentry reporting: Only the first billing failure per job is reported to Sentry, avoiding alert storms.
  • Comprehensive tests: elapsed_minutes and BillingDelta have thorough unit tests covering edge cases (negative input, overflow, zero elapsed, catch-up deltas).
  • Deterministic tests: Uses DateTime::TEST per CLAUDE.md guidelines.

Issues & Concerns

1. bill_final_minutes called before job status transition (Medium)

In handle_runner_message, for the Completed/Failed/Canceled paths, bill_final_minutes is called after handle_completed/handle_failed/handle_canceled, which transition the job to a terminal status. But in bill_final_minutes_inner, UpdateJob::final_billing uses execute() (no status filter) — this is intentionally safe per the comment. However, in the disconnect/timeout paths at the bottom of execute_job_loop, bill_final_minutes is called before handle_timeout (for timeout) but without any status transition (for disconnect). This means:

  • On disconnect (lines 1343, 1365, 1405): billing runs but the job may still be in Running status — is that intentional? Who transitions the job to a terminal state on disconnect?
  • On WebSocket error (line 1365): same concern.

This isn't a billing bug per se, but it could lead to billing for minutes on a job that's stuck in Running without cleanup.

2. elapsed_minutes minimum of 1 for BillingDelta on first heartbeat (Low)

elapsed_minutes always returns at least 1, even at 0 seconds elapsed. Combined with BillingDelta::new returning delta: 1 on the very first heartbeat (when last_billed_minute is None/0), this means a job is billed 1 minute immediately upon the first heartbeat — which could be seconds after starting. This is documented and intentional (minimum billing), but worth confirming this aligns with the pricing model shown to customers.

3. metered_plan_id uses auth_conn! inside BillingState (Low)

BillingState::metered_plan_id uses auth_conn!(context) for the read. This is fine per CLAUDE.md (read-only authenticated access), but note that this read happens outside the write_conn! block in handle_heartbeat. If the plan were to be updated between the write_conn! block (where billing delta is calculated) and the auth_conn! read (where plan ID is fetched), you could theoretically bill against a stale plan. In practice this is negligible since plan changes mid-job are extremely unlikely, and the plan is cached after first lookup anyway.

4. Missing #[cfg(feature = "plus")] guard on billing types? (Low)

BillingState, BillingDelta, CachedMeteredPlan, and the billing functions are defined without a #[cfg(feature = "plus")] gate. The file itself is gated by the crate's feature structure (plus/api_runners), so this is likely fine, but worth confirming these don't leak into the open-source build.

5. cargo deny set to continue-on-error: true (Low-Medium)

Making cargo deny non-blocking in CI (continue-on-error: true) while simultaneously removing cargo audit means there's no blocking supply-chain security check left. This was presumably done to unblock CI, but it weakens the security posture. Consider re-enabling as blocking once the underlying advisory is resolved.


Minor / Style

  • clippy.toml allow-panic-in-tests = true: Reasonable cleanup — removes the need for #[expect(clippy::panic)] on many test functions. Consistent with the test attribute removals.
  • Rename METRICS_METER_EVENT_NAMEMETRICS_METER_NAME: Good — the old name was misleading since it's a meter name, not an event name.
  • DESIGN.md / PLAN.md updates: Accurately reflect the implemented billing flow and remove the resolved TODO.

Verdict

The core billing logic is well-designed with proper race condition handling and defensive error management. The main area to revisit is ensuring job cleanup on disconnects so that billed jobs don't get stuck in non-terminal states. The cargo deny change should be tracked as temporary.


Model: claude-opus-4-6

claude added 3 commits March 15, 2026 20:56
Adds a separate Stripe meter event name for runner usage billing,
distinct from the existing metrics-count-based meter.

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
- Rename METRICS_METER_EVENT_NAME -> METRICS_METER_NAME and add
  RUNNER_MINUTES_METER_NAME constant for runner-specific billing
- Rename record_metered_usage -> record_metrics_usage for clarity
- Add Biller::record_runner_usage() using the runner_minutes meter
- Add UpdateJob::heartbeat_with_billing() to atomically update both
  last_heartbeat and last_billed_minute in a single SQL UPDATE
- Implement billing logic in handle_heartbeat(): on each heartbeat,
  calculate elapsed minutes (ceil division), compare with
  last_billed_minute, and bill the delta to Stripe for metered plans
- Billing failures are logged but don't fail the heartbeat or job
- Add 6 unit tests for elapsed_minutes ceil calculation
- Update DESIGN.md and PLAN.md to reflect completed billing TODO

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
…ailures

Stripe billing failures in bill_elapsed_minutes() were logged but
otherwise invisible. This adds:

- OTEL counter `runner.minutes.billed` on successful Stripe billing
- OTEL counter `runner.minutes.billing_failed` on Stripe failure
- Debounced Sentry error reporting via BillingFailureTracker (only
  first failure per job execution is sent to Sentry)

The BillingFailureTracker is scoped to each job's execute_loop,
so it is automatically cleaned up when the job finishes.

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
@epompeii
epompeii force-pushed the claude/runner-per-minute-billing-0JOha branch from 73e1ede to dc1f23b Compare March 15, 2026 20:59
@github-actions

github-actions Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchclaude/runner-per-minute-billing-0JOha
Testbedubuntu-22.04
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
microseconds (µs)
(Result Δ%)
Upper Boundary
microseconds (µs)
(Limit %)
Adapter::Json📈 view plot
🚷 view threshold
3.80 µs
(+9.47%)Baseline: 3.48 µs
4.62 µs
(82.31%)
Adapter::Magic (JSON)📈 view plot
🚷 view threshold
3.75 µs
(+8.56%)Baseline: 3.46 µs
4.52 µs
(82.97%)
Adapter::Magic (Rust)📈 view plot
🚷 view threshold
25.24 µs
(-1.59%)Baseline: 25.65 µs
31.05 µs
(81.30%)
Adapter::Rust📈 view plot
🚷 view threshold
2.84 µs
(-0.34%)Baseline: 2.85 µs
3.34 µs
(85.15%)
Adapter::RustBench📈 view plot
🚷 view threshold
2.82 µs
(-1.07%)Baseline: 2.85 µs
3.32 µs
(84.92%)
head_version_insert/batch/10📈 view plot
🚷 view threshold
99.39 µs
(-0.76%)Baseline: 100.15 µs
120.45 µs
(82.51%)
head_version_insert/batch/100📈 view plot
🚷 view threshold
235.28 µs
(-0.87%)Baseline: 237.33 µs
266.98 µs
(88.12%)
head_version_insert/batch/255📈 view plot
🚷 view threshold
457.70 µs
(-0.76%)Baseline: 461.21 µs
492.34 µs
(92.96%)
head_version_insert/batch/50📈 view plot
🚷 view threshold
160.98 µs
(+0.35%)Baseline: 160.42 µs
182.51 µs
(88.20%)
threshold_query/join/10📈 view plot
🚷 view threshold
141.98 µs
(-1.69%)Baseline: 144.42 µs
170.07 µs
(83.48%)
threshold_query/join/20📈 view plot
🚷 view threshold
157.91 µs
(-0.42%)Baseline: 158.57 µs
186.03 µs
(84.88%)
threshold_query/join/5📈 view plot
🚷 view threshold
134.66 µs
(-1.29%)Baseline: 136.42 µs
159.81 µs
(84.26%)
threshold_query/join/50📈 view plot
🚷 view threshold
197.76 µs
(-1.11%)Baseline: 199.99 µs
232.82 µs
(84.94%)
🐰 View full continuous benchmarking report in Bencher

claude and others added 12 commits March 15, 2026 21:35
… completion

- Don't advance last_billed_minute when Stripe billing fails, so unbilled
  minutes are retried on the next heartbeat instead of silently lost
- Cache metered plan lookup in BillingState (renamed from BillingFailureTracker)
  to avoid redundant DB queries on every heartbeat
- Clamp negative elapsed seconds inside elapsed_minutes() instead of at call site
- DRY out record_runner_usage/record_metrics_usage into shared record_metered_usage
- Add bill_final_minutes() to bill remaining partial minutes on job completion,
  failure, cancellation, timeout, and disconnect — prevents lost revenue when
  jobs end between heartbeats

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
The time crate v0.3.44 was flagged by both cargo audit and cargo deny
for RUSTSEC-2026-0009 (DoS via stack exhaustion in RFC 2822 parsing).
Updating to v0.3.47 resolves the advisory.

https://claude.ai/code/session_01UkGyKNzdPLmeSVY9WP1Z5T
…, write_conn reads

- bill_final_minutes on disconnect/timeout paths now logs errors instead of
  propagating them, so a transient DB failure doesn't change ExecuteResult
  from Disconnected to an error
- elapsed_minutes(0) now returns 1, ensuring every job is billed at least
  1 minute even if it completes in under a second
- handle_heartbeat and bill_final_minutes now read jobs via write_conn!
  instead of auth_conn! to avoid stale last_billed_minute from SQLite WAL
  read snapshots

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
…inal_minutes

The write_conn change is unnecessary given the single-threaded execution
loop already prevents concurrent heartbeat billing races.

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
- metered_plan_id() DB lookup failures now return Ok(None) with a warning
  log instead of propagating as ChannelError, so a transient DB error
  during plan lookup doesn't kill the WebSocket connection
- bill_final_minutes_best_effort keeps sentry::capture_error for its
  ChannelError (not BillingError), which is correct since it's a one-time
  disconnect event not subject to heartbeat debouncing

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
Addresses RUSTSEC-2026-0037 (denial of service in Quinn endpoints).
Also restores continue-on-error for the cargo audit CI job.

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
cargo deny is a superset of cargo audit (advisories + licenses + bans),
so cargo audit is redundant. Also remove .cargo/audit.toml config.

https://claude.ai/code/session_01NuCYYTWFHyZtCjKTrFWEug
…lanId

- Replace two-step if-let + match in CachedMeteredPlan::metered_plan_id with
  a single match, returning owned MeteredPlanId instead of a reference
- Squash bill_final_minutes_best_effort into bill_final_minutes so all
  callers get best-effort error handling, preventing billing failures from
  failing job completion
- Remove extra blank line in test.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Hold the write lock for both the read and the UPDATE of
last_billed_minute, preventing a concurrent heartbeat from
advancing the value between our read and write (double-billing).
Stripe is called after releasing the lock — if it fails, the
delta is already claimed in the DB (acceptable: under-bill one
partial minute rather than double-bill).
@epompeii epompeii self-assigned this Mar 16, 2026
@epompeii epompeii changed the title Add RUNNER_METER_EVENT_NAME constant for per-minute runner billing Add per-minute runner billing Mar 16, 2026
@epompeii
epompeii merged commit 3050d04 into devel Mar 17, 2026
66 checks passed
@epompeii
epompeii deleted the claude/runner-per-minute-billing-0JOha branch March 17, 2026 04:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants