feat(billing): stop ingestion once an org is 3 days overdue and never paid#152
Open
Makisuo wants to merge 1 commit into
Open
feat(billing): stop ingestion once an org is 3 days overdue and never paid#152Makisuo wants to merge 1 commit into
Makisuo wants to merge 1 commit into
Conversation
… paid Adds a dunning enforcement path that hard-stops OTLP + Cloudflare Logpush ingestion for orgs that signed up, never paid an invoice, and are now 3+ days past due. Autumn's `check()` does not block on `past_due` by default and can't express the narrower "3 days AND never paid" policy, so this is implemented as Autumn-idiomatic state replication: a Svix-verified `billing.updated` webhook maintains the overdue clock, a daily reconcile cron promotes overdue-≥3d + never-paid orgs to suspended (and clears them on payment), and the ingest gateway reads the resulting flag and 402s. - db: new `org_billing_suspensions` table (+ incremental migration 0003); `suspended_at IS NOT NULL` is the gateway enforcement flag. - api: `billing-webhook.http.ts` (Web Crypto Svix verification, no new dep), `BillingSuspensionService` + pure `BillingSuspensionPolicy`, daily cron wired via `event.cron` dispatch in worker.ts + alchemy/wrangler crons, new `AUTUMN_WEBHOOK_SECRET` env. Extracted the shared Autumn-call helper into `lib/AutumnClient.ts`; added `invoices` to the domain `BillingCustomer`. - ingest: `ingest_suspended` threaded through `OrgRouting` (1s TTL), a `LEFT JOIN org_billing_suspensions` in the key/connector/routing queries, and a `402 billing_suspended` before the entitlement gate on both paths. Tests: policy boundaries, PGlite reconcile (promote/clear), Svix signature accept/tamper/wrong-secret, gateway suspension propagation. Full api suite (654) + ingest cargo tests (39) + typecheck (24 pkgs) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
|
Your LLM provider API key was rejected. Rotate the key in your provider dashboard, then update the matching GitHub Actions secret. Update repo secret → · Model settings → · Setup docs → · Ask in Discord →
|
Ingest Rust Test + Benchmark ResultsCommit: Load Benchmark —
|
| Metric | main (median) | PR (median) | Delta |
|---|---|---|---|
| Requests/sec | 609.77 | 1109.92 | +82.0% better |
| Rows/sec | 6097.73 | 11099.18 | +82.0% better |
| p50 latency | 77.80 ms | 38.97 ms | -49.9% better |
| p95 latency | 305.15 ms | 161.01 ms | -47.2% better |
| p99 latency | 504.06 ms | 280.42 ms | -44.4% better |
| Export catch-up | 0.026 s | 0.027 s | +1.7% worse |
| Max RSS | 106.54 MiB | 104.78 MiB | -1.7% better |
| Failures | 0 | 0 | same |
Same code path on both sides (same LOAD_TEST_INGEST_MODE), so the delta column is meaningful. Numbers come from ubuntu-latest, which is noisy — treat single-digit-percent deltas as noise.
PR load benchmark JSON (per-iteration)
[
{
"ingest_mode": "tinybird",
"requests": 2000,
"successes": 2000,
"failures": 0,
"rows_sent": 20000,
"rows_exported": 20000,
"imports": 25,
"duration_seconds": 1.5710919049999998,
"export_catchup_seconds": 0.026657121,
"request_rps": 1273.0000031411275,
"row_rps": 12730.000031411277,
"p50_ms": 38.968,
"p95_ms": 110.595,
"p99_ms": 129.699,
"max_rss_mb": 103.73828125,
"max_cpu_percent": 37.5,
"avg_cpu_percent": 30.299999999999997
},
{
"ingest_mode": "tinybird",
"requests": 2000,
"successes": 2000,
"failures": 0,
"rows_sent": 20000,
"rows_exported": 20000,
"imports": 25,
"duration_seconds": 3.785971262,
"export_catchup_seconds": 0.025215308,
"request_rps": 528.2660278154008,
"row_rps": 5282.660278154008,
"p50_ms": 54.978,
"p95_ms": 328.75,
"p99_ms": 1157.642,
"max_rss_mb": 105.78125,
"max_cpu_percent": 26.7,
"avg_cpu_percent": 18.9
},
{
"ingest_mode": "tinybird",
"requests": 2000,
"successes": 2000,
"failures": 0,
"rows_sent": 20000,
"rows_exported": 20000,
"imports": 28,
"duration_seconds": 1.801934901,
"export_catchup_seconds": 0.026730813,
"request_rps": 1109.918010295534,
"row_rps": 11099.180102955339,
"p50_ms": 38.141,
"p95_ms": 161.01,
"p99_ms": 280.419,
"max_rss_mb": 104.77734375,
"max_cpu_percent": 42.8,
"avg_cpu_percent": 32.025
}
]main load benchmark JSON (per-iteration)
[
{
"ingest_mode": "tinybird",
"requests": 2000,
"successes": 2000,
"failures": 0,
"rows_sent": 20000,
"rows_exported": 20000,
"imports": 27,
"duration_seconds": 4.370867142,
"export_catchup_seconds": 0.026205154,
"request_rps": 457.57510695803256,
"row_rps": 4575.751069580326,
"p50_ms": 97.947,
"p95_ms": 436.35,
"p99_ms": 491.501,
"max_rss_mb": 104.60546875,
"max_cpu_percent": 16.6,
"avg_cpu_percent": 10.633333333333333
},
{
"ingest_mode": "tinybird",
"requests": 2000,
"successes": 2000,
"failures": 0,
"rows_sent": 20000,
"rows_exported": 20000,
"imports": 26,
"duration_seconds": 2.399513216,
"export_catchup_seconds": 0.025813347,
"request_rps": 833.5023898447242,
"row_rps": 8335.023898447243,
"p50_ms": 47.667,
"p95_ms": 305.148,
"p99_ms": 504.055,
"max_rss_mb": 108.15625,
"max_cpu_percent": 30.3,
"avg_cpu_percent": 24.76
},
{
"ingest_mode": "tinybird",
"requests": 2000,
"successes": 2000,
"failures": 0,
"rows_sent": 20000,
"rows_exported": 20000,
"imports": 27,
"duration_seconds": 3.279909851,
"export_catchup_seconds": 0.026371769,
"request_rps": 609.7728568333142,
"row_rps": 6097.728568333142,
"p50_ms": 77.798,
"p95_ms": 268.808,
"p99_ms": 516.428,
"max_rss_mb": 106.5390625,
"max_cpu_percent": 23.6,
"avg_cpu_percent": 19.74285714285714
}
]WAL-acked microbench (cargo bench --bench ingest_bench)
Compiling maple-ingest v0.1.0 (/home/runner/work/maple/maple/apps/ingest)
Finished `bench` profile [optimized] target(s) in 32.43s
Running benches/ingest_bench.rs (target/release/deps/ingest_bench-581d2100de893627)
Gnuplot not found, using plotters backend
test ingest_accept/logs_10_rows_wal_ack ... bench: 2820887 ns/iter (+/- 1804311)
test ingest_accept/traces_10_spans_wal_ack ... bench: 1196406 ns/iter (+/- 1046054)
cargo test
test telemetry::tests::metric_encoder_matches_all_tinybird_datasource_shapes ... ok
test telemetry::tests::metrics_summary_data_points_are_dropped ... ok
test telemetry::tests::metrics_emit_exactly_the_jsonpaths_declared_in_datasources_ts ... ok
test telemetry::tests::migrate_legacy_shard_relocates_frames_into_lanes ... ok
test telemetry::tests::pipeline_can_start_for_clickhouse_only_without_tinybird_credentials ... ok
test telemetry::tests::clickhouse_export_drops_passworded_non_https_endpoint_without_sending ... ok
test telemetry::tests::pipeline_e2e_exports_gzip_ndjson_to_fake_tinybird ... ok
test telemetry::tests::pipeline_e2e_exports_metrics_to_fake_tinybird ... ok
test telemetry::tests::sampling_keeps_errors_even_when_ratio_low ... ok
test telemetry::tests::scraper_contract::scraper_otlp_json_decodes_with_gateway_serde_and_encodes_to_rows ... ok
test telemetry::tests::pipeline_e2e_exports_traces_to_fake_tinybird ... ok
test telemetry::tests::timestamp_has_nano_precision ... ok
test telemetry::tests::timestamps_match_clickhouse_datetime64_nine_format ... ok
test telemetry::tests::trace_encoder_matches_tinybird_row_shape ... ok
test telemetry::tests::traces_emit_exactly_the_jsonpaths_declared_in_datasources_ts ... ok
test telemetry::tests::wal_partial_drain_advances_cursor_without_truncating ... ok
test telemetry::tests::wal_round_trips_frame ... ok
test telemetry::tests::wal_truncates_after_full_drain_allowing_further_appends ... ok
test telemetry::tests::pipeline_exports_ready_org_to_clickhouse_without_tinybird_calls ... ok
test telemetry::tests::slow_clickhouse_lane_does_not_block_cosharded_tinybird_org ... ok
test telemetry::tests::clickhouse_breaker_sheds_after_threshold_failures ... ok
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.77s
Running unittests src/bin/load_test.rs (target/debug/deps/load_test-661a0aa1eb3f6d6d)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/maple_ingest-c33bf80c577edb95)
running 39 tests
test autumn::tests::allowed_only_no_balance_field ... ok
test autumn::tests::flat_hardcap_with_remaining_allows ... ok
test autumn::tests::flat_hardcap_depleted_blocks ... ok
test autumn::tests::flat_overage_allows ... ok
test autumn::tests::flat_sub_one_gb_remaining_still_allows ... ok
test autumn::tests::flat_unlimited_allows ... ok
test autumn::tests::nested_balance_object_with_remaining_allows ... ok
test autumn::tests::nested_balance_object_depleted_blocks ... ok
test autumn::tests::nested_overage_allows ... ok
test autumn::tests::null_balance_no_subscription_blocks ... ok
test tests::api_error_kind_maps_status_to_stable_label ... ok
test autumn::tests::unrecognized_shape_returns_none ... ok
test tests::clickhouse_destination_is_terminal_in_dual_mode ... ok
test tests::clickhouse_destination_uses_native_pipeline_even_in_forward_mode ... ok
test tests::clickhouse_target_resolver_requires_current_schema ... ok
test tests::clickhouse_target_resolver_decrypts_current_schema_password ... ok
test tests::cloudflare_log_record_maps_body_severity_and_attributes ... ok
test tests::cloudflare_timestamps_support_rfc3339_unix_and_unix_nano ... ok
test tests::cloudflare_ndjson_payload_parses_multiple_records ... ok
test tests::cloudflare_validation_payload_is_detected ... ok
test tests::decrypt_aes256_gcm_matches_node_crypto_fixture ... ok
test tests::enrichment_overwrites_tenant_fields ... ok
test tests::extract_ingest_key_returns_sentinel_literal_unchanged ... ok
test tests::clickhouse_target_resolver_rejects_password_over_http ... ok
test tests::hash_is_deterministic ... ok
test tests::rejection_span_status_is_error_only_for_5xx ... ok
test tests::resolve_ingest_key_is_not_suspended_by_default ... ok
test tests::resolve_ingest_key_keeps_stale_schema_on_managed_native_path ... ok
test tests::resolve_ingest_key_propagates_ingest_suspended_flag ... ok
test tests::resolve_connector_refreshes_routing_before_auth_cache_expires ... ok
test tests::resolve_ingest_key_returns_none_when_hash_missing ... ok
test tests::resolve_ingest_key_returns_self_managed_false_when_no_settings_row ... ok
test tests::resolve_ingest_key_returns_self_managed_true_when_active_settings_row ... ok
test tests::resolve_ingest_key_refreshes_routing_before_auth_cache_expires ... ok
test tests::sentinel_token_matches_only_exact_literal ... ok
test tests::tinybird_destination_keeps_forward_mode_on_forward_path ... ok
test autumn::tests::fails_open_on_transport_error ... ok
test tests::resolve_ingest_key_serves_last_known_routing_when_refresh_fails ... ok
test tests::forward_mode_switches_ready_org_to_clickhouse_without_forwarding_again ... ok
test result: ok. 39 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
Doc-tests maple_ingest
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What & why
Hard-stops OTLP + Cloudflare Logpush ingestion for orgs that signed up, never paid an invoice, and are now 3+ days past due. Today the gateway's only billing enforcement is the per-request Autumn
/v1/check(gated byAUTUMN_ENFORCE_LIMITS), which blocks hard-capped/zero-balance or no-subscription orgs but ignorespast_dueentirely.Verified against Autumn's docs, two facts shaped the design:
check()does not block onpast_dueby default — Autumn: "If you'd like to block feature access when a subscription ispast_due, please contact us." The native toggle is also all-or-nothing, so it can't express "3 days and never paid". Custom logic is required.billing.updated, Svix-signed), not polling.So this is the Autumn-idiomatic shape: webhook replicates the overdue clock → a small time-based reconcile cron promotes/clears → the gateway reads a DB flag and 402s.
How it works
billing-webhook.http.ts) — Svix-verifiedPOST /api/billing/autumn/webhook(Web Crypto HMAC, mirrors the GitHub webhook verifier — no new dependency). Onbilling.updatedit re-derives the org'spastDuefrom Autumn and upserts/clears its overdue row.BillingSuspensionService+ pureBillingSuspensionPolicy) — daily, scoped to the overdue set only (not a full-org scan; the "+3 days" transition is a timer no webhook fires for). Promotes overdue-≥3d + never-paid (nostatus:"paid"invoice) →suspended_at, clears rows once the org pays. Wired viaevent.crondispatch in worker.ts + alchemy/wrangler crons.main.rs) —ingest_suspendedthreaded throughOrgRouting(refreshes on the 1s routing TTL → fast un-suspension; fail-open on DB error), aLEFT JOIN org_billing_suspensionsin the key/connector/routing queries, and a402 "billing_suspended"before the entitlement gate on both OTLP and Logpush paths.Schema
New
org_billing_suspensionstable (incremental migration0003).overdue_sinceis set by the webhook;suspended_at IS NOT NULLis the gateway's enforcement flag.Decisions baked in
status === "paid"(targets free/trial signups that never converted; lapsed paying customers are left to normal dunning).due_date, so the anchor is when the webhook first observespastDue.Reviewer notes
AutumnClient.tsand addedinvoicesto the domainBillingCustomer(decoded only when expanded).AUTUMN_WEBHOOK_SECRETand register thebilling.updatedwebhook in the Autumn dashboard (without it the receiver 401s and only the daily cron drives state). The webhook payload'scustomer_idlocation is parsed defensively — worth confirming against a realbilling.updateddelivery in a preview stage.Testing
customer_idextraction, gateway suspension propagation.@maple/apisuite 654/654, ingest cargo tests 39/39,bun typecheck24/24 packages; migration applies cleanly on a fresh DB via the real drizzle migrator.🤖 Generated with Claude Code