Skip to content

keyspace: cache meta-service group status#10976

Open
bufferflies wants to merge 18 commits into
tikv:masterfrom
bufferflies:pr-cp-10892-cache-meta-service-group-status
Open

keyspace: cache meta-service group status#10976
bufferflies wants to merge 18 commits into
tikv:masterfrom
bufferflies:pr-cp-10892-cache-meta-service-group-status

Conversation

@bufferflies

@bufferflies bufferflies commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: Close #10892

This is a cherry-pick of 2baf2a5 from the PD microservices branch.

Original author: @AmoebaProtozoa

Problem Summary:

Meta service group status was loaded and persisted through storage on assignment paths. This adds an in-memory status cache and flush loop so assignment count updates can be handled locally and persisted in batches by the serving leader.

What is changed and how does it work?

  • Cache meta-service group status in MetaServiceGroupManager and refresh it from storage on initialization/service readiness.
  • Update assignment counts in the cached status for group selection, reassignment, rollback, and removal paths.
  • Flush dirty cached status to storage periodically or when the dirty threshold is reached, guarded by a leader checker.
  • Keep PatchStatus synchronous with storage and cache so API updates are immediately visible.
  • Adapt the cherry-pick to current master after PR meta-service-group: add status #10904, preserving the current UpdateGroupsSafely delete guard and authoritative keyspace assignment scanner.
  • Add regression coverage for cache refresh and threshold-triggered flush.

Check List

Tests

  • Unit test

Code changes

  • Has persistent data change

Side effects

  • No compatibility impact

Related changes

  • Need to cherry-pick to the release branch

Release note

Cache meta-service group status and flush assignment count updates to storage in batches.

Validation

  • GOFLAGS=-buildvcs=false make gotest GOTEST_ARGS='./pkg/keyspace -run TestMetaServiceGroupTestSuite -count=1 -timeout=5m'
  • GOFLAGS=-buildvcs=false make gotest GOTEST_ARGS='./pkg/keyspace -run "TestMetaServiceGroupTestSuite|TestAssignGroupAndSaveKeyspace|TestKeyspaceTestSuite/(TestUpdateKeyspaceState|TestTombstoneKeyspaceUnassignsMetaServiceGroup)" -count=1 -timeout=5m'
  • GOFLAGS=-buildvcs=false make gotest GOTEST_ARGS='./pkg/keyspace -count=1 -timeout=5m'
  • GOFLAGS=-buildvcs=false make gotest GOTEST_ARGS='./server -run TestNonExistent -count=0 -timeout=5m'
  • git diff --check && git diff --cached --check
  • GOFLAGS=-buildvcs=false make check

Summary by CodeRabbit

  • New Features
    • Added cached in-memory meta-service group status with batched persistence and leader-aware flushing.
    • Added the ability to refresh group status from storage and expose whether groups are available.
  • Bug Fixes
    • Tightened assignment/config concurrency handling to avoid races during group changes, including safer rollback when updates fail after reassignment.
  • Tests
    • Updated manager initialization to use context/error handling.
    • Added coverage for cache refresh and batched persistence after reaching write thresholds.

* cache meta-service group status

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* fix lint

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* fix lint

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* fix deadlock

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* cleanup

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* assign to group no longer error

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* lint

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* fix integration test

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* leader callback

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* add more tests

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* lint

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* don't error keyspace udpate when assignment count failed

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* add some comments

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* don't flush on follower

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add comments for leader check

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

* improve comments

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>

---------

Signed-off-by: AmoebaProtozoa <8039876+AmoebaProtozoa@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 2baf2a5)
Signed-off-by: bufferflies <1045931706@qq.com>

# Conflicts:
#	pkg/keyspace/keyspace.go
#	pkg/keyspace/keyspace_test.go
#	pkg/keyspace/meta_service_group.go
#	pkg/keyspace/meta_service_group_test.go
#	server/server.go
#	tests/integrations/client/meta_service_group_test.go
@ti-chi-bot ti-chi-bot Bot added release-note Denotes a PR that will be considered when it comes time to generate release notes. dco-signoff: yes Indicates the PR's author has signed the dco. do-not-merge/needs-triage-completed labels Jul 3, 2026
@ti-chi-bot

ti-chi-bot Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign lhy1024 for approval. For more information see the Code Review Process.
Please ensure that each of them provides their approval before proceeding.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@bufferflies

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@ti-chi-bot ti-chi-bot Bot added the size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. label Jul 3, 2026
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai

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

MetaServiceGroupManager now caches status in memory with batched flushing. Keyspace assignment and config updates use write-locked accounting with rollback handling, and server startup plus tests were updated for the new constructor and cache refresh flow.

Changes

Meta-service group cache and locking refactor

Layer / File(s) Summary
Cached status and flush loop
pkg/keyspace/meta_service_group.go
MetaServiceGroupManager adds cached status storage, dirty tracking, leader-aware flushing, cache refresh, and deep-copy status reads.
Cached assignment updates
pkg/keyspace/meta_service_group.go
PatchStatus, PickGroup, AssignToGroup, persistGroupsLocked, assignedKeyspaceCounts, and updateGroups update cached counts and reconcile cached entries with group changes.
Write-locked keyspace assignment and rollback
pkg/keyspace/keyspace.go
assignGroupAndSaveKeyspace, runTxnWithMetaGroupLock, and updateKeyspaceConfigTxn use the meta-service group manager write lock, re-check group availability, and conditionally roll back assignment counters after failures.
Server startup and test updates
server/server.go, pkg/keyspace/keyspace_test.go, pkg/keyspace/meta_service_group_test.go
Server startup handles the context-aware constructor error, sets leader checking, refreshes cache on readiness, and tests use the new constructor while covering cache refresh and flush-threshold behavior.

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

Possibly related PRs

  • tikv/pd#10597: Introduced the meta-service group assignment plumbing that this PR refines.
  • tikv/pd#10881: Uses the meta-service group manager paths that this PR updates for cached status handling.
  • tikv/pd#10904: Touches the same assignment-count and meta-service group accounting flow updated here.

Suggested labels: lgtm, approved

Suggested reviewers: ystaticy, rleungx, lhy1024

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: caching meta-service group status in keyspace handling.
Description check ✅ Passed The description includes the issue link, problem statement, change summary, tests, side effects, and release note.
Linked Issues check ✅ Passed The changes implement #10892 by adding cached status, flush behavior, refresh on readiness, and regression tests.
Out of Scope Changes check ✅ Passed The server and keyspace updates support the cache feature and do not introduce unrelated scope beyond the issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@pkg/keyspace/meta_service_group.go`:
- Around line 315-336: updateAssignmentLockedTxn is mutating cachedStatus and
dirtyCount without using the storage txn, so aborted transactions can
desynchronize cache state from persisted state. Update
MetaServiceGroupManager.updateAssignmentLockedTxn to make cache changes
transactional by tying the cachedStatus assignment count and markDirtyLocked
updates to the txn outcome (or otherwise rolling them back on error), and keep
the oldGroupID decrement/newGroupID increment behavior symmetric even when the
source count is zero.
🪄 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

Run ID: 85dd8198-22b4-4b3e-a3fa-d656ff749e92

📥 Commits

Reviewing files that changed from the base of the PR and between 1de3200 and 373334d.

📒 Files selected for processing (5)
  • pkg/keyspace/keyspace.go
  • pkg/keyspace/keyspace_test.go
  • pkg/keyspace/meta_service_group.go
  • pkg/keyspace/meta_service_group_test.go
  • server/server.go

Comment thread pkg/keyspace/meta_service_group.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/keyspace/meta_service_group.go (1)

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

Pass the lifecycle context through RefreshCache, flushLoop, and flushToStorage instead of keeping m.ctx on MetaServiceGroupManager. NewMetaServiceGroupManager can hand the constructor context to the goroutine, and the service-ready callback already has a context.Context to pass into RefreshCache.

🤖 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/keyspace/meta_service_group.go` at line 40, Remove the stored lifecycle
context from MetaServiceGroupManager and thread context explicitly through
RefreshCache, flushLoop, and flushToStorage instead. Update
NewMetaServiceGroupManager to pass the constructor context into the flush
goroutine, and use the context already available in the service-ready callback
when calling RefreshCache. Locate the affected flow in MetaServiceGroupManager,
RefreshCache, flushLoop, and flushToStorage and update their signatures/call
sites accordingly.

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.

Inline comments:
In `@pkg/keyspace/keyspace.go`:
- Around line 868-873: The rollback logic around runTxnWithMetaGroupLock and
UpdateKeyspaceGroup needs to stay under the meta-service group lock, and commit
failures must revert all side effects, not just the meta-service cache. Move the
non-transactional rollback work into the locked helper/error path so
UpdateGroupsSafely cannot see transient reassignment counts after the lock is
released, and track the successful UpdateKeyspaceGroup/Tso group move state
alongside metaServiceGroupReassigned so the rollback callback can restore both
before unlocking.

In `@pkg/keyspace/meta_service_group.go`:
- Around line 420-433: Persist the reset status for re-added groups instead of
only creating it in memory. In meta_service_group.go, update the cached-status
initialization around the metaServiceGroups loop so newly created
endpoint.MetaServiceGroupStatus entries are also added to newStatuses (or
otherwise marked dirty) before the cleanup transaction runs. Keep the
deletedGroups cleanup behavior, but ensure the status persistence path in
MetaServiceGroup metadata writes the zeroed state so a restart cannot reload
stale assignment/enabled values.

---

Nitpick comments:
In `@pkg/keyspace/meta_service_group.go`:
- Line 40: Remove the stored lifecycle context from MetaServiceGroupManager and
thread context explicitly through RefreshCache, flushLoop, and flushToStorage
instead. Update NewMetaServiceGroupManager to pass the constructor context into
the flush goroutine, and use the context already available in the service-ready
callback when calling RefreshCache. Locate the affected flow in
MetaServiceGroupManager, RefreshCache, flushLoop, and flushToStorage and update
their signatures/call sites accordingly.
🪄 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

Run ID: 4fc3ed40-bd11-4452-a951-20fd54e535fc

📥 Commits

Reviewing files that changed from the base of the PR and between 1de3200 and 373334d.

📒 Files selected for processing (5)
  • pkg/keyspace/keyspace.go
  • pkg/keyspace/keyspace_test.go
  • pkg/keyspace/meta_service_group.go
  • pkg/keyspace/meta_service_group_test.go
  • server/server.go

Comment thread pkg/keyspace/keyspace.go Outdated
Comment thread pkg/keyspace/meta_service_group.go Outdated
bufferflies and others added 3 commits July 7, 2026 14:29
Flush cached status outside the mgm lock via a snapshot so a slow etcd
write no longer blocks concurrent assignment operations, restoring the
dirty count on failure. Document that the assignment count update is
applied to the cache and is intentionally not part of the caller's txn,
fix a truncated rollback comment, and drop unused ctx parameters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
Resolve the conflict with tikv#10959 (tombstone keyspace unassignment) and fix a
lock-order deadlock it surfaced.

tikv#10959 added unassignKeyspaceFromMetaServiceGroup, which calls
updateAssignmentTxn while holding the keyspace metaLock, on the invariant that
updateAssignmentTxn takes no mgm lock. This PR's status cache had made
updateAssignmentTxn acquire the mgm write lock, so the unassign paths ran
metaLock -> mgm.Lock while the create/config paths run mgm.Lock -> metaLock: an
AB-BA deadlock on the same keyspace id.

Introduce a dedicated leaf mutex (statusMu) guarding cachedStatus and dirtyCount
only. updateAssignmentTxn now mutates the cache under statusMu instead of the mgm
lock, so metaLock -> statusMu and mgm.Lock -> statusMu coexist without a cycle.
statusMu is always the innermost lock and never wraps storage I/O.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
@ti-chi-bot ti-chi-bot Bot added size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. and removed size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Jul 7, 2026
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.06977% with 63 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.24%. Comparing base (aa5a988) to head (5ffb5bf).
⚠️ Report is 10 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10976      +/-   ##
==========================================
+ Coverage   79.21%   79.24%   +0.02%     
==========================================
  Files         541      542       +1     
  Lines       75677    76247     +570     
==========================================
+ Hits        59949    60423     +474     
- Misses      11491    11561      +70     
- Partials     4237     4263      +26     
Flag Coverage Δ
unittests 79.24% <79.06%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@bufferflies
bufferflies requested review from lhy1024 and rleungx July 8, 2026 06:36
In persistGroupsLocked the deleted-group status removal ran after the
in-memory metaServiceGroups/cache update. Move it ahead of the memory
mutation so all storage writes (persist config + clear deleted status)
complete before the in-memory view changes, keeping durable state and
memory consistent on the failure path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
The keyspace creation path only needs to know whether any meta-service
group exists, but called len(GetGroups()) > 0, which allocates and copies
the whole group map. Add HasGroups, a lock-guarded existence check that
reuses hasGroupsLocked without copying, and use it on both create paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
Comment thread pkg/keyspace/meta_service_group.go Outdated
m.statusMu.Unlock()
return nil
}
snapshot := copyStatusMap(m.cachedStatus)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

flushToStorage can overwrite concurrent synchronous status updates. It snapshots the whole cachedStatus, releases statusMu, and then blindly writes every status from that snapshot. A concurrent PatchStatus can persist a new enabled/count value while this flush is in flight; if the older snapshot commits afterward, the patch is lost on the next RefreshCache or restart. The same race can also recreate a status key removed by UpdateGroupsSafely.

Please serialize flush persistence with PatchStatus/group deletion, or make the flush conditional/merge-safe and skip groups deleted after the snapshot.

@lhy1024 lhy1024 Jul 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A concrete regression test for this race could look like this:

import (
    "sync/atomic"

    "github.com/stretchr/testify/require"
)

type blockingMetaServiceGroupStorage struct {
    *endpoint.StorageEndpoint
    blockNextSave atomic.Bool
    saveStarted   chan struct{}
    unblockSave   chan struct{}
}

func (s *blockingMetaServiceGroupStorage) SaveMetaServiceGroupStatus(txn kv.Txn, id string, status *endpoint.MetaServiceGroupStatus) error {
    if s.blockNextSave.CompareAndSwap(true, false) {
        close(s.saveStarted)
        <-s.unblockSave
    }
    return s.StorageEndpoint.SaveMetaServiceGroupStatus(txn, id, status)
}

func TestFlushDoesNotOverwriteConcurrentPatchStatus(t *testing.T) {
    re := require.New(t)
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    store := &blockingMetaServiceGroupStorage{
        StorageEndpoint: endpoint.NewStorageEndpoint(kv.NewMemoryKV(), nil),
        saveStarted:     make(chan struct{}),
        unblockSave:     make(chan struct{}),
    }
    manager, err := NewMetaServiceGroupManager(ctx, store, mockMetaServiceGroups())
    re.NoError(err)

    groupID := "etcd-group-0"
    manager.statusMu.Lock()
    manager.cachedStatus[groupID] = &endpoint.MetaServiceGroupStatus{AssignmentCount: 1, Enabled: true}
    manager.dirtyCount = 1
    manager.statusMu.Unlock()

    store.blockNextSave.Store(true)
    flushErr := make(chan error, 1)
    go func() {
        flushErr <- manager.flushToStorage()
    }()
    <-store.saveStarted

    newCount := 99
    re.NoError(manager.PatchStatus(ctx, groupID, &MetaServiceGroupStatusPatch{
        AssignmentCount: &newCount,
    }))

    close(store.unblockSave)
    re.NoError(<-flushErr)
    re.NoError(manager.RefreshCache())

    statusMap, err := manager.GetStatus(ctx)
    re.NoError(err)
    re.Equal(newCount, statusMap[groupID].AssignmentCount)
}

I ran this focused test locally against the current implementation. It fails with expected: 99 and actual: 1, which means the old flush snapshot overwrites the synchronous PatchStatus update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e5262c6. flushToStorage now holds the mgm write lock across the storage write, serializing it with PatchStatus and persistGroupsLocked, so the snapshot can no longer overwrite a synchronous write or recreate a deleted status key. It stays deadlock-free because the metaLock-holding paths (RemoveKeyspace, tombstone unassignment) only take statusMu, never the mgm lock.

Also fixed an adjacent read-modify-write race in PatchStatus (88ae189): it now applies only the patched fields to the live cached status instead of overwriting it with a pre-I/O snapshot, so a concurrent updateAssignmentTxn count change is not dropped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the repro — added TestFlushDoesNotOverwriteConcurrentPatchStatus based on it. One tweak: with the fix, PatchStatus blocks on the mgm lock the paused flush holds, so calling it synchronously (as in the snippet) deadlocks against the unblockSave that comes after it. So I run PatchStatus in a goroutine and assert its value wins once the flush releases the lock.

bufferflies and others added 3 commits July 13, 2026 14:51
flushToStorage snapshotted cachedStatus, released the lock, then wrote
the snapshot without any lock. A concurrent PatchStatus could persist a
new value that the stale snapshot then overwrote, losing the patch after
the next RefreshCache; the same race could recreate a status key that
UpdateGroupsSafely had deleted.

Hold the mgm write lock across the whole flush, including the storage
write, so it serializes with PatchStatus and persistGroupsLocked. This
does not deadlock: the metaLock-holding paths only take statusMu, never
the mgm lock. Add a regression test that pauses a flush mid-write and
checks a concurrent PatchStatus wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
persistGroupsLocked only cleared status for deleted groups; a newly added
(or re-added) group got a zero status in the cache but nothing in storage.
If an earlier best-effort deletion had failed, the stale enabled/count was
resurrected by the next RefreshCache. Reset the persisted status of added
groups to zero synchronously, in the same best-effort transaction that
clears deleted ones. Add a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
UpdateKeyspaceGroup persists the TSO keyspace group move immediately, but
the inline revert only covered a SaveKeyspaceMeta error inside txnFunc. A
commit-time failure after txnFunc returned nil left the move applied while
the keyspace meta was rolled back. Track the move and revert it from the
outer error path too, guarded so the inner revert is not duplicated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
Comment thread pkg/keyspace/meta_service_group.go Outdated
return err
}
m.statusMu.Lock()
m.cachedStatus[groupID] = &newStatus

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PatchStatus releases statusMu before persisting and then replaces the whole cached entry with the earlier snapshot. updateAssignmentTxn intentionally skips the manager lock, so a concurrent tombstone or removal can update AssignmentCount between those critical sections; an enabled-only patch then overwrites that successful delta while dirtyCount remains set. The next flush persists the stale count, even though both operations returned success.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 88ae189. PatchStatus now applies only the patched fields to the live cached status instead of replacing the whole entry with the pre-I/O snapshot, so a concurrent updateAssignmentTxn count delta is preserved. The synchronous store write still lands under the same serialization as the flush (see below).

Comment thread pkg/keyspace/meta_service_group.go Outdated
m.dirtyCount = 0
m.statusMu.Unlock()

if err := m.store.RunInTxn(m.ctx, func(txn kv.Txn) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

isLeader is checked only before this transaction, so the write is not fenced to the leadership term. If this PD loses its lease after line 158, the flush continues with the server-lifetime context; these status saves are unconditional puts with no loaded-key comparison, so the former leader can commit its snapshot after the new leader has refreshed or patched the same status. A leader transfer can therefore overwrite a newer synchronous status update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mitigated in 52c082b: the flush now runs on the leadership-term context (set from the service-ready callback and canceled when the lease is lost) instead of the server-lifetime context, and re-checks isLeader right before the write. That cancels a former leader's in-flight flush and stops new ones after the term ends.

You are right that this does not fully close it: an already-issued commit can still land after cancellation. Fully fencing it needs a leader-key comparison inside the flush txn (so the put aborts when the lease key changed), which requires plumbing the leadership guard into the keyspace storage writes. I left that as a follow-up and noted it in the commit message — happy to do it here if you prefer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The term context narrows the window but does not fence a transaction whose commit has already been issued, so the former leader can still overwrite a newer synchronous PatchStatus after a transfer. Because the stale write changes API-visible enabled/count state, the original correctness issue remains.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is still reproducible on the current head (52c082b98). The term context narrows the window, but it is canceled only after the old leader loop observes lease expiry and returns. The election key can already be gone, and a new leader can already be serving, while that context is still live.

I added a deterministic regression test that pauses the old flush after its leader check, marks the old instance non-serving while leaving the term context live (the interval before the leader ticker returns), lets the new leader persist count 99, and then resumes the old flush. The final persisted count is 1, not 99:

TestFormerLeaderFlushIsFencedBeforeTermContextCancellation
expected: 99
actual:   1

So context cancellation is not a leadership fence. Please include the election leader-key/value comparison in the same etcd transaction, equivalent to Leadership.LeaderTxn; otherwise an unconditional write that passed the preflight check immediately before lease expiry can still land in the next term.

Comment thread pkg/keyspace/meta_service_group.go Outdated
// paths (RemoveKeyspace, tombstone unassignment) only take statusMu, never the
// mgm lock, so nothing waits on the mgm lock while holding metaLock, and flush
// never takes metaLock.
m.Lock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

flushToStorage holds the manager write lock across the etcd transaction. AttachEndpoints on keyspace read paths and the group configuration/automatic-assignment paths need this lock, so a slow flush stalls otherwise read-only keyspace lookups and unrelated group operations on the serving PD. Routine threshold or periodic flushes can therefore turn one slow storage write into a broad request-latency spike.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 52c082b. Introduced persistMu, an outermost mutex that serializes the storage writers (flush, PatchStatus, persistGroupsLocked, RefreshCache) instead of the mgm write lock. The flush now holds only persistMu across the etcd transaction and takes the mgm read lock just briefly to read isLeader/leaderCtx, so a slow flush no longer blocks AttachEndpoints/GetGroups read paths or assignment operations. PatchStatus likewise no longer holds the mgm write lock across its I/O.

bufferflies and others added 3 commits July 14, 2026 16:22
PatchStatus replaced the whole cached status with a snapshot taken before
its storage write, dropping any assignment-count change a concurrent
updateAssignmentTxn (statusMu only) applied during the I/O window. Apply
only the patched fields to the live cached status instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
The TSO keyspace group and meta-service assignment rollbacks in
updateKeyspaceConfigTxn ran after runTxnWithMetaGroupLock released the mgm
lock. Take the mgm lock across both undos so they are atomic with respect
to UpdateGroupsSafely, matching the locking of the forward path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
… term

Two issues raised on the flush path:

- It held the mgm write lock across the etcd transaction, so a slow flush
  blocked read paths (AttachEndpoints/GetGroups) and assignment operations
  on the serving PD.
- It checked isLeader only before the write and used the server-lifetime
  context, so a former leader could commit its snapshot after a new leader
  had refreshed or patched the same status.

Introduce persistMu, an outermost mutex that serializes the storage writers
(flush, PatchStatus, persistGroupsLocked, RefreshCache) instead of the mgm
write lock, so the flush no longer blocks mgm readers or assignments while
still preventing writers from clobbering each other. PatchStatus now takes
only the mgm read lock for its existence check. Flush uses the leadership
term context (set from the service-ready callback and canceled when the
lease is lost) so a lost-leadership PD stops writing. The residual TOCTOU
between the leadership check and an in-flight commit would need a
leader-key-fenced txn, left as a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
Comment thread pkg/keyspace/meta_service_group.go
The best-effort status reset in persistGroupsLocked only logged on failure,
so a re-added group with stale persisted status was left with a zero cache
entry that was never flushed; the next RefreshCache resurrected the stale
enabled/count. Mark the zeroed added groups dirty on reconcile failure so a
later flush re-persists their zero status and the reset invariant holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
Comment thread pkg/keyspace/meta_service_group.go Outdated
return err
}
m.statusMu.Lock()
m.cachedStatus = statusMap

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

RefreshCache replaces the cache with the persisted batching snapshot and resets dirtyCount, but it never reconciles AssignmentCount with authoritative keyspace metadata. A leader change before a successful flush therefore permanently drops all assignment deltas since the previous flush; subsequent PickGroup decisions continue from the stale baseline.

The manager already has keyspaceAssignmentCounter. A focused test configures that authoritative counter to return 1, while storage contains the last flushed value 0; after RefreshCache, the current implementation still returns 0:

TestRefreshCacheReconcilesAuthoritativeAssignmentCounts
expected: 1
actual:   0

Please preserve persisted administrative fields such as Enabled, while rebuilding assignment counts from keyspace metadata during leader readiness.

…sting

Reviews on the status flush surfaced a chain of hard problems (leader
fencing, reconcile-failure resets, RefreshCache dropping deltas, cache
deltas not tied to storage txns) that all stem from persisting
AssignmentCount and keeping it consistent across leader terms. But
AssignmentCount is a derived, best-effort load-balancing hint: the delete
guard and every correctness-sensitive use already rely on the authoritative
keyspace scan (CountKeyspacesByMetaServiceGroup), and only Enabled is truly
administrative state.

Stop persisting the count. RefreshCache now rebuilds AssignmentCount from
the keyspace scan at the start of every leader term and keeps Enabled from
storage; during the term the count is maintained purely in memory with zero
storage I/O on the assignment hot path. This removes the flush loop,
persistMu, the leadership context/fence, dirty tracking and the count
reconcile entirely:

- leader fencing: no async count write to fence.
- reconcile-failure reset: only Enabled is persisted, cleared hard on group
  add/remove; there is no stale count to resurrect.
- RefreshCache dropping deltas: RefreshCache *is* the authoritative rebuild.
- cache delta vs txn: the count is an in-memory hint that self-heals on the
  next term, so it is intentionally not tied to a caller storage txn.

PatchStatus persists only Enabled; a patched count is applied in memory for
API compatibility. Tests updated; flush/threshold tests removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
Comment thread pkg/keyspace/meta_service_group.go Outdated
@@ -344,57 +419,72 @@ func (m *MetaServiceGroupManager) persistGroupsLocked(
if err := persist(); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is not atomic with the real callers. persist() is SetKeyspaceConfigWithoutKeyspaceManagerUpdate in both API paths, which CASes and writes the keyspace config before the independent status-cleanup transaction below. If RemoveMetaServiceGroupStatus fails, this method returns an error and skips afterPersist, but the config is already durable while metaServiceGroups and the keyspace manager still retain the old group. The serving PD can then assign new keyspaces to a group that has already been removed from persisted config.

Please make the config/status updates atomic, or compensate the config rollback under the same lock. Returning the cleanup error after committing the config leaves a split-brain state.

A focused regression test:

var errInjectedStatusCleanup = errors.New("injected status cleanup failure")

type cleanupFailingStorage struct {
    *endpoint.StorageEndpoint
}

func (*cleanupFailingStorage) RemoveMetaServiceGroupStatus(kv.Txn, string) error {
    return errInjectedStatusCleanup
}

func TestUpdateGroupsSafelyDoesNotCommitConfigBeforeStatusCleanup(t *testing.T) {
    re := require.New(t)
    ctx := context.Background()
    store := &cleanupFailingStorage{
        StorageEndpoint: endpoint.NewStorageEndpoint(kv.NewMemoryKV(), nil),
    }
    manager, err := NewMetaServiceGroupManager(ctx, store, mockMetaServiceGroups())
    re.NoError(err)

    newGroups := mockMetaServiceGroups()
    delete(newGroups, "etcd-group-0")
    configPersisted := false
    err = manager.UpdateGroupsSafely(ctx, newGroups, []string{"etcd-group-0"}, func() error {
        configPersisted = true
        return nil
    }, nil)

    re.ErrorIs(err, errInjectedStatusCleanup)
    re.False(configPersisted, "a failed group update must not leave config committed")
}

This fails on 1bc6846 because configPersisted is true.

Comment thread pkg/keyspace/meta_service_group.go Outdated
bufferflies and others added 2 commits July 21, 2026 09:36
persistGroupsLocked persisted the config, then cleared status keys and
updated memory. If the status clear failed after the config was persisted,
it returned an error with the config changed but the in-memory group set
stale, so a later PickGroup could assign a keyspace to a group no longer in
the config.

Reorder so the reset invariant is independent of the config write: clear the
added groups' persisted status first (hard fail, nothing else changed yet),
then persist the config, then update memory, then best-effort clean the
deleted groups' now-orphan status. Config and the in-memory view can no
longer diverge on a partial failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: tongjian <1045931706@qq.com>
Signed-off-by: tongjian <1045931706@qq.com>
@bufferflies
bufferflies requested review from lhy1024 and rleungx July 23, 2026 03:22
Signed-off-by: tongjian <1045931706@qq.com>
Comment thread server/server.go Outdated
Signed-off-by: tongjian <1045931706@qq.com>
@ti-chi-bot

ti-chi-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@bufferflies: The following tests failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
pull-error-log-review 5ffb5bf link true /test pull-error-log-review
pull-unit-test-next-gen-2 5ffb5bf link true /test pull-unit-test-next-gen-2
pull-unit-test-next-gen-3 5ffb5bf link true /test pull-unit-test-next-gen-3

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

statusMap, err := m.store.LoadMetaServiceGroupStatus(txn, map[string]string{groupID: ""})
if err != nil {
return nil, err
if changed {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

An unassignment only advances the epoch when the cached count is greater than zero. A fresh leader starts at zero, so a tombstone/remove during the scan can commit without invalidating a result that still counted the old binding; that stale result is then marked ready. Please advance the epoch for every logical assignment mutation, even when the decrement is clamped at zero.

shouldRetry = true
}
m.statusMu.Unlock()
if shouldRetry {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

An epoch change immediately starts another full scan. With 1M keyspaces, steady assignment or tombstone traffic can keep invalidating the scan and turn this into an unthrottled loop of about 8,334 range reads, while assignment_count_ready never becomes true. Please add backoff/rate limiting or reconcile the deltas without restarting the full scan.

}
select {
case <-ctx.Done():
m.statusMu.Lock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A canceled rebuild from the previous leader term can reach this branch after the new term rebuild has completed and unconditionally clear the shared ready/refreshing flags. That leaves the current leader reporting not ready with no worker running. Please guard these updates with a term/rebuild generation, or wait for the previous worker to exit.

Comment thread pkg/keyspace/keyspace.go
}
manager.mgm.RLock()
defer manager.mgm.RUnlock()
manager.mgm.Lock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This turns the cached assignment path into a global etcd-latency queue. assignGroupAndSaveKeyspace holds mgm.Lock() until saveNewKeyspace commits, so a slow transaction blocks every other keyspace creation (and PickGroup), even though their reservations are independent. statusMu already serializes selecting and reserving a group; the outer lock only needs to prevent UpdateGroupsSafely from deleting a selected group, which a shared RLock can do.

I reproduced this with a deterministic unit test: block the first RunInTxn after it has reserved a group, start a second assignGroupAndSaveKeyspace, and assert that it finishes before the first transaction is unblocked. The current head times out at 100ms:

store.blockNextTxn()
go assign(first)
<-store.txnStarted
go assign(second)
select {
case <-secondDone:
case <-time.After(100 * time.Millisecond):
    t.Fatal("a blocked storage save must not serialize another group assignment")
}

Please keep group deletion as the writer, but let normal assignment transactions take RLock while statusMu retains the atomic reservation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the dco. release-note Denotes a PR that will be considered when it comes time to generate release notes. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2baf2a50 cache meta-service group status

4 participants