Skip to content

perf: scale shared tenant backend cache - #801

Merged
srstack merged 12 commits into
mainfrom
fix/shared-tenant-cache-scaling
Jul 27, 2026
Merged

perf: scale shared tenant backend cache#801
srstack merged 12 commits into
mainfrom
fix/shared-tenant-cache-scaling

Conversation

@srstack

@srstack srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • use a provider-aware tenant backend cache default: 20,480 entries for tidb_cloud_native_shared and 1,024 for standalone/native providers, while preserving DRIVE9_POOL_MAX_TENANTS overrides
  • keep shared tenant backends out of time-based idle eviction while retaining the existing capacity-bounded LRU
  • keep safety-net AcquireCached scans from changing capacity LRU order, so only foreground/durable tenant activity influences eviction priority
  • replace per-tenant quota-config polling goroutines with request-driven caching: successful refreshes are coalesced and spread across 27-30s, stale config is served on failure, and failed loads retry after 5s
  • isolate coalesced MetaDB loads from the triggering request cancellation while bounding each load to 5s
  • keep quota refreshes off concurrent request latency: warm waiters immediately use stale config, while cold waiters stop waiting when their own context expires

Why

Shared tenants are lightweight fs_id-scoped backends over shared physical DB handles. Applying the standalone idle TTL caused stable high-cardinality traffic to churn through cold opens. Raising the shared cache without removing per-tenant quota polling would also scale goroutine count and MetaDB version polling linearly, so quota config is now loaded only for active request paths.

Shared physical sql.DB handles intentionally remain available while cached tenant entries reference them. This does not pin active database connections indefinitely: the shared connection pool prunes idle connections after its configured idle time, 5m by default. Capacity eviction releases tenant references; once a handle has no references, the existing 30m handle reaper closes it. Explicit invalidation and process shutdown close handles immediately.

Test plan

  • make test
  • go test -race ./pkg/backend ./pkg/tenant ./cmd/drive9-server -count=1
  • go test ./pkg/server ./pkg/meta -count=1
  • make lint
  • CGO_ENABLED=0 go build ./...
  • git diff --check

Summary by CodeRabbit

  • New Features
    • Added provider-aware defaults for tenant backend quota cache capacity, with updated environment-variable semantics and explicit overrides honored.
  • Bug Fixes
    • Improved quota configuration caching: passive lazy loading, stale-while-retry refresh behavior, coalesced concurrent refreshes, better handling of canceled/deadlined callers, and safer refresh timing (including failure cooldown/retry with jitter).
    • Shared-schema tenant backends are no longer evicted by standalone idle/capacity logic; cached access no longer alters LRU recency.
  • Documentation
    • Updated guidance for the cache-capacity environment variable to reflect provider-specific defaults.

Copilot AI review requested due to automatic review settings July 26, 2026 17:09
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR replaces quota configuration version polling with lazy TTL-based caching, removes the quota version API, adds provider-aware tenant cache defaults, and changes shared-tenant idle reaping and LRU behavior.

Changes

Quota cache refresh

Layer / File(s) Summary
Passive quota cache lifecycle
pkg/backend/quota_cache.go, pkg/backend/quota.go, pkg/backend/options.go
Quota configuration uses defensive snapshots, lazy TTL refreshes, concurrent load coalescing, bounded loads, retry jitter, and stale-on-error behavior without background polling.
Quota cache behavior validation
pkg/backend/quota_cache_test.go, pkg/backend/quota_integration_test.go
Tests cover passive construction, snapshot reuse, defensive copies, refresh coalescing, cancellation, failure handling, panic recovery, and explicit reloads.

Quota configuration API removal

Layer / File(s) Summary
Remove quota version plumbing
pkg/meta/quota.go, pkg/backend/quota_store.go, pkg/tenant/quota_adapter.go, pkg/backend/*_test.go
GetQuotaConfigVersion is removed from metadata, backend interfaces, adapters, and test fakes.
Update quota persistence assertions
pkg/meta/quota_setting_test.go, pkg/server/quota_test.go
Tests validate quota-row persistence and storage-default materialization directly rather than using version tokens.

Tenant cache defaults

Layer / File(s) Summary
Provider-aware tenant cache limits
cmd/drive9-server/main.go, cmd/drive9-server/main_test.go
Tenant backend cache limits use provider-specific defaults, positive environment overrides, updated help text, and environment restoration that preserves empty-but-present variables.

Shared tenant reaping

Layer / File(s) Summary
Shared-schema eviction rules
pkg/tenant/pool.go, pkg/tenant/pool_test.go
Idle reaping skips shared-schema entries, cache hits do not refresh LRU ordering, and capacity eviction still removes shared entries.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Backend
  participant QuotaConfigCache
  participant QuotaStore
  Backend->>QuotaConfigCache: get(ctx)
  QuotaConfigCache->>QuotaStore: load quota configuration when expired
  QuotaStore-->>QuotaConfigCache: configuration or load error
  QuotaConfigCache-->>Backend: snapshot, stale snapshot, or nil
Loading

Possibly related PRs

Suggested reviewers: qiffang, mornyx

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: expanding and tuning the shared tenant backend cache for performance.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/shared-tenant-cache-scaling

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI 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.

Pull request overview

This PR improves scalability for high-cardinality “shared tenant” deployments by tuning the tenant backend pool behavior (capacity/eviction defaults and idle handling) and by eliminating per-tenant quota-config polling goroutines in favor of a request-driven, TTL-based cache.

Changes:

  • Skip time-based idle eviction for shared-DB tenant backends (shared entries are retained until capacity/LRU eviction).
  • Introduce provider-aware defaults for DRIVE9_POOL_MAX_TENANTS (larger default for tidb_cloud_native_shared, unchanged override behavior).
  • Replace background quota-config polling with a passive, coalesced, lazy-refresh cache (TTL + stale-on-error + cooldown), and update tests accordingly.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
pkg/tenant/pool.go Skips idle TTL eviction for shared-DB tenant entries; retains capacity/LRU eviction and shared-handle reaping.
pkg/tenant/pool_test.go Adds coverage ensuring shared entries bypass idle eviction but are still removed by capacity eviction.
pkg/backend/quota.go Routes quota config reads through the new request-driven cache API.
pkg/backend/quota_cache.go Replaces background quota config polling with passive TTL-based lazy loading, coalescing, and failure cooldown.
pkg/backend/quota_cache_test.go Updates/expands tests to validate passive behavior, TTL reuse, coalescing, and stale-on-error + cooldown semantics.
pkg/backend/quota_integration_test.go Adjusts integration tests to force quota-config reloads under the new cache model.
cmd/drive9-server/main.go Adds provider-aware default sizing for tenant backend cache and updates env help text.
cmd/drive9-server/main_test.go Adds tests for provider-default cache sizing and explicit env override behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 806623310f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread pkg/backend/quota_cache.go

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/backend/quota_cache_test.go (1)

55-236: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use t.Errorf for assertion checks, t.Fatal(f) only for setup failures.

All new tests in this file (TestQuotaConfigCacheIsPassiveUntilFirstAccess, TestQuotaConfigCacheReturnsDefensiveCopy, TestQuotaConfigCacheReusesSnapshotUntilTTLExpires, TestQuotaConfigCacheCoalescesConcurrentExpiredAccess, TestQuotaConfigCacheRefreshFailureReturnsStaleAndUsesRetryCooldown, TestQuotaConfigCacheInitialFailureUsesRetryCooldown) use t.Fatalf for every value-comparison assertion (e.g. lines 67-89, 105-107, 122-140, 170-179, 198-213, 222-236), not just setup failures. Per coding guidelines, assertion failures should use t.Errorf so a single mismatch doesn't hide subsequent assertions in the same test.

As per coding guidelines, "Use t.Fatal or t.Fatalf for setup failures and t.Errorf for assertion failures."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/backend/quota_cache_test.go` around lines 55 - 236, The new quota cache
tests use t.Fatalf for value assertions instead of reserving fatal failures for
setup. In TestQuotaConfigCacheIsPassiveUntilFirstAccess,
TestQuotaConfigCacheReturnsDefensiveCopy,
TestQuotaConfigCacheReusesSnapshotUntilTTLExpires,
TestQuotaConfigCacheCoalescesConcurrentExpiredAccess,
TestQuotaConfigCacheRefreshFailureReturnsStaleAndUsesRetryCooldown, and
TestQuotaConfigCacheInitialFailureUsesRetryCooldown, change assertion-only
t.Fatalf calls to t.Errorf while preserving t.Fatal/t.Fatalf for setup failures
and synchronization prerequisites.

Source: Coding guidelines

🧹 Nitpick comments (1)
cmd/drive9-server/main_test.go (1)

22-63: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add fallback-boundary test cases.

These tests cover only an unset variable and a positive override. Add cases for 0, negative values, malformed input, and an unrecognized provider to protect the helper’s fallback behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/drive9-server/main_test.go` around lines 22 - 63, Extend the tests for
tenantBackendCacheMaxTenants to cover DRIVE9_POOL_MAX_TENANTS values of 0,
negative, and malformed input, plus an unrecognized provider. Assert each
invalid or unsupported case returns the appropriate provider/default fallback
rather than the configured value, while preserving the existing unset and
positive-override coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/backend/quota_cache.go`:
- Around line 118-146: Decouple the coalesced refresh in quotaConfigCache.load
from the caller’s cancellation by creating a background-owned context with a
bounded timeout before calling c.store.GetQuotaConfig. Preserve the existing
locking, caching, failure handling, and metrics flow, but ensure one request’s
canceled ctx cannot fail the shared load for other waiters.

---

Outside diff comments:
In `@pkg/backend/quota_cache_test.go`:
- Around line 55-236: The new quota cache tests use t.Fatalf for value
assertions instead of reserving fatal failures for setup. In
TestQuotaConfigCacheIsPassiveUntilFirstAccess,
TestQuotaConfigCacheReturnsDefensiveCopy,
TestQuotaConfigCacheReusesSnapshotUntilTTLExpires,
TestQuotaConfigCacheCoalescesConcurrentExpiredAccess,
TestQuotaConfigCacheRefreshFailureReturnsStaleAndUsesRetryCooldown, and
TestQuotaConfigCacheInitialFailureUsesRetryCooldown, change assertion-only
t.Fatalf calls to t.Errorf while preserving t.Fatal/t.Fatalf for setup failures
and synchronization prerequisites.

---

Nitpick comments:
In `@cmd/drive9-server/main_test.go`:
- Around line 22-63: Extend the tests for tenantBackendCacheMaxTenants to cover
DRIVE9_POOL_MAX_TENANTS values of 0, negative, and malformed input, plus an
unrecognized provider. Assert each invalid or unsupported case returns the
appropriate provider/default fallback rather than the configured value, while
preserving the existing unset and positive-override coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd63e473-15e6-447d-a19a-a3cdd9ebdb0c

📥 Commits

Reviewing files that changed from the base of the PR and between bf995bb and 8066233.

📒 Files selected for processing (8)
  • cmd/drive9-server/main.go
  • cmd/drive9-server/main_test.go
  • pkg/backend/quota.go
  • pkg/backend/quota_cache.go
  • pkg/backend/quota_cache_test.go
  • pkg/backend/quota_integration_test.go
  • pkg/tenant/pool.go
  • pkg/tenant/pool_test.go

Comment thread pkg/backend/quota_cache.go Outdated
@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Review

Reviewed at 80662331. Verified locally in a worktree on this branch:

  • CGO_ENABLED=0 go build ./... — clean
  • go test -race ./pkg/backend -run TestQuotaConfigCache -count=1 — ok
  • go test ./pkg/tenant -run 'TestIdleEvictionSkipsSharedTenantEntry|TestCapacityEvictionStillRemovesSharedTenantEntry' -count=1 — ok
  • go test ./cmd/drive9-server -run TestTenantBackendCacheMaxTenants -count=1 — ok

Overview

Three coupled changes to make shared-schema tenants cheap to keep warm:

  1. tenantBackendCacheMaxTenants(provider) picks 20480 for tidb_cloud_native_shared, 1024 otherwise, with DRIVE9_POOL_MAX_TENANTS still winning when set to a positive value.
  2. reapOnce skips entries with sharedDBID > 0, so shared tenant backends leave the pool only via the capacity LRU.
  3. quotaConfigCache drops its per-tenant polling goroutine in favour of request-driven 30s TTL, loadMu coalescing, stale-on-error, and a 5s failure cooldown.

The direction is right, and (3) is the prerequisite that makes (1) affordable — 20480 goroutines plus 20480 get_quota_config_version queries per 30s per pod would have been the real cost. GetQuotaConfigVersion is now the cheaper-but-unused half of the interface, and the removal of the raced_refresh path leaves the cache noticeably simpler.

Findings

1. reapIdleSharedDBs becomes effectively unreachable below pool capacity (pkg/tenant/pool.go:742)

A cached shared entry holds a sharedDBRefs[dbID] ref (retainSharedDB on insert, releaseSharedDB in closeEntry), and reapIdleSharedDBs skips any handle with refs > 0. Before this PR, the 5m idle TTL dropped those refs, which then let defaultSharedDBHandleIdleTTL (30m) close the physical handle. Now, on a pod holding fewer than 20480 entries, refs never reach zero, so the 30m shared-handle TTL never fires and a shared physical *sql.DB stays open for the process lifetime — even after every tenant on it has been idle for hours.

That may well be the intent ("keep them warm"), but it is a behavior change the description doesn't mention, and it has cost implications for serverless/scale-to-zero shared pools plus lingering connections against a db_pool that has been drained. Worth either stating explicitly in the commit message or reclaiming handles on a much longer TTL — e.g. still skip the standalone idle TTL, but allow eviction of shared entries idle for > defaultSharedDBHandleIdleTTL. InvalidateSharedDB remains available as a manual escape hatch, so this is not a correctness bug.

2. Failure cooldown amplifies load during a meta DB outage

At 20480 warm shared tenants, a central DB outage turns into up to 20480/5s ≈ 4k qps of GetQuotaConfig retries per pod — versus roughly 20480/30s ≈ 680 qps of the (cheaper) version query previously. Since the path already serves stale config successfully, the retry does not need to be that eager. Suggest exponential backoff capped at the normal TTL, or at least jitter on quotaConfigCacheFailureRetryInterval.

3. No jitter on the success TTL either (pkg/backend/quota_cache.go:135)

Tenants warmed together (pod start, a burst of cold opens, post-outage recovery) get near-identical nextRefresh values and then re-expire in lockstep every 30s, producing synchronized query waves rather than a smooth rate. A few percent of jitter on nextRefresh would flatten it and costs nothing.

4. Provider default is chosen from the pod's default provider, not per tenant (cmd/drive9-server/main.go:346)

MaxTenants is a single pool-wide number derived from DRIVE9_TENANT_PROVIDER, but the pool caches tenants of any provider. On a tidb_cloud_native_shared pod that also serves standalone tenants, the standalone cap effectively rises to 20480 as well. The 5m idle TTL still bounds those in practice, so this is mostly a note — but a mixed-provider pod under a burst could hold far more standalone connection pools than the old 1024 ceiling allowed.

5. Per-tenant metric cardinality now grows with the shared cache

metrics.DeleteTenantCounters runs from closeEntry, which for shared tenants now only happens on capacity eviction or shutdown. RecordTenantOperationWithOrg attaches tenant_id + tidbcloud_org_id to counters, so a pod steadily accumulates per-tenant series up to 20480 tenants × component/operation/result combinations. Did you measure /metrics payload size and registry memory at, say, 20k cached tenants? If it is significant, a cap or a decision to drop tenant_id from the highest-fanout counters may be needed alongside this.

6. Nits

  • get(ctx) is a pure pass-through to load(ctx) (pkg/backend/quota_cache.go:112). Collapse into one exported-to-package method; keeping both invites future divergence.
  • GetQuotaConfigVersion now has no production caller — only metaQuotaAdapter and tests. Consider dropping it from MetaQuotaStore (and meta.Store) in a follow-up so the version-token contract doesn't rot.
  • stop() as a no-op with a compatibility comment is fine, but since the only caller is Dat9Backend.Close, removing both would be cleaner than documenting a vestige.
  • cached() returning (nil, true) to mean "currently nil and suppressed" is subtle. The comment covers it; a named result or a small enum would read better.
  • finishFailedLoad's config_empty branch is unreachable through metaQuotaAdapter (meta.GetQuotaConfig returns defaults on ErrNoRows, never nil). Harmless, just dead in production.

Tests

Good coverage of the new cache semantics: passivity at construction, defensive copies, TTL reuse, coalescing under 32 concurrent callers, stale-on-error, and initial-failure cooldown. reloadQuotaConfigForTest is a clean replacement for the refresh() call sites in the integration tests.

Gaps worth closing:

  • Nothing asserts the finding-1 consequence — a test showing what happens to the physical shared handle when all its tenants go idle would pin down whichever behavior you intend.
  • TestQuotaConfigCacheIsPassiveUntilFirstAccess mutates the package-level quotaConfigCacheRefreshInterval and sleeps 20ms as a negative assertion. Safe today (no t.Parallel() in pkg/backend), but the shortened interval isn't actually needed for what the test asserts — dropping it removes a latent global-state coupling.
  • Both new pool tests read pool.items without pool.mu. Consistent with the existing TestIdleEviction, and no reaper goroutine runs in these tests, so -race is quiet; flagging only for consistency if you touch them again.

Security

Nothing material. The stale-config window widens from "up to 30s" to "up to 30s, or the duration of a meta DB outage" for an already-loaded tenant, meaning a quota lowered during an outage takes effect later. That matches the pre-existing fail-open posture of these soft admission checks, and strict upload reservations still read central usage directly.

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

@srstack Review follow-up is in 5a998767.

Implemented:

  • detached the coalesced quota-config load from the winning request cancellation and bounded it with an independent 5s timeout;
  • changed failed-load retry from 5s to a jittered 27-30s window, preventing an outage from producing roughly 4k retries/s per fully warm pod;
  • added the same bounded jitter to successful refreshes, while keeping the maximum config convergence window at 30s;
  • added invalid env/provider fallback tests and converted assertion-only failures to t.Errorf;
  • collapsed the get/load pass-through.

The shared-handle retention is intentional and is now explicit in the PR description. A cached shared tenant keeps the lightweight physical sql.DB handle reachable, but the underlying connection pool uses a 5m ConnMaxIdleTime, so idle connections are pruned independently. Capacity eviction releases tenant references; an unreferenced handle is closed by the existing 30m handle reaper.

No code change for the pool-wide mixed-provider cap: Pool.maxSize is intentionally one capacity LRU selected from the deployment provider, and standalone entries remain subject to the existing idle TTL. Splitting capacity by tenant provider would add a second eviction policy outside this performance fix.

No metric-label change in this PR. The cardinality concern is real but changing or capping tenant-labelled series needs measurement and a separate compatibility decision; it is not required for cache/provisioning correctness.

GetQuotaConfigVersion, the no-op stop, and the defensive config_empty branch remain follow-up cleanup only. The 5ms interval in TestQuotaConfigCacheIsPassiveUntilFirstAccess is intentional: it makes the test fail if a background ticker is reintroduced, rather than merely proving no query occurred during an arbitrary short sleep.

Verified after the follow-up: full pkg/backend and cmd/drive9-server tests, -race for backend/tenant/server command, lint, build, and diff check are clean.

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/drive9-server/main_test.go (1)

24-26: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve presence when restoring environment snapshots.

snapshotEnv collapses an originally present empty variable the same way as a missing variable, and restoreEnv turns empty snapshots into os.Unsetenv. If a test starts with DRIVE9_POOL_MAX_TENANTS cleared to "", cleanup leaves it absent instead of preserving the empty value. Use os.LookupEnv or a restore helper that records presence separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/drive9-server/main_test.go` around lines 24 - 26, Update snapshotEnv and
restoreEnv to record whether each environment variable was present separately
from its value, using os.LookupEnv or an equivalent presence-aware
representation. During restoration, set originally present variables even when
their value is empty, and unset only variables that were originally absent;
preserve the existing cleanup flow around unsetEnv.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/backend/quota_cache.go`:
- Around line 161-167: Restore a dedicated five-second failure retry cooldown in
quotaConfigCache.finishFailedLoad instead of clamping to the normal refresh TTL;
update pkg/backend/quota_cache.go lines 161-167 accordingly. Adjust the related
assertions in pkg/backend/quota_cache_test.go lines 92-112 to verify the
five-second cooldown.

---

Outside diff comments:
In `@cmd/drive9-server/main_test.go`:
- Around line 24-26: Update snapshotEnv and restoreEnv to record whether each
environment variable was present separately from its value, using os.LookupEnv
or an equivalent presence-aware representation. During restoration, set
originally present variables even when their value is empty, and unset only
variables that were originally absent; preserve the existing cleanup flow around
unsetEnv.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bbb9319f-d2ff-4c72-8e90-f6b0f2be73e3

📥 Commits

Reviewing files that changed from the base of the PR and between 8066233 and 5a99876.

📒 Files selected for processing (3)
  • cmd/drive9-server/main_test.go
  • pkg/backend/quota_cache.go
  • pkg/backend/quota_cache_test.go

Comment thread pkg/backend/quota_cache.go Outdated
@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Follow-up commit 053a701 fixes the valid environment-snapshot finding. The test helper now records value and presence separately with os.LookupEnv, so an originally present empty value is restored as present rather than being unset. Added a regression test that fails with the old helper and passes with the presence-aware snapshot. Verified the complete cmd/drive9-server package, its race run, and lint.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@srstack Thanks for fixing this. Preserving the presence bit separately from the value correctly handles the previously broken “set but empty” environment-variable case, and the regression coverage pins that behavior down.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up: two reproduced regressions in the config-cache rewrite

Took a closer second pass, this time diffing the old quotaConfigCache against the new one rather than reading the new file in isolation. That surfaced two concrete problems I under-weighted in my first comment — both are consequences of the same structural change: config loads moved from a background goroutine onto the request path, so the caller's request context and the caller's own latency budget are now coupled to the tenant's shared cache state.

Old shape:

func (b *Dat9Backend) cachedQuotaConfig(ctx) *QuotaConfigView {
    if cfg := b.quotaConfigCache.get(); cfg != nil { return cfg }  // no ctx, never blocks, never fails
    return b.quotaConfigCache.load(ctx)                            // ctx used ONLY when snapshot is nil
}

New shape: get(ctx)load(ctx), so a request ctx reaches the store on every TTL boundary, and the 5s cooldown persists that request's outcome for the whole tenant.

1. A canceled/timed-out client request disables quota enforcement tenant-wide for 5s

load passes the request ctx to store.GetQuotaConfig. A real *sql.DB query returns ctx.Err() when the caller has already gone away (client disconnect, expired deadline). finishFailedLoad then unconditionally sets nextRefresh = now + 5s — and with no snapshot yet, cached() returns (nil, true) for the next 5 seconds, so every request for that tenant gets nil and checkStorageQuotaServerTx / ensureFileCountQuotaServer take the fail_open branch.

Reproduced against this branch with a ctx-honouring store (the existing cacheTestStore ignores ctx, which is why the suite doesn't catch it):

store := &ctxAwareStore{fakeMetaQuotaStore: newFakeMetaQuotaStore()} // returns ctx.Err() if ctx is done
c := newQuotaConfigCache("tenant-poison", "org", store)

canceled, cancel := context.WithCancel(context.Background())
cancel()
c.get(canceled)                       // nil, as expected

if cfg := c.get(context.Background()); cfg == nil {  // healthy, unrelated request
    t.Fatalf("POISONED: healthy request got nil config; quota fails open")
}
--- FAIL: TestScratchCanceledRequestPoisonsTenantConfig
    POISONED: healthy request got nil config; quota fails open. store calls=1

Note store calls=1 — the second request never even tried, it was suppressed by the cooldown.

Scope, to be fair to the change:

  • Warm cache is safe. I verified separately that once a snapshot exists, a canceled refresh returns the stale copy, so quota stays enforced. That path is genuinely well-designed.
  • The dangerous window is before the first successful load — cold pod, newly-cached tenant, or right after a restart. Precisely the moment this PR makes far more common, since 20480 shared tenants now get cached and each one's first quota check is a cold load.
  • Pre-PR this was much narrower: load(ctx) was also reachable with a request ctx, but there was no cooldown, so the very next request retried immediately. One unlucky client cost one request's enforcement, not 5 seconds of the tenant's.

It is also cheaply self-inflictable: a tenant that wants to exceed its own quota can fire a request and abandon it immediately, then issue real writes inside the 5s fail-open window, repeating every 5s. Low sophistication, and the tenant is exactly the party motivated to do it.

Suggested fix — detach the store read from the caller's cancellation, and don't treat caller cancellation as evidence the meta DB is unhealthy:

loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), quotaConfigLoadTimeout)
defer cancel()
cfg, err := c.store.GetQuotaConfig(loadCtx, c.tenantID)
if err != nil {
    if ctx.Err() != nil {
        // caller vanished; don't burn the tenant's cooldown on it
        return c.staleSnapshot()
    }
    return c.finishFailedLoad(start, "config_error")
}

context.WithoutCancel is available (go 1.25.1). This also stops one client's short deadline from deciding the fate of the shared load.

2. Waiters on loadMu ignore their own deadline, now on every TTL boundary

The double-checked loadMu correctly coalesces, but a waiter blocks on sync.Mutex.Lock() — which is not ctx-aware — for however long the leader's query takes, regardless of the waiter's own budget.

--- FAIL: TestScratchWaiterIgnoresOwnDeadline
    waiter with a 20ms deadline is still blocked on loadMu after 400ms

(First version of this test deadlocked my run outright, since the waiter never returns to release the leader.)

Pre-PR this only happened on the cold first load. Now it recurs every 30s per tenant, and it happens inside an open tenant transaction — ensureFileCountQuotaServer is called from within b.store.InTx(...) at pkg/backend/dat9.go:308 (and the other create/write sites). So a slow meta DB now converts into: N concurrent writers for one tenant parked on loadMu, each holding an open tenant tx, extending lock/MVCC hold time on the tenant side too. That is the p99 mechanism I should have named in my first pass instead of just flagging "retry volume".

Suggested fix — serve stale and refresh out of band, which restores the old "get never blocks" property:

if c.loadMu.TryLock() {
    defer c.loadMu.Unlock()
    // ... leader does the load
}
// not the leader: return the stale snapshot immediately rather than queueing
if cfg, _ := c.staleSnapshot(); cfg != nil {
    return cfg
}

With TryLock, only the leader pays the query; everyone else keeps their latency budget. Combined with fix 1, a cold-start herd still coalesces to one query, but no waiter is held past its deadline.

Relation to my earlier comment

Findings 2 and 3 there (retry amplification, missing TTL jitter) still stand and are unaffected by these fixes. Finding 1 (shared handles pinned by sharedDBRefs, making reapIdleSharedDBs unreachable below capacity) is unchanged and independent of the quota cache. The two items above are the ones I would treat as blocking; the rest are tuning and docs.

Happy to be wrong about the exploitability framing on (1) if there's a deadline floor upstream I've missed — but the fail-open-for-5s behavior after a canceled first load is reproducible regardless of intent.

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

@srstack Follow-up implemented in 5fe0d367.

  1. The canceled-caller poisoning case was already fixed in 5a998767: the shared MetaDB load uses context.WithTimeout(context.WithoutCancel(ctx), 5s), and TestQuotaConfigCacheCanceledCallerDoesNotPoisonSharedRefresh exercises a context-aware store.
  2. The loadMu waiter issue was still valid. The mutex queue is replaced by explicit in-flight load state. Warm callers immediately receive the stale snapshot while one refresh is running. Cold callers wait for the shared result only until their own context is done; the leader continues and publishes the cache result. Regression tests cover both paths and failed against the prior implementation.
  3. Per the updated owner decision, failed config loads now retry after a fixed 5s so quota changes do not remain stale for up to 30s. Successful refreshes retain the jittered 27-30s TTL.

Verified after the change: focused red/green regressions, full backend/tenant/drive9-server tests, the same packages under -race, lint, build, and diff check.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/backend/quota_cache.go`:
- Around line 20-24: Update the failed-load retry path associated with
quotaConfigCacheFailureRetryInterval to use quotaConfigCacheRefreshDelay instead
of the fixed 5-second interval, preserving the default jittered 27–30 second
delay for MetaDB failures. Remove the obsolete fixed retry interval constant and
ensure both referenced retry sites use quotaConfigCacheRefreshDelay.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d46ba5cd-0c07-41f2-97d4-08a1afe3bc6f

📥 Commits

Reviewing files that changed from the base of the PR and between 5a99876 and 5fe0d36.

📒 Files selected for processing (3)
  • cmd/drive9-server/main_test.go
  • pkg/backend/quota_cache.go
  • pkg/backend/quota_cache_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/backend/quota_cache_test.go

Comment thread pkg/backend/quota_cache.go
@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Third pass: the capacity LRU that now carries all shared-tenant reclaim is inverted by the safety-net scan

My first two comments covered the quota cache and the pinned shared handles. This pass looked at the part I had waved past: now that reapOnce skips sharedDBID > 0, the capacity LRU is the only reclaim path for shared tenants — so it matters a great deal that the LRU ordering is actually a usage signal. It isn't.

The mechanism

AcquireCached deliberately does not refresh lastUsed — that's an explicit, documented invariant:

// pkg/tenant/pool.go:79
// AcquireCached (safety-net scan) does NOT refresh the idle timer on ...

// pkg/tenant/pool.go:240
lastUsed  time.Time // refreshed by Get/Acquire/S3Backend (foreground + durable work) ...; never by AcquireCached

But it does call p.order.MoveToFront(e.elem) (pkg/tenant/pool.go:571). So the scan is excluded from the lastUsed signal and included in the list-position signal — two orderings that are supposed to mean the same thing.

Pre-PR that inconsistency was harmless: the 5m idle TTL keyed on lastUsed was the primary reclaim path for every tenant, so list position rarely decided anything. This PR removes that path for shared tenants and promotes list position to the sole arbiter — which promotes the latent inconsistency into the deciding factor.

safetyNetScan walks every active tenant this pod owns, in created_at/id keyset order, every 5 minutes by default (cmd/drive9-server/main.go:494), calling AcquireCached on each warm one. Each call yanks that entry to the front. So on every scan cycle the LRU is rewritten into roughly scan order — i.e. tenant creation order — with real traffic contributing almost nothing.

Reproduced

Two shared tenants, MaxTenants: 2. busy gets real foreground Acquire traffic; idle gets nothing but one safety-net AcquireCached. Then a third tenant cold-opens:

busy still cached=false  idle still cached=true
--- FAIL: TestScratchSafetyNetScanInvertsSharedCapacityLRU
    LRU INVERTED: busy tenant evicted while scan-only idle tenant survived

The actively-used tenant is evicted and pays a cold open; the tenant with zero user traffic stays resident. At 20480 capacity with a 5m scan over every owned tenant, this isn't an edge case — it's the steady state once a pod is at capacity. And a shared-tenant cold open is exactly what the reapOnce skip comment says it wants to avoid ("turns stable high-cardinality shared traffic into continuous cold opens"), so the change partially defeats its own stated goal at the capacity boundary.

Fix

Dropping the MoveToFront from AcquireCached makes list position agree with lastUsed and matches the documented intent:

 	e.refs++
-	p.order.MoveToFront(e.elem)
 	p.mu.Unlock()
 	metrics.RecordOperation("tenant_pool", "cache_lookup", "hit", 0)

Verified on this branch: the repro flips to busy still cached=true idle still cached=false, and the full go test ./pkg/tenant suite still passes — nothing depends on the current behavior. (I reverted the patch afterward; it's a suggestion, not a pushed change.)

If you'd rather keep list position as "recently referenced by anything", the alternative is to have capacity eviction pick the victim by minimum lastUsed instead of order.Back(). Either way the two signals should stop disagreeing, because this PR makes one of them load-bearing.

Running tally

Blocking, in the order I'd fix them:

  1. Canceled request → 5s tenant-wide quota fail-open (comment 2, finding 1) — reproduced, security-relevant, worst exactly at cold start which this PR makes common.
  2. This LRU inversion — reproduced, one-line fix, suite-clean.
  3. Waiters on loadMu ignore their deadline inside an open tenant tx (comment 2, finding 2) — reproduced, now recurs every 30s per tenant.

Non-blocking, still open: shared handles pinned via sharedDBRefs so reapIdleSharedDBs is unreachable below capacity (comment 1, finding 1 — worth an explicit decision either way); 5s failure-retry amplification and missing TTL jitter (comment 1, findings 2–3); per-tenant metric cardinality at 20k resident tenants (comment 1, finding 5).

The core idea — drop the per-tenant goroutine so keeping shared tenants warm is affordable — is right, and the warm-path stale-on-error handling in the new cache is genuinely careful work. The problems are all in what became load-bearing as a side effect.

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Complete review disposition

The third-pass LRU finding is valid and fixed in fd2a813a.

AcquireCached is a safety-net/background observation path. It deliberately does not refresh lastUsed, so it must not refresh the capacity LRU either. The MoveToFront call is removed. Added TestAcquireCachedDoesNotRefreshSharedCapacityLRU, which reproduces the old behavior (busy cached=false, idle cached=true) before the fix and verifies that foreground-active busy survives while scan-only idle is evicted afterward.

Blocking findings

  1. Canceled caller poisoning cold quota config: fixed in 5a998767. Shared loads use context.WithTimeout(context.WithoutCancel(ctx), 5s) and the context-aware regression test verifies that one canceled caller cannot put the tenant into fail-open cooldown.
  2. Safety-net scan inverts shared capacity LRU: fixed in fd2a813a by removing MoveToFront from AcquireCached; regression test added.
  3. loadMu waiters ignore request deadlines: fixed in 5fe0d367. Warm waiters return stale config immediately; cold waiters wait only until their own context ends; one leader still publishes the shared result.

Non-blocking findings and decisions

  • Shared handles remain referenced below tenant-cache capacity: intentional. Shared tenant entries are kept warm until capacity eviction/invalidation/shutdown. Referenced sql.DB handles remain lightweight; underlying idle connections are pruned by the shared connection pool after 5m by default. Once tenant refs reach zero, the existing 30m shared-handle reaper can close the handle. This behavior is explicit in the PR description.
  • Five-second failed-load retry amplification: intentionally retained by owner decision. Quota config must retry within 5s after MetaDB recovery. The cache is request-driven, per-tenant loads are coalesced, and warm waiters do not add queries. The outage-load tradeoff is documented and pinned by TestQuotaConfigCacheFailureCooldownIsFiveSeconds.
  • Missing success-TTL jitter: fixed in 5a998767; successful refreshes are spread over 27-30s at the default TTL. Failed retries remain fixed at 5s by the policy above.
  • Per-tenant metric cardinality at 20k cached tenants: not changed in this PR. Removing or aggregating tenant_id labels is a metrics-schema compatibility change and needs measurement plus a separate rollout decision; it is not required for cache correctness. Capacity eviction and shutdown still delete tenant counters through the existing close path.
  • Mixed-provider capacity: Pool.maxSize remains one deployment-level LRU selected from the configured default provider. Standalone/native entries remain subject to idle TTL even on a shared-provider pod. Splitting one pool into provider-specific capacity budgets would add a second eviction policy and is outside this scaling fix.
  • GetQuotaConfigVersion, no-op stop, and defensive config_empty: left as cleanup-only compatibility code. Removing them does not change the production behavior addressed here and would expand the interface/change surface.
  • cached() returning (nil, true) during initial-failure cooldown: retained and documented; it distinguishes a suppressed retry from an expired cache without adding another state enum.
  • Passive-cache test global TTL: the short interval is intentional so the test detects accidental reintroduction of a background ticker, rather than merely observing no query during an arbitrary sleep.
  • Existing pool tests reading pool.items without the mutex: deterministic tests run without a reaper goroutine and pass under -race; no production race is involved. The new LRU regression uses pool.mu for its state assertions.

Verification for fd2a813a: focused red/green regression, full pkg/tenant, pkg/backend, and cmd/drive9-server tests, tenant/backend/server race tests, lint, build, and diff check all pass. The PR description now includes the safety-net/LRU invariant.

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Fourth pass: I measured the metrics-cardinality point I raised — it's a request-path stall, not just a scrape-size concern

In my first comment I asked whether you'd measured /metrics at 20k cached tenants (finding 5) without measuring it myself. That was speculation on my part, so I went and quantified it. The scrape size is the lesser half; the real cost is that eviction became an O(all series) operation under the global metrics lock, on the request path.

Measurement

Seeded the global registry with the per-tenant series a normal request mix emits (7 component/operation/result combos per tenant: storage_check, file_count_check, usage_cache, pending_delta_cache, quota_config_cache load, acquire_cached, safety_net_tenant_scan), then measured one eviction and one scrape:

resident=1024   DeleteTenantCounters=325.406µs   scrape=3.460632ms   payload=1.07 MiB
resident=20480  DeleteTenantCounters=17.690209ms scrape=73.528369ms  payload=20.57 MiB
=> eviction cost x54.4, scrape cost x21.2, payload x19.3

Why the eviction number is the one that matters

DeleteTenantCountersDeleteCountersByLabel / DeleteHistogramsByLabel / DeleteGaugesByLabel, each of which full-scans every series of every instrument doing a substring match, while holding r.mu:

// pkg/metrics/registry.go:322
func (r *Registry) DeleteCountersByLabel(labelKey, labelValue string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	for _, inst := range r.counters {
		for labels := range inst.values {        // every series, every instrument
			if labelHasKeyValue(labels, match) { // substring scan per series

That's the same r.mu that every addCounter needs (pkg/metrics/registry.go:273). So for ~18ms, all metric recording process-wide blocks — not just this tenant's.

And it runs synchronously on the acquire path. Pool.Acquire collects capacity-evicted entries and calls closeEntry inline after dropping p.mu (pkg/tenant/pool.go:465-479), and closeEntry calls metrics.DeleteTenantCounters (pkg/tenant/pool.go:1639).

Chaining that with finding 2 from my third comment: once a pod is at capacity, every cold open evicts, and the safety-net scan keeps rewriting the LRU so cold opens stay frequent even for actively-used tenants. So the steady state at 20480 is a ~18ms global metrics stall per cold open, on a request. At 1024 it was ~0.3ms and rare, because the idle TTL did most of the reclaiming outside the request path.

Suggestions

The cheap, targeted fix is to make deletion not depend on total registry size — index series by tenant_id, or keep a per-tenant label-set list so removal is O(that tenant's series):

// registry: map[tenantID][]seriesKey maintained on first write per (tenant, series)
func (r *Registry) DeleteByTenant(tenantID string) {
	r.mu.Lock(); defer r.mu.Unlock()
	for _, key := range r.tenantSeries[tenantID] { /* targeted deletes */ }
	delete(r.tenantSeries, tenantID)
}

Failing that, moving the DeleteTenantCounters call off the request path (queue evicted tenant IDs, drain them from the existing reaper tick) would at least stop it from landing in request latency — though it leaves the global-lock stall.

Worth noting the 20.57 MiB scrape payload independently: at a typical 15–30s scrape interval that's a real bandwidth and TSDB-ingest cost per pod, and 73ms of scrape holds the same lock. If per-tenant granularity isn't needed on the highest-fanout counters, dropping tenant_id from those (keeping it on the ones used for billing/alerting) would cut both numbers substantially.

Caveat on these numbers

Single-run wall-clock on my machine, one seeding pattern, real series counts will differ with your actual operation mix — treat the ratios (x54 eviction, x21 scrape) as the signal rather than the absolute µs. The structural point doesn't depend on the timings: DeleteCountersByLabel is O(total series) under a global lock, and this PR multiplies total series by ~20x while making eviction the primary reclaim path. Scratch test removed after measuring; nothing pushed.

Status

This upgrades comment 1's finding 5 from "worth measuring" to a fourth blocking item, since it's on the request path and compounds with the LRU inversion. Full list now:

  1. Canceled request → 5s tenant-wide quota fail-open (comment 2)
  2. Safety-net scan inverts the capacity LRU (comment 3)
  3. loadMu waiters ignore their deadline inside an open tenant tx (comment 2)
  4. ~18ms global metrics-lock stall per cold open at 20480 residents (this comment)

All four are consequences of the same thing: the PR raises the resident-tenant ceiling 20x and makes capacity eviction the primary reclaim path, which puts previously-rare O(N) work onto the hot path. The goroutine removal that motivates the change is sound — it's the second-order effects at 20480 that need attention before this ships. Happy to look at a revision.

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

This finding is valid, and the measured request-path impact makes the merge dependency explicit.

The fix is intentionally owned by PR #802 rather than duplicated here:

That addresses both parts measured here: the synchronous global metrics-lock stall and the dominant resident-series growth. The safety-net LRU inversion mentioned in the chain is already fixed in fd2a813a; AcquireCached no longer changes LRU order.

I am not duplicating the metrics implementation in #801 because the same files and metric contracts are already being changed comprehensively in #802. #801 should merge only after #802 is merged and this branch is rebased onto it, or after equivalent #802 commits are otherwise present in the merge result.

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up cleanup disposition

The reasonable architecture-cleanup findings are now addressed in c0b8625a:

  • removed the no-op quotaConfigCache.stop() lifecycle method and the obsolete close-time call; the passive cache now has no fake worker lifecycle, while backend close still releases the cache reference;
  • removed the unused quota-config version-token contract from backend.MetaQuotaStore, the tenant adapter, and meta.Store, and deleted/reworked tests that only exercised the discarded polling token;
  • replaced server test assertions that inferred row persistence from a version string with direct tenant_quota_config row checks.

The shared tenant / physical shared DB lifetime behavior remains intentional and is already covered by the existing pool tests: shared tenant entries bypass the standalone idle TTL, capacity eviction still closes the tenant store and releases its shared DB reference, and an unreferenced physical handle is reaped after the longer shared-handle TTL. No duplicate test-only abstraction was added.

The newly measured O(total metrics series) eviction stall is valid, but is being fixed comprehensively in PR #802. That PR removes synchronous DeleteTenantCounters from Pool.closeEntry, narrows high-fanout metric labels, and adds exact-label quota gauge lifecycle handling. Metrics changes are intentionally not duplicated in this PR.

The fixed five-second failed quota-config retry remains an owner requirement; successful refreshes retain the 27–30 second jitter. Mixed-provider capacity remains a deployment-level LRU by design, with standalone entries still protected by their idle TTL; splitting the pool into provider-specific eviction budgets would be a separate policy change rather than cleanup of this PR.

Focused verification after c0b8625a: go test ./pkg/backend -count=1, go test ./pkg/meta -count=1, and go test ./pkg/server -count=1 all pass.

@srstack

srstack commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Verified all four fixes independently — they hold

Re-reviewed at c0b8625a. Rather than take the disposition at face value, I re-ran my own repros against the updated branch. All four pass, and the two dispositions that defer or decline are reasonable. Details, since the get() rewrite is substantial enough to warrant checking it didn't trade one problem for another.

Fix verification

Finding Repro result on c0b8625a
1. Canceled caller poisons cold config fix 1 holds: healthy request served config (store calls=1)
3. Cold waiter ignores own deadline fix 3 holds: cold waiter returned after 20.12ms (own deadline 20ms)
3b. Warm waiter should serve stale, not queue fix 3b holds: warm waiter served stale in 2.77µs
Jitter bounds 27.001s .. 29.999s over 2000 draws (ttl=30s)

The 20.12ms return against a 20ms deadline is exactly the intended behavior — the waiter now honors its own budget instead of the leader's. And 2.77µs for the warm waiter confirms the stale-first ordering: warm callers never touch the wait path at all, so the every-30s convoy inside an open tenant tx is gone.

Jitter lands strictly within [TTL - TTL/10, TTL], so the convergence window is preserved rather than extended — the right direction for a quota TTL, and better than the symmetric jitter I'd loosely suggested.

Also confirmed stop() and GetQuotaConfigVersion are now fully gone (c0b8625a), including from meta.Store and the adapter. Greps for both come back empty.

Full-suite verification

go test -race ./pkg/backend ./pkg/tenant ./cmd/drive9-server -count=1
ok  github.com/mem9-ai/drive9/pkg/backend        62.502s
ok  github.com/mem9-ai/drive9/pkg/tenant         23.445s
ok  github.com/mem9-ai/drive9/cmd/drive9-server   1.044s

And fd2a813a is the exact one-line MoveToFront removal, with TestAcquireCachedDoesNotRefreshSharedCapacityLRU pinning it.

On the #802 deferral

I verified the claims rather than assume them. In fix/metrics-cardinality-observability (#802, open, MERGEABLE):

  • metrics.DeleteTenantCounters is deleted from Pool.closeEntry and moved onto the outbox-poller path behind WorkMetricsCleanup — so capacity eviction no longer does O(total series) work while serving Acquire.
  • tenant_id/tidbcloud_org_id are dropped from serviceOperationsTotal (the high-fanout success counter) and kept on a new drive9_tenant_operation_failures_total.

That addresses both halves of what I measured — the request-path stall and the resident-series growth — so not duplicating it here is the right call. The stated merge order (801 after 802, or rebased onto it) is the load-bearing part: merging #801 alone would ship the 20x resident-tenant ceiling with the O(N) eviction delete still on the request path. Worth enforcing that as an actual gate rather than a note, since the two PRs are independently mergeable today.

On the two declined items

Both reasonable, recording my read for the archive:

  • 5s failure retry, no backoff — accepted as an owner decision, and the reasoning is sound now that fix 3b means warm waiters add zero queries during an outage. My amplification estimate assumed every request queued a load; with stale-first, only genuinely cold tenants retry. That materially shrinks the concern, and the 5s convergence-after-recovery requirement is a legitimate tradeoff to make explicitly. Pinned by a test, which is the right way to hold it.
  • Mixed-provider single capacity budget — agreed this is out of scope. Standalone entries still get the idle TTL even on a shared pod, so the exposure is a burst-only ceiling, not steady state.

The cached() (nil, true) shape and the passive-cache test's short TTL: I'll withdraw both. The former is documented and the alternative isn't clearly better; on the latter, the author's point is stronger than mine — the short interval makes the test detect a reintroduced ticker rather than merely observe silence, which is the more meaningful assertion.

Remaining

Only the shared-handle lifetime item (comment 1, finding 1), now explicitly documented in the PR description as intentional: shared entries stay warm until capacity eviction, connections are pruned by the 5m pool idle setting, and the 30m handle reaper still applies once refs hit zero. That answers my actual concern — connections don't accumulate unboundedly — so I'm satisfied.

LGTM once #802 is in the merge result. Nice work turning these around; the stale-first restructuring of get() is a cleaner solution than the TryLock I sketched, since it makes the warm path unconditionally non-blocking instead of merely usually-non-blocking.

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

Deep review (pool/cache mechanics, quota cache concurrency, cross-cutting lifecycle) with every finding traced end-to-end. Approving: the design is sound — provider-aware default + env precedence is correct, exempting shared tenants from idle eviction cannot leak (every removal reason still funnels through removeLocked/closeEntry with exactly-once shared-handle refcount pairing, and the 30m handle reaper covers refs==0), AcquireCached really is the only LRU-order consumer removed, the polling goroutines are genuinely gone with no per-refresh goroutine spawning, and the channel-based load coalescing has no missed wakeup or double-leader. Tests pin the new semantics (idle exemption, capacity still evicts, LRU untouched by AcquireCached).

One worth-fixing robustness regression (panic wedge) and a few nits inline.

Comment thread pkg/backend/quota_cache.go
Comment thread pkg/backend/quota_cache.go Outdated
Comment thread pkg/tenant/pool.go
@qiffang

qiffang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

adversary-2 R2 at 9d69fea9

The two #801-owned blockers are fixed; coupling to #802 still holds (the 20× cap raise is still present).

B2 quota-cache panic-wedge — RESOLVED. loadConfig now has a defer that on panic calls finishPanickedLoad (closes/clears loadDone under lock via finishConfigLoadLocked + sets retry) then re-panics. loadDone is never left non-nil forever. Regression TestQuotaConfigCachePanicDoesNotWedgeNextLoad: store panics once, next healthy load succeeds.

Foreground-latency regression — RESOLVED. With a stale snapshot, the leader now refreshes asynchronously (goroutine + own recover) and returns stale immediately, no longer blocking on the 5s detached MetaDB context past the request deadline. Only a cold cache does the synchronous load. loadDone is correctly closed on every goroutine exit path (success/error/panic).

LRU-neutrality: AcquireCached drops p.order.MoveToFront — cache/safety-net lookups no longer bump eviction order.

Coupling to #802 — still required. The 20× cap raise is still in this PR: cmd/drive9-server/main.go defaultSharedBackendCacheMaxTenants = 20480 applied via tenantBackendCacheMaxTenants(provider) (test asserts shared=20480). So the B1 amplification premise stands: closeEntry still calls metrics.DeleteTenantCounters (that removal is in #802, not here), and the 20× resident cap amplifies that request-path registry scan under eviction churn. #801 + #802 must land in the same merge result. #802 is currently REVISE (4 metric-correctness blockers). Merging #801 (with the 20× cap) without #802's metrics-off-request-path reintroduces the original B1 production risk.

Verdict: GREEN on this head's quota-cache/LRU fixes; do not merge decoupled from #802.

@qiffang

qiffang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

adversary-2 — re-anchored to 06591fd1 (new "keep shared handle reaper independent" commit)

The new commit fixes a real bug the prior head (9d69fea9) introduced. At 9d69fea9, reapOnce early-returned when idleTimeout <= 0 and Start only launched the reaper when idleTimeout > 0. But the shared physical-DB handle reaper (reapIdleSharedDBs, independent of tenant-entry idle eviction) must run regardless of idleTimeout — otherwise a deployment with idleTimeout=0 (standalone idle eviction disabled) would never reap shared physical handles → shared-handle leak.

06591fd1 fixes it correctly:

  • Start now gates on reapInterval > 0 (NewPool always sets a default reapInterval), so the reaper goroutine always launches.
  • reapOnce wraps the standalone tenant-entry eviction in if p.idleTimeout > 0 but unconditionally calls p.reapIdleSharedDBs(now) at the end. Verified: the shared-handle reaping runs after the closeEntry loop regardless of idleTimeout.
  • pool_test.go +23 covers reaper independence.

All #801-owned fixes GREEN at this head: B2 panic-wedge, foreground-latency (stale + async refresh), LRU-neutrality, shared-handle-reaper-independence.

Coupling unchanged: the 20× cap raise is still in main.go (defaultSharedBackendCacheMaxTenants=20480), so #801 + #802 must merge together. #802 remains REVISE on 4 metric-correctness blockers.

Verdict: GREEN on this head's fixes; do not merge decoupled from #802.

@srstack

srstack commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in commit 6f157069. Ordinary value and row-existence assertions in the changed meta/server tests now use Errorf/Error; Fatal remains only for setup and database/query failures. Targeted pkg/meta and pkg/server tests pass.

@qiffang

qiffang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

adversary-2 — retracting GREEN, REVISE (re-anchored to 6f157069)

@dat9-dev2's regression-failure blocker is confirmed, and the latest commit (6f157069 "test: use nonfatal assertion failures", touching only quota_setting_test/quota_test) does NOT fix it.

Existing regression tests contradict the new semantics:

  • pkg/tenant/pool_test.go:1456 TestIdleEvictionStartNoOpWhenDisabled asserts pool.reapStop == nil when IdleTimeout=0. But 06591fd1 changed Start to gate on reapInterval > 0 (always defaulted), so with IdleTimeout=0 the reaper now launches → reapStop != nil → this test fails. The reaper-independence direction is correct (shared reaper must be independent of idleTimeout), so the test needs updating to assert "reaper starts but standalone idle eviction is disabled" — but shipping a failing/self-contradictory regression is a blocker.
  • pkg/backend: TestQuotaConfigCacheReusesSnapshotUntilTTLExpires and TestQuotaConfigCacheRefreshFailureReturnsStaleAndUsesRetryCooldown fail because the async-refresh + failure-jitter changes invalidated their old synchronous-refresh / fixed-5s-cooldown assertions.

The three functional fixes (panic-wedge, foreground-latency, reaper-independence) are directionally correct, but the semantic changes weren't synced to existing tests that assert the old behavior. All assertions of changed behavior must be updated before merge.

Verdict: REVISE at 6f157069. Coupling to #802 unchanged (20× cap still present).

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact base bf995bbab94f13379380097f2838941323b993ac → head 6f1570695550adc59680217f6412fce38e4a7265.

CHANGES_REQUESTED

The quota-cache revision substantively fixes the two prior cache blockers: a panicking load now clears/wakes loadDone before re-panicking, and an expired warm leader serves stale immediately while one detached refresh completes. The latest 06591fd1 reaper revision also correctly keeps shared physical-handle expiry independent from standalone tenant idle eviction. Two merge blockers remain on this exact head:

  1. The 20× shared resident-cap change still requires the metrics-lifecycle fix in the same tested merge result. cmd/drive9-server/main.go still sets the shared-provider default to 20480 rather than 1024, while this branch's Pool.closeEntry still synchronously calls metrics.DeleteTenantCounters from capacity eviction. PR #802 contains the intended lifecycle change, but it is not in this head, its current review decision is still CHANGES_REQUESTED, and a synthetic merge of exact #801 head 6f157069 with #802 head e13d5e98 still conflicts in pkg/tenant/pool_test.go. Required: either remove the cap increase from #801, or produce a real resolved/rebased #801+#802 merge result and rerun both PRs' correctness and race gates on that exact result. Merge-order intent is not an executable gate.

  2. The exact-head test suite is red because three pre-existing regressions still encode the synchronous/disabled behavior that this revision removed. Hosted ci run 30234214795 fails:

    • TestQuotaConfigCacheReusesSnapshotUntilTTLExpires expects the first expired warm call to synchronously return refreshed 2000, but the new contract returns stale and refreshes asynchronously.
    • TestQuotaConfigCacheRefreshFailureReturnsStaleAndUsesRetryCooldown reads configCalls before the detached failed refresh runs/completes.
    • TestIdleEvictionStartNoOpWhenDisabled still asserts reapStop == nil for IdleTimeout=0, contradicting 06591fd1, which intentionally starts the shared physical-handle reaper.

    I independently reproduced all three failures locally by bypassing only the package TestMain database bootstrap; the production tests themselves were unchanged. Update these regressions to synchronize on the detached load and assert the new reaper contract, then require fresh green ci and local-e2e on the final exact head.

Validation on this exact revision / its direct parents:

  • full PR git diff --check; changed-file gofmt -l
  • normal and race compile-only gates for pkg/backend, pkg/tenant, and cmd/drive9-server
  • go vet ./pkg/backend ./pkg/tenant ./cmd/drive9-server ./pkg/meta ./pkg/server
  • normal and race TestTenantBackendCacheMaxTenants
  • make lint; make build-server
  • focused normal/race tests for panic cleanup, cold-waiter cancellation, expired-warm leader stale return, and a review-only cold-leader boundary proof
  • hosted ci failure log confirmation for the same three tests

The worktree was restored clean after review-only TestMain isolation/reproduction. Please request another exact-head review after the red tests are corrected and the #801/#802 composition gate is executable.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

adversary-1 R2 at 6f1570695550adc59680217f6412fce38e4a7265

Requesting changes. This head is not mergeable.

Blocking findings:

  1. Hosted code-ci is red on this exact head: https://github.com/mem9-ai/drive9/actions/runs/30234214795/job/89878726463. These are PR-owned contract/test failures, not infrastructure noise. The PR changed warm-expired quota config refresh to return stale immediately and refresh asynchronously, but the existing tests still assert synchronous refresh:
  • pkg/backend/quota_cache_test.go:319: expected refreshed storage 2000, got stale 1000.
  • pkg/backend/quota_cache_test.go:322: expected configCalls=2, got 1.
  • pkg/backend/quota_cache_test.go:380 / :387: expected refresh call count 2, got 1.

The latest reaper change also intentionally makes Pool.Start run when IdleTimeout=0, but pkg/tenant/pool_test.go:1463 still asserts reapStop == nil. Update the stale regressions to the new intended contracts and get code-ci green. The last commit only changed unrelated tests from fatal to nonfatal assertions; it does not address these blockers.

  1. #801 still cannot merge independently from #802. The 20x shared backend cap raise remains in cmd/drive9-server/main.go:49 (defaultSharedBackendCacheMaxTenants = 20480), while this PR still has Pool.closeEntry call metrics.DeleteTenantCounters at pkg/tenant/pool.go:1647. #802 contains the request-path metrics cleanup, but current #802 is still CHANGES_REQUESTED and an exact synthetic merge of current #801 + #802 still conflicts in pkg/tenant/pool_test.go. Until fixed #802 or equivalent cleanup is present in the same tested merge result, #801 alone is unsafe.

Resolved/positive checks: the prior quota-cache panic wedge is structurally addressed by the panic defer and load completion cleanup; warm-expired callers now avoid foreground DB latency by returning stale and refreshing asynchronously; cached shared acquire is LRU-neutral; and the shared physical-DB handle reaper now runs independently of standalone idle eviction. Those fixes do not override the red CI and unresolved #802 coupling.

Validation performed on this head:

  • Passed locally: git diff --check origin/main...HEAD, changed-file gofmt -l, go test ./cmd/drive9-server -run '^TestTenantBackendCacheMaxTenants' -count=1, test binary compilation for pkg/backend, pkg/tenant, cmd/drive9-server, go vet ./pkg/backend ./pkg/tenant ./cmd/drive9-server ./pkg/meta ./pkg/server, and go build ./....
  • Hosted drive9-server-local e2e smoke is green, but hosted code-ci fails in Test, so the required gate is still red.

@qiffang

qiffang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

adversary-2 — re-anchored to 60dfea19 (commit "test: align cache tests with async refresh semantics")

The three previously-broken existing regressions are now fixed — verified as correct alignment, not silent weakening:

  • pool_test.go: TestIdleEvictionStartNoOpWhenDisabled renamed to TestIdleEvictionStartKeepsSharedHandleReaperWhenDisabled; assertion flipped from reapStop != nil → fail to reapStop == nil → fail. Now correctly asserts the shared handle reaper DOES start when IdleTimeout=0 (the intended new behavior) + t.Cleanup(pool.Close).
  • TestQuotaConfigCacheReusesSnapshotUntilTTLExpires: post-expiry first get() now asserts stale (1000, immediate — the async behavior), then waitForQuotaConfigLoad (waits on loadDone), then asserts refreshed (2000). Correctly models serve-stale + background-refresh.
  • TestQuotaConfigCacheRefreshFailureReturnsStaleAndUsesRetryCooldown: adds waitForQuotaConfigLoad before the configCalls == 2 assertion so the async refresh completes first.
  • waitForQuotaConfigLoad is a legitimate sync primitive (RLock reads loadDone, select + 300ms timeout), not an assertion bypass.

All #801-owned fixes GREEN at this head: panic-wedge, foreground-latency, reaper-independence, and existing regressions synced.

Coupling unchanged: 20× cap still present (main.go defaultSharedBackendCacheMaxTenants=20480), so #801 + #802 must merge together; #802 remains REVISE on 4 metric-correctness blockers.

Verdict: GREEN on this head's fixes; do not merge decoupled from #802.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

adversary-1 R3 at 60dfea1999ab825aebbabe403d00eb5e3113d699

Requesting changes for the remaining composition blocker. The #801-owned red-test blocker from 6f157069 is closed on this head, but #801 still is not safe to merge independently.

What is now fixed:

  • Hosted code-ci and drive9-server-local e2e smoke are green on this exact head.
  • The quota-cache tests now assert the intended stale-then-async-refresh behavior instead of the old synchronous refresh contract. waitForQuotaConfigLoad waits on loadDone and does not bypass the behavior assertion: the first expired warm call must still return stale, and the later read must see the refreshed snapshot.
  • The IdleTimeout=0 reaper regression is now aligned with the intended contract: Pool.Start should keep the shared physical-DB handle reaper running while standalone tenant idle eviction remains disabled.
  • Prior #801 code concerns remain closed: panic-wedge recovery, warm-expired foreground latency, LRU-neutral shared cached acquire, and shared-handle reaper independence.

Remaining blocker:

  • #801 still contains the 20x shared backend cap increase (cmd/drive9-server/main.go:49, defaultSharedBackendCacheMaxTenants = 20480) while this branch still has Pool.closeEntry call metrics.DeleteTenantCounters (pkg/tenant/pool.go:1647). That makes the metrics-lifecycle cleanup in #802 part of the same safety boundary, not an optional follow-up. Current #802 head is 09debf242306518087545c06b8d69a5cddd9be1c, still CHANGES_REQUESTED, with hosted code-ci failing; an exact synthetic merge of #801 60dfea19 + #802 09debf24 still conflicts in pkg/tenant/pool_test.go. Therefore there is still no executable, tested merge result that proves the 20x cap and metrics cleanup land together.

Required before this can be green: either remove the 20x cap increase from #801, or include/prove the metrics cleanup in the same resolved and green merge result with #802/equivalent changes.

Validation performed on this head:

  • Passed locally: git diff --check origin/main...HEAD, changed-file gofmt -l, go test ./cmd/drive9-server -run '^TestTenantBackendCacheMaxTenants' -count=1, test binary compilation for pkg/backend, pkg/tenant, and cmd/drive9-server, go vet ./pkg/backend ./pkg/tenant ./cmd/drive9-server ./pkg/meta ./pkg/server, and go build ./....
  • Local runtime go test ./pkg/tenant ... is blocked by the machine's known rootless Docker not found TestMain bootstrap, so I relied on hosted code-ci for package runtime coverage.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact base bf995bbab94f13379380097f2838941323b993ac → head 60dfea1999ab825aebbabe403d00eb5e3113d699.

CHANGES_REQUESTED

The new test-only revision correctly closes the prior exact-head CI blocker. waitForQuotaConfigLoad synchronizes with the detached refresh instead of weakening assertions; the expiry test now pins stale-first then refreshed publication, the failure test waits before checking the retry cooldown, and the reaper test now asserts the intentional IdleTimeout=0 physical-handle-reaper behavior. Fresh hosted ci and local-e2e are green. The panic-wedge, expired-warm-leader latency, LRU-neutrality, and shared-handle-reaper fixes remain sound.

One merge blocker remains:

  1. This exact PR still cannot merge independently while it contains the 20× shared resident-cap increase without the metrics-lifecycle fix. cmd/drive9-server/main.go still changes the shared-provider default from 1024 to 20480, while this head's capacity-eviction path still reaches Pool.closeEntry and synchronously calls metrics.DeleteTenantCounters, which scans tenant-labeled metric vectors under registry locks. PR #802 is the intended companion, but it is not in this head. Its latest head is 09debf242306518087545c06b8d69a5cddd9be1c, its hosted ci and review decision are currently failing/CHANGES_REQUESTED, and git merge-tree --write-tree for exact #801 head 60dfea19 plus exact #802 head 09debf24 still conflicts in pkg/tenant/pool_test.go.

Required: choose one executable boundary:

  • remove the 20× cap/provider-cap wiring from #801 so the now-green quota-cache/LRU/reaper work can merge independently; or
  • produce a real resolved/rebased #801+#802 combined head, close #802's correctness blockers, and run both normal/race/full hosted gates on that exact merge result.

Validation on exact head 60dfea19:

  • full PR git diff --check; changed-file gofmt -l
  • normal and race compile-only gates for pkg/backend, pkg/tenant, and cmd/drive9-server
  • go vet ./pkg/backend ./pkg/tenant ./cmd/drive9-server ./pkg/meta ./pkg/server
  • normal/race TestTenantBackendCacheMaxTenants
  • isolated normal/race execution of all TestQuotaConfigCache* tests and TestIdleEvictionStartKeepsSharedHandleReaperWhenDisabled
  • make lint; make build-server
  • hosted ci, drive9-server-local e2e smoke, and CodeRabbit are green
  • exact #801/#802 merge-tree conflict reproduced

The worktree is clean. Please request another exact-head review after the cap is split out or the real combined merge boundary exists.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Correction / refreshed companion check for exact #801 head 60dfea1999ab825aebbabe403d00eb5e3113d699:

PR #802 advanced during my review from 09debf24 to 5e9af58e42779a11e9213dd1d4f0eb607723637b. I fetched that new exact companion head and reran the composition gate. The core verdict is unchanged:

CHANGES_REQUESTED

  • #801's own quota-cache/LRU/reaper implementation and revised tests are green on 60dfea19.
  • #801 still contains the shared cap increase to 20480 and still lacks the companion metrics-lifecycle change in its own merge result.
  • git merge-tree --write-tree refs/review/pr801-r5 refs/review/pr802-r3 for exact #801 60dfea19 + exact #802 5e9af58e still produces a content conflict in pkg/tenant/pool_test.go; there is no real combined revision to test or approve.
  • #802's hosted ci and local-e2e are currently in progress on 5e9af58e, and its formal review decision has not yet cleared.

Therefore #801 remains non-mergeable as an independent PR unless the 20× cap change is removed. Otherwise, resolve the cross-PR conflict into a real combined head and run both PRs' normal/race/full hosted gates on that exact result.

This note supersedes only the stale #802 head/status identifiers in my immediately preceding review; all #801 exact-head validation and the composition requirement remain valid.

@qiffang

qiffang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Correction to my exact-head review above: PR #802 advanced during submission from 09debf24 to 5e9af58e42779a11e9213dd1d4f0eb607723637b. I fetched that exact companion head and reran the composition gate.

The verdict remains CHANGES_REQUESTED for #801 head 60dfea1999ab825aebbabe403d00eb5e3113d699: #801's own fixes/tests are green, but exact #801 60dfea19 + exact #802 5e9af58e still has a content conflict in pkg/tenant/pool_test.go, so no real combined revision exists to test/approve. #802's hosted gates are currently in progress and its formal review decision has not yet cleared.

This correction supersedes only the stale #802 head/status identifiers in review #801 (review); the #801 validation and required boundary are unchanged: either remove the 20× cap increase from #801 or provide a resolved, fully tested combined head.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

R4 at exact head 60dfea1999ab825aebbabe403d00eb5e3113d699: REQUEST_CHANGES.

The #802 composition blocker has changed, but this PR is still not approvable at the current head. #802 is now merged on main as c67c7105 (PR #802 head 5e9af58e) and its checks are green, so the missing metrics-cleanup prerequisite is available on main. However #801 is still based on bf995bba, GitHub reports mergeStateStatus=DIRTY, and a local synthetic merge of current origin/main (c67c7105) with #801 head fails with a content conflict in pkg/tenant/pool_test.go.

Because there is no resolved merge/rebase head, the green checks on 60dfea19 do not validate the actual post-#802 combined state. Please rebase/merge #801 onto current main, resolve the pkg/tenant/pool_test.go conflict, and rerun code-ci plus drive9-server-local e2e smoke on the new exact head before approval.

I did not find a new #801-owned correctness blocker on the stale head in this pass. The 20x shared default cap, lazy quota config refresh semantics, refresh ownership/waiter behavior, LRU-neutral cached acquire behavior, and independent shared-handle reaper tests still match the previously reviewed intent.

Validation performed:

  • gh pr view 801: head 60dfea19, base bf995bba, mergeStateStatus=DIRTY, checks green on the stale head.
  • gh pr view 802: merged at c67c7105, checks green.
  • git merge-tree --write-tree origin/main HEAD: conflict in pkg/tenant/pool_test.go.
  • git diff --check origin/main...HEAD
  • gofmt -l on changed Go files
  • go test ./cmd/drive9-server -run '^TestTenantBackendCacheMaxTenants' -count=1
  • go test -c ./pkg/backend ./pkg/tenant ./cmd/drive9-server
  • go vet ./pkg/backend ./pkg/tenant ./cmd/drive9-server ./pkg/meta ./pkg/server
  • go build ./...

@srstack

srstack commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@qiffang PR #802 has now been merged into main as c67c710. I merged the current origin/main into this branch and resolved the pkg/tenant/pool_test.go conflict. The #801 merge result now includes the metrics cleanup from #802; new head is 26c14d3. code-ci and drive9-server-local e2e smoke are rerunning on this combined head.

@srstack

srstack commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review at 26c14d3b — one new blocking issue in the async-refresh change

#802 is merged (2026-07-27T04:03:33Z) and now present in this branch, so my earlier merge-order gate is satisfied. 06591fd1 is correct and closes the shared-handle item properly. But 9d69fea9 introduced an unbounded goroutine fan-out that I think needs to be addressed before merge.

Full -race suite on this branch is green: pkg/backend, pkg/tenant, cmd/drive9-server, pkg/metrics all pass.

Blocking: unbounded background-refresh fan-out during a MetaDB slowdown

9d69fea9 made the warm-path refresh asynchronous — a warm caller returns stale immediately and spawns go c.loadConfig(...). That fixes the tail latency, but there is no bound on how many of those goroutines can be in flight, and each one holds a MetaDB query.

The load-ownership guard doesn't bound it. c.loadDone != nil serializes refreshes per tenant, so the ceiling is one in-flight query per warm tenant — which at the 20480 ceiling this PR introduces is the problem, not the protection.

Measured on this branch with a store that hangs (degraded MetaDB) and 2000 warm tenants expiring together:

tenants=2000  peak concurrent MetaDB queries=2000  still in flight=2000
              (meta pool default max_open=100)

2000 goroutines, all queued on a 100-connection meta pool (DRIVE9_META_DB_MAX_OPEN_CONNS, default 100). At 20480 residents it scales linearly. Each goroutine also holds a 5s quotaConfigCacheLoadTimeout, so during a slow-but-alive MetaDB they accumulate for the full timeout window before draining — and the 5s failure cooldown then lets the next wave start.

The practical effect is that the async change converts a latency problem into a connection-pool starvation problem: those queued refreshes compete with foreground quota/meta reads on the same pool. That's a worse failure mode than the original synchronous version, because previously backpressure was implicit — a slow MetaDB slowed callers, which limited how fast new loads were issued. The async path removes that feedback entirely.

Note this also partly reverses the PR's own premise. The stated motivation was that per-tenant goroutines don't scale to 20480 tenants; 9d69fea9 reintroduces goroutines on the same per-tenant axis, just transiently rather than permanently. Transient is genuinely better, but "bounded" was the property that made the original design safe.

Suggested fix — a package-level refresh budget, so warm callers still never block but the fan-out has a ceiling:

var quotaConfigRefreshSlots = make(chan struct{}, 32) // tune to meta pool size

// in get(), warm path:
if stale := c.snapshotCopy(); stale != nil {
    select {
    case quotaConfigRefreshSlots <- struct{}{}:
        go func() {
            defer func() { <-quotaConfigRefreshSlots }()
            defer recoverAsyncLoad(c)
            c.loadConfig(context.WithoutCancel(ctx), start)
        }()
    default:
        // budget exhausted: skip this refresh, keep serving stale.
        // nextRefresh is untouched, so the next request retries.
        c.abandonLoad()   // must clear c.loadDone
    }
    return stale
}

The default branch needs care: c.loadDone is already set by the time the warm path runs, so bailing out without clearing it would leave the tenant permanently believing a refresh is in flight — cold waiters would then block on a channel nobody closes. Whatever shape you choose, that unwind path needs a test.

A simpler alternative, if you'd rather not add a budget: keep one shared refresher goroutine that pulls expired tenants off a queue. That caps concurrency at 1 by construction and needs no slot accounting, at the cost of slower convergence when many tenants expire at once.

Smaller notes on 9d69fea9

  • Double panic recovery. loadConfig has a defer that calls finishPanickedLoad then re-panics, and the async caller wraps it in another recover that logs. So an async panic is recovered twice and the goroutine survives; a synchronous panic propagates to the request. That asymmetry is probably intended, but it reads as accidental — worth a comment saying the sync path deliberately lets the caller see it.
  • finishPanickedLoad checks c.loadDone == nil and returns early, which is the right guard, but it means a panic after a successful publish silently records nothing. Fine, just noting it's deliberate-looking rather than obviously correct.
  • Failure-retry jitter is now symmetric (ttl-jitter to ttl+jitter), while success jitter is one-sided (ttl-jitter to ttl). The asymmetry is documented in the comment and the reasoning is sound — failures may safely retry late, successes must not extend the convergence window. Good.

06591fd1 — correct, and it closes my earlier finding

This is the right fix for the shared-handle lifetime item. reapIdleSharedDBs(now) now runs unconditionally in reapOnce, the reaper starts on reapInterval rather than idleTimeout > 0, and the standalone-eviction block is scoped under if p.idleTimeout > 0. So DRIVE9_POOL_IDLE_TTL=0 no longer silently disables physical-handle reclamation — which was a real latent bug independent of my finding, since the old Start returned early and left shared handles unreclaimable forever. Config comments updated to match. Nice catch.

Status

Everything from my earlier passes is resolved and verified. One new blocker:

  • Unbounded async refresh fan-out (this comment) — measured 2000 concurrent MetaDB queries against a 100-connection pool; scales to 20480.

Once that has a ceiling, I'm happy with this. The rest of the branch is in good shape, and 06591fd1 fixed something worth having on its own.

@qiffang

qiffang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

adversary-2 final verdict at 26c14d3 (post-#802 merge head) — GREEN

26c14d3 confirmed = PR #801 current GitHub head + remote branch head + clean local tree (not a temp merge commit).

Code-level verification (adversary-2 + adversary-1 + dat9-dev1 independently agree):

#801's own four fixes all GREEN: panic-wedge, foreground-latency (serve-stale + async), reaper-independence, existing regressions aligned to async semantics.

Gates (fresh local @ exact head): diff-check/gofmt/vet/lint/server-build, 5 key packages normal+race compile, quota-cache normal+race, reaper+live-counter+cap focused normal+race, cmd/drive9-server full race — all PASS. DB-backed suites blocked only by the known local rootless-Docker env gate (not a changed-code failure); rely on hosted CI.

Verdict: GREEN (unconditional). #802 is already in main. #801 awaits adversary-1's final verdict + hosted exact-head CI green → dual-adversary GREEN → merge on qiffang's signal.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVED at exact post-#802 head 26c14d3b80b98fefed0e234aa4237b9424aac39f (parents 60dfea1 + main@c67c7105). The conflict resolution preserves both #801 shared-reaper/LRU regressions and #802 live-counter lifecycle coverage; production Pool.closeEntry no longer deletes tenant counters, so the 20x cap and off-request-path metrics cleanup coexist in the tested build. Fresh local diff/gofmt/vet/lint/build, normal/race compile and focused quota/reaper/counter/cap gates pass. Hosted exact-head code-ci (including Test, failpoint, build), local E2E, and CodeRabbit are green.

@srstack

srstack commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@qiffang Fixed in cac4d5a. Warm quota-config refreshes now use a package-level non-blocking budget of 32 detached MetaDB loads, independent of the 20,480 tenant LRU capacity. A tenant that cannot claim a slot returns stale immediately, clears its loadDone ownership, and gets a 1s retry cooldown; no goroutine waits for a slot. Completed, failed, and panicking loads release their slot. Added TestQuotaConfigCacheAsyncRefreshHasGlobalSlotBudget with 40 simultaneously expired tenants; it verifies at most 32 blocked MetaDB loads and that deferred tenants can retry instead of remaining wedged. Verified normal and -race TestQuotaConfigCache runs.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Holding approval at current exact head cac4d5abf90624be66a32752edcc9d5764856a93.

This PR is no longer at the reviewed/announced post-#802 head 26c14d3b80b98fefed0e234aa4237b9424aac39f. A new commit, cac4d5ab (fix: bound async quota refresh concurrency), was pushed on top and changes pkg/backend/quota_cache.go plus pkg/backend/quota_cache_test.go (+140/-3). That new code has not received my final review, and GitHub currently shows both hosted exact-head gates still running:

  • code-ci: IN_PROGRESS, run 30236952247 / job 89886453095
  • drive9-server-local e2e smoke: IN_PROGRESS, run 30236952243 / job 89886453165
  • CodeRabbit: SUCCESS

Do not merge based on the 26c14d3 approval evidence. Re-review must be against cac4d5ab with exact-head hosted checks green. I will inspect the new quota-cache concurrency delta and update the verdict after the current head is fully reviewed and gated.

@qiffang

qiffang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

adversary-2 — re-anchored to cac4d5ab (new commit "fix: bound async quota refresh concurrency")

This is a real hardening, not a rebase — the 26c14d3 GREEN is superseded. The async warm-refresh (from the foreground-latency fix) could spawn one detached goroutine per resident tenant under a MetaDB slowdown; with the 20× cap (20,480 residents) that's up to ~20k concurrent MetaDB queries, saturating the shared metadata pool and starving foreground work.

cac4d5ab bounds it with a 32-slot global semaphore:

  • quotaConfigCacheAsyncRefreshSlots = make(chan struct{}, 32); tryAcquire...Slot is a non-blocking select send.
  • Slot acquired: the goroutine defer releaseQuotaConfigAsyncRefreshSlot is registered first, so it runs LAST (LIFO), releasing even on panic — no slot leak.
  • Slot full: deferAsyncRefresh sets nextRefresh = now + 1s (no per-request retry storm) + closes loadDone (releases waiters) + returns stale. No wedge, bounded backoff.
  • loadDone is closed exactly once on both paths.

Regression TestQuotaConfigCacheAsyncRefreshHasGlobalSlotBudget: 32+8 tenants → exactly 32 consume slots, the excess 8 are deferred (return stale, no retained load ownership, deferred>0). Proves the semaphore bound + overflow path.

B1 interaction still closed at this head (the commit touches only quota_cache.go, not pool.go/main.go cap).

Verdict: GREEN at cac4d5ab. Awaits hosted exact-head CI green + adversary-1's review of the new commit.

@srstack

srstack commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review at cac4d5ab — fan-out blocker fixed and verified. LGTM.

cac4d5ab resolves the last blocker. I re-ran my original repro against this head rather than reading the diff alone, and also tested the unwind path I'd warned about.

Verified

Fan-out is bounded. Same repro as before — 2000 warm tenants expiring together during a MetaDB slowdown:

before (9d69fea9):  peak concurrent MetaDB queries=2000   (meta pool max_open=100)
after  (cac4d5ab):  peak concurrent MetaDB queries=32     budget=32   foreground=1.73ms

Exactly the budget, no overshoot. The 1.73ms foreground time across all 2000 callers confirms the stale-first property is intact — bounding the refresh didn't reintroduce blocking on the warm path, which was the thing worth protecting.

The unwind path is correct. This was my specific concern: c.loadDone is set before the warm branch runs, so a slot-denied bail-out that skipped clearing it would leave the tenant permanently believing a refresh is in flight, wedging cold waiters on a channel nobody closes. deferAsyncRefresh guards on c.loadDone != nil and calls finishConfigLoadLocked():

deferred path cleared loadDone correctly
subsequent get returned without blocking

Setting nextRefresh = now + 1s on the deferred path is the right call too — without it a budget-starved tenant would re-attempt on every single request, and you'd have traded a goroutine storm for a lock-contention storm on c.mu. The dedicated "deferred" metric result makes budget exhaustion observable rather than silent, which matters since the 32-slot ceiling is the kind of thing that needs an alert if it's saturating in production.

TestQuotaConfigCacheAsyncRefreshHasGlobalSlotBudget covers this properly: slots + 8 tenants, asserts exactly slots refreshes start and no more, and deliberately clears configHook during warm-up so the cold first load isn't miscounted against the budget. That last detail is easy to get wrong and would have made the test pass for the wrong reason.

Full -race on this head:

ok  pkg/backend           47.959s
ok  pkg/tenant            22.692s
ok  cmd/drive9-server      1.046s
ok  pkg/metrics            1.066s

Final disposition

All findings across my six passes are resolved:

# Finding Resolution
1 Canceled request → 5s tenant-wide quota fail-open 5a998767 — verified
2 Safety-net scan inverts capacity LRU fd2a813a — verified
3 loadMu waiters ignore deadline inside open tx 5fe0d367 — verified
4 ~18ms global metrics-lock stall per cold open #802, merged as c67c7105 and present here
5 Unbounded async refresh fan-out cac4d5ab — verified
Shared handle lifetime / IdleTimeout=0 reaper 06591fd1 — verified

Non-blocking items were resolved or consciously declined with sound reasoning: 5s failure retry without backoff (owner decision, and fix 3b means warm callers add zero queries during an outage, which materially shrinks the concern I'd raised), mixed-provider capacity budget (out of scope; standalone entries still get the idle TTL), and the GetQuotaConfigVersion / stop() cleanup (done in c0b8625a).

One note for the archive rather than a change request: the 32-slot budget and the 1s slot-retry interval are both compile-time constants. If a shared pod's steady state turns out to saturate the budget, the symptom will be quota config converging slower than the nominal 30s TTL, visible only as a rising "deferred" rate. Worth a dashboard line, and worth making env-tunable if it ever does saturate — but not something to hold the merge for, since the current defaults leave sensible headroom against the 100-connection meta pool.

LGTM. The end state is better than where this started: 06591fd1 fixed a genuine latent bug (IdleTimeout=0 silently disabling physical shared-handle reclamation) that was independent of the scaling work, and the stale-first get() is a cleaner design than the TryLock I originally sketched, since it makes the warm path unconditionally non-blocking rather than usually-non-blocking. Good iteration on a change with a lot of subtle second-order effects.

@qiffang qiffang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Final R5 at exact head cac4d5abf90624be66a32752edcc9d5764856a93: APPROVED.

This approval supersedes my prior hold on the same head. The branch no longer relies on the superseded 26c14d3 evidence: I reviewed the new cac4d5ab delta (fix: bound async quota refresh concurrency) and found no remaining blocker.

What changed in R5:

  • Adds a global 32-slot budget for warm-cache async quota config refreshes, preventing a MetaDB stall from producing one detached refresh query per resident tenant under the 20x shared-cache cap.
  • Cold initial loads are not throttled by this async budget.
  • Successful async refreshes release the slot via deferred release, including panic/recover paths.
  • Full-slot warm refreshes close loadDone, return stale config, and defer retry for 1s instead of busy-looping per request.
  • The new deferred service result is not classified as a tenant operation failure.

Post-#802 composition remains closed at this head: the 20x shared cap is present, and production Pool.closeEntry no longer calls metrics.DeleteTenantCounters, so capacity eviction no longer triggers request-path counter-registry scanning.

Validation:

  • Hosted exact-head code-ci: SUCCESS (30236952247 / 89886453095)
  • Hosted exact-head drive9-server-local e2e smoke: SUCCESS (30236952243 / 89886453165)
  • CodeRabbit: SUCCESS
  • Local git diff --check origin/main...HEAD
  • Local changed-Go gofmt -l
  • Local go test -c ./pkg/backend
  • Local go test -race -c ./pkg/backend
  • Local go test ./cmd/drive9-server -run '^TestTenantBackendCacheMaxTenants' -count=1
  • Local go test -race ./cmd/drive9-server -count=1
  • Local go vet ./pkg/backend ./pkg/tenant ./cmd/drive9-server ./pkg/meta ./pkg/server
  • Local go build ./...

Local runtime go test ./pkg/backend -run '^TestQuotaConfigCache' remains blocked before test execution by the known package-level Docker/TestMain bootstrap error (rootless Docker not found); hosted CI covers the runtime package tests for this exact head.

@srstack
srstack merged commit ee2e6e2 into main Jul 27, 2026
3 checks passed
@srstack
srstack deleted the fix/shared-tenant-cache-scaling branch July 27, 2026 04:33
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.

4 participants