Skip to content

feat(router): env-configurable DB pool size + async billing debit for scale - #959

Open
steventohme wants to merge 5 commits into
mainfrom
router-db-pool-scaling
Open

feat(router): env-configurable DB pool size + async billing debit for scale#959
steventohme wants to merge 5 commits into
mainfrom
router-db-pool-scaling

Conversation

@steventohme

Copy link
Copy Markdown
Collaborator

What

Two changes so the router's Postgres path holds up under a large jump in concurrent traffic:

  1. ROUTER_POSTGRES_MAX_CONNS makes the pgxpool size configurable (default unchanged at 6). Per-instance pool size becomes a deploy knob so a higher-concurrency deployment can raise arrival-rate capacity without a rebuild. The deploy pin is set in the WorkWeave terraform change (companion PR), bounded by the primary's connection budget.

  2. fireBilling runs async via observability.SafeGo (matching the existing fireTelemetry pattern) instead of synchronously on the request path. The multi-CTE inference ledger write serializes on an org's single organization_credit_balance row; with many concurrent engineers in one organization that was the sharpest pool-contention point. The response is fully streamed before this runs, so durability-before-handler-return bought nothing but lock wait.

Notes

  • fireBilling drops the now-unused ctx param (matches fireTelemetry(p) signature); logBillingDebitFailure likewise. Both had a single caller.
  • Updated TestBillAuxiliaryInferenceUsesSummarizerProviderForBYOK to wait for the async debit, using the same pattern its sibling test (TestBillAuxiliaryInferenceBillsWithoutInstallation) already uses.
  • Deliberately not routing reads to the read replica: the spend-cap and balance gates are intentionally uncached to bound unbilled spend (api_key_spend_cap.go, balance_check.go document this), and session pins are write-then-read within a session, so replica lag would undermine both.

Validation

  • go build ./..., go vet ./internal/proxy ./cmd/router clean
  • go test ./internal/proxy/ ./cmd/router/ pass

🤖 Generated with Weave Router

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


  • Run git diff and analyze added comment blocks
  • Flag verbose blocks — posted advisory review with 2 suggestions

Comment thread cmd/router/main.go Fixed
Comment thread internal/proxy/service.go Fixed

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Advisory only — comment-length nits. Won't block merge.

Comment thread internal/proxy/service.go Outdated
Comment thread cmd/router/main.go Outdated
Comment thread internal/proxy/service.go Outdated
Comment thread internal/proxy/auxiliary_inference_internal_test.go Outdated
@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown

Security Review

A prepaid and spend-cap enforcement bypass was reproduced. Because a completed request's asynchronous debit is neither settled nor reserved before the next admission check, sequential requests can be accepted using stale balance and spend-counter values, exceeding the available prepaid balance or configured spending limits.

T-Rex T-Rex Logs

What T-Rex did

  • T-Rex produced proof for a posted P1 finding.
  • T-Rex produced proof for the posted P1 finding, covering the focused HTTP shutdown billing harness and related baseline and shutdown maneuvers.
  • T-Rex produced proof for the posted P1 finding that exercises the router pool-size overflow harness and boundary configurations.
  • T-Rex produced general-contract-validation-proof describing pre- and post-debit balance checks and noting the untracked test capture.
  • T-Rex produced general-contract-validation-proof detailing shutdown behavior where in-flight debit may be abandoned after router shutdown due to unjoined SafeGo goroutines.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (4)

  1. General comment

    P1 Asynchronous debit creates a sequential prepaid and spend-cap admission window

    • Bug
      • fireBilling returns after scheduling the debit in SafeGo; the balance and three spend-cap gates read committed state independently. A completed request whose ledger write is delayed therefore does not affect admission of the next sequential request. The reproduced $1 debit admitted another request while all checks still observed $0 spend/$0 debit, then rejected once the first debit committed.
    • Cause
      • internal/proxy/service.go:4531-4546 invokes s.billing.DebitForInference in a background goroutine. The debit is atomic when it executes, but neither the balance gate nor spend-cap gates reserve or include pending charges. internal/sqlc/billing.sql.go:57-165 updates balance and counters only as part of the eventual debit statement.
    • Fix
      • Reserve an upper-bound charge or serialize admission with debit settlement before dispatch, then reconcile to actual usage after completion. If preserving asynchronous final accounting, maintain an atomic pending-reservation counter included by balance and cap admission reads; release/reconcile it on debit completion or failure.

    T-Rex Ran code and verified through T-Rex

  2. General comment

    P1 Billing debit can be abandoned after response completion during router shutdown

    • Bug
      • fireBilling returns immediately after scheduling the debit in an untracked goroutine. A real HTTP harness showed that the response and http.Server.Shutdown can both complete while the real billing service is still blocked inside DebitForInference. The router main function then has no billing-drain phase before process exit, so an in-flight debit can be lost.
    • Cause
      • internal/proxy/service.go:4531-4546 uses observability.SafeGo, whose implementation at internal/observability/safego.go:22-32 launches a bare goroutine without a WaitGroup, registry, or shutdown hook. Router shutdown at cmd/router/main.go:998-1010 does not await these goroutines.
    • Fix
      • Register billable background work with a service-level drain mechanism and await it during shutdown within the termination budget, or commit the debit before treating the served request as fully complete. Preserve a bounded context and reconciliation logging for failures.

    T-Rex Ran code and verified through T-Rex

  3. General comment

    P1 Over-MaxInt32 PostgreSQL pool override wraps and prevents router startup

    • Bug
      • At cmd/router/main.go:87, setting ROUTER_POSTGRES_MAX_CONNS=2147483648 produces configured_max_conns=-2147483648; pgxpool.NewWithConfig then returns MaxSize must be >= 1, so the router follows its panic-on-construction-error startup failure path.
    • Cause
      • parseEnvInt uses strconv.Atoi, which accepts this value on the 64-bit deployment architecture, but the result is narrowed with an unchecked int32(...) conversion before it is assigned to pgxpool's MaxConns.
    • Fix
      • Parse this variable with a 32-bit bound or explicitly reject values outside 1..math.MaxInt32 before assigning cfg.MaxConns; retain the documented fallback or fail-fast behavior consistently.

    T-Rex Ran code and verified through T-Rex

  4. General comment

    P1 Fixed 50 ms wait makes async BYOK billing assertion flaky

    • Bug
      • The assertion at internal/proxy/auxiliary_inference_internal_test.go:305-307 assumes fireBilling has completed within 50 ms. fireBilling deliberately starts a SafeGo goroutine, so there is no completion synchronization. A controlled run held a correct debit after the background goroutine entered DebitInference; after the same 50 ms, debits=0, which makes the existing require.Len(debits, 1) fail. Releasing the debit then produced exactly one debit with zero inference delta and a non-zero BYOK fee.
    • Cause
      • billAuxiliaryInference invokes asynchronous fireBilling, while the test synchronizes with a fixed-duration sleep rather than an observable completion condition.
    • Fix
      • Replace the fixed sleep with bounded polling or a test-only completion signal that waits for one debit, then retain the debit-content assertions.

    T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "feat(router): env-configurable DB pool s..." | Re-trigger Greptile

Comment thread internal/proxy/service.go
Comment thread internal/proxy/service.go
Comment thread cmd/router/main.go Outdated
Comment thread internal/proxy/auxiliary_inference_internal_test.go
steventohme and others added 2 commits August 20, 2026 16:28
… scale

Two changes so the router can absorb a large jump in concurrent
organizations/traffic:

- ROUTER_POSTGRES_MAX_CONNS makes the pgxpool size configurable (default
  unchanged at 6). Per-instance pool size becomes a deploy knob so a
  higher-concurrency deployment can raise arrival-rate capacity without a
  rebuild, staying within the primary's connection budget.

- fireBilling now runs via observability.SafeGo off the request path instead
  of synchronously. The multi-CTE inference ledger write serializes on an
  org's single credit-balance row; with many concurrent engineers in one
  organization that was the sharpest pool contention point. The response is
  fully streamed before this runs, so durability-before-handler-return bought
  nothing but lock wait. Matches the existing fireTelemetry pattern.

Co-Authored-By: Weave Router <noreply@workweave.ai>
…nups

Review follow-up on the async billing debit:

- Drain at shutdown (bugbot, medium): billing debits now launch via
  observability.SafeGoTracked against a TrackedGroup that graceful shutdown
  Waits on after srv.Shutdown and before pool.Close. A SIGTERM during a debit
  previously killed the write, leaving served inference unbilled. Shutdown
  budget re-split 4.5s server + 1.5s drain + 1.5s emitter + 1.5s APM = 9.0s
  (same 10s SIGTERM window).
- CodeQL go/incorrect-integer-conversion: bound ROUTER_POSTGRES_MAX_CONNS to
  MaxInt32 before the int32 conversion.
- CodeQL go/clear-text-logging: no raw key is logged; the error chain from
  the billing repo references only the opaque API key ID, never the token.
- Comment nits (workweave-bot): shortened fireBilling + MaxConns comments.
- Test fix: TestBillAuxiliaryInferenceMatchesLedgerAmount still assumed sync
  billing; now polls for the async debit (require.Eventually), matching the
  sibling tests.

Co-Authored-By: Weave Router <noreply@workweave.ai>
@steventohme
steventohme force-pushed the router-db-pool-scaling branch from 3af8554 to 3727959 Compare August 20, 2026 23:36
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


  • Run git diff and analyze added comment blocks
  • Flag verbose blocks — posted advisory review with 1 suggestion

View job

@steventohme

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in 3727959:

  • Bugbot (medium) — async billing skipped on shutdown. Real. fireBilling debits now launch via a new observability.SafeGoTracked against a TrackedGroup; graceful shutdown Waits on it after srv.Shutdown and before pool.Close, so a deploy or scale-to-zero can't SIGKILL an in-flight DebitForInference. Shutdown budget re-split 4.5s server + 1.5s drain + 1.5s emitter + 1.5s APM = 9.0s within the 10s SIGTERM window. Each debit still carries its own 5s timeout, so the drain can't hang.
  • CodeQL go/incorrect-integer-conversion. Bounded ROUTER_POSTGRES_MAX_CONNS to math.MaxInt32 before the int32 conversion.
  • CodeQL go/clear-text-logging. No raw key is logged — the billing error chain references only the opaque API key ID, never the token; APIKeyID isn't in either log's fields. Flow confirmed safe.
  • workweave-bot comment nits (2). Shortened the fireBilling and ROUTER_POSTGRES_MAX_CONNS comments as suggested.
  • Bugbot (low) — test assumes sync billing. TestBillAuxiliaryInferenceMatchesLedgerAmount now polls for the async debit with require.Eventually, matching the sibling tests.

go build ./... + go vet clean; internal/observability, internal/proxy, internal/billing tests pass.

Comment thread cmd/router/main.go Fixed

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Advisory only — comment-length nit. Won't block merge.

Comment thread cmd/router/main.go Outdated
Comment thread cmd/router/main.go Outdated
strconv.Atoi yields an arch-dependent int, so the int->int32 conversion at
the assignment still tripped go/incorrect-integer-conversion even with a
range check. Parse at bit size 32 via parseEnvInt32 so the value is int32-wide
at the source; out-of-range input falls back to the default rather than
clamping.

Co-Authored-By: Weave Router <noreply@workweave.ai>
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


  • Run git diff and analyze added comment blocks
  • Flag verbose blocks — posted advisory review with 2 suggestions

View job

@steventohme

Copy link
Copy Markdown
Collaborator Author

Follow-up on the int-conversion alert: CodeQL still flagged it after the range check because the source is strconv.Atoi (arch-dependent int), so it tracked the narrowing to the int32(...) cast regardless of the bound above. Fixed properly in c0c24a9ROUTER_POSTGRES_MAX_CONNS now parses at bit size 32 via parseEnvInt32 (strconv.ParseInt(raw, 10, 32)), so the value is int32-wide at the source and there's no int→int32 narrowing on the returned value. Out-of-range input now falls back to the default rather than clamping.

Separately, the go/clear-text-logging alert on service.go is a false positive: the only API-key-typed value on the billing path is the opaque APIKeyID (a DB id), and it is not among the fields passed to either log call. Nothing sensitive reaches the log.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Advisory only — comment-length nits. Won't block merge.

Comment thread cmd/router/main.go Outdated
Comment thread cmd/router/main.go
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


Claude finished @steventohme's task —— View job


  • Run git diff and analyze added comment blocks
  • Flag verbose blocks — posted advisory review with 3 suggestions

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Advisory only — comment-length nits. Won't block merge.

Comment thread internal/observability/safego.go Outdated
Comment thread internal/proxy/service.go Outdated
Comment thread internal/proxy/service.go Outdated
Follow-up review hardening:

- Bound the shutdown drain. TrackedGroup is now cancellable
  (NewTrackedGroup): Cancel aborts every in-flight debit at its next context
  check, and the drain WaitWithContext is bounded by the 1.5s shutdown budget,
  so a contended ledger write can no longer push the process past the 10s
  SIGKILL window (bugbot high). Main's serverErr path also drains billing
  before return so pool.Close via defer can't drop in-flight debits.
- Unit-test the group semantics: cancel aborts in-flight work; WaitWithContext
  returns when the budget expires even if an operation overruns.
- Replace fixed 50ms sleeps with require.Eventually polling in the async
  billing tests (MatchesLedgerAmount already polled; BYOK + BillsWithout-
  Installation now do too) so slow-but-correct scheduling can't cause a
  spurious failure.
- Comment nits: fireBilling + ROUTER_POSTGRES_MAX_CONNS comments shortened
  (the tripwire/Background WHYs kept, redundancy dropped).

Co-Authored-By: Weave Router <noreply@workweave.ai>
@steventohme
steventohme force-pushed the router-db-pool-scaling branch from 1d7e556 to 6997d9f Compare August 21, 2026 00:35
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


  • Run git diff and analyze added comment blocks
  • Flag verbose blocks — post advisory review if needed

Co-Authored-By: Weave Router <noreply@workweave.ai>

@steventohme steventohme left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

(dummy)

@steventohme steventohme left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

(dummy)

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


  • Run git diff and analyze added comment blocks
  • Flag verbose blocks — posted advisory review with 6 suggestions

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 92a30db. Configure here.

Comment thread cmd/router/main.go
billingDrainCtx, billingDrainCancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer billingDrainCancel()
billingInflight.Cancel()
billingInflight.WaitWithContext(billingDrainCtx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shutdown cancels billing drain

High Severity

billingInflight.Cancel() runs before WaitWithContext on both shutdown paths, so every in-flight DebitForInference inherits a canceled group context and aborts immediately. The drain was meant to let async debits finish before pool.Close; cancel-first turns every deploy or scale-to-zero into guaranteed debit failures (logged for manual reconciliation) instead of a bounded wait for completion.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 92a30db. Configure here.

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Advisory only — comment-length nits. Won't block merge.

Comment thread cmd/router/main.go
Comment on lines +1025 to +1029
// srv.Shutdown only waits for handler goroutines; billing debits run in
// SafeGoTracked goroutines it doesn't know about. Drain before pool.Close
// so a deploy or scale-to-zero can't SIGKILL an in-flight debit. Cancel
// aborts overruns at their next context check; the bounded wait keeps the
// drain inside the 1.5s budget above.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// srv.Shutdown only waits for handler goroutines; billing debits run in
// SafeGoTracked goroutines it doesn't know about. Drain before pool.Close
// so a deploy or scale-to-zero can't SIGKILL an in-flight debit. Cancel
// aborts overruns at their next context check; the bounded wait keeps the
// drain inside the 1.5s budget above.
// srv.Shutdown only waits for handler goroutines; billing debits run in
// SafeGoTracked goroutines it doesn't know about. Drain before pool.Close
// so a deploy or scale-to-zero can't SIGKILL an in-flight debit.

Was 5 lines; the last two sentences restate what Cancel() + WaitWithContext visibly do.

Comment thread cmd/router/main.go
Comment on lines +1345 to +1348
// parseEnvInt32 reads an env var as a positive int32. Parses at bit size 32 so
// the value is int32-width at the source — no int->int32 narrowing on the
// returned value. Returns fallback when the var is unset, empty, out of
// int32 range, or unparseable.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// parseEnvInt32 reads an env var as a positive int32. Parses at bit size 32 so
// the value is int32-width at the source — no int->int32 narrowing on the
// returned value. Returns fallback when the var is unset, empty, out of
// int32 range, or unparseable.
// parseEnvInt32 reads an env var as a positive int32. Parses at bit size 32
// so the value is int32-width at the source — no int->int32 narrowing.

Was 4 lines; sentences 3–4 restate the fallback logic visible in the function body.

Comment on lines +36 to +39
// TrackedGroup is a WaitGroup for SafeGo-style background work that must not
// be dropped at shutdown (e.g. billing debits). Create with NewTrackedGroup,
// which wires a cancellable context so shutdown can abort in-flight work
// rather than waiting on its individual timeouts.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// TrackedGroup is a WaitGroup for SafeGo-style background work that must not
// be dropped at shutdown (e.g. billing debits). Create with NewTrackedGroup,
// which wires a cancellable context so shutdown can abort in-flight work
// rather than waiting on its individual timeouts.
// TrackedGroup is a WaitGroup for SafeGo-style background work that must not
// be dropped at shutdown (e.g. billing debits); Cancel aborts in-flight work.

Was 4 lines; the constructor-usage sentence restates what NewTrackedGroup's godoc already says.

Comment on lines +65 to +69
// SafeGoTracked runs fn exactly like SafeGo but registers it on g so a
// graceful shutdown can drain in-flight work before closing shared resources
// like the DB pool. The operation context derives from the group's cancellable
// context (so Cancel aborts it) with a generous per-operation timeout so one
// slow debit can't hold the drain open.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// SafeGoTracked runs fn exactly like SafeGo but registers it on g so a
// graceful shutdown can drain in-flight work before closing shared resources
// like the DB pool. The operation context derives from the group's cancellable
// context (so Cancel aborts it) with a generous per-operation timeout so one
// slow debit can't hold the drain open.
// SafeGoTracked runs fn exactly like SafeGo but registers it on g so
// graceful shutdown can drain in-flight work before closing shared resources.

Was 5 lines; the last two sentences explain Cancel/timeout behavior visible in the implementation.

Comment on lines +89 to +91
// Wait blocks until every goroutine launched through SafeGoTracked has
// finished. Each carries its own bounded timeout, so this cannot hang past
// the longest of them. For shutdown use Cancel + WaitWithContext instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Wait blocks until every goroutine launched through SafeGoTracked has
// finished. Each carries its own bounded timeout, so this cannot hang past
// the longest of them. For shutdown use Cancel + WaitWithContext instead.
// Wait blocks until every goroutine launched through SafeGoTracked has finished.

Was 3 lines; sentences 2–3 cross-reference other methods and restate the per-op timeout already visible in SafeGoTracked.

Comment on lines +96 to +99
// WaitWithContext blocks until the tracked work is done or ctx expires —
// whichever comes first, so the drain is bounded by the shutdown budget even
// if a single operation is overrunning. Call Cancel first to stop overruns at
// their next context check.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// WaitWithContext blocks until the tracked work is done or ctx expires —
// whichever comes first, so the drain is bounded by the shutdown budget even
// if a single operation is overrunning. Call Cancel first to stop overruns at
// their next context check.
// WaitWithContext blocks until the tracked work is done or ctx expires,
// bounding the drain to the shutdown budget.

Was 4 lines; the last two sentences explain overrun/Cancel semantics visible in the select body.

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