Skip to content

Commit 135d4a6

Browse files
committed
statistics, schedule: stop republishing metrics for a known-tombstoned store
Pick the pure Prometheus-metric-leak subset of tikv#11166 (still open on master) onto this branch, on top of tikv#11127's backport: - ObserveHotStat / ResetStoreStatistics / Reset(): stop storeStatusGauge from being republished by an in-flight StoreHeartbeat after bury, clean up placementStatusGauge, reset StoreLimitGauge on a full leader-election reset. - collectHotMetrics: gate hasHotLeader/hasHotPeer on a single IsRemoved() read so a tombstoned store's stale HotPeerCache data can't republish hotSpotStatusGauge between HotPeerCache.gc() ticks. - SetStoreLimit: reject setting a limit on an already-tombstoned store, closing the only other write path that could re-add a cleared StoreLimitGauge/config entry. - summaryPendingInfluence: re-check each store fresh through the cluster instead of trusting the possibly-stale StoreSummaryInfo snapshot before writing HotPendingSum. Left out (not applicable to this branch): tikv#11166's evict_slow_store.go / adjustNetworkSlowStore guards (network-slow-store eviction doesn't exist here) and its memory-leak-only fixes (region rule fit cache, storesOfRegion reverse index, StoreHistoryLoads GC), which are a separate concern from metric leakage and tracked separately. Signed-off-by: bufferflies <1045931706@qq.com>
1 parent 26bfee8 commit 135d4a6

7 files changed

Lines changed: 129 additions & 11 deletions

File tree

pkg/schedule/coordinator.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,15 @@ func collectHotMetrics(cluster sche.ClusterInformer, stores []*core.StoreInfo, t
507507
storeAddress := s.GetAddress()
508508
storeID := s.GetID()
509509
storeLabel := strconv.FormatUint(storeID, 10)
510+
// HotPeerCache.gc() only removes a tombstoned store from status.AsLeader/
511+
// AsPeer's source data on its own TTL-throttled schedule, not every tick,
512+
// so a known-tombstoned store here can still have stale hot-peer data.
513+
// Treat it as not hot regardless, so the delete branches below run
514+
// instead of republishing hotSpotStatusGauge every tick until
515+
// HotPeerCache.gc() eventually catches up.
516+
removed := s.IsRemoved()
510517
stat, hasHotLeader := status.AsLeader[storeID]
518+
hasHotLeader = hasHotLeader && !removed
511519
if hasHotLeader {
512520
hotSpotStatusGauge.WithLabelValues(storeAddress, storeLabel, "total_"+kind+"_bytes_as_leader").Set(stat.TotalBytesRate)
513521
hotSpotStatusGauge.WithLabelValues(storeAddress, storeLabel, "total_"+kind+"_keys_as_leader").Set(stat.TotalKeysRate)
@@ -521,6 +529,7 @@ func collectHotMetrics(cluster sche.ClusterInformer, stores []*core.StoreInfo, t
521529
}
522530

523531
stat, hasHotPeer := status.AsPeer[storeID]
532+
hasHotPeer = hasHotPeer && !removed
524533
if hasHotPeer {
525534
hotSpotStatusGauge.WithLabelValues(storeAddress, storeLabel, "total_"+kind+"_bytes_as_peer").Set(stat.TotalBytesRate)
526535
hotSpotStatusGauge.WithLabelValues(storeAddress, storeLabel, "total_"+kind+"_keys_as_peer").Set(stat.TotalKeysRate)
@@ -546,7 +555,7 @@ func collectHotMetrics(cluster sche.ClusterInformer, stores []*core.StoreInfo, t
546555
// iteration's own s was still live: once a snapshot correctly shows
547556
// IsRemoved(), a tombstoned store sitting in GetStores() for up to 30
548557
// days doesn't cost a scan on every tick.
549-
if !s.IsRemoved() {
558+
if !removed {
550559
if store := cluster.GetStore(storeID); store == nil || store.IsRemoved() {
551560
DeleteStoreMetrics(storeLabel)
552561
}

pkg/schedule/schedulers/hot_region.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727
"github.com/pingcap/kvproto/pkg/pdpb"
2828
"github.com/pingcap/log"
2929
"github.com/prometheus/client_golang/prometheus"
30+
"go.uber.org/zap"
31+
3032
"github.com/tikv/pd/pkg/core"
3133
"github.com/tikv/pd/pkg/core/constant"
3234
"github.com/tikv/pd/pkg/errs"
@@ -41,7 +43,6 @@ import (
4143
"github.com/tikv/pd/pkg/statistics/utils"
4244
"github.com/tikv/pd/pkg/utils/keyutil"
4345
"github.com/tikv/pd/pkg/utils/syncutil"
44-
"go.uber.org/zap"
4546
)
4647

4748
const (
@@ -111,7 +112,7 @@ func newBaseHotScheduler(
111112
// each store, only update read or write load detail
112113
func (s *baseHotScheduler) prepareForBalance(typ resourceType, cluster sche.SchedulerCluster) {
113114
storeInfos := statistics.SummaryStoreInfos(cluster.GetStores())
114-
s.summaryPendingInfluence(storeInfos)
115+
s.summaryPendingInfluence(cluster, storeInfos)
115116
storesLoads := cluster.GetStoresLoads()
116117
isTraceRegionFlow := cluster.GetSchedulerConfig().IsTraceRegionFlow()
117118

@@ -156,7 +157,7 @@ func (s *baseHotScheduler) updateHistoryLoadConfig(sampleDuration, sampleInterva
156157
// summaryPendingInfluence calculate the summary of pending Influence for each store
157158
// and clean the region from regionInfluence if they have ended operator.
158159
// It makes each dim rate or count become `weight` times to the origin value.
159-
func (s *baseHotScheduler) summaryPendingInfluence(storeInfos map[uint64]*statistics.StoreSummaryInfo) {
160+
func (s *baseHotScheduler) summaryPendingInfluence(cluster sche.SchedulerCluster, storeInfos map[uint64]*statistics.StoreSummaryInfo) {
160161
for id, p := range s.regionPendings {
161162
for _, from := range p.froms {
162163
from := storeInfos[from]
@@ -179,6 +180,14 @@ func (s *baseHotScheduler) summaryPendingInfluence(storeInfos map[uint64]*statis
179180
}
180181
// for metrics
181182
for storeID, info := range storeInfos {
183+
// storeInfos is built from a snapshot taken at the top of
184+
// prepareForBalance, so info.IsRemoved() can be stale by the time
185+
// this loop runs; re-check the store fresh through cluster instead,
186+
// otherwise a store buried after the snapshot was taken but before
187+
// this write can still recreate HotPendingSum for it.
188+
if store := cluster.GetStore(storeID); store == nil || store.IsRemoved() {
189+
continue
190+
}
182191
storeLabel := strconv.FormatUint(storeID, 10)
183192
if infl := info.PendingSum; infl != nil && len(infl.Loads) != 0 {
184193
utils.ForeachRegionStats(func(rwTy utils.RWType, dim int, kind utils.RegionStatKind) {

pkg/schedule/schedulers/hot_region_test.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ import (
2222
"time"
2323

2424
"github.com/docker/go-units"
25+
promtestutil "github.com/prometheus/client_golang/prometheus/testutil"
26+
2527
"github.com/pingcap/kvproto/pkg/metapb"
2628
"github.com/pingcap/kvproto/pkg/pdpb"
2729
"github.com/stretchr/testify/require"
30+
2831
"github.com/tikv/pd/pkg/core"
2932
"github.com/tikv/pd/pkg/mock/mockcluster"
3033
"github.com/tikv/pd/pkg/schedule/operator"
@@ -180,7 +183,7 @@ func checkGCPendingOpInfos(re *require.Assertions, enablePlacementRules bool) {
180183
}
181184

182185
storeInfos := statistics.SummaryStoreInfos(tc.GetStores())
183-
hb.summaryPendingInfluence(storeInfos) // Calling this function will GC.
186+
hb.summaryPendingInfluence(tc, storeInfos) // Calling this function will GC.
184187

185188
for i := range opInfluenceCreators {
186189
for j, typ := range typs {
@@ -2089,7 +2092,7 @@ func TestInfluenceByRWType(t *testing.T) {
20892092
re.NotNil(op)
20902093

20912094
storeInfos := statistics.SummaryStoreInfos(tc.GetStores())
2092-
hb.(*hotScheduler).summaryPendingInfluence(storeInfos)
2095+
hb.(*hotScheduler).summaryPendingInfluence(tc, storeInfos)
20932096
re.True(nearlyAbout(storeInfos[1].PendingSum.Loads[utils.RegionWriteKeys], -0.5*units.MiB))
20942097
re.True(nearlyAbout(storeInfos[1].PendingSum.Loads[utils.RegionWriteBytes], -0.5*units.MiB))
20952098
re.True(nearlyAbout(storeInfos[4].PendingSum.Loads[utils.RegionWriteKeys], 0.5*units.MiB))
@@ -2114,7 +2117,7 @@ func TestInfluenceByRWType(t *testing.T) {
21142117
re.NotNil(op)
21152118

21162119
storeInfos = statistics.SummaryStoreInfos(tc.GetStores())
2117-
hb.(*hotScheduler).summaryPendingInfluence(storeInfos)
2120+
hb.(*hotScheduler).summaryPendingInfluence(tc, storeInfos)
21182121
// assert read/write influence is the sum of write peer and write leader
21192122
re.True(nearlyAbout(storeInfos[1].PendingSum.Loads[utils.RegionWriteKeys], -1.2*units.MiB))
21202123
re.True(nearlyAbout(storeInfos[1].PendingSum.Loads[utils.RegionWriteBytes], -1.2*units.MiB))
@@ -2982,6 +2985,43 @@ func TestEncodeConfig(t *testing.T) {
29822985
re.NotEqual("null", string(data))
29832986
}
29842987

2988+
func TestSummaryPendingInfluenceSkipsRemovedStoreMetric(t *testing.T) {
2989+
re := require.New(t)
2990+
defer HotPendingSum.Reset()
2991+
2992+
cancel, _, tc, _ := prepareSchedulersTest()
2993+
defer cancel()
2994+
hb := newBaseHotScheduler(nil, 0, 0, initHotRegionScheduleConfig())
2995+
storeID := uint64(1)
2996+
removed := core.NewStoreInfo(&metapb.Store{
2997+
Id: storeID,
2998+
NodeState: metapb.NodeState_Removed,
2999+
})
3000+
tc.PutStore(removed)
3001+
// storeInfos holds a stale snapshot still showing the store as serving,
3002+
// simulating one taken before the store was buried -- this only passes
3003+
// if the check re-reads the store fresh through cluster instead of
3004+
// trusting info.IsRemoved() on the snapshot.
3005+
stale := core.NewStoreInfo(&metapb.Store{
3006+
Id: storeID,
3007+
NodeState: metapb.NodeState_Serving,
3008+
})
3009+
loads := make([]float64, utils.RegionStatCount)
3010+
loads[utils.RegionWriteBytes] = 1
3011+
storeInfos := map[uint64]*statistics.StoreSummaryInfo{
3012+
storeID: {
3013+
StoreInfo: stale,
3014+
PendingSum: &statistics.Influence{Loads: loads},
3015+
},
3016+
}
3017+
3018+
metric := HotPendingSum.WithLabelValues("1", utils.Write.String(), utils.DimToString(utils.ByteDim))
3019+
metric.Set(42)
3020+
hb.summaryPendingInfluence(tc, storeInfos)
3021+
3022+
re.Equal(float64(42), promtestutil.ToFloat64(metric))
3023+
}
3024+
29853025
func TestBucketFirstStat(t *testing.T) {
29863026
re := require.New(t)
29873027
testdata := []struct {

pkg/statistics/store_collection.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,15 @@ func (s *storeStatistics) observe(store *core.StoreInfo) {
151151

152152
// ObserveHotStat records the hot region metrics for the store.
153153
func ObserveHotStat(store *core.StoreInfo, stats *StoresStats) {
154+
// A store's RollingStoreStats can be recreated after bury by a StoreHeartbeat
155+
// that was already in flight (HandleStoreHeartbeat only rejects a fully
156+
// unknown store, not a tombstoned one). Without this check, that would make
157+
// this function keep republishing storeStatusGauge every collection tick for
158+
// as long as the entry exists, up to 30 days until final removal, instead of
159+
// stopping once the store is known tombstoned like observe() already does.
160+
if store.IsRemoved() {
161+
return
162+
}
154163
// Store flows.
155164
storeAddress := store.GetAddress()
156165
id := strconv.FormatUint(store.GetID(), 10)
@@ -272,6 +281,11 @@ func (s *storeStatistics) collect() {
272281
// previous address.
273282
func ResetStoreStatistics(id string) {
274283
storeStatusGauge.DeletePartialMatch(prometheus.Labels{"store": id})
284+
// placementStatusGauge's "name" label is an arbitrary, unbounded label-rule
285+
// name (unlike clusterStatusGauge's small, fixed engine set below), so an
286+
// exact DeleteLabelValues per combination isn't feasible here either --
287+
// same tradeoff as storeStatusGauge above.
288+
placementStatusGauge.DeletePartialMatch(prometheus.Labels{"store": id})
275289
// mcs never cleaned StoreLimitGauge on its own: unlike the classic path's
276290
// RemoveStoreLimit, the mcs scheduling service has no store-limit config
277291
// of its own to persist a removal for. Deleting it here, alongside
@@ -309,6 +323,7 @@ func Reset() {
309323
storeStatusGauge.Reset()
310324
clusterStatusGauge.Reset()
311325
placementStatusGauge.Reset()
326+
StoreLimitGauge.Reset()
312327
ResetRegionStatsMetrics()
313328
ResetLabelStatsMetrics()
314329
ResetHotCacheStatusMetrics()

pkg/statistics/store_collection_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,13 @@ import (
2020
"time"
2121

2222
"github.com/docker/go-units"
23+
"github.com/prometheus/client_golang/prometheus"
24+
"github.com/prometheus/client_golang/prometheus/testutil"
25+
2326
"github.com/pingcap/kvproto/pkg/metapb"
2427
"github.com/pingcap/kvproto/pkg/pdpb"
2528
"github.com/stretchr/testify/require"
29+
2630
"github.com/tikv/pd/pkg/core"
2731
"github.com/tikv/pd/pkg/core/constant"
2832
"github.com/tikv/pd/pkg/mock/mockconfig"
@@ -95,6 +99,22 @@ func TestStoreStatistics(t *testing.T) {
9599
re.Len(stats.LabelCounter["zone:unknown"], 2)
96100
}
97101

102+
func TestResetStoreStatisticsClearsPlacementStatusGauge(t *testing.T) {
103+
re := require.New(t)
104+
defer placementStatusGauge.Reset()
105+
106+
metric := placementStatusGauge.WithLabelValues("label-type", "label-name", "1")
107+
metric.Set(1)
108+
re.NotZero(testutil.ToFloat64(metric))
109+
110+
ResetStoreStatistics("1")
111+
// DeletePartialMatch returns how many series it found and removed, so a
112+
// zero return here proves ResetStoreStatistics already deleted it --
113+
// unlike checking WithLabelValues' value, which would recreate a fresh
114+
// (zero-valued) series regardless of whether the old one was cleaned up.
115+
re.Zero(placementStatusGauge.DeletePartialMatch(prometheus.Labels{"store": "1"}))
116+
}
117+
98118
func TestSummaryStoreInfos(t *testing.T) {
99119
re := require.New(t)
100120
rw := utils.Read

server/cluster/cluster.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2473,6 +2473,18 @@ func (c *RaftCluster) SetExternalTS(timestamp uint64) error {
24732473

24742474
// SetStoreLimit sets a store limit for a given type and rate.
24752475
func (c *RaftCluster) SetStoreLimit(storeID uint64, typ storelimit.Type, ratePerMin float64) error {
2476+
// A tombstoned store's config entry is only ever cleared once, at bury time
2477+
// (RemoveStoreLimit); nothing sweeps it again afterward. Without this check,
2478+
// setting a limit for an already-tombstoned store re-adds it, and
2479+
// StoreLimitGauge stays republished for it until final removal.
2480+
//
2481+
// GetStore returning nil is deliberately NOT treated the same as removed
2482+
// here: callers legitimately set a store's limit before the store itself
2483+
// is registered (see testCluster.addRegionStore), so nil just means
2484+
// "not created yet," not "already gone."
2485+
if store := c.GetStore(storeID); store != nil && store.IsRemoved() {
2486+
return errs.ErrStoreRemoved.FastGenByArgs(storeID)
2487+
}
24762488
old := c.opt.GetScheduleConfig().Clone()
24772489
c.opt.SetStoreLimit(storeID, typ, ratePerMin)
24782490
if err := c.opt.Persist(c.storage); err != nil {

tests/server/cluster/cluster_test.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import (
3232
"github.com/pingcap/kvproto/pkg/pdpb"
3333
"github.com/pingcap/kvproto/pkg/replication_modepb"
3434
"github.com/stretchr/testify/require"
35+
"google.golang.org/grpc/codes"
36+
"google.golang.org/grpc/status"
37+
3538
"github.com/tikv/pd/pkg/core"
3639
"github.com/tikv/pd/pkg/core/storelimit"
3740
"github.com/tikv/pd/pkg/dashboard"
@@ -55,8 +58,6 @@ import (
5558
"github.com/tikv/pd/server/config"
5659
"github.com/tikv/pd/tests"
5760
"github.com/tikv/pd/tests/server/api"
58-
"google.golang.org/grpc/codes"
59-
"google.golang.org/grpc/status"
6061
)
6162

6263
const (
@@ -474,8 +475,20 @@ func testStateAndLimit(re *require.Assertions, clusterID uint64, rc *cluster.Raf
474475
// prepare
475476
storeID := store.GetId()
476477
oc := rc.GetOperatorController()
477-
rc.SetStoreLimit(storeID, storelimit.AddPeer, 60)
478-
rc.SetStoreLimit(storeID, storelimit.RemovePeer, 60)
478+
// The store can be left tombstoned by a previous call to this helper (the
479+
// tombstone beforeState block runs it twice on the same store). Production
480+
// never un-tombstones a store, so don't fake that transition here either --
481+
// these SetStoreLimit calls only exist to seed a limit before resetStoreState
482+
// below establishes the state this specific case actually wants to test, and
483+
// resetStoreState's own Tombstone branch clears any limit anyway, so seeding
484+
// one is pointless (and rejected by SetStoreLimit) once the store is already
485+
// tombstoned.
486+
if store := rc.GetStore(storeID); store == nil || !store.IsRemoved() {
487+
err := rc.SetStoreLimit(storeID, storelimit.AddPeer, 60)
488+
re.NoError(err)
489+
err = rc.SetStoreLimit(storeID, storelimit.RemovePeer, 60)
490+
re.NoError(err)
491+
}
479492
op := operator.NewTestOperator(2, &metapb.RegionEpoch{}, operator.OpRegion, operator.AddPeer{ToStore: storeID, PeerID: 3})
480493
oc.AddOperator(op)
481494
op = operator.NewTestOperator(2, &metapb.RegionEpoch{}, operator.OpRegion, operator.RemovePeer{FromStore: storeID})

0 commit comments

Comments
 (0)