Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5052fa3
*: delete per-store metrics when a store is tombstoned
bufferflies Aug 10, 2026
2ff912f
server, mcs/scheduling: avoid re-scanning metric vecs every tick for …
bufferflies Aug 10, 2026
a52f905
address review feedback: fix gci import order, drop unsafe dedup, har…
bufferflies Aug 11, 2026
c7b7180
schedule/filter, schedule/hbstream: sweep tombstoned-store metrics ev…
bufferflies Aug 11, 2026
9cef474
schedule/filter, schedule/hbstream: reset counters on scheduling stop
bufferflies Aug 12, 2026
2f87de5
address maintainer review: idle-cluster gc, address changes, final-re…
bufferflies Aug 12, 2026
0c7a066
mcs, server: replace periodic tombstone-metric sweeps with bury/remov…
bufferflies Aug 14, 2026
7595061
mcs, server: don't tear down the RegionHeartbeat stream for a tombsto…
bufferflies Aug 14, 2026
1f8cc07
mcs, server, statistics: close remaining tombstone-metric leaks
bufferflies Aug 14, 2026
9633926
statistics: keep tombstone-count metrics visible across bury
bufferflies Aug 17, 2026
e1e18bb
schedulers: reset balanceWitnessCounter alongside the other per-store…
bufferflies Aug 17, 2026
be431db
tests/integrations/client: don't let TestGetStore tombstone a shared …
bufferflies Aug 17, 2026
2190b39
statistics: gc tombstoned stores from HotPeerCache at bury time
bufferflies Aug 17, 2026
f294195
schedule/filter: don't count filter rejections for tombstoned stores
bufferflies Aug 17, 2026
8763552
statistics: skip tombstoned peers in HotPeerCache.CheckPeerFlow
bufferflies Aug 17, 2026
8ceab2c
server, mcs: reject region-buckets reports whose leader is tombstoned
bufferflies Aug 17, 2026
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
15 changes: 10 additions & 5 deletions pkg/core/basic_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,17 @@ func (bc *BasicCluster) GetLeaderStoreByRegionID(regionID uint64) *StoreInfo {
func (bc *BasicCluster) getWriteRate(
f func(storeID uint64) (bytesRate, keysRate float64),
) (storeIDs []uint64, bytesRates, keysRates []float64) {
storeIDs = bc.GetStoreIDs()
count := len(storeIDs)
bytesRates = make([]float64, 0, count)
keysRates = make([]float64, 0, count)
for _, id := range storeIDs {
stores := bc.GetStores()
storeIDs = make([]uint64, 0, len(stores))
bytesRates = make([]float64, 0, len(stores))
keysRates = make([]float64, 0, len(stores))
for _, store := range stores {
if store.IsRemoved() {
continue
}
id := store.GetID()
bytesRate, keysRate := f(id)
storeIDs = append(storeIDs, id)
bytesRates = append(bytesRates, bytesRate)
keysRates = append(keysRates, keysRate)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/mcs/router/server/meta/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (w *Watcher) initializeStoreWatcher() error {
}

if store.GetNodeState() == metapb.NodeState_Removed {
statistics.ResetStoreStatistics(store.GetAddress(), strconv.FormatUint(store.GetId(), 10))
statistics.ResetStoreStatistics(strconv.FormatUint(store.GetId(), 10))
// TODO: remove hot stats
}

Expand Down
11 changes: 11 additions & 0 deletions pkg/mcs/scheduling/server/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"io"
"net/http"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
Expand All @@ -47,6 +48,7 @@ import (
"github.com/tikv/pd/pkg/schedule"
"github.com/tikv/pd/pkg/schedule/affinity"
sc "github.com/tikv/pd/pkg/schedule/config"
"github.com/tikv/pd/pkg/schedule/filter"
"github.com/tikv/pd/pkg/schedule/hbstream"
"github.com/tikv/pd/pkg/schedule/keyrange"
"github.com/tikv/pd/pkg/schedule/labeler"
Expand Down Expand Up @@ -324,6 +326,10 @@ func (c *Cluster) SetRuntimeResources(
c.configWatcher = configWatcher
c.ruleWatcher = ruleWatcher
c.affinityWatcher = affinityWatcher
metaWatcher.SetOnStoreTombstoned(func(storeID uint64) {
c.hotStat.RemoveRollingStoreStats(storeID)
DeleteStoreMetrics(strconv.FormatUint(storeID, 10))
})
}

func (c *Cluster) stopCluster() {
Expand Down Expand Up @@ -745,6 +751,8 @@ func resetMetrics() {
statistics.Reset()
schedulers.ResetSchedulerMetrics()
schedule.ResetHotSpotMetrics()
filter.ResetFilterMetrics()
hbstream.ResetHeartbeatStreamMetrics()
}

// StartBackgroundJobs starts background jobs.
Expand Down Expand Up @@ -917,6 +925,9 @@ func (c *Cluster) processRegionBuckets(buckets *metapb.Buckets) error {
if region == nil {
return errors.Errorf("region %v not found", buckets.GetRegionId())
}
if store := c.GetStore(region.GetLeader().GetStoreId()); store != nil && store.IsRemoved() {
return nil
}
// use CAS to update the bucket information.
// the two request(A:3,B:2) get the same region and need to update the buckets.
// the A will pass the check and set the version to 3, the B will fail because the region.bucket has changed.
Expand Down
27 changes: 21 additions & 6 deletions pkg/mcs/scheduling/server/grpc_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package server

import (
"context"
"fmt"
"io"
"net/http"
"strconv"
Expand Down Expand Up @@ -149,6 +150,10 @@ func (s *Service) RegionHeartbeat(stream schedulingpb.Scheduling_RegionHeartbeat
if store == nil {
return errors.Errorf("invalid store ID %d, not found", storeID)
}
if store.IsRemoved() {
log.Debug("skip region heartbeat from tombstone store", zap.Uint64("store-id", storeID))
continue
}

storeAddress := store.GetAddress()
storeLabel := strconv.FormatUint(storeID, 10)
Expand Down Expand Up @@ -220,6 +225,8 @@ func (s *Service) RegionBuckets(stream schedulingpb.Scheduling_RegionBucketsServ
// As TiKV report buckets just after the region heartbeat, for new created region, PD may receive buckets report before the first region heartbeat is handled.
// So we should not return error here.
log.Debug("the store of the bucket in region is not found", zap.Uint64("region-id", buckets.GetRegionId()))
} else if store.IsRemoved() {

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: the MCS RegionBuckets handler has the same incomplete tombstone branch. It logs the condition, then calls HandleRegionBuckets and creates region-buckets metrics with store="". That label cannot be removed by the store-ID cleanup introduced by this PR.

The deterministic MCS regression test I constructed is in pkg/mcs/scheduling/server/grpc_service_test.go (TestRegionBucketsIgnoresTombstoneLeader):

Regression test
cluster.PutRegion(region)
cluster.PutStore(
    cluster.GetStore(region.GetLeader().GetStoreId()).Clone(
        core.SetStoreState(metapb.StoreState_Tombstone),
    ),
)
stream := &captureRegionBucketsStream{
    recvs: []*schedulingpb.RegionBucketsRequest{
        {Buckets: &metapb.Buckets{
            RegionId:   region.GetID(),
            Version:    1,
            Keys:       [][]byte{region.GetStartKey(), region.GetEndKey()},
            PeriodInMs: 1000,
        }},
    },
)

require.NoError(t, svc.RegionBuckets(stream))
require.Nil(t, cluster.GetRegion(region.GetID()).GetReportBuckets())
labels := prometheus.Labels{"store": ""}
require.Zero(t, regionBucketsCounter.DeletePartialMatch(labels))
require.Zero(t, regionBucketsHandleDuration.DeletePartialMatch(labels))
require.Zero(t, regionBucketsReportInterval.DeletePartialMatch(labels))

The current handler updates the report and creates all three empty-label series, so this test directly covers the regression.

continue
} else {
storeLabel = strconv.FormatUint(store.GetID(), 10)
storeAddress = store.GetAddress()
Expand Down Expand Up @@ -257,17 +264,25 @@ func (s *Service) StoreHeartbeat(_ context.Context, request *schedulingpb.StoreH
return &schedulingpb.StoreHeartbeatResponse{Header: notBootstrappedHeader()}, nil
}

start := time.Now()
if c.GetStore(request.GetStats().GetStoreId()) == nil {
storeID := request.GetStats().GetStoreId()
if c.GetStore(storeID) == nil {
metaWatcher.GetStoreWatcher().ForceLoad()
}

storeID := request.GetStats().GetStoreId()
store := c.GetStore(storeID)
storeAddress := ""
if store != nil {
storeAddress = store.GetAddress()
if store == nil {
return &schedulingpb.StoreHeartbeatResponse{
Header: wrapErrorToHeader(schedulingpb.ErrorType_UNKNOWN, fmt.Sprintf("store %v not found", storeID)),
}, nil
}
if store.IsRemoved() {
return &schedulingpb.StoreHeartbeatResponse{
Header: wrapErrorToHeader(schedulingpb.ErrorType_UNKNOWN, fmt.Sprintf("store %v is tombstone", storeID)),
}, nil
}

start := time.Now()
storeAddress := store.GetAddress()
storeLabel := strconv.FormatUint(storeID, 10)
if err := c.HandleStoreHeartbeat(request); err != nil {
storeHeartbeatCounter.WithLabelValues(storeAddress, storeLabel, "error").Inc()
Expand Down
47 changes: 45 additions & 2 deletions pkg/mcs/scheduling/server/meta/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"context"
"strconv"
"sync"
"sync/atomic"

"github.com/gogo/protobuf/proto"
"go.etcd.io/etcd/api/v3/mvccpb"
Expand All @@ -28,6 +29,10 @@ import (
"github.com/pingcap/log"

"github.com/tikv/pd/pkg/core"
"github.com/tikv/pd/pkg/schedule"
"github.com/tikv/pd/pkg/schedule/filter"
"github.com/tikv/pd/pkg/schedule/hbstream"
"github.com/tikv/pd/pkg/schedule/schedulers"
"github.com/tikv/pd/pkg/statistics"
"github.com/tikv/pd/pkg/utils/etcdutil"
"github.com/tikv/pd/pkg/utils/keypath"
Expand All @@ -42,6 +47,13 @@ type Watcher struct {
etcdClient *clientv3.Client
basicCluster *core.BasicCluster
storeWatcher *etcdutil.LoopWatcher

// onStoreTombstoned is late-bound via SetOnStoreTombstoned once the parent
// Cluster (which owns hotStat and the scheduling-server-only metrics that this
// package can't import without a cycle) finishes construction. The watcher
// itself starts watching before that point, so it's read through an atomic
// pointer to stay safe against a store event racing the setup.
onStoreTombstoned atomic.Pointer[func(storeID uint64)]
}

// NewWatcher creates a new watcher to watch the meta change from PD.
Expand Down Expand Up @@ -82,8 +94,15 @@ func (w *Watcher) initializeStoreWatcher() error {
}

if store.GetNodeState() == metapb.NodeState_Removed {
statistics.ResetStoreStatistics(store.GetAddress(), strconv.FormatUint(store.GetId(), 10))
// TODO: remove hot stats
storeIDStr := strconv.FormatUint(store.GetId(), 10)
statistics.ResetStoreStatistics(storeIDStr)
filter.DeleteStoreMetrics(storeIDStr)
hbstream.DeleteStoreMetrics(storeIDStr)
schedulers.DeleteStoreMetrics(storeIDStr)
schedule.DeleteStoreMetrics(storeIDStr)
if fn := w.onStoreTombstoned.Load(); fn != nil {
(*fn)(store.GetId())
}
}

return nil
Expand All @@ -96,7 +115,15 @@ func (w *Watcher) initializeStoreWatcher() error {
}
origin := w.basicCluster.GetStore(storeID)
if origin != nil {
storeIDStr := strconv.FormatUint(storeID, 10)
statistics.DeleteClusterStatusMetrics(origin)
filter.DeleteStoreMetrics(storeIDStr)
hbstream.DeleteStoreMetrics(storeIDStr)
schedulers.DeleteStoreMetrics(storeIDStr)
schedule.DeleteStoreMetrics(storeIDStr)
if fn := w.onStoreTombstoned.Load(); fn != nil {
(*fn)(storeID)
}
w.basicCluster.DeleteStore(origin)
log.Info("delete store meta", zap.Uint64("store-id", storeID))
}
Expand All @@ -117,6 +144,22 @@ func (w *Watcher) initializeStoreWatcher() error {
return w.storeWatcher.WaitLoad()
}

// SetOnStoreTombstoned sets the callback invoked (at least once) when a store
// transitions to tombstone, for cleanup that only the parent Cluster can do
// without an import cycle (removing rolling hot stats, resetting the
// scheduling-server-owned heartbeat metrics). NewWatcher's initial load runs
// before the caller has a chance to install this callback, so a store already
// tombstoned at startup would otherwise never get it invoked; reconcile against
// whatever the watcher has already loaded here to close that gap.
func (w *Watcher) SetOnStoreTombstoned(fn func(storeID uint64)) {
w.onStoreTombstoned.Store(&fn)
for _, store := range w.basicCluster.GetStores() {
if store.IsRemoved() {
fn(store.GetID())
}
}
}

// Close closes the watcher.
func (w *Watcher) Close() {
w.cancel()
Expand Down
16 changes: 16 additions & 0 deletions pkg/mcs/scheduling/server/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,19 @@ func init() {
prometheus.MustRegister(regionBucketsCounter)
prometheus.MustRegister(regionBucketsReportInterval)
}

// DeleteStoreMetrics deletes the per-store heartbeat/bucket metrics of a store.
// Matches on the store label alone, not address: PD allows an existing store ID to
// change address (e.g. after a TiKV restart with a new IP), so requiring the current
// address to match as well would permanently leak any series recorded under a
// previous address.
func DeleteStoreMetrics(id string) {
labels := prometheus.Labels{"store": id}
storeHeartbeatHandleDuration.DeletePartialMatch(labels)
storeHeartbeatCounter.DeletePartialMatch(labels)
regionHeartbeatHandleDuration.DeletePartialMatch(labels)
regionHeartbeatCounter.DeletePartialMatch(labels)
regionBucketsHandleDuration.DeletePartialMatch(labels)
regionBucketsCounter.DeletePartialMatch(labels)
regionBucketsReportInterval.DeletePartialMatch(labels)
}
41 changes: 26 additions & 15 deletions pkg/schedule/filter/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,19 @@ func SelectSourceStores(stores []*core.StoreInfo, filters []Filter, conf config.
return slice.AllOf(filters, func(i int) bool {
status := filters[i].Source(conf, s)
if !status.IsOK() {
if counter != nil {
counter.inc(source, filters[i].Type(), s.GetID())
} else {
sourceID := strconv.FormatUint(s.GetID(), 10)
// TODO: pre-allocate gauge metrics
filterSourceCounter.WithLabelValues(filters[i].Scope(), filters[i].Type().String(), sourceID).Inc()
// A tombstoned store is rejected here on every scheduling cycle for as
// long as it stays known, so counting it would either leak (nothing
// ever calls Counter.Flush again to zero it) or fight with tombstone
// cleanup deleting the series between Flush calls. It's not an
// actionable rejection reason for an operator either way.
if !s.IsRemoved() {
if counter != nil {
counter.inc(source, filters[i].Type(), s.GetID())
} else {
sourceID := strconv.FormatUint(s.GetID(), 10)
// TODO: pre-allocate gauge metrics
filterSourceCounter.WithLabelValues(filters[i].Scope(), filters[i].Type().String(), sourceID).Inc()
}
}
if collector != nil {
collector.Collect(plan.SetResource(s), plan.SetStatus(status))
Expand All @@ -64,10 +71,12 @@ func SelectUnavailableTargetStores(stores []*core.StoreInfo, filters []Filter, c
return slice.AnyOf(filters, func(i int) bool {
status := filters[i].Target(conf, s)
if !status.IsOK() {
if counter != nil {
counter.inc(target, filters[i].Type(), s.GetID())
} else {
filterTargetCounter.WithLabelValues(filters[i].Scope(), filters[i].Type().String(), targetID).Inc()
if !s.IsRemoved() {
if counter != nil {
counter.inc(target, filters[i].Type(), s.GetID())
} else {
filterTargetCounter.WithLabelValues(filters[i].Scope(), filters[i].Type().String(), targetID).Inc()
}
}

if collector != nil {
Expand All @@ -92,11 +101,13 @@ func SelectTargetStores(stores []*core.StoreInfo, filters []Filter, conf config.
filter := filters[i]
status := filter.Target(conf, s)
if !status.IsOK() {
if counter != nil {
counter.inc(target, filter.Type(), s.GetID())
} else {
targetIDStr := strconv.FormatUint(s.GetID(), 10)
filterTargetCounter.WithLabelValues(filter.Scope(), filter.Type().String(), targetIDStr).Inc()
if !s.IsRemoved() {
if counter != nil {
counter.inc(target, filter.Type(), s.GetID())
} else {
targetIDStr := strconv.FormatUint(s.GetID(), 10)
filterTargetCounter.WithLabelValues(filter.Scope(), filter.Type().String(), targetIDStr).Inc()
}
}
if collector != nil {
collector.Collect(plan.SetResource(s), plan.SetStatus(status))
Expand Down
12 changes: 12 additions & 0 deletions pkg/schedule/filter/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,15 @@ func init() {
prometheus.MustRegister(filterSourceCounter)
prometheus.MustRegister(filterTargetCounter)
}

// DeleteStoreMetrics deletes the filter metrics of a store.
func DeleteStoreMetrics(storeID string) {
filterSourceCounter.DeletePartialMatch(prometheus.Labels{"source": storeID})
filterTargetCounter.DeletePartialMatch(prometheus.Labels{"target": storeID})
}

// ResetFilterMetrics resets the filter metrics.
func ResetFilterMetrics() {
filterSourceCounter.Reset()
filterTargetCounter.Reset()
}
8 changes: 8 additions & 0 deletions pkg/schedule/hbstream/heartbeat_streams.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ func (s *HeartbeatStreams) run() {
delete(s.streams, storeID)
continue
}
if store.IsRemoved() {
delete(s.streams, storeID)
continue
}
storeAddress := store.GetAddress()
if stream, ok := s.streams[storeID]; ok {
if err := stream.Send(msg); err != nil {
Expand All @@ -172,6 +176,10 @@ func (s *HeartbeatStreams) run() {
delete(s.streams, storeID)
continue
}
if store.IsRemoved() {
delete(s.streams, storeID)
continue
}
storeAddress := store.GetAddress()
storeLabel := strconv.FormatUint(storeID, 10)
if err := stream.Send(keepAlive); err != nil {
Expand Down
10 changes: 10 additions & 0 deletions pkg/schedule/hbstream/metric.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,13 @@ var (
func init() {
prometheus.MustRegister(heartbeatStreamCounter)
}

// DeleteStoreMetrics deletes the heartbeat stream metrics of a store.
func DeleteStoreMetrics(storeID string) {
heartbeatStreamCounter.DeletePartialMatch(prometheus.Labels{"store": storeID})
}

// ResetHeartbeatStreamMetrics resets the heartbeat stream metrics.
func ResetHeartbeatStreamMetrics() {
heartbeatStreamCounter.Reset()
}
5 changes: 5 additions & 0 deletions pkg/schedule/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ var (
func init() {
prometheus.MustRegister(hotSpotStatusGauge)
}

// DeleteStoreMetrics deletes the hotspot status metrics of a store.
func DeleteStoreMetrics(storeID string) {
hotSpotStatusGauge.DeletePartialMatch(prometheus.Labels{"store": storeID})
}
16 changes: 16 additions & 0 deletions pkg/schedule/schedulers/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,22 @@ func init() {
prometheus.MustRegister(balanceRangeJobGauge)
}

// DeleteStoreMetrics deletes the per-store scheduler metrics of a store.
func DeleteStoreMetrics(storeID string) {
opInfluenceStatus.DeletePartialMatch(prometheus.Labels{"store": storeID})
balanceWitnessCounter.DeleteLabelValues("move-witness", storeID+"-out")
balanceWitnessCounter.DeleteLabelValues("move-witness", storeID+"-in")
hotSchedulerResultCounter.DeletePartialMatch(prometheus.Labels{"store": storeID})
balanceDirectionCounter.DeletePartialMatch(prometheus.Labels{"store": storeID})
hotDirectionCounter.DeletePartialMatch(prometheus.Labels{"store": storeID})
evictedSlowStoreStatusGauge.DeletePartialMatch(prometheus.Labels{"store": storeID})
evictedStoppingStoreStatusGauge.DeleteLabelValues(storeID)
slowStoreTriggerLimitGauge.DeletePartialMatch(prometheus.Labels{"store": storeID})
storeSlowTrendEvictedStatusGauge.DeletePartialMatch(prometheus.Labels{"store": storeID})

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.

HotPendingSum is also keyed by the store label and is written by the hot-region scheduler, but this helper never deletes it. Consequently, pd_scheduler_hot_pending_sum series survive store tombstoning even when every caller invokes this cleanup.

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 2f87de5: DeleteStoreMetrics now also deletes HotPendingSum.

balanceRangeGauge.DeletePartialMatch(prometheus.Labels{"store": storeID})
HotPendingSum.DeletePartialMatch(prometheus.Labels{"store": storeID})
}

func balanceLeaderCounterWithEvent(event string) prometheus.Counter {
return schedulerCounter.WithLabelValues(types.BalanceLeaderScheduler.String(), event)
}
Expand Down
Loading
Loading