Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions pkg/keyspace/keyspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const (
// Config is the interface for keyspace config.
type Config interface {
GetPreAlloc() []string
IsKeyspaceLevelMetricsEnabled() bool
ToWaitRegionSplit() bool
GetWaitRegionSplitTimeout() time.Duration
GetCheckRegionSplitInterval() time.Duration
Expand Down Expand Up @@ -242,7 +243,13 @@ func (manager *Manager) initReserveKeyspace(id uint32, name string) error {

// UpdateConfig update keyspace manager's config.
func (manager *Manager) UpdateConfig(cfg Config) {
keyspaceInfoMetricsMu.Lock()
wasEnabled := manager.config.IsKeyspaceLevelMetricsEnabled()
manager.config = cfg
if wasEnabled && !cfg.IsKeyspaceLevelMetricsEnabled() {
resetKeyspaceInfoMetricsLocked()
}
keyspaceInfoMetricsMu.Unlock()
if manager.mgm != nil {
manager.mgm.updateGroups(cfg.GetMetaServiceGroups())
}
Expand Down Expand Up @@ -564,6 +571,7 @@ func (manager *Manager) saveNewKeyspace(keyspace *keyspacepb.KeyspaceMeta) error
if err == nil {
// Update the keyspace name cache only after the transaction commits.
manager.keyspaceNameLookup.Store(keyspace.GetId(), keyspace.Name)
manager.UpdateKeyspaceInfoMetrics(keyspace)
Comment thread
nolouch marked this conversation as resolved.
Outdated
}
return err
}
Expand Down Expand Up @@ -770,6 +778,9 @@ func (manager *Manager) LoadKeyspace(name string) (*keyspacepb.KeyspaceMeta, err
if manager.mgm != nil && meta != nil {
manager.mgm.AttachEndpoints(meta.GetConfig())
}
if err == nil {
manager.UpdateKeyspaceInfoMetrics(meta)
}
return meta, err
}

Expand All @@ -793,6 +804,9 @@ func (manager *Manager) LoadKeyspaceByID(spaceID uint32) (*keyspacepb.KeyspaceMe
if manager.mgm != nil && meta != nil {
manager.mgm.AttachEndpoints(meta.GetConfig())
}
if err == nil {
manager.UpdateKeyspaceInfoMetrics(meta)
Comment thread
nolouch marked this conversation as resolved.
Outdated

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.

Blocking: a load that read before deletion can permanently recreate the deleted series.

The read transaction finishes before this metric update. A concurrent RemoveKeyspace can therefore commit and run finishRemoveKeyspace after the load has read the old metadata but before this line executes. The load then recreates the series after the only cleanup event has finished, so it remains indefinitely. LoadKeyspace and LoadRangeKeyspace have the same post-transaction write.

The metric publication needs ordering or version validation against removal. A second unsynchronized existence check would leave the same check-then-write race.

Unit-test reproducer

Add sync/atomic to the imports.

type blockingKeyspaceStorage struct {
	endpoint.KeyspaceStorage
	blockNext atomic.Bool
	reached   chan struct{}
	release   chan struct{}
}

func (s *blockingKeyspaceStorage) RunInTxn(ctx context.Context, f func(kv.Txn) error) error {
	err := s.KeyspaceStorage.RunInTxn(ctx, f)
	if s.blockNext.CompareAndSwap(true, false) {
		close(s.reached)
		<-s.release
	}
	return err
}

func (suite *keyspaceTestSuite) TestStaleLoadCannotRestoreRemovedInfoMetric() {
	re := suite.Require()
	suite.manager.UpdateConfig(&mockConfig{EnableKeyspaceLevelMetrics: true})
	resetKeyspaceInfoMetrics()
	created, err := suite.manager.CreateKeyspace(&CreateKeyspaceRequest{
		Name: "review_stale_load", CreateTime: time.Now().Unix(),
	})
	re.NoError(err)
	for _, state := range []keyspacepb.KeyspaceState{
		keyspacepb.KeyspaceState_DISABLED,
		keyspacepb.KeyspaceState_ARCHIVED,
		keyspacepb.KeyspaceState_TOMBSTONE,
	} {
		_, err = suite.manager.UpdateKeyspaceState(created.GetName(), state, time.Now().Unix())
		re.NoError(err)
	}

	store := &blockingKeyspaceStorage{
		KeyspaceStorage: suite.manager.store,
		reached: make(chan struct{}),
		release: make(chan struct{}),
	}
	suite.manager.store = store
	store.blockNext.Store(true)
	loadDone := make(chan error, 1)
	go func() {
		_, loadErr := suite.manager.LoadKeyspaceByID(created.GetId())
		loadDone <- loadErr
	}()
	<-store.reached

	re.NoError(suite.manager.RemoveKeyspace(created.GetId()))
	re.Equal(0, promtestutil.CollectAndCount(keyspaceInfo))
	close(store.release)
	re.NoError(<-loadDone)
	re.Equal(0, promtestutil.CollectAndCount(keyspaceInfo))
}

Run:

make gotest GOTEST_ARGS='./pkg/keyspace -run TestKeyspaceTestSuite/TestStaleLoadCannotRestoreRemovedInfoMetric -count=1 -timeout=30s'

On 247ace82be, the final assertion reports expected: 0, actual: 1.

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.

This metric is now defined as a client-observed mapping, not a strongly consistent metadata snapshot. A concurrent deletion may leave a previously observed mapping temporarily, but deleted keyspaces won’t have active storage-size series to join with. Avoiding this window would require extra lifecycle synchronization, which we want to avoid here.

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 current head makes this broader than the concurrent window described above: no successful removal path deletes an observed series at all. RemoveKeyspace removes durable metadata, and RemoveKeyspacesFromGroup commits that transaction, but there is no metric cleanup caller anywhere.

func (suite *keyspaceTestSuite) TestRemovalClearsObservedInfoMetric() {
    re := suite.Require()
    resetKeyspaceInfoMetrics()
    suite.T().Cleanup(resetKeyspaceInfoMetrics)

    created, err := suite.manager.CreateKeyspace(&CreateKeyspaceRequest{
        Name: "review_rm_metric", CreateTime: time.Now().Unix(),
    })
    re.NoError(err)
    SetKeyspaceInfoMetrics(created.GetId(), created.GetName())
    re.Equal(1, testutil.CollectAndCount(keyspaceInfo))

    for _, state := range []keyspacepb.KeyspaceState{
        keyspacepb.KeyspaceState_DISABLED,
        keyspacepb.KeyspaceState_ARCHIVED,
    } {
        _, err = suite.manager.UpdateKeyspaceStateByID(
            created.GetId(), state, time.Now().Unix())
        re.NoError(err)
    }
    re.NoError(suite.manager.store.RunInTxn(suite.ctx, func(txn kv.Txn) error {
        return suite.manager.RemoveKeyspace(txn, created.GetId())
    }))
    re.Equal(0, testutil.CollectAndCount(keyspaceInfo))
}

Run:

make gotest GOTEST_ARGS="./pkg/keyspace -run TestKeyspaceTestSuite/TestRemovalClearsObservedInfoMetric -count=1 -timeout=30s"

On f23de01da, the removal commits but the final count is 1. I also reproduced the same result through the public GroupManager.RemoveKeyspacesFromGroup path after first populating the series through LoadKeyspaceByID.

This is explicitly required by Issue #11175. The fact that another storage-size metric may currently have no sample does not make stale high-cardinality labels safe: ID reuse makes the stale series ambiguous, and repeated create/remove cycles retain series indefinitely. Please restore post-commit cleanup.

}
return meta, err
}

Expand Down Expand Up @@ -1037,31 +1051,59 @@ func (manager *Manager) UpdateKeyspaceState(name string, newState keyspacepb.Key

// RemoveKeyspace removes the keyspace specified by id if it's in proper state and not protected.
func (manager *Manager) RemoveKeyspace(txn kv.Txn, id uint32) error {
_, err := manager.stageRemoveKeyspace(txn, id)
return err
}

// stageRemoveKeyspace stages keyspace removal and returns its metadata for post-commit cleanup.
func (manager *Manager) stageRemoveKeyspace(txn kv.Txn, id uint32) (*keyspacepb.KeyspaceMeta, error) {
manager.metaLock.Lock(id)
defer manager.metaLock.Unlock(id)
if isProtectedKeyspaceID(id) {
return newModifyProtectedKeyspaceError()
return nil, newModifyProtectedKeyspaceError()
}
meta, err := manager.store.LoadKeyspaceMeta(txn, id)
if err != nil {
return err
return nil, err
}
if meta == nil {
return errs.ErrKeyspaceNotFound
return nil, errs.ErrKeyspaceNotFound
}
if meta.GetState() == keyspacepb.KeyspaceState_ENABLED || meta.GetState() == keyspacepb.KeyspaceState_DISABLED {
return errors.Errorf("cannot remove keyspace in state %s", meta.GetState().String())
return nil, errors.Errorf("cannot remove keyspace in state %s", meta.GetState().String())
}
err = manager.store.RemoveKeyspace(txn, id, meta.GetName())
if err != nil {
return err
return nil, err
}
manager.keyspaceNameLookup.Delete(id)
manager.keyspaceStateLookup.Delete(id)
// Keep the meta-service group assignment accounting in sync within the same
// txn. Without this, removed keyspaces leak count and could permanently block
// deleting an otherwise-empty group.
return manager.unassignKeyspaceFromMetaServiceGroup(txn, meta)
if err := manager.unassignKeyspaceFromMetaServiceGroup(txn, meta); err != nil {
return nil, err
}
return meta, nil
}

// finishRemoveKeyspace clears the caches and metrics after a keyspace removal transaction commits.
func (manager *Manager) finishRemoveKeyspace(meta *keyspacepb.KeyspaceMeta) {
manager.keyspaceNameLookup.Delete(meta.GetId())
manager.keyspaceStateLookup.Delete(meta.GetId())
keyspaceInfoMetricsMu.Lock()
defer keyspaceInfoMetricsMu.Unlock()
if manager.config.IsKeyspaceLevelMetricsEnabled() {
deleteKeyspaceInfoMetricsLocked(meta.GetId(), meta.GetName())
}
}

// UpdateKeyspaceInfoMetrics updates the keyspace ID-to-name mapping metric when keyspace-level metrics are enabled.
func (manager *Manager) UpdateKeyspaceInfoMetrics(meta *keyspacepb.KeyspaceMeta) {
keyspaceInfoMetricsMu.Lock()
defer keyspaceInfoMetricsMu.Unlock()
if meta == nil || !manager.config.IsKeyspaceLevelMetricsEnabled() {
Comment thread
nolouch marked this conversation as resolved.
Outdated
return
}
setKeyspaceInfoMetricsLocked(meta.GetId(), meta.GetName())
}

// UpdateKeyspaceStateByID updates target keyspace to the given state if it's not already in that state.
Expand Down
82 changes: 77 additions & 5 deletions pkg/keyspace/keyspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ package keyspace

import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
"testing"
"time"

"github.com/prometheus/client_golang/prometheus"
promtestutil "github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.uber.org/goleak"
Expand Down Expand Up @@ -64,14 +67,19 @@ func TestKeyspaceTestSuite(t *testing.T) {
}

type mockConfig struct {
PreAlloc []string
WaitRegionSplit bool
WaitRegionSplitTimeout typeutil.Duration
CheckRegionSplitInterval typeutil.Duration
PreAlloc []string
EnableKeyspaceLevelMetrics bool
WaitRegionSplit bool
WaitRegionSplitTimeout typeutil.Duration
CheckRegionSplitInterval typeutil.Duration
// MetaServiceGroups is used to mock the meta-service groups for keyspace assignment.
MetaServiceGroups map[string]string
}

func (m *mockConfig) IsKeyspaceLevelMetricsEnabled() bool {
return m.EnableKeyspaceLevelMetrics
}

func (m *mockConfig) GetPreAlloc() []string {
return m.PreAlloc
}
Expand Down Expand Up @@ -108,9 +116,69 @@ func (suite *keyspaceTestSuite) SetupTest() {
}

func (suite *keyspaceTestSuite) TearDownTest() {
resetKeyspaceInfoMetrics()
suite.cancel()
}

func (suite *keyspaceTestSuite) TestKeyspaceInfoMetricsLifecycle() {
re := suite.Require()
existing, err := suite.manager.CreateKeyspace(&CreateKeyspaceRequest{
Name: "metrics_existing",
CreateTime: time.Now().Unix(),
})
re.NoError(err)

cfg := &mockConfig{EnableKeyspaceLevelMetrics: true}
suite.manager.UpdateConfig(cfg)
re.Equal(0, promtestutil.CollectAndCount(keyspaceInfo))

existing, err = suite.manager.LoadKeyspace(existing.GetName())
re.NoError(err)
re.Equal(float64(1), promtestutil.ToFloat64(keyspaceInfo.WithLabelValues(
strconv.FormatUint(uint64(existing.GetId()), 10), existing.GetName())))

created, err := suite.manager.CreateKeyspace(&CreateKeyspaceRequest{
Name: "metrics_new",
CreateTime: time.Now().Unix(),
})
re.NoError(err)
re.Equal(float64(1), promtestutil.ToFloat64(keyspaceInfo.WithLabelValues(
strconv.FormatUint(uint64(created.GetId()), 10), created.GetName())))

_, err = suite.manager.UpdateKeyspaceState(created.GetName(), keyspacepb.KeyspaceState_DISABLED, time.Now().Unix())
re.NoError(err)
_, err = suite.manager.UpdateKeyspaceState(created.GetName(), keyspacepb.KeyspaceState_ARCHIVED, time.Now().Unix())
re.NoError(err)
_, err = suite.manager.UpdateKeyspaceState(created.GetName(), keyspacepb.KeyspaceState_TOMBSTONE, time.Now().Unix())
re.NoError(err)

errRollback := errors.New("rollback keyspace removal")
err = suite.manager.store.RunInTxn(suite.ctx, func(txn kv.Txn) error {
_, err := suite.manager.stageRemoveKeyspace(txn, created.GetId())
re.NoError(err)
return errRollback
})
re.ErrorIs(err, errRollback)
_, err = suite.manager.LoadKeyspace(created.GetName())
re.NoError(err)

var removed *keyspacepb.KeyspaceMeta
re.NoError(suite.manager.store.RunInTxn(suite.ctx, func(txn kv.Txn) error {
var err error
removed, err = suite.manager.stageRemoveKeyspace(txn, created.GetId())
return err
}))
suite.manager.finishRemoveKeyspace(removed)
expected := fmt.Sprintf(`# HELP pd_keyspace_info Keyspace metadata. The value is always 1.
# TYPE pd_keyspace_info gauge
pd_keyspace_info{keyspace_id="%d",keyspace_name="%s"} 1
`, existing.GetId(), existing.GetName())
re.NoError(promtestutil.CollectAndCompare(keyspaceInfo, strings.NewReader(expected), "pd_keyspace_info"))

suite.manager.UpdateConfig(&mockConfig{})
re.Equal(0, promtestutil.CollectAndCount(keyspaceInfo))
}

func (suite *keyspaceTestSuite) SetupSuite() {
re := suite.Require()
re.NoError(failpoint.Enable("github.com/tikv/pd/pkg/keyspace/skipSplitRegion", "return(true)"))
Expand Down Expand Up @@ -1203,9 +1271,13 @@ func (suite *keyspaceTestSuite) TestTombstoneKeyspaceUnassignsMetaServiceGroup()
// Removing the already-tombstoned keyspace must not decrement the counter
// again: the group binding was cleared and persisted during the tombstone
// transition, so unassignment is a no-op and the count stays at zero.
var removed *keyspacepb.KeyspaceMeta
re.NoError(manager.store.RunInTxn(suite.ctx, func(txn kv.Txn) error {
return manager.RemoveKeyspace(txn, updated.GetId())
var err error
removed, err = manager.stageRemoveKeyspace(txn, updated.GetId())
return err
}))
manager.finishRemoveKeyspace(removed)
counts, err = manager.mgm.GetAssignmentCounts(suite.ctx)
re.NoError(err)
re.Equal(0, counts[groupID])
Expand Down
43 changes: 43 additions & 0 deletions pkg/keyspace/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package keyspace

import (
"strconv"
"sync"
"time"

"github.com/prometheus/client_golang/prometheus"
Expand All @@ -40,6 +42,16 @@ const (
)

var (
keyspaceInfo = prometheus.NewGaugeVec(

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.

Blocking: bootstrap never publishes the reserved keyspace mapping.

When enable-keyspace-level-metrics is true from startup, Bootstrap persists DEFAULT (or SYSTEM in NextGen) through initReserveKeyspace, but only the two public create methods call UpdateKeyspaceInfoMetrics. PD does not guarantee that an external client will subsequently open WatchKeyspaces or load this reserved keyspace, so a freshly bootstrapped cluster can expose no mapping for it. Bootstrap is already a creation path; publishing after the keyspace-group update succeeds completes that path without adding an etcd scan.

Unit-test reproducer
func (suite *keyspaceTestSuite) TestBootstrapPublishesKeyspaceInfoMetric() {
	re := suite.Require()
	resetKeyspaceInfoMetrics()

	store := endpoint.NewStorageEndpoint(kv.NewMemoryKV(), nil)
	kgm := NewKeyspaceGroupManager(suite.ctx, store, nil)
	re.NoError(kgm.Bootstrap(suite.ctx))
	manager := NewKeyspaceManager(
		suite.ctx,
		store,
		nil,
		mockid.NewIDAllocator(),
		&mockConfig{EnableKeyspaceLevelMetrics: true},
		kgm,
		nil,
	)
	re.NoError(manager.Bootstrap())

	id, name := GetBootstrapKeyspaceID(), GetBootstrapKeyspaceName()
	re.Equal(1, promtestutil.CollectAndCount(keyspaceInfo))
	re.Equal(float64(1), promtestutil.ToFloat64(keyspaceInfo.WithLabelValues(
		strconv.FormatUint(uint64(id), 10), name)))
}

Run:

make gotest GOTEST_ARGS='./pkg/keyspace -run TestKeyspaceTestSuite/TestBootstrapPublishesKeyspaceInfoMetric -count=1 -timeout=30s'

On 247ace82be, the first assertion fails because the actual series count is 0.

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.

This metric is populated on demand. CSE calls GetAllKeyspaces at startup when name-based storage-size metrics are enabled, and that response includes the reserved keyspace. We intentionally don’t populate it during PD bootstrap to avoid another lifecycle path.

prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "info",
Help: "Keyspace metadata. The value is always 1.",
}, []string{"keyspace_id", "keyspace_name"})
keyspaceInfoMetricsMu sync.Mutex
keyspaceInfoMetricsCache = make(map[uint32]prometheus.Gauge)

createKeyspaceStepDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: namespace,
Expand All @@ -60,6 +72,7 @@ var (
)

func init() {
prometheus.MustRegister(keyspaceInfo)
prometheus.MustRegister(createKeyspaceStepDuration)
createKeyspaceStepDurationTotal = createKeyspaceStepDuration.WithLabelValues(StepTotal)
createKeyspaceStepDurationAllocateID = createKeyspaceStepDuration.WithLabelValues(StepAllocateID)
Expand All @@ -70,6 +83,36 @@ func init() {
createKeyspaceStepDurationUpdateKG = createKeyspaceStepDuration.WithLabelValues(StepUpdateKeyspaceGroup)
}

// setKeyspaceInfoMetricsLocked updates a keyspace info series while keyspaceInfoMetricsMu is held.
func setKeyspaceInfoMetricsLocked(id uint32, name string) {
if gauge, ok := keyspaceInfoMetricsCache[id]; ok {
gauge.Set(1)
return
}
gauge := keyspaceInfo.WithLabelValues(strconv.FormatUint(uint64(id), 10), name)
keyspaceInfoMetricsCache[id] = gauge
gauge.Set(1)
}

// deleteKeyspaceInfoMetricsLocked deletes a keyspace info series while keyspaceInfoMetricsMu is held.
func deleteKeyspaceInfoMetricsLocked(id uint32, name string) {
keyspaceInfo.DeleteLabelValues(strconv.FormatUint(uint64(id), 10), name)
delete(keyspaceInfoMetricsCache, id)
}

// resetKeyspaceInfoMetrics deletes all keyspace info series and cached gauges.
func resetKeyspaceInfoMetrics() {
keyspaceInfoMetricsMu.Lock()
defer keyspaceInfoMetricsMu.Unlock()
resetKeyspaceInfoMetricsLocked()
}

// resetKeyspaceInfoMetricsLocked resets the metric while keyspaceInfoMetricsMu is held.
func resetKeyspaceInfoMetricsLocked() {
keyspaceInfo.Reset()
clear(keyspaceInfoMetricsCache)
}

// createKeyspaceTracer traces create-keyspace steps: one callback per step (same pattern as RegionHeartbeatProcessTracer), records metrics and logs per step.
type createKeyspaceTracer struct {
beginTime time.Time
Expand Down
13 changes: 10 additions & 3 deletions pkg/keyspace/tso_keyspace_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

"github.com/pingcap/errors"
"github.com/pingcap/failpoint"
"github.com/pingcap/kvproto/pkg/keyspacepb"
"github.com/pingcap/kvproto/pkg/tsopb"
"github.com/pingcap/log"

Expand Down Expand Up @@ -568,8 +569,9 @@ func (m *GroupManager) RemoveKeyspacesFromGroup(groupID uint32, km *Manager, key
defer m.Unlock()

var (
kg *endpoint.KeyspaceGroup
err error
kg *endpoint.KeyspaceGroup
removedKeyspaces []*keyspacepb.KeyspaceMeta
err error
)

if err := m.store.RunInTxn(m.ctx, func(txn kv.Txn) error {
Expand Down Expand Up @@ -612,10 +614,12 @@ func (m *GroupManager) RemoveKeyspacesFromGroup(groupID uint32, km *Manager, key
if _, shouldRemove := toRemove[ks]; !shouldRemove {
newKeyspaces = append(newKeyspaces, ks)
} else {
err = km.RemoveKeyspace(txn, ks)
meta, removeErr := km.stageRemoveKeyspace(txn, ks)
err = removeErr
if err != nil {
return err
}
removedKeyspaces = append(removedKeyspaces, meta)
}
}
kg.Keyspaces = newKeyspaces
Expand All @@ -625,6 +629,9 @@ func (m *GroupManager) RemoveKeyspacesFromGroup(groupID uint32, km *Manager, key
}); err != nil {
return nil, err
}
for _, meta := range removedKeyspaces {
km.finishRemoveKeyspace(meta)
}

// Update the cache
userKind := endpoint.StringUserKind(kg.UserKind)
Expand Down
7 changes: 7 additions & 0 deletions server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,8 @@ func (c *MicroserviceConfig) IsResourceManagerFallbackEnabled() bool {
type KeyspaceConfig struct {
// PreAlloc contains the keyspace to be allocated during keyspace manager initialization.
PreAlloc []string `toml:"pre-alloc" json:"pre-alloc"`
// EnableKeyspaceLevelMetrics enables metrics with keyspace-level labels.
EnableKeyspaceLevelMetrics bool `toml:"enable-keyspace-level-metrics" json:"enable-keyspace-level-metrics"`
// WaitRegionSplit indicates whether to wait for the region split to complete
WaitRegionSplit bool `toml:"wait-region-split" json:"wait-region-split"`
// WaitRegionSplitTimeout indicates the max duration to wait region split.
Expand Down Expand Up @@ -971,6 +973,11 @@ func (c *KeyspaceConfig) GetPreAlloc() []string {
return ret
}

// IsKeyspaceLevelMetricsEnabled returns whether metrics with keyspace-level labels are enabled.
func (c *KeyspaceConfig) IsKeyspaceLevelMetricsEnabled() bool {
return c.EnableKeyspaceLevelMetrics
}

// ToWaitRegionSplit returns whether to wait for the region split to complete.
func (c *KeyspaceConfig) ToWaitRegionSplit() bool {
return c.WaitRegionSplit
Expand Down
1 change: 1 addition & 0 deletions server/keyspace_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ func (s *KeyspaceServer) WatchKeyspaces(request *keyspacepb.WatchKeyspacesReques
defer cancel() // cancel context to stop watcher
return err
}
s.GetKeyspaceManager().UpdateKeyspaceInfoMetrics(meta)

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.

Blocking: DELETE events never clear the metric.

The putFn above adds every watched keyspace to the metric, but deleteFn immediately below is still a no-op. A keyspace deleted from etcd through WatchKeyspaces therefore leaves its child series in pd_keyspace_info. This is an ordinary removal path, and the series can remain indefinitely because subsequent puts only update other IDs. The delete callback needs to remove the deleted ID/name pair from the metric cache/vector; the DELETE event provides the key, while the name must come from the cached metadata.

Unit-test reproducer
func TestReviewWatchDeleteRemovesMetric(t *testing.T) {
    re := require.New(t)
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    cluster, err := NewTestCluster(ctx, 1, func(c *config.Config, _ string) {
        c.Keyspace.EnableKeyspaceLevelMetrics = true
    })
    re.NoError(err)
    defer cluster.Destroy()
    re.NoError(cluster.RunInitialServers())
    leader := cluster.GetServer(cluster.WaitLeader())
    re.NoError(leader.BootstrapCluster())

    _, conn := testutil.MustNewGrpcClient(re, leader.GetAddr())
    defer conn.Close()
    watch, err := keyspacepb.NewKeyspaceClient(conn).WatchKeyspaces(ctx,
        &keyspacepb.WatchKeyspacesRequest{
            Header: &pdpb.RequestHeader{ClusterId: leader.GetClusterID()},
        })
    re.NoError(err)
    _, err = watch.Recv() // initial snapshot
    re.NoError(err)

    put := func(id uint32, name string) {
        raw, marshalErr := proto.Marshal(&keyspacepb.KeyspaceMeta{
            Keyspace: &keyspacepb.KeyspaceMeta_Id{Id: id},
            Name: name,
            State: keyspacepb.KeyspaceState_ENABLED,
        })
        re.NoError(marshalErr)
        _, putErr := leader.GetEtcdClient().Put(ctx, keypath.KeyspaceMetaPath(id), string(raw))
        re.NoError(putErr)
    }

    put(1000, "review_watch_deleted")
    testutil.Eventually(re, func() bool {
        return metricExists(1000, "review_watch_deleted")
    })
    _, err = leader.GetEtcdClient().Delete(ctx, keypath.KeyspaceMetaPath(1000))
    re.NoError(err)
    put(1001, "review_watch_sentinel")
    testutil.Eventually(re, func() bool {
        return metricExists(1001, "review_watch_sentinel")
    })

    deleted := metricExists(1000, "review_watch_deleted")
    cancel()
    _ = conn.Close()
    re.False(deleted)
}

Here metricExists scans prometheus.DefaultGatherer for the exact keyspace_id and keyspace_name labels without calling WithLabelValues.

Run:
make gotest GOTEST_ARGS='-tags without_dashboard ./tests -run TestReviewWatchDeleteRemovesMetric -count=1 -timeout=40s -v'

Observed output:
metric after put: true
metric after sentinel put: true; deleted metric: true
The final assertion fails with Should be false.

Please implement the delete callback and cover both an etcd DELETE event and the resulting metric removal.

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 c02f996. WatchKeyspaces now parses the deleted keyspace ID and removes the series through the mutex-protected metric cache, which retains the corresponding name label. The cached-ID deletion path is covered by the keyspace metrics test.

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 has regressed on the current head. Commit 5f7668851 removed both the PUT update and the DELETE cleanup from WatchKeyspaces; deleteFn is a no-op again. The original reproducer above now fails before deletion because the PUT never creates the series.

I also ran the narrower test below to separate watch population from every create/load path:

func TestWatchPutPopulatesKeyspaceInfoMetric(t *testing.T) {
    re := require.New(t)
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    cluster, err := NewTestCluster(ctx, 1, func(cfg *config.Config, _ string) {
        cfg.Keyspace.EnableKeyspaceLevelMetrics = true
    })
    re.NoError(err)
    defer cluster.Destroy()
    re.NoError(cluster.RunInitialServers())
    leader := cluster.GetServer(cluster.WaitLeader())
    re.NoError(leader.BootstrapCluster())

    _, conn := testutil.MustNewGrpcClient(re, leader.GetAddr())
    defer conn.Close()
    watch, err := keyspacepb.NewKeyspaceClient(conn).WatchKeyspaces(ctx,
        &keyspacepb.WatchKeyspacesRequest{
            Header: testutil.NewRequestHeader(leader.GetClusterID()),
        })
    re.NoError(err)
    _, err = watch.Recv()
    re.NoError(err)

    const id, name = uint32(1000002), "review_watch_metric"
    raw, err := proto.Marshal(&keyspacepb.KeyspaceMeta{
        Keyspace: &keyspacepb.KeyspaceMeta_Id{Id: id},
        Name: name,
        State: keyspacepb.KeyspaceState_ENABLED,
    })
    re.NoError(err)
    _, err = leader.GetEtcdClient().Put(ctx, keypath.KeyspaceMetaPath(id), string(raw))
    re.NoError(err)
    _, err = watch.Recv()
    re.NoError(err)
    re.True(metricExists(id, name))
}

Run:

make gotest GOTEST_ARGS="-tags without_dashboard ./tests -run TestWatchPutPopulatesKeyspaceInfoMetric -count=1 -timeout=90s"

On f23de01da, the client receives the PUT metadata, while the final assertion is false. This also invalidates the earlier startup-ordering explanation, which specifically depended on WatchKeyspaces populating the metric. Please restore both PUT population and DELETE cleanup with per-ID cached state.

keyspaces = append(keyspaces, meta)
return nil
}
Expand Down
Loading