From 8c9a85826df2ecdcda7010eba7aa2aca19d8aba9 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 25 Aug 2026 09:43:38 -0500 Subject: [PATCH] appview: stop leasing the billing tier refresh, and let it be cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6a7ddb8 moved RefreshHoldTiers under a lease with the comment "one-shot; the lease is released when it returns". It never returns: past the startup retries it sits on a 30-minute ticker forever. Three consequences, all observed on a two-instance run against one database: The billing-tiers lease is never released on a clean stop, so it survives to its TTL and the replacement instance waits a full minute for a worker the old one is no longer running. Worse, the goroutine stays in the shutdown WaitGroup, so "Timed out waiting for leased workers to stop" now fires on EVERY clean shutdown. The other four leases released in 12ms and the warning fired anyway. A warning that is always present cannot report the case it exists for, which is the jetstream lease genuinely failing to release. And the lease was the wrong tool regardless. The commit justified it as "RefreshHoldTiers writes tier state derived from Stripe" that instances would race on. It writes holdTierCache, a per-process map, from read-only ListTiers calls; there is no shared state anywhere in the path. Electing one refresher means every other instance keeps an empty cache forever, so aggregateHoldFeatures reports "no hold data" on all but one — a regression that only appears at the scale the lease was added to support. This is the hold health worker's situation exactly, and that one was deliberately left unleased in the same commit. So it runs on every instance again, with a context. The retry backoff was time.Sleep for up to 45s total against an unreachable hold; it and the ticker now select on ctx.Done, and ListTiers gets the context instead of context.Background. LeaseBillingTiers is gone rather than left as a dead name. Verified: with the backoff restored to time.Sleep the new test fails on its own 2s deadline rather than hanging, which is how a shutdown regression here should present. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011rmjvU2gSRL9wFnmqVsWaF --- pkg/appview/db/leases.go | 7 ++- pkg/appview/server.go | 13 +++-- pkg/billing/billing.go | 42 +++++++++++---- pkg/billing/billing_stub.go | 3 +- pkg/billing/hold_tiers_test.go | 93 ++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 pkg/billing/hold_tiers_test.go diff --git a/pkg/appview/db/leases.go b/pkg/appview/db/leases.go index c75d092..e3bf7d1 100644 --- a/pkg/appview/db/leases.go +++ b/pkg/appview/db/leases.go @@ -10,10 +10,9 @@ import ( // Lease names used by the AppView. Each names exactly one background worker // that must not run on more than one instance at a time. const ( - LeaseJetstream = "jetstream" - LeaseBackfill = "backfill" - LeaseCleanup = "cleanup" - LeaseBillingTiers = "billing-tiers" + LeaseJetstream = "jetstream" + LeaseBackfill = "backfill" + LeaseCleanup = "cleanup" ) // LeaseLabeler returns the lease name for a labeler subscriber. Each labeler has diff --git a/pkg/appview/server.go b/pkg/appview/server.go index b5bb4f7..eee70d2 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -310,13 +310,12 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, return nil, fmt.Errorf("billing is enabled but STRIPE_WEBHOOK_SECRET is not set; refusing to start with a forgeable webhook endpoint") } slog.Info("Billing enabled", "appview_did", appviewDID, "managed_holds", len(cfg.Server.ManagedHolds)) - // Leased: RefreshHoldTiers writes tier state derived from Stripe, and - // several instances refreshing the same holds concurrently would race - // on those writes for no benefit. - s.Leases.Go(workerCtx, db.LeaseBillingTiers, func(context.Context) error { - s.BillingManager.RefreshHoldTiers() - return nil // one-shot; the lease is released when it returns - }) + // Deliberately not leased. holdTierCache is per-process memory fed by + // read-only ListTiers calls, so there is nothing to serialise, and + // electing one refresher would leave every other instance reporting + // "no hold data" from aggregateHoldFeatures. Same reasoning as the + // hold health worker above. + go s.BillingManager.RefreshHoldTiers(workerCtx) } // Create webhook dispatcher diff --git a/pkg/billing/billing.go b/pkg/billing/billing.go index 7737a71..8bc449a 100644 --- a/pkg/billing/billing.go +++ b/pkg/billing/billing.go @@ -952,11 +952,20 @@ func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) { const holdTierCacheTTL = 30 * time.Minute -// RefreshHoldTiers queries all managed holds for their tier definitions and caches the results. -// It runs once immediately (with retries for holds that aren't ready yet) and then -// periodically in the background. -// Safe to call from a goroutine. -func (m *Manager) RefreshHoldTiers() { +// RefreshHoldTiers queries all managed holds for their tier definitions and +// caches the results. It runs once immediately (with retries for holds that are +// not ready yet) and then periodically until ctx is cancelled. +// +// This runs on every instance rather than under a lease. holdTierCache is +// per-process memory and refreshHoldTiersOnce issues read-only ListTiers calls, +// so there is no shared state to serialise and nothing to race on. Electing one +// refresher would leave every other instance with an empty cache, which +// aggregateHoldFeatures reports as "no hold data" — the same reasoning that +// keeps the hold health worker unleased. +// +// It only returns when ctx is cancelled, so start it with `go` and hand it a +// context that shutdown closes. +func (m *Manager) RefreshHoldTiers(ctx context.Context) { if !m.Enabled() || len(m.managedHolds) == 0 { return } @@ -967,7 +976,7 @@ func (m *Manager) RefreshHoldTiers() { const initialDelay = 3 * time.Second for attempt := range maxRetries { - m.refreshHoldTiersOnce() + m.refreshHoldTiersOnce(ctx) // Check if all managed holds are cached m.holdTierCacheMu.RLock() @@ -982,20 +991,31 @@ func (m *Manager) RefreshHoldTiers() { delay := initialDelay * time.Duration(1<