Add per-minute runner billing - #707
Conversation
🤖 Claude Code ReviewPR: #707 PR Review: Per-Minute Runner Job Billing via StripeSummaryThis PR implements per-minute billing for runner jobs, adds OTEL metering for billing failures, hardens billing robustness with TOCTOU race prevention, removes Positive Highlights
Issues & Concerns1.
|
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
73e1ede to
dc1f23b
Compare
|
| Branch | claude/runner-per-minute-billing-0JOha |
| Testbed | ubuntu-22.04 |
Click to view all benchmark results
| Benchmark | Latency | Benchmark 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%) |
… 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).
This changeset adds Stripe meter event based runner usage billing.