Skip to content

feat: enforce TiDB Cloud free plan limits - #785

Open
srstack wants to merge 32 commits into
mainfrom
feat/tidbcloud-free-plan-limits
Open

feat: enforce TiDB Cloud free plan limits#785
srstack wants to merge 32 commits into
mainfrom
feat/tidbcloud-free-plan-limits

Conversation

@srstack

@srstack srstack commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add TiDB Cloud Billing plan lookup for native and native-shared modes, including required startup validation for shared credentials.
  • Classify an organization as non-free only when its effective plan is on_demand, or when it is poc with positive POC credits. Cache only non-free results for 30 minutes.
  • Reject free organizations from admin APIs and quota mutations with HTTP 403.
  • Restrict free organizations to zero-spending-limit provisioning and enforce a configurable free-tenant count, defaulting to 5.
  • Count occupied free tenants from existing provider metadata without adding a billing-binding table: native uses tenant_tidbcloud_org_bindings; native-shared uses tenant_placements through db_pool. Deleted tenants, NULL/nonzero spending limits, and unclaimed pool inventory with pool_status=free are excluded.
  • Serialize free quota across pods with the existing per-organization advisory lock. Native holds it until the Cluster ID and native org binding are durable, then releases it before connection-metadata polling. Shared holds it until placement or pool claim is durable.
  • Apply configurable free storage, maximum file size, and maximum file count limits; make the global maximum file count configurable with zero meaning unlimited.
  • Split direct native cluster creation from connection-metadata polling, persist the Cluster ID immediately after creation, and remove the legacy synchronous provision interfaces.
  • Reconcile stale free reservations without confusing direct native tenants with pool-owned tenants.
  • Unify TiDB Cloud Cluster, IAM, and Billing OpenAPI request metrics.

Validation

  • GOCACHE=/tmp/drive9-go-cache go test ./pkg/meta -count=1
  • Feature-focused pkg/server tests covering free limits, Billing, native early binding, shared claims, and pending reconciliation.
  • GOCACHE=/tmp/drive9-go-cache go test ./pkg/... -run "^$" -count=1
  • GOCACHE=/tmp/drive9-go-cache GOLANGCI_LINT_CACHE=/tmp/drive9-golangci-cache make lint
  • Merged current origin/main; branch is not behind and merge-tree reports no conflict.

Test infrastructure note

A full pkg/server run reached the existing shared MySQL test environment failure after about 146 seconds: the first failures were unexpected EOF / invalid connection, followed by cascading setup failures after the database closed. The feature-focused server tests pass.

Related

Summary by CodeRabbit

  • New Features

    • Added environment-driven TiDB Cloud plan cache TTL and free-quota/tenant-file defaults, including configurable tenant-file count limits.
    • Improved native “early binding” provisioning with credential-based metadata completion.
    • Added richer TiDB Cloud free-tenant pool claiming/release and atomic tenant status transitions.
  • Bug Fixes

    • Strengthened TiDB Cloud admin/quota authorization and refined billing/IAM error handling (including “quota busy” retry responses).
    • Prevented inconsistent quota updates and improved provisioning rollback/cleanup safety.
  • Tests

    • Expanded unit tests for env parsing, free-quota/locks, plan cache behavior, atomicity, and billing/access error mapping.

Copilot AI review requested due to automatic review settings July 24, 2026 15:52
@coderabbitai

coderabbitai Bot commented Jul 24, 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

TiDB Cloud provisioning now resolves billing plans, enforces free-tier quotas and tenant limits, supports early cluster binding with metadata polling, persists tenant state atomically, and applies authorization and structured error handling across provisioning, admin, quota, and native provider flows.

Changes

TiDB Cloud plan resolution and provisioning

Layer / File(s) Summary
Billing contracts and plan resolution
pkg/tenant/..., pkg/tenant/tidbcloudnative/..., pkg/server/tidbcloud_access.go
Adds billing-plan contracts, structured API errors, Billing API resolution, shared-access validation, request classification, and non-free plan caching.
Free quota metadata and tenant state
pkg/meta/..., pkg/meta/quota.go, pkg/meta/tidbcloud_free_quota.go
Adds configurable file-count defaults, free-tenant reservations and counting, stale cleanup, named locks, ownership checks, quota-aware pool claims, and atomic tenant transitions.
Access gates and pool claiming
pkg/server/admin_tenants.go, pkg/server/admin_tenant_pool.go, pkg/server/quota.go, pkg/server/tidbcloud_free_limits.go
Authorizes TiDB Cloud admin operations, rejects prohibited free-plan mutations, normalizes free quotas, and synchronizes free pool claims with headroom checks.
Startup configuration
cmd/drive9-server/main.go, cmd/drive9-server/main_test.go, pkg/server/server.go
Parses plan-cache, free-plan, and tenant file-count environment settings and wires normalized values into server configuration.
Provisioning and early binding
pkg/server/server.go, pkg/tenant/tidbcloudnative/provisioner.go, pkg/server/provision_test.go, pkg/tenant/tidbcloudnative/provisioner_test.go
Adds separate cluster creation and metadata-wait phases, persists early cluster references, finalizes connections with CAS semantics, handles free reservations and locks, and centralizes cleanup and reconciliation behavior.
Validation and observability
pkg/*/*_test.go, pkg/metrics/*, pkg/tenant/tidbcloudnative/*
Expands coverage for billing responses, quota limits, locks, rollback, authorization, early binding, structured errors, metrics, and partial batch results.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • mem9-ai/drive9#557: Modifies the TiDB Cloud Native provisioning interfaces and server provisioning flow.
  • mem9-ai/drive9#632: Adds related tenant-pool and TiDB Cloud tenant lifecycle behavior.
  • mem9-ai/drive9#717: Modifies related TiDB Cloud client-error mapping and HTTP status handling.

Suggested reviewers: qiffang

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.61% 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 summarizes the main change: enforcing TiDB Cloud free plan limits.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tidbcloud-free-plan-limits

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.

@srstack
srstack force-pushed the feat/tidbcloud-free-plan-limits branch from 060e7ae to 5430661 Compare July 24, 2026 15:57

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 strengthens TiDB Cloud “free plan” enforcement across provisioning, admin, and quota mutation paths by adding Billing-plan resolution (with positive-only caching), persisting billing-organization bindings, and improving TiDB Cloud native provisioning to persist cluster identity before polling for metadata.

Changes:

  • Add TiDB Cloud Billing-plan resolution, non-free plan caching, and access gating (admin + quota mutation restrictions for free orgs).
  • Persist tenant ↔ billing organization bindings (including backfill + new meta APIs) and enforce free-plan tenant/quota limits.
  • Split TiDB Cloud native provisioning into “create cluster” vs “wait for metadata,” persisting cluster ID early and improving stale pending-tenant reconciliation + metrics.

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pkg/tenant/tidbcloudnative/provisioner_test.go Expands TiDB Cloud native provisioner test coverage for Billing plan resolution, typed errors, and early-binding behavior.
pkg/tenant/tidbcloud_plan.go Introduces tenant-level Billing/free-plan error types and plan resolver interface.
pkg/tenant/provisioner.go Adds TiDBCloudAPIError typed error and updates the credential provisioner interface to early-binding semantics.
pkg/tenant/provisioner_error_test.go Adds tests validating TiDBCloudAPIError structure and formatting.
pkg/server/tidbcloud_plan_cache.go Adds a positive-only (non-free) Billing plan cache with TTL and bounded size.
pkg/server/tidbcloud_plan_cache_test.go Tests cache hit/expiry/removal and max-entries bounds.
pkg/server/tidbcloud_free_limits.go Adds server-side free-plan quota normalization and enforcement for provision requests.
pkg/server/tidbcloud_free_limits_test.go Tests free-plan quota normalization and rejection conditions.
pkg/server/tidbcloud_access.go Adds access-profile resolution (IAM + Billing), cache integration, and Billing error classification helpers.
pkg/server/tidbcloud_access_test.go Tests caching rules (non-free only), cross-credential sharing, and Billing error response mapping.
pkg/server/shared_db_pool.go Aligns shared-tenant quota materialization with default file-count behavior.
pkg/server/server.go Wires Billing cache + free-plan limits into server config; adds early-binding cluster reference persistence and improved pending-tenant reconciliation.
pkg/server/quota.go Rejects free-org quota mutations and maps Billing lookup failures to appropriate client responses.
pkg/server/quota_test.go Adds tests ensuring no handler-level OpenAPI double-counting and free-org admin/quota mutation rejections.
pkg/server/provision_test.go Adds extensive coverage for free-plan provisioning, billing bindings, early-binding lifecycle, and error mapping behavior.
pkg/server/admin_tenants.go Gates admin tenant operations behind Billing-plan access checks and preserves Billing error contracts.
pkg/server/admin_tenant_pool.go Gates pool operations behind Billing-plan access checks; adds free-plan headroom checks and customer binding/quota persistence during pool claims.
pkg/server/admin_tenant_pool_test.go Adds tests for free-plan pool-claim headroom, binding persistence, and quota clamping.
pkg/metrics/operations.go Adds Billing cache metric and changes TiDB Cloud OpenAPI metric labels to api/operation/result.
pkg/metrics/operations_test.go Updates metric tests to cover the new Billing cache metric and new OpenAPI label set.
pkg/meta/tenant_pool_membership.go Adds shared pool-claim variant that can persist billing org bindings during claim.
pkg/meta/tenant_pool_failed_cleanup_test.go Adds cleanup to avoid cross-test interference for pool membership fixtures.
pkg/meta/tenant_billing_org_binding.go Adds tenant billing-org binding schema + CRUD, free-tenant reservation helpers, free quota lock, and backfill routine.
pkg/meta/tenant_billing_org_binding_test.go Adds extensive tests for binding backfill, free-tenant reservation/locking, and stale reservation cleanup.
pkg/meta/quota.go Introduces configurable default max file count and uses it for quota defaults/patches.
pkg/meta/quota_setting_test.go Adds tests for default max file count behavior and updates expectations accordingly.
pkg/meta/meta.go Adds tenant billing-org binding table, free-quota lock settings, a reusable insertTenantTx, and runs billing-org binding backfill in migration.
pkg/meta/meta_test.go Adds tests for exact-candidate pool claim behavior, early cluster reference persistence, and atomic finalize connection CAS.
cmd/drive9-server/main.go Adds env parsing for Billing cache TTL, free-plan limits, and global max tenant file-count default; updates shared access validation hook.
cmd/drive9-server/main_test.go Adds unit tests for the new env parsing helpers.
Comments suppressed due to low confidence (1)

pkg/server/quota.go:257

  • rejectFreeQuotaMutation runs before loading the tenant and checking its provider. That means quota updates for non‑TiDBCloud tenants (or when the provisioner doesn't support billing/identity resolution) can now fail with a Billing/IAM error instead of the intended "quota setting is only supported for tidb_cloud_native tenants" conflict, and it also adds unnecessary billing lookups.

Move the free-plan mutation check to after quotaTenant + provider validation so it only applies to TiDB Cloud tenants.

	if err := s.rejectFreeQuotaMutation(r.Context(), cred, "quota_set"); err != nil {
		writeQuotaSetError(w, r.Context(), err, "authorize")
		return
	}
	t, ok := s.quotaTenant(w, r.Context(), req.TenantID)
	if !ok {
		return
	}
	if t.Provider != tenant.ProviderTiDBCloudNative && t.Provider != tenant.ProviderTiDBCloudNativeShared {
		errJSON(w, http.StatusConflict, "quota setting is only supported for tidb_cloud_native tenants")
		return
	}

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

Comment thread pkg/meta/tenant_billing_org_binding.go Outdated

@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: 2

🧹 Nitpick comments (1)
pkg/server/tidbcloud_access.go (1)

92-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share the billing-lookup operation constant across packages.

pkg/server/tidbcloud_access.go matches apiErr.Operation with the "Billing plan lookup" string that pkg/tenant/tidbcloudnative sets via statusError. Add an exported typed constant in pkg/tenant shared by both packages so this detection cannot drift silently.

🤖 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/server/tidbcloud_access.go` around lines 92 - 98, Define an exported
typed constant in pkg/tenant for the billing-plan lookup operation currently
represented by "Billing plan lookup" in tidbcloudnative. Update statusError
usage there and the comparison in isTiDBCloudBillingLookupError to reference
this shared tenant constant, preserving the existing case-insensitive trimmed
matching behavior.
🤖 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/server/tidbcloud_free_limits.go`:
- Around line 45-56: Update normalizeTiDBCloudFreeProvisionQuota so positive
MaxStorageBytes and MaxFileSizeBytes convert to quota units using ceiling
division rather than floor division, ensuring any positive configured byte limit
yields at least one unit. Guard the maxStorageSize and maxFileSize defaults
against zero before applying omitted-field values, while preserving the existing
validation for explicitly supplied quota fields.

In `@pkg/tenant/tidbcloudnative/provisioner.go`:
- Around line 1489-1490: The delete response recording in both branch deletion
(pkg/tenant/tidbcloudnative/provisioner.go#L1489-L1490) and cluster deprovision
(pkg/tenant/tidbcloudnative/provisioner.go#L1515-L1516) must classify an
accepted 404 as tidbCloudResultOK rather than client_error. Adjust the
recordTiDBCloudHTTPResponse calls or their placement while preserving existing
handling for other statuses.

---

Nitpick comments:
In `@pkg/server/tidbcloud_access.go`:
- Around line 92-98: Define an exported typed constant in pkg/tenant for the
billing-plan lookup operation currently represented by "Billing plan lookup" in
tidbcloudnative. Update statusError usage there and the comparison in
isTiDBCloudBillingLookupError to reference this shared tenant constant,
preserving the existing case-insensitive trimmed matching behavior.
🪄 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: aff542a7-6d7f-4b19-9e86-e013833dab7d

📥 Commits

Reviewing files that changed from the base of the PR and between 9539a53 and 060e7ae.

📒 Files selected for processing (31)
  • cmd/drive9-server/main.go
  • cmd/drive9-server/main_test.go
  • pkg/meta/meta.go
  • pkg/meta/meta_test.go
  • pkg/meta/quota.go
  • pkg/meta/quota_setting_test.go
  • pkg/meta/tenant_billing_org_binding.go
  • pkg/meta/tenant_billing_org_binding_test.go
  • pkg/meta/tenant_pool_failed_cleanup_test.go
  • pkg/meta/tenant_pool_membership.go
  • pkg/metrics/operations.go
  • pkg/metrics/operations_test.go
  • pkg/server/admin_tenant_pool.go
  • pkg/server/admin_tenant_pool_test.go
  • pkg/server/admin_tenants.go
  • pkg/server/provision_test.go
  • pkg/server/quota.go
  • pkg/server/quota_test.go
  • pkg/server/server.go
  • pkg/server/shared_db_pool.go
  • pkg/server/tidbcloud_access.go
  • pkg/server/tidbcloud_access_test.go
  • pkg/server/tidbcloud_free_limits.go
  • pkg/server/tidbcloud_free_limits_test.go
  • pkg/server/tidbcloud_plan_cache.go
  • pkg/server/tidbcloud_plan_cache_test.go
  • pkg/tenant/provisioner.go
  • pkg/tenant/provisioner_error_test.go
  • pkg/tenant/tidbcloud_plan.go
  • pkg/tenant/tidbcloudnative/provisioner.go
  • pkg/tenant/tidbcloudnative/provisioner_test.go

Comment thread pkg/server/tidbcloud_free_limits.go Outdated
Comment thread pkg/tenant/tidbcloudnative/provisioner.go Outdated

@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: 060e7ae368

ℹ️ 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/server/tidbcloud_free_limits.go
Comment thread pkg/server/server.go
Comment thread cmd/drive9-server/main.go
Comment thread pkg/server/admin_tenant_pool.go Outdated
Comment thread pkg/meta/tenant_billing_org_binding.go Outdated
Comment thread pkg/server/server.go Outdated
@srstack

srstack commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

LGTM. Reviewed the full diff in depth (31 files, +4437) — I read the core access-control, billing-classification, and provisioning-flow paths directly and fanned out parallel deep-dives across the provisioner, server wiring, and meta layer. This is a well-architected, fail-closed implementation of billing-plan-based access control. CI is green. (Posting as a comment — GitHub blocks approving one's own PR.)

Security properties verified

Free/non-free classification fails closed. parseOrganizationPlanResponse (provisioner.go) defaults isFree := true and only flips to non-free on an explicit on_demand plan or poc with strictly-positive credits (big.Rat behind a decimal-pattern guard). Unknown plans, malformed/trailing JSON, oversized bodies, and org mismatch all error or stay free — a free org can never be silently mis-classified as non-free. The response org is checked against the IAM-resolved org (both string and numeric identity forms).

Access-control gating is complete and correctly ordered. Every admin route (create/list/get/quotaSet/delete, pool create/get/update/delete) is gated by authorizeTiDBCloudAdminAccess (403 for free orgs), and the sole quota-mutation route handleQuotaSet by rejectFreeQuotaMutation — both placed before any state mutation or pool lock. Quota GET is intentionally left open so free orgs can still read. On any billing-lookup failure the request is rejected (fail-closed), mapped to 401/403/502 with generic messages; no upstream body or credential leaks.

Cluster create/poll split preserves the cluster ID. CreateClusterWithCredentialsAndQuota returns a populated ClusterInfo (ClusterID set) even when metadata is incomplete; the server persists the cluster reference via PersistTiDBCloudTenantClusterReference (with org verification) before calling WaitForClusterMetadataWithCredentials. A poll failure after early binding therefore can't orphan a created cluster — cleanup can find it. Org identity is verified both before persist and after the metadata wait.

Free-tenant cap is race-free at both creation entry points. Direct provisioning uses ReserveTiDBCloudFreeTenant (count + insert inside WithTiDBCloudFreeQuotaLock, a per-org SHA-256-keyed GET_LOCK), and the pool-claim path nests WithTiDBCloudFreeQuotaLockWithTenantPoolLock with checkFreePoolClaimHeadroom inside the lock. Both free-creating writers serialize on the same per-org lock, so the configurable count can't be exceeded. Busy → ErrTiDBCloudFreeQuotaBusy (503); non-free path passes nil quota so it isn't counted. All meta SQL is parameterized.

Stale-reservation reconciliation is well-guarded. DeleteStaleTiDBCloudFreeReservation is a single atomic UPDATE (RowsAffected()==1) requiring the row still carries reservation metadata and has no cluster/namespace/api-keys/org-binding/pool-membership/placement — it can't clobber a tenant that raced into provisioning, and HasTenantPoolOwnership cleanly separates direct-native from pool-owned. Confirmed reservations are inserted as TenantPending, matching the cleanup filter. Release via status→Deleted (count filters <> TenantDeleted) — no double-count.

Migration backfill is conflict-safe. backfillTenantBillingOrgBindings fails loudly if a tenant resolves to >1 distinct org across reliable sources, rather than picking arbitrarily; idempotent via ON DUPLICATE KEY UPDATE.

Non-blocking nits

  • Stale non-free cache window (tidbcloud_access.go): isNonFree short-circuits without a billing call while cached (default 30 min TTL). A paid→free downgrade retains admin/quota-mutation access for up to the TTL. Bounded and low-risk (direction is 'was paying'); worth a comment, or explicit invalidation on downgrade.
  • Exported lock-free reservation writer: InsertTiDBCloudTenantReservation is exported and creates a counted (spending_limit=0) row without the free-quota lock. Current callers are safe (all go through the locked reserve path), but a future direct caller could bypass the cap — consider unexporting or documenting the lock precondition.
  • Backfill org source: billing org for shared-placed tenants derives from db_pool.org_id. Correct under feat: authorize TiDB Cloud operations through IAM #780's model (org_id == customer org), but worth confirming no legacy shared pool row carries a non-customer/infra org before running the migration on production data.
  • Migration hard-abort: a single cross-source org conflict fails migrate() and blocks startup. Loud-and-safe, but operationally consider logging/skipping the one bad tenant instead.
  • Double identity resolution on admin get/quotaSet/delete (authorize + authorizedAdminTenant) — extra RBAC-cached IAM round-trip, no correctness impact.
  • tidbCloudFreeQuotaLockTimeoutSeconds is a mutable package var (siblings are const); presumably for tests.
  • Latent quota-default divergence: new upsertTenantQuotaPatchTx uses DefaultMaxFileCount() while the older SetQuotaConfigPatch still hardcodes 0 — equivalent today (default is 0), diverges only if the default becomes non-zero.

None of these block. Test coverage across the new files (plan cache, free limits, access profile, billing-org binding, provisioner billing) is thorough, secret-redaction is well-tested, and the create/poll refactor is guarded end-to-end. Nice work.

@srstack srstack left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed against the approved design doc. Overall this is a high-quality implementation — the 16 commits map cleanly onto the design's three-phase sequence (typed errors → metric unification → feature), and the correctness-critical mechanisms hold up under close reading. I ran focused passes over four risk areas; three are clean, one has a single item worth fixing before merge.

✅ Verified correct

Free-slot reservation + pending reconciler

  • The reconciler pool-discriminator fix is correct. The old ClusterID != "" && DBUser == "" proxy is now only a trigger to do the real lookup; the skip decision goes through the explicit HasTenantPoolOwnership() (checks tenant_tidbcloud_org_bindings.pool_id / tenant_pool_memberships / tenant_placements). An early-bound direct-native tenant with no pool metadata correctly falls through to pending → failed instead of being silently skipped. This was the main risk from the design review and it's handled properly.
  • Free-count query uses t.status (not state), three INNER JOINs, tidbcloud_spending_limit = 0 — matches spec.
  • Recount happens inside the org lock; the three inserts (tenants + tenant_billing_org_bindings + tenant_quota_config) are one transaction; the lock is released before any cloud HTTP call. 5s lock wait → ErrTiDBCloudFreeQuotaBusy → 503 + Retry-After: 1.
  • DeleteStaleTiDBCloudFreeReservation checks all nine required conditions; a tenant with a cluster_id can never be local-deleted.
  • Native early-cluster-reference persistence happens before the metadata wait; connection persist + pending → provisioning CAS is one transaction; org mismatch triggers cluster cleanup.

Billing resolver + response parsing

  • poc_credits: strict decimal regex → big.Rat.SetStringSign(), no float64 anywhere. tenant_id decoded via json.Number (exact, no float64 truncation); both tenant_id/tenant_id_str must match when present.
  • Classification, fail-closed behavior, body size-limiting, and org-ID redaction (/v1beta1/tenants/***/plans) all match the design.

Access profile + admin/quota gates

  • Resolution order is correct: IAM identity+role first → cache hit skips billing → miss calls billing → non-free cached / free removes stale positive / failure fails closed with no cache write. Cache keyed by org ID only, stores just the expiry marker (no plan/credentials), bounded at 10k.
  • Every admin endpoint rejects a free org with 403 before any tenant/pool/cloud lookup (no existence oracle); malformed/missing creds still 400. Free quota is readable; every free mutation is 403 regardless of direction. The zero-file-count → 403 case (0 = unlimited) is correct end-to-end.
  • Startup validation: billing URL required only for native/native-shared, absolute HTTPS + host + trailing-slash trim; ValidateSharedAccess runs IAM+billing, requires org match and non-free.

⚠️ Worth fixing before merge — typed error dropped Service, so billing classification fell back to cross-package string matching

The design specifies TiDBCloudAPIError{Service, Operation, Status}. The implementation is:

type TiDBCloudAPIError struct {
    Operation    string
    StatusCode   int
    UpstreamBody string
}

There's no Service field, so the server distinguishes a billing error from IAM/cluster errors by matching the operation string:

func isTiDBCloudBillingLookupError(err error) bool {
    apiErr, ok := tiDBCloudAPIError(err)
    return ok && strings.EqualFold(strings.TrimSpace(apiErr.Operation), "Billing plan lookup")
}

That literal "Billing plan lookup" is produced in a different package (statusError("Billing plan lookup", ...) in tidbcloudnative/provisioner.go). This is exactly the fragile cross-package string coupling the typed-error refactor set out to eliminate: rename either magic string and billing 401/403 silently degrades to 502. It's functionally correct today (tests pin the strings), but it reintroduces the failure mode the refactor was meant to remove.

Suggestion: add back a Service field ("billing" / "iam" / "cluster") and switch isTiDBCloudBillingLookupError to apiErr.Service == "billing".

Related: the struct retains UpstreamBody, which the design says the type must not do. IAM and billing paths both pass "" (so no credential/body leak on the sensitive paths), but cluster paths store sanitizeUpstreamBody(raw). If "sanitized body is acceptable" is the intended behavior, worth relaxing the design wording so doc and code agree.

Minor (non-blocking)

  • Cache eviction can drop the just-inserted key. rememberNonFree inserts first (size → max+1) then for key := range c.entries { delete; break }, which can evict the entry just added. The bound is respected and the direction is safe (at worst one extra billing lookup), but evicting-before-insert would be cleaner.
  • Spending-limit 400 vs 403 deviates from the spec table. The design says "positive → 403", but the existing validateQuotaSetRequest runs first and returns 400 for 1–9 RMB and for values above max ("must be 0 or at least 10 RMB"). Only a positive value in [10, max] reaches the 403 path. Either adjust the spec table or normalize free-path spending rejection to 403.
  • normalizeTiDBCloudFreeProvisionQuota folds negatives into the 403 branch. Currently masked because validateQuotaSetRequest always runs first (negatives → 400), so no live bug — but the function isn't self-defensive if a future caller bypasses validation.
  • get_branch metric operation isn't in the design's enumerated operation list. Low-cardinality, so not a cardinality problem — just a doc gap.

Verdict

Good to merge once the Service field / billing-classification string-matching item is addressed — it doesn't affect current behavior, but it will silently regress billing 401/403 mapping on any future rename, contrary to the refactor's own goal. Everything else is polish.

@srstack

srstack commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the typed-error review in 9934e79. TiDBCloudAPIError now carries a typed Service field with iam/billing/cluster constants; every native OpenAPI status error sets it, and server billing classification checks Service instead of an operation string. The tests assert service propagation and prove that a cluster error using the same operation text is not misclassified as Billing. This commit also makes every nonzero free-org provision spending limit return 403 before generic quota validation, including negative, below-minimum, and above-maximum values. I intentionally left the positive-cache eviction policy and sanitized cluster UpstreamBody behavior unchanged because they are non-blocking and outside these correctness fixes.

@srstack
srstack force-pushed the feat/tidbcloud-free-plan-limits branch from 8ef95b1 to 4d42871 Compare July 24, 2026 18:56

@srstack srstack left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-reviewed after the latest push. The fix: address TiDB Cloud free plan review findings commit resolves the blocking item.

✅ Blocking item resolved

TiDBCloudAPIError now carries a Service field, and isTiDBCloudBillingLookupError checks apiErr.Service == tenant.TiDBCloudAPIServiceBilling instead of matching the "Billing plan lookup" string across package boundaries. IAM and cluster classification switched to Service too. The regression test locks it in — a renamed operation with Service=billing still classifies as billing, and Service=cluster with the old magic string does not. The cross-package string coupling is gone.

Follow-up nits (non-blocking)

  • Default free tenant count changed 3 → 5 (DefaultTiDBCloudFreeTenantCount = 5), but the design doc still says "a default of three" / Default: 3, and I don't see a --help / quota-guide update for the new value in this PR. Please sync the doc + operator help to 5.
  • Cache eviction still inserts-then-evicts (rememberNonFree): the entry is added before the over-cap loop runs, so the just-inserted key can be the one dropped. Bound is respected and it's safe (at worst one extra billing lookup), but evicting before insert would be cleaner.
  • UpstreamBody retained on TiDBCloudAPIError: cluster paths still populate it with sanitizeUpstreamBody(...), which is against the design's "must not retain an upstream body" wording. IAM/billing paths pass "", so no leak on the sensitive paths — if a sanitized cluster body is intentional, worth relaxing the design text so doc and code agree.

Net: merge-blocker cleared. Remaining items are polish plus the 3→5 doc sync.

@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: 5

Caution

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

⚠️ Outside diff range comments (1)
pkg/server/admin_tenants.go (1)

1-1: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Reuse the resolved TiDB Cloud identity/plan in the tenant-authorization paths. authorizeTiDBCloudAdminAccess and rejectFreeQuotaMutation already resolve the caller’s API key identity once; the follow-up tenant checks call resolveTiDBCloudIdentity again for the same request. Return and pass the resolved identity through the free-plan gates so authorizedAdminTenant, authorizeNativeTenantCredentials, and authorizeSharedQuotaCredentials can skip the duplicate IAM lookup, while the billing lookup remains needed when the plan cache misses.

🤖 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/server/admin_tenants.go` at line 1, Update authorizeTiDBCloudAdminAccess
and rejectFreeQuotaMutation to retain the already resolved TiDB Cloud identity
and plan, then pass them through the free-plan authorization flow. Extend
authorizedAdminTenant, authorizeNativeTenantCredentials, and
authorizeSharedQuotaCredentials to accept and reuse the resolved identity,
avoiding duplicate resolveTiDBCloudIdentity calls while preserving the billing
lookup when the plan cache misses.
🧹 Nitpick comments (4)
pkg/tenant/provisioner.go (1)

58-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a typed string type for the TiDB Cloud API service enum.

Service is a domain enum compared across pkg/tenant, pkg/tenant/tidbcloudnative, and pkg/server; a defined type (e.g. type TiDBCloudAPIService string) would prevent accidental assignment of arbitrary operation/label strings to this field.

As per coding guidelines: "Use typed string constants for domain enums."

♻️ Suggested typing
+type TiDBCloudAPIService string
+
 const (
-	TiDBCloudAPIServiceIAM     = "iam"
-	TiDBCloudAPIServiceBilling = "billing"
-	TiDBCloudAPIServiceCluster = "cluster"
+	TiDBCloudAPIServiceIAM     TiDBCloudAPIService = "iam"
+	TiDBCloudAPIServiceBilling TiDBCloudAPIService = "billing"
+	TiDBCloudAPIServiceCluster TiDBCloudAPIService = "cluster"
 )
 
 type TiDBCloudAPIError struct {
-	Service      string
+	Service      TiDBCloudAPIService
 	Operation    string
🤖 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/tenant/provisioner.go` around lines 58 - 69, Introduce a defined string
type, such as TiDBCloudAPIService, for the TiDB Cloud service enum and declare
TiDBCloudAPIServiceIAM, TiDBCloudAPIServiceBilling, and
TiDBCloudAPIServiceCluster with that type. Update TiDBCloudAPIError.Service and
all related comparisons or assignments across the affected tenant and server
code to use the typed constants while preserving existing behavior.

Source: Coding guidelines

pkg/metrics/operations.go (1)

307-313: 📐 Maintainability & Code Quality | 🔵 Trivial

Label rename is a dashboard/alert-breaking change. drive9_tidbcloud_openapi_requests_total switches its first label from path to api; existing queries, recording rules, and alerts selecting on path= will silently return no series. Worth calling out in the release/migration notes.

🤖 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/metrics/operations.go` around lines 307 - 313, Document the label rename
from path to api for drive9_tidbcloud_openapi_requests_total in the release or
migration notes, explicitly noting that existing queries, recording rules, and
alerts filtering on path= must be updated.
pkg/tenant/tidbcloudnative/provisioner_test.go (1)

456-464: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Absolute metric counts couple these tests to execution order.

assertTiDBCloudOpenAPIMetric asserts an exact cumulative value from the process-global registry, so any other test in this package that produces the same api/operation/result triple shifts the count. Line 404 already encodes that accumulation (wantCount: 2 for protocol_error), and iam/resolve_api_key_identity/ok is also emitted by TestValidateSharedAccessUsesConfiguredKeyAtStartup. The delta helper tiDBCloudOpenAPIMetricValue already in this file is order-independent; consider asserting deltas instead.

♻️ Sketch
-func assertTiDBCloudOpenAPIMetric(t *testing.T, api, operation, result string, count int) {
+func assertTiDBCloudOpenAPIMetricDelta(t *testing.T, api, operation, result string, before float64, want float64) {
 	t.Helper()
-	rec := httptest.NewRecorder()
-	metrics.WritePrometheus(rec)
-	want := fmt.Sprintf(`drive9_tidbcloud_openapi_requests_total{api=%q,operation=%q,result=%q} %d`, api, operation, result, count)
-	if !strings.Contains(rec.Body.String(), want) {
-		t.Fatalf("missing metric %q:\n%s", want, rec.Body.String())
-	}
+	if got := tiDBCloudOpenAPIMetricValue(t, api, operation, result) - before; got != want {
+		t.Fatalf("metric delta for %s/%s/%s = %v, want %v", api, operation, result, got, want)
+	}
 }
🤖 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/tenant/tidbcloudnative/provisioner_test.go` around lines 456 - 464,
Update assertTiDBCloudOpenAPIMetric to assert metric deltas using the existing
tiDBCloudOpenAPIMetricValue helper instead of exact cumulative values from the
global registry. Adjust its callers or setup to capture the baseline and compare
the expected increment, preserving existing expectations such as the
protocol_error accumulation and shared IAM metric behavior.
pkg/meta/tidbcloud_free_quota.go (1)

185-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share one implementation with SetQuotaConfigPatch to avoid divergence.

This is a near-verbatim copy of SetQuotaConfigPatch (pkg/meta/quota.go Lines 251-304), including the default-vs-COALESCE semantics. Two copies will drift the moment a quota column or default is added. Extracting the argument construction + SQL into a helper that both the *sql.DB and *sql.Tx paths call (e.g. via a small execer interface) keeps the semantics single-sourced.

🤖 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/meta/tidbcloud_free_quota.go` around lines 185 - 227, Refactor
upsertTenantQuotaPatchTx and SetQuotaConfigPatch to share one implementation for
quota patch argument construction and SQL execution, using a small common execer
interface if needed for *sql.DB and *sql.Tx. Preserve the existing
default-on-insert and COALESCE-on-update semantics, including all quota columns
and TiDB Cloud fields, so future schema or default changes are made in one
place.
🤖 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/server/admin_tenants.go`:
- Around line 323-326: Update handleAdminTenantQuotaSet and the related
authorizedAdminTenant flow to reuse the TiDB Cloud identity returned by
authorizeTiDBCloudAdminAccess instead of resolving it again through
resolveTiDBCloudIdentity and adminTenantMetricPath. Preserve the existing
authorization and quota-update behavior while ensuring each request performs
only one external IAM identity check.
- Around line 287-290: Reuse the resolved TiDB Cloud identity/profile from
authorizeTiDBCloudAdminAccess throughout handleAdminTenantGet,
handleAdminTenantQuotaSet, and handleAdminTenantDelete, passing it into
authorizedAdminTenant and subsequent IAM/metric checks instead of resolving cred
again. Apply the same propagation through rejectFreeQuotaMutation and
shared/native tenant authorization in quota.go, preserving existing
authorization outcomes while eliminating duplicate credential cache lookups.

In `@pkg/server/quota.go`:
- Around line 242-245: Refactor the quota mutation authorization flow around
rejectFreeQuotaMutation and the subsequent authorizeTiDBCloudOrganization calls
to resolve the TiDB Cloud identity once and reuse the resulting authorization
data. Return or propagate the shared authorization result from the initial
check, then pass it through later native/shared authorization checks without
invoking resolveTiDBCloudIdentity again.

In `@pkg/server/server.go`:
- Around line 5446-5458: Update the reservationErr busy-error branch in the
server provisioning flow to match errors against meta.ErrTiDBCloudFreeQuotaBusy
instead of tenant.ErrTiDBCloudFreeQuotaBusy, while preserving the existing 503
response and retry behavior. Ensure the corresponding error message uses the
meta sentinel consistently with AcquireTiDBCloudFreeQuotaLock.

In `@pkg/tenant/tidbcloudnative/provisioner.go`:
- Around line 268-275: Update the IAM error handling in the response path around
recordTiDBCloudHTTPResponse to ignore any readErr from draining resp.Body and
always return the typed statusError containing the HTTP status, matching the
existing Billing path behavior and preserving downstream error classification.

---

Outside diff comments:
In `@pkg/server/admin_tenants.go`:
- Line 1: Update authorizeTiDBCloudAdminAccess and rejectFreeQuotaMutation to
retain the already resolved TiDB Cloud identity and plan, then pass them through
the free-plan authorization flow. Extend authorizedAdminTenant,
authorizeNativeTenantCredentials, and authorizeSharedQuotaCredentials to accept
and reuse the resolved identity, avoiding duplicate resolveTiDBCloudIdentity
calls while preserving the billing lookup when the plan cache misses.

---

Nitpick comments:
In `@pkg/meta/tidbcloud_free_quota.go`:
- Around line 185-227: Refactor upsertTenantQuotaPatchTx and SetQuotaConfigPatch
to share one implementation for quota patch argument construction and SQL
execution, using a small common execer interface if needed for *sql.DB and
*sql.Tx. Preserve the existing default-on-insert and COALESCE-on-update
semantics, including all quota columns and TiDB Cloud fields, so future schema
or default changes are made in one place.

In `@pkg/metrics/operations.go`:
- Around line 307-313: Document the label rename from path to api for
drive9_tidbcloud_openapi_requests_total in the release or migration notes,
explicitly noting that existing queries, recording rules, and alerts filtering
on path= must be updated.

In `@pkg/tenant/provisioner.go`:
- Around line 58-69: Introduce a defined string type, such as
TiDBCloudAPIService, for the TiDB Cloud service enum and declare
TiDBCloudAPIServiceIAM, TiDBCloudAPIServiceBilling, and
TiDBCloudAPIServiceCluster with that type. Update TiDBCloudAPIError.Service and
all related comparisons or assignments across the affected tenant and server
code to use the typed constants while preserving existing behavior.

In `@pkg/tenant/tidbcloudnative/provisioner_test.go`:
- Around line 456-464: Update assertTiDBCloudOpenAPIMetric to assert metric
deltas using the existing tiDBCloudOpenAPIMetricValue helper instead of exact
cumulative values from the global registry. Adjust its callers or setup to
capture the baseline and compare the expected increment, preserving existing
expectations such as the protocol_error accumulation and shared IAM metric
behavior.
🪄 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: fc7b1993-5ed5-4cda-986e-0f9fd3f22ad1

📥 Commits

Reviewing files that changed from the base of the PR and between b078a2e and 3a460f1.

📒 Files selected for processing (30)
  • cmd/drive9-server/main.go
  • cmd/drive9-server/main_test.go
  • pkg/meta/meta.go
  • pkg/meta/meta_test.go
  • pkg/meta/quota.go
  • pkg/meta/quota_setting_test.go
  • pkg/meta/tenant_pool_failed_cleanup_test.go
  • pkg/meta/tidbcloud_free_quota.go
  • pkg/meta/tidbcloud_free_quota_test.go
  • pkg/metrics/operations.go
  • pkg/metrics/operations_test.go
  • pkg/server/admin_tenant_pool.go
  • pkg/server/admin_tenant_pool_test.go
  • pkg/server/admin_tenants.go
  • pkg/server/provision_test.go
  • pkg/server/quota.go
  • pkg/server/quota_test.go
  • pkg/server/server.go
  • pkg/server/shared_db_pool.go
  • pkg/server/tidbcloud_access.go
  • pkg/server/tidbcloud_access_test.go
  • pkg/server/tidbcloud_free_limits.go
  • pkg/server/tidbcloud_free_limits_test.go
  • pkg/server/tidbcloud_plan_cache.go
  • pkg/server/tidbcloud_plan_cache_test.go
  • pkg/tenant/provisioner.go
  • pkg/tenant/provisioner_error_test.go
  • pkg/tenant/tidbcloud_plan.go
  • pkg/tenant/tidbcloudnative/provisioner.go
  • pkg/tenant/tidbcloudnative/provisioner_test.go

Comment thread pkg/server/admin_tenants.go Outdated
Comment thread pkg/server/admin_tenants.go Outdated
Comment thread pkg/server/quota.go Outdated
Comment thread pkg/server/server.go
Comment thread pkg/tenant/tidbcloudnative/provisioner.go Outdated
srstack added 2 commits July 25, 2026 12:52
…lan-limits

# Conflicts:
#	cmd/drive9-server/main.go
#	pkg/server/provision_test.go
#	pkg/server/server.go

@srstack srstack left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-reviewed the latest push (simplify TiDB Cloud free quota accounting, preserve IAM status on body drain errors, plus main merges). This is a larger change than the commit titles suggest — +2800/-1093 across accounting, shared-pool, provisioning, and admin-pool — so I traced the correctness-critical paths again.

✅ Confirmed fixed from the prior round

  • IAM body-drain fix is correct. A body-read error on a non-2xx IAM response previously discarded the HTTP status (returned the read error); now statusError(...) is returned with the real status regardless of drain outcome. Status mapping is preserved.
  • Service field fix holds (from the earlier round): billing classification is Service-based, not string-matched.

⚠️ Architecture change worth an explicit sign-off: tenant_billing_org_bindings removed

The approved design added a dedicated tenant_billing_org_bindings table as the source of truth for free-tenant counting. This push deletes that table and rewrites CountTiDBCloudFreeTenants to derive org ownership from existing metadata:

  • native arm: tenant_tidbcloud_org_bindings (org binding, pool_status <> 'free')
  • shared arm: db_pool.org_id → tenant_placements → fs_registry → tenants

I verified the new count query is sound on its own terms:

  • No double-counting. The two UNION arms are disjoint by provider (native vs native_shared), which is single-valued per tenant. The shared arm yields exactly one row per tenant (tenant_placements.fs_id is PK, fs_registry.tenant_id is unique), so no intra-arm multiplicity.
  • Cleanup guards are safe. DeleteStaleTiDBCloudFreeReservation / DeleteTiDBCloudFreeReservation still refuse any tenant with a cluster_id, api key, native binding, pool membership, or placement — a placement-owning shared tenant can never be local-deleted.
  • reconciler not regressed. Still uses explicit HasTenantPoolOwnership() (native binding pool_id<>'' / membership / placement), not the ClusterID/DBUser proxy.

⚠️ Consequence of the rewrite — the free-quota lock now spans a cloud create (deviates from the design)

This is the one item reviewers should consciously accept. With the dedicated binding table gone, a freshly reserved pending tenant is not yet counted — its tenant_tidbcloud_org_bindings row (native) / placement (shared) is written later, during cloud provisioning. To keep the cross-pod atomicity guarantee (two concurrent reservations must not both pass the recount), the code now holds the free-quota org lock across the cloud cluster/pool create HTTP call:

  • native: acquire → recount → reserve → CreateClusterWithCredentialsAndQuota (HTTP) → PersistTiDBCloudTenantClusterReference (writes the binding, pool_status=Used so it counts) → release, then the metadata wait runs unlocked.
  • shared: acquire → recount → reserve → ensureManagedSharedDBPhysical (HTTP) / placement → release before the readiness continuation.

The design explicitly stated (spec line 514): "The lock does not cover external HTTP calls, shared database readiness waits, or schema initialization," with release right after the count-and-insert transaction (steps 8-10). The implementation is self-consistent and correct — the extended hold is exactly what preserves the guarantee once the binding table is removed — but it trades the design's property for a real cost:

  • The 5-second free-quota lock now wraps a cloud create for the first concurrent provision per org, so a second concurrent free-provision for the same org can hit ErrTiDBCloudFreeQuotaBusy → 503 while the first is mid-create.
  • It pins a dedicated *sql.Conn across that external call.

Both are bounded (readiness/metadata waits are still unlocked, and it only bites concurrent same-org provisions), but it's a deliberate departure from the approved spec. Please either (a) update the design doc to record that the lock intentionally covers the create call and why, or (b) reintroduce a lightweight countable marker written inside the reservation transaction so the lock can be released immediately as originally specced.

Minor / non-blocking

  • retryTenantPoolClaimCAS now returns (zero, nil) after exhausting retries — this is correct "miss" semantics (only reached when every attempt was ErrNotFound; other errors propagate). Just confirm all callers treat a nil+zero result as "no claim."
  • Prior round's nits still open: default free tenant count 3→5 not reflected in the design doc / --help; cache rememberNonFree still inserts-then-evicts; TiDBCloudAPIError.UpstreamBody retained on cluster paths.

Verdict

No new correctness bugs found — the accounting rewrite is internally consistent and the atomicity guarantee is preserved. The one thing that needs an explicit decision is the lock-across-cloud-create deviation from the approved design: it's correct but changes the concurrency/latency profile the spec promised, so it shouldn't land silently. Everything else is polish.

@srstack srstack left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

One migration follow-up to the accounting-rewrite review above, after tracing the dropped-table path fully:

  • No explicit DROP TABLE tenant_billing_org_bindings. The table's DDL and all Go references are removed and the count no longer touches it (verified: zero non-test references remain), but there's no drop in the migration path. On already-migrated deployments the table is left orphaned and unused — harmless dead state, but worth a deliberate call: either add a drop, or note in the doc that it's intentionally left in place for rollback safety.

Also confirming the atomicity analysis for the reviewers: the freshly-reserved pending tenant is genuinely invisible to CountTiDBCloudFreeTenants (both count arms inner-join a provisioning artifact — native binding / shared placement — that the reservation transaction does not write). This is safe only because that invisibility window is fully contained inside the per-org GET_LOCK critical section, which is held until the binding/placement is persisted. A concurrent same-org reservation blocks on the lock and cannot recount until the first is visible, so two reservations can't both pass count < limit. That's the crux of why the lock-across-cloud-create hold (flagged above) is load-bearing rather than incidental — if it's ever shortened, the atomic guarantee goes with it. A reserved-but-crashed tenant (quota row, no binding) consumes no slot and is reaped by DeleteStaleTiDBCloudFreeReservation, whose guard matches exactly that shape.

@srstack

srstack commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the latest review and rebased/merged the current main into this branch.

  • The old tenant_billing_org_bindings table is intentionally not dropped. It has no remaining runtime Go references or counting-path usage. Leaving the orphaned table avoids an irreversible migration change and preserves rollback safety; it is inert dead state for now.
  • The free-quota organization lock intentionally remains held through the first native cluster/shared placement create. After removing the extra billing-binding marker table, the reservation row is not visible to the count query until the native binding or shared placement is persisted. Holding the existing per-org lock through that create is therefore what preserves the cross-pod count invariant without introducing another marker/heartbeat/reaper design. Same-org free requests may wait or receive the existing 503 busy response, and the DB connection is held only during the create; metadata/readiness waits remain outside the lock.
  • The concrete review fixes are included in 9cb31659: free byte-limit ceiling conversion, pool-claim error mapping, backfill exclusion for unclaimed free inventory, reservation cleanup after failed provisioning, rollback of failed pool-claim metadata, upload-cap default clamping, zero spending-limit handling for shared routing, and accepted-404 delete metrics.

The branch is now up to date with origin/main (behind=0) and GitHub reports it as mergeable.

@srstack

srstack commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my previous review reply: tenant_billing_org_bindings was only part of the unmerged design/implementation iteration and was never released to a deployed schema. There is therefore no production table or migration residue to clean up, and no DROP TABLE is needed. The current branch has no runtime or migration reference to it.

The free-quota lock trade-off described in the previous reply is unchanged: after removing that never-released marker-table approach, the existing per-org lock remains held through native binding/shared placement persistence so the cross-pod free-slot count stays atomic.

@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/tenant/tidbcloudnative/provisioner.go (1)

887-893: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve decoded pools when a partial response is malformed.

BatchCreateClusters may return a non-empty subset with an error. A later malformed/unknown returned cluster still causes an immediate nil return, discarding already decoded pools; the caller can retry them and create duplicate physical pools. Accumulate that validation error into partialErr and return the successful out slice.

🤖 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/tenant/tidbcloudnative/provisioner.go` around lines 887 - 893, Update the
BatchCreateClusters handling and subsequent cluster-validation flow so malformed
or unknown clusters are accumulated into partialErr when createdClusters is
non-empty, rather than returning nil immediately. Preserve the successfully
decoded pools in the out slice and return them alongside the accumulated
validation error, while keeping the existing immediate return for an entirely
empty response.
🧹 Nitpick comments (3)
pkg/tenant/tidbcloudnative/clusters_api.go (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a package-level documentation comment.

Add // Package tidbcloudnative ... immediately before the package declaration. As per coding guidelines, “Add a package-level documentation comment to the first file in each package.”

🤖 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/tenant/tidbcloudnative/clusters_api.go` at line 1, Add a package-level
documentation comment immediately before the tidbcloudnative package declaration
in clusters_api.go, beginning with “Package tidbcloudnative” and briefly
describing the package.

Source: Coding guidelines

pkg/meta/meta.go (1)

2184-2209: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wrap the scan error with operation context.

The final error path returns the raw row.Scan error unwrapped, unlike sibling meta functions (e.g. GetSharedDB, GetTenantPlacement) that wrap DB errors with fmt.Errorf("...: %w", ...). As per coding guidelines, **/*.go: "Wrap errors with operation context using %w; check sentinel errors with errors.Is rather than comparing strings."

♻️ Proposed fix
 	row := s.db.QueryRowContext(ctx, fmt.Sprintf(countFreeTenantPoolBindingsSQL, placeholders, placeholders), args...)
 	if err = row.Scan(&out); err != nil {
-		return 0, err
+		return 0, fmt.Errorf("count free tidbcloud pool bindings for organization %s: %w", organizationID, err)
 	}
🤖 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/meta/meta.go` around lines 2184 - 2209, Update
countFreeTenantPoolBindingsByStatus so the row.Scan failure is wrapped with
descriptive operation context using fmt.Errorf and %w before returning. Preserve
the existing output and error handling behavior, including the named err used by
observeMeta.

Source: Coding guidelines

pkg/server/provision_test.go (1)

402-496: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated managed-shared-DB test fixture setup into a helper.

The meta.Opent.Cleanuptestmysql.ResetMetaDB → generate AES key → encrypt.NewLocalAESEncryptortenant.NewPoolpool.SetMetaStore sequence is duplicated near-verbatim in this test and in TestProvisionTiDBCloudNativeSharedPlansManagedPoolAndReturnsPending (544-563), TestManagedSharedDBContinuationBatchesDoNotEnterBlockingMetadataWait (1381-1398), TestManagedSharedDBBatchCreateRunsAllChunksConcurrently (1732-1747), and TestManagedSharedDBBatchCreateLocksEachAllocationIdentityIndependently (1808-1823). Consider factoring this into a shared t.Helper()-based fixture constructor (mirroring the existing mustTempDir(t) helper) to cut duplication and centralize future setup changes.

🤖 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/server/provision_test.go` around lines 402 - 496, Extract the repeated
metadata store, encryption, and tenant pool setup into a shared t.Helper()-based
fixture constructor, modeled after mustTempDir(t). Replace the duplicated setup
in TestManagedSharedDBMetadataWorkerRefillsReadySlots,
TestProvisionTiDBCloudNativeSharedPlansManagedPoolAndReturnsPending,
TestManagedSharedDBContinuationBatchesDoNotEnterBlockingMetadataWait,
TestManagedSharedDBBatchCreateRunsAllChunksConcurrently, and
TestManagedSharedDBBatchCreateLocksEachAllocationIdentityIndependently,
preserving each test’s cleanup and returned fixture usage.
🤖 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/tenant/tidbcloudnative/http_clusters_api.go`:
- Around line 191-193: Avoid double-counting successful delete metrics in the
delete-cluster and delete-branch response branches: in
pkg/tenant/tidbcloudnative/http_clusters_api.go lines 191-193 and 260-262, keep
the accepted 200, 204, and 404 responses returning success, but call
recordTiDBCloudOpenAPIRequest only for StatusNotFound because
doDigestAuthRequest already records 200/204 responses.

---

Outside diff comments:
In `@pkg/tenant/tidbcloudnative/provisioner.go`:
- Around line 887-893: Update the BatchCreateClusters handling and subsequent
cluster-validation flow so malformed or unknown clusters are accumulated into
partialErr when createdClusters is non-empty, rather than returning nil
immediately. Preserve the successfully decoded pools in the out slice and return
them alongside the accumulated validation error, while keeping the existing
immediate return for an entirely empty response.

---

Nitpick comments:
In `@pkg/meta/meta.go`:
- Around line 2184-2209: Update countFreeTenantPoolBindingsByStatus so the
row.Scan failure is wrapped with descriptive operation context using fmt.Errorf
and %w before returning. Preserve the existing output and error handling
behavior, including the named err used by observeMeta.

In `@pkg/server/provision_test.go`:
- Around line 402-496: Extract the repeated metadata store, encryption, and
tenant pool setup into a shared t.Helper()-based fixture constructor, modeled
after mustTempDir(t). Replace the duplicated setup in
TestManagedSharedDBMetadataWorkerRefillsReadySlots,
TestProvisionTiDBCloudNativeSharedPlansManagedPoolAndReturnsPending,
TestManagedSharedDBContinuationBatchesDoNotEnterBlockingMetadataWait,
TestManagedSharedDBBatchCreateRunsAllChunksConcurrently, and
TestManagedSharedDBBatchCreateLocksEachAllocationIdentityIndependently,
preserving each test’s cleanup and returned fixture usage.

In `@pkg/tenant/tidbcloudnative/clusters_api.go`:
- Line 1: Add a package-level documentation comment immediately before the
tidbcloudnative package declaration in clusters_api.go, beginning with “Package
tidbcloudnative” and briefly describing the package.
🪄 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: e6c66bf9-d2cc-48d2-ab0e-5c93d1b42409

📥 Commits

Reviewing files that changed from the base of the PR and between 3a460f1 and 9cb3165.

📒 Files selected for processing (12)
  • cmd/drive9-server/main.go
  • pkg/meta/meta.go
  • pkg/server/admin_tenant_pool.go
  • pkg/server/admin_tenant_pool_test.go
  • pkg/server/provision_test.go
  • pkg/server/server.go
  • pkg/server/shared_db_pool.go
  • pkg/tenant/provisioner.go
  • pkg/tenant/tidbcloudnative/clusters_api.go
  • pkg/tenant/tidbcloudnative/http_clusters_api.go
  • pkg/tenant/tidbcloudnative/provisioner.go
  • pkg/tenant/tidbcloudnative/provisioner_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • pkg/server/shared_db_pool.go
  • pkg/server/admin_tenant_pool_test.go
  • cmd/drive9-server/main.go
  • pkg/server/admin_tenant_pool.go
  • pkg/server/server.go

Comment thread pkg/tenant/tidbcloudnative/http_clusters_api.go
@srstack

srstack commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Handled the latest review and merged the current main (f2191832) into the branch.

  • BatchProvisionSharedDBPoolsWithCredentials now preserves successfully decoded clusters when another returned cluster has malformed/unknown metadata, returning the successful results together with the accumulated validation error so callers do not retry already-created pools.
  • HTTP cluster/branch delete paths now emit the explicit success metric only for accepted 404 responses; 200/204 are already recorded once by doDigestAuthRequest.
  • Added operation context to the free-pool binding scan error.
  • The package-level tidbcloudnative documentation comment already exists in provisioner.go; no duplicate comment was added. The repeated test-fixture extraction was left out as a non-functional refactor.

Focused TiDB Cloud, server, command, and repository compile checks passed. The branch is up to date with origin/main (behind=0) and GitHub reports it as mergeable.

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

Caution

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

⚠️ Outside diff range comments (3)
pkg/metrics/operations.go (1)

521-537: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Classify business events using the normalized event value.

The metric label is trimmed, but tenantAttributedBusinessEvent receives the raw input. An event such as " tenant_provision " is emitted as tenant_provision without tenant/org labels, producing inconsistent attribution. Normalize once before both uses.

Proposed fix
+	event = cleanMetricValue(event, "unknown")
 	attrs = append(attrs, Attr("event", cleanMetricValue(event, "unknown")))
🤖 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/metrics/operations.go` around lines 521 - 537, Normalize the event value
once before the tenant attribution check and metric emission, then pass that
normalized value to tenantAttributedBusinessEvent and use it consistently for
the event label. Update the surrounding metric-building function rather than
changing the classification cases in tenantAttributedBusinessEvent.
pkg/meta/quota.go (2)

677-692: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Propagate request cancellation to this quota update.

applyQuotaCounterDeltasTx is called from transactions with an application context, but IncrQuotaUsageCountersTx uses tx.Exec and never observes cancellation or SQL errors. Add ctx context.Context as the first parameter, use tx.ExecContext(ctx, ...), wrap errors with %w, and update the shared MetaQuotaStore/adapter/fake signatures and callers.

🤖 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/meta/quota.go` around lines 677 - 692, Update IncrQuotaUsageCountersTx to
accept ctx context.Context first and execute the quota UPDATE via
tx.ExecContext, wrapping returned SQL errors with %w while preserving
ensureRowsAffected handling. Propagate the new context parameter through
MetaQuotaStore, its adapter and fake implementations, applyQuotaCounterDeltasTx,
and every caller.

Source: Coding guidelines


1509-1511: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle RowsAffected failures before interpreting a row count mismatch.

A driver error from res.RowsAffected() is currently discarded and converted to errMutationAlreadyApplied, which can make a transient/SQL failure look like a duplicate-applied mutation. Wrap the read error directly and only treat n != len(ids) as “already applied.”

Proposed fix
-	n, _ := res.RowsAffected()
+	n, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("read batch mark-applied result: %w", err)
+	}
 	if int(n) != len(ids) {
🤖 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/meta/quota.go` around lines 1509 - 1511, Update the RowsAffected handling
in the batch mutation flow to capture and check its returned error before
comparing the affected count. Return the RowsAffected error directly with
appropriate context when it fails; only return errMutationAlreadyApplied when
the successful count differs from len(ids).

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@pkg/meta/quota.go`:
- Around line 677-692: Update IncrQuotaUsageCountersTx to accept ctx
context.Context first and execute the quota UPDATE via tx.ExecContext, wrapping
returned SQL errors with %w while preserving ensureRowsAffected handling.
Propagate the new context parameter through MetaQuotaStore, its adapter and fake
implementations, applyQuotaCounterDeltasTx, and every caller.
- Around line 1509-1511: Update the RowsAffected handling in the batch mutation
flow to capture and check its returned error before comparing the affected
count. Return the RowsAffected error directly with appropriate context when it
fails; only return errMutationAlreadyApplied when the successful count differs
from len(ids).

In `@pkg/metrics/operations.go`:
- Around line 521-537: Normalize the event value once before the tenant
attribution check and metric emission, then pass that normalized value to
tenantAttributedBusinessEvent and use it consistently for the event label.
Update the surrounding metric-building function rather than changing the
classification cases in tenantAttributedBusinessEvent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f741621e-05fd-4dec-a601-afb357de67d2

📥 Commits

Reviewing files that changed from the base of the PR and between 9cb3165 and 0df7fed.

📒 Files selected for processing (19)
  • cmd/drive9-server/main.go
  • pkg/meta/meta.go
  • pkg/meta/meta_test.go
  • pkg/meta/quota.go
  • pkg/meta/quota_setting_test.go
  • pkg/meta/tenant_pool_failed_cleanup_test.go
  • pkg/metrics/operations.go
  • pkg/metrics/operations_test.go
  • pkg/server/admin_tenant_pool.go
  • pkg/server/admin_tenant_pool_test.go
  • pkg/server/admin_tenants.go
  • pkg/server/provision_test.go
  • pkg/server/quota_test.go
  • pkg/server/server.go
  • pkg/server/shared_db_pool.go
  • pkg/tenant/provisioner.go
  • pkg/tenant/tidbcloudnative/http_clusters_api.go
  • pkg/tenant/tidbcloudnative/provisioner.go
  • pkg/tenant/tidbcloudnative/provisioner_test.go
🚧 Files skipped from review as they are similar to previous changes (15)
  • pkg/server/shared_db_pool.go
  • pkg/meta/tenant_pool_failed_cleanup_test.go
  • pkg/tenant/provisioner.go
  • pkg/meta/meta_test.go
  • pkg/server/admin_tenant_pool_test.go
  • pkg/meta/quota_setting_test.go
  • pkg/server/quota_test.go
  • pkg/server/admin_tenants.go
  • pkg/metrics/operations_test.go
  • cmd/drive9-server/main.go
  • pkg/server/admin_tenant_pool.go
  • pkg/meta/meta.go
  • pkg/server/provision_test.go
  • pkg/server/server.go
  • pkg/tenant/tidbcloudnative/provisioner.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.

Caution

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

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

3780-3791: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle RowsAffected() errors in FinalizeTenantConnection.

n, _ := res.RowsAffected() currently discards driver errors from RowsAffected(). If that call fails, the function returns (false, nil), which can make callers treat finalization as a harmless no-op. Assign and handle the error, and wrap ExecContext errors with operation context.

Proposed fix
  res, err := s.db.ExecContext(ctx, ...)
  if err != nil {
-   return false, err
+   return false, fmt.Errorf("finalize tenant connection: %w", err)
  }
- n, _ := res.RowsAffected()
+ n, err := res.RowsAffected()
+ if err != nil {
+   return false, fmt.Errorf("read finalized tenant rows affected: %w", err)
+ }
  return n > 0, nil
🤖 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/meta/meta.go` around lines 3780 - 3791, Update FinalizeTenantConnection
to handle and return errors from res.RowsAffected() instead of discarding them,
and wrap ExecContext failures with clear operation context while preserving the
existing success result based on affected rows.

Source: Coding guidelines

🧹 Nitpick comments (2)
pkg/server/notify_coalescer_test.go (1)

219-223: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise the actual retry-success path.

Because rec.err is cleared before the c.flush call, this flush succeeds on its initial attempt; the new assertion therefore does not cover finishFlush after an internal retry. Use an inserter that fails once and succeeds on the retry, and retain a direct assertion for the pending gauge if that metric remains part of the contract.

🤖 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/server/notify_coalescer_test.go` around lines 219 - 223, Update the
retry-success test around the coalescer flush to use an inserter that fails on
its first call and succeeds on the retry, ensuring the assertion exercises
finishFlush after an internal retry. Retain a direct assertion for the pending
gauge if it remains part of the contract, alongside the existing pending and
inFlight state checks.
pkg/meta/meta.go (1)

2677-2691: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wrap raw transaction errors with operation context.

Both new metadata flows lose the operation stage when returning database or transaction errors. Apply %w context at each affected boundary:

  • pkg/meta/meta.go#L2677-L2691: identify binding update, affected-row validation, and quota deletion failures.
  • pkg/meta/meta.go#L3831-L3868: identify lock, transaction, conflict cleanup, availability, tenant update, upsert, and commit failures.

As per coding guidelines, errors must be wrapped with operation context using %w.

🤖 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/meta/meta.go` around lines 2677 - 2691, Wrap each returned error with
operation-specific context using %w. In pkg/meta/meta.go lines 2677-2691, label
binding update, requireAffected validation, and quota deletion failures. In
pkg/meta/meta.go lines 3831-3868, label lock, transaction, conflict cleanup,
availability, tenant update, upsert, and commit failures; preserve the
underlying errors for unwrapping.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@pkg/meta/meta.go`:
- Around line 3780-3791: Update FinalizeTenantConnection to handle and return
errors from res.RowsAffected() instead of discarding them, and wrap ExecContext
failures with clear operation context while preserving the existing success
result based on affected rows.

---

Nitpick comments:
In `@pkg/meta/meta.go`:
- Around line 2677-2691: Wrap each returned error with operation-specific
context using %w. In pkg/meta/meta.go lines 2677-2691, label binding update,
requireAffected validation, and quota deletion failures. In pkg/meta/meta.go
lines 3831-3868, label lock, transaction, conflict cleanup, availability, tenant
update, upsert, and commit failures; preserve the underlying errors for
unwrapping.

In `@pkg/server/notify_coalescer_test.go`:
- Around line 219-223: Update the retry-success test around the coalescer flush
to use an inserter that fails on its first call and succeeds on the retry,
ensuring the assertion exercises finishFlush after an internal retry. Retain a
direct assertion for the pending gauge if it remains part of the contract,
alongside the existing pending and inFlight state checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 61b7b889-e097-4842-8615-92f6610cf8fd

📥 Commits

Reviewing files that changed from the base of the PR and between 0df7fed and 94148ff.

📒 Files selected for processing (14)
  • cmd/drive9-server/main.go
  • cmd/drive9-server/main_test.go
  • pkg/meta/meta.go
  • pkg/meta/quota.go
  • pkg/meta/quota_setting_test.go
  • pkg/metrics/operations.go
  • pkg/metrics/operations_test.go
  • pkg/server/admin_tenant_pool.go
  • pkg/server/admin_tenant_pool_test.go
  • pkg/server/notify_coalescer_test.go
  • pkg/server/provision_test.go
  • pkg/server/quota_test.go
  • pkg/server/server.go
  • pkg/server/shared_db_pool.go
💤 Files with no reviewable changes (1)
  • pkg/meta/quota.go
🚧 Files skipped from review as they are similar to previous changes (11)
  • pkg/server/shared_db_pool.go
  • pkg/metrics/operations.go
  • pkg/meta/quota_setting_test.go
  • pkg/metrics/operations_test.go
  • cmd/drive9-server/main_test.go
  • pkg/server/admin_tenant_pool_test.go
  • pkg/server/quota_test.go
  • cmd/drive9-server/main.go
  • pkg/server/admin_tenant_pool.go
  • pkg/server/provision_test.go
  • pkg/server/server.go

@srstack

srstack commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review at 94148ff — LGTM ✅ (non-blocking notes)

Re-reviewed the current head against the design doc, across four risk areas (free-slot reservation + reconciler, billing resolver + access gates, native early-bound provisioning, typed-error/metric unification). Build, vet, and the full meta / tenant / tidbcloudnative / server / cmd test suites pass.

Prior blocking item is fixed. The typed error carries Service again and isTiDBCloudBillingLookupError now matches apiErr.Service == tenant.TiDBCloudAPIServiceBilling instead of the cross-package operation string. Verified only the billing path sets Service = Billing (IAM sets IAM, cluster paths set Cluster), so billing 401/403 can no longer be misclassified — the failure mode the refactor set out to remove is gone.

Verified clean (no live CRITICAL/MAJOR):

  • Billing fail-closed with no cache write; billing/IAM typed errors carry UpstreamBody == "" (no credential/PII leak); org ID redacted to /v1beta1/tenants/***/plans.
  • poc_credits via strict decimal + big.Rat, tenant_id via exact json.Number; both id forms must match the IAM org; trailing JSON rejected.
  • Every admin/quota mutation gate rejects a free org with 403 before any tenant/pool/cloud lookup — no existence oracle; free reads stay readable.
  • Reservation insert is one transaction (rollback tested); DeleteStale guards all disqualifiers (a tenant with a cluster_id can never be local-deleted); reconciler discriminates via HasTenantPoolOwnership so an early-bound direct-native tenant with pool_id='' correctly falls to pending→failed.
  • Early-bound native persistence writes the cluster reference before the metadata wait; org mismatch triggers cluster cleanup; all partial-failure paths funnel through cleanupProvisionedClusterAfterProvisionFailure — no orphan/leak path found.
  • New metric operations are fixed constants (no tenant/org IDs as label values).

[MINOR] Free-quota lock is load-bearing across the cloud cluster-create call — reconcile with the design

The design (§"Atomic free tenant slot reservation", steps 7–9) specifies the reservation transaction inserts a tenant_billing_org_bindings row so the committed reservation is self-counting, and that "the lock does not cover external HTTP calls." The implementation diverges: tenant_billing_org_bindings is never written (the "simplify accounting" commit reused tenant_tidbcloud_org_bindings for native and tenant_placements for shared), so a bare reservation (InsertTiDBCloudFreeTenantReservation, tenant row + tenant_quota_config only) is invisible to CountTiDBCloudFreeTenants. Correctness therefore depends on the free-quota lock staying held from recount (server.go:5717) until the countable binding/placement is persisted (:5890 native / :5590 shared) — which spans CreateClusterWithCredentialsAndQuota (:5881, a cloud POST). Held from :5714 to :5901.

This is correct today and there's no over-provisioning bug. But: (a) it contradicts the design's stated lock lifecycle and the 5s-timeout rationale ("this lock covers only a count-and-insert transaction"); (b) same-org free provisions are fully serialized behind a cloud cluster-create, so bursts will routinely hit the 5s GET_LOCK timeout → 503; (c) it's a latent CRITICAL if a future change "optimizes" toward the design by releasing the lock right after the reservation insert — two concurrent same-org requests would each count N and each create a cluster → N+2. Suggest either a code comment documenting that the lock must span the countable-binding persist, or updating the design doc to match the shipped self-counting model. This is the only "technical debt" item I'd want addressed.

[MINOR] Free-provision path maps structurally-invalid inputs to 403 instead of 400

normalizeTiDBCloudFreeProvisionQuota (tidbcloud_free_limits.go:56-66) returns ErrTiDBCloudFree... (→403) for max_storage_size<=0, max_file_size<=0, max_file_count<=0, negative spending. The free provision branch (server.go:4866-4871) never calls validateQuotaSetRequest, so these bypass the 400 path that non-free requests get. Design's status table reserves 403 for policy violations and 400 for malformed input. Request is still correctly rejected — spec-table deviation only.

[MINOR] Billing URL validation accepts query/fragment/userinfo

provisioner.go:212-223 checks scheme==https + Host!="" but not query/fragment/opaque, and only trims trailing slashes. https://host?x=y passes startup, then billingURL + "/v1beta1/..." yields a malformed endpoint so every billing call fails at runtime. Same for the API/IAM URLs. Operator-facing, caught immediately in any environment.

[NIT] rememberNonFree eviction can drop the just-inserted key

tidbcloud_plan_cache.go:70-76 inserts then evicts a random entry while over the 10k bound — can evict the entry just added (~1/10001). Fail-open only (an extra billing lookup), never toward granting free-as-non-free.

Verdict: solid, well-tested, security-critical paths clean, and the prior blocking issue is resolved. None of the above blocks merge. I'd recommend landing the one-line lock-invariant comment (or the design reconciliation) with this PR since it's the item most likely to bite a future change. LGTM.

(GitHub blocks formally approving one's own PR, so this comment stands in for the approval.)

@srstack

srstack commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the valid items from the latest review in 4122bde.\n\n- FinalizeTenantConnection now wraps ExecContext failures and propagates RowsAffected failures instead of treating them as a silent no-op; added a driver-level regression test.\n- Native, IAM, and Billing base URL validation now rejects query strings, fragments, empty query/fragment markers, opaque URLs, and userinfo; added table-driven startup validation coverage.\n- Added an explicit code comment documenting that the per-org free-quota lock must remain held until the native binding or shared placement becomes countable.\n\nIntentionally unchanged:\n- Free provision quota-shape violations remain HTTP 403, matching the requested policy that free-org rejections use 403.\n- Non-free cache eviction remains bounded and fail-closed; evicting the just-inserted key only causes an extra Billing lookup and does not change authorization.\n\nThe branch also includes the latest main commit 80ed57b. Fresh verification passed: pkg/meta, pkg/tenant/tidbcloudnative, focused pkg/server tests, repository compile-only test, and make lint.

@srstack srstack left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-reviewed 4122bde (fix: address remaining TiDB Cloud review findings) on top of the main merge. Tight, well-tested commit — no new correctness issues. Build passes.

Confirmed fixes:

  • FinalizeTenantConnection swallowed RowsAffected error — previously n, _ := res.RowsAffected() meant a driver that couldn't report affected rows returned updated=false, err=nil, indistinguishable from a lost CAS race, letting the caller proceed on an unknown write status. Now wrapped and propagated, with a driver-injecting test asserting updated=false && errors.Is(err, wantErr). 👍

  • validTiDBCloudBaseURL — good consolidation of the triplicated validation, and it's stricter than before: rejects userinfo, query, fragment, and opaque forms in addition to non-https/empty-host. ContainsAny(rawURL, "?#") is a reasonable belt-and-suspenders alongside the RawQuery/Fragment emptiness checks. Table test covers the empty-?, empty-#, and user:password@ cases. Leaving Path unrestricted is correct — path-prefixed base URLs are legitimate.

  • Lock-scope deviation now documented at the acquisition site (the reservation is uncountable until its native binding / shared placement is durable, so the lock must span the persist). Good to have it recorded next to the code that depends on it.

One item still open — look like deliberate choices, just flagging for an explicit call:

  1. The in-code comment documents the lock-scope deviation, but the design doc (§ "The lock does not cover external HTTP calls...") still states the opposite. Either amend the doc or note there that the implementation intentionally supersedes it.

Nothing blocking from my side.

@srstack srstack left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-reviewed 6d11171 (refactor: reuse TiDB Cloud access profiles). Pure dedup, no behavior change — resolve the IAM identity once in each handler, then thread the resolved OrganizationID into the downstream authorizers instead of having each of them re-resolve. Build + pkg/server tests pass on this commit.

Verified:

  • Semantically equivalent. All three former resolvers (firstManagedOrganization, authorizeTiDBCloudOrganization via resolveTiDBCloudIdentity, and resolveTiDBCloudAccessProfile) derived their org from the same resolveTiDBCloudIdentity(...).OrganizationID. So passing access.OrganizationID down is identical to each callee resolving it itself — just one IAM round-trip per request instead of two. The new authorizedTenantPool/authorizedAdminTenant/authorizeSharedQuotaOrganization/authorizeNativeTenantOrganization all TrimSpace the org before the tiDBCloudOrganizationMatches compare, matching the prior trimmed comparison.

  • No dead code / dangling refs. authorizeTiDBCloudOrganization is fully removed (zero references). The read path (quota_get) still uses the *Credentials variants, so those aren't orphaned. Removed struct fields (tiDBCloudAccessProfile.Role, TiDBCloudOrganizationPlan.EffectivePlan) have no remaining readers — the surviving .Role reads are on tenant.TiDBCloudAPIKeyIdentity.Role (owner-role check, still needed), and effective_plan is still parsed locally to compute IsFree, just no longer stored.

  • authorizeTiDBCloudQuotaMutation correctly generalizes the old rejectFreeQuotaMutation — still fail-closed on IsFree, now also returns the profile so the caller reuses its org.

Looks good. Nothing blocking.

@srstack

srstack commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review at 6d11171 — LGTM ✅

Reviewed the incremental changes since 94148ff (fix: address remaining review findings + refactor: reuse TiDB Cloud access profiles, on top of a main merge). Build, vet, gofmt, and the full meta / tidbcloudnative / server / cmd test suites pass.

My prior notes are addressed:

  • Lock invariant now documented. server.go:5713 gains the comment "A reservation becomes countable only after its native org binding or shared placement is durable, so this lock must remain held through that persist." This is exactly the load-bearing invariant I flagged — a future change can no longer "optimize" the lock release without reading why it spans the cloud call. The one technical-debt item is closed.
  • URL validation tightened. New validTiDBCloudBaseURL (provisioner.go:232) rejects query, fragment, userinfo, and opaque forms in addition to the scheme/host check, so https://host?x=y no longer passes startup and breaks at runtime. Applied uniformly to API/IAM/billing URLs.

Nice additional cleanups in this revision (not requested, but reduce debt):

  • The access-profile reuse refactor removes a genuine double IAM/billing resolution: handleQuotaSet and the admin get/quota_set/delete handlers previously resolved the identity once for the free-plan/admin gate and then re-resolved it inside authorizeSharedQuotaCredentials / authorizeNativeTenantCredentials / authorizedAdminTenant. They now thread the already-resolved access.OrganizationID through, so each request resolves once. Fewer upstream calls, and the org-match check is unchanged (still tiDBCloudOrganizationMatches, still fail-closed on empty). Verified the gate still fires before any tenant/pool lookup.
  • Dead fields removed: TiDBCloudOrganizationPlan.EffectivePlan, tiDBCloudAccessProfile.Role, and the adminTenantMetricPath helper — all now unused after the refactor.

Still verified clean (re-checked the touched authz paths): admin/quota mutation gates reject free orgs with 403 before any lookup (no existence oracle); org matching is fail-closed; billing classification via Service == Billing intact; no credential leak on the touched paths.

Remaining non-blocking item (unchanged, [MINOR]): normalizeTiDBCloudFreeProvisionQuota still maps structurally-invalid free-provision inputs (max_storage_size<=0, negative spending, etc.) to 403 rather than the design's 400, because the free branch skips validateQuotaSetRequest. Untouched this round (file unchanged), and it only affects the status code of an already-rejected request — fine to leave or fold into a follow-up.

Verdict: the revision resolves every actionable point from my last review and adds two real debt-reducing cleanups, with no regressions. LGTM.

(GitHub blocks formally approving one's own PR, so this comment stands in for the approval.)

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

Review summary

Verdict: Approve

Reviewed in an isolated worktree at 6d11171 (detached), against merge-base 80ed57b with origin/main. This is a large but coherent free-plan enforcement stack: Billing plan resolution → non-free-only cache → free provision quotas/tenant cap → org advisory lock → early native cluster binding → stale free-reservation reconcile. CI green; local focused suites passed:

  • go test ./pkg/meta -run 'TiDBCloudFree|FreeQuota'
  • go test ./pkg/server -run 'TiDBCloudFree|FreeQuota|FreePlan|FreeAdmin|FreeTenant|Billing'
  • go test ./pkg/tenant/tidbcloudnative -run 'Plan|Billing|Free'

Correctness checks (deep)

  1. Free classificationparseOrganizationPlanResponse: free by default; non-free only for on_demand, or poc with strictly positive poc_credits (decimal via big.Rat). Org identity cross-check against request org. Local clusters API short-circuits to non-free (correct for local e2e).
  2. Cache policy — only non-free is cached (30m, max 10k). Free always re-hits Billing. Paid→free downgrade can remain treated as non-free until TTL (fail-open toward paid privileges for ≤30m). Acceptable product tradeoff; free→paid upgrades are immediate on next miss.
  3. Admin / quota mutationauthorizeTiDBCloudAdminAccess / authorizeTiDBCloudQuotaMutation hard-forbid free orgs (403). Shared credentials startup ValidateSharedAccess requires non-free shared org.
  4. Free provision quotasnormalizeTiDBCloudFreeProvisionQuota forces spending limit 0, caps storage/file size/count, rejects positive spending and over-cap requests with 403.
  5. Occupied free countCountTiDBCloudFreeTenants unions native bindings and shared placements; excludes status=deleted, spending_limit != 0 (and NULL via SQL = 0), and unclaimed pool inventory (pool_status = free). Matches the PR write-up.
  6. Lock + countability window — free path holds GET_LOCK until org binding / shared placement is durable; reservation rows alone are not countable. Concurrent pods serialize. Crash between insert and binding is under-count (fail-open free slots) until DeleteStaleTiDBCloudFreeReservation in reconcilePendingTenant — intentional and wired.
  7. Early native bindingCreateCluster… then PersistTiDBCloudTenantClusterReference (cluster id + binding) before metadata wait; free lock released after durable binding, before long polling. Failure after cluster id keeps failed+counted until deprovision+metadata clear, then reservation release.
  8. HTTP mapping — free limit 403; free quota busy 503 + Retry-After: 1 via writeProvisionTenantError. Billing lookup failures mapped by status (401/403/502).
  9. Metrics — unified TiDB Cloud OpenAPI observation path for Billing with cache hit/miss counters.

Non-blocking / follow-up

  • Paid→free cache lag (≤30m): document in ops notes if free-admin ban must be hard real-time.
  • Crash under-count window for free reservations before binding: mitigated by stale pending reconcile; worth an alert if pending free rows without binding grow.
  • Cache eviction at 10k entries deletes an arbitrary map key (not LRU). Fine at current scale.
  • Full pkg/server suite may still hit shared MySQL env flake noted in the PR body; feature-focused coverage is solid.

No merge blockers found on this pass. Approved.

Comment thread pkg/tenant/tidbcloudnative/provisioner.go
Comment thread pkg/server/tidbcloud_plan_cache.go
Comment thread pkg/meta/tidbcloud_free_quota.go
Comment thread pkg/server/server.go
Comment thread pkg/server/server.go
Comment thread pkg/server/tidbcloud_free_limits.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants