@@ -67,9 +67,10 @@ type Sampler interface {
6767
6868// Defaults for MemSamplerOptions when fields are left zero.
6969const (
70- DefaultStep = 60 * time .Second
71- DefaultHistoryColumns = 1440 // 24 hours at 60s steps.
72- DefaultMaxTrackedRoutes = 10_000
70+ DefaultStep = 60 * time .Second
71+ DefaultHistoryColumns = 1440 // 24 hours at 60s steps.
72+ DefaultMaxTrackedRoutes = 10_000
73+ DefaultMaxMemberRoutesPerSlot = 256
7374)
7475
7576// MemSamplerOptions configures NewMemSampler. Zero values fall back to
@@ -86,6 +87,14 @@ type MemSamplerOptions struct {
8687 // returns false past this cap, the route ID maps into a virtual
8788 // bucket, and Snapshot reports it with Aggregate=true.
8889 MaxTrackedRoutes int
90+ // MaxMemberRoutesPerSlot caps how many distinct RouteIDs a single
91+ // virtual bucket records in MemberRoutes. Beyond this cap the
92+ // route still folds into the bucket counters (so traffic is not
93+ // dropped) but the routeID is not appended — keeping per-column
94+ // payload size bounded when total routes far exceed
95+ // MaxTrackedRoutes. Snapshot consumers should treat the list as
96+ // "first N members" rather than authoritative attribution.
97+ MaxMemberRoutesPerSlot int
8998 // Now overrides time.Now for tests; nil falls back to time.Now.
9099 Now func () time.Time
91100}
@@ -267,6 +276,9 @@ func NewMemSampler(opts MemSamplerOptions) *MemSampler {
267276 if opts .MaxTrackedRoutes == 0 {
268277 opts .MaxTrackedRoutes = DefaultMaxTrackedRoutes
269278 }
279+ if opts .MaxMemberRoutesPerSlot <= 0 {
280+ opts .MaxMemberRoutesPerSlot = DefaultMaxMemberRoutesPerSlot
281+ }
270282 now := opts .Now
271283 if now == nil {
272284 now = time .Now
@@ -371,7 +383,7 @@ func (s *MemSampler) RegisterRoute(routeID uint64, start, end []byte) bool {
371383 }
372384 next .sortedSlots = appendSorted (next .sortedSlots , bucket )
373385 } else {
374- foldIntoBucket (next , bucket , routeID , start , end )
386+ s . foldIntoBucket (next , bucket , routeID , start , end )
375387 }
376388 next .virtualForRoute [routeID ] = bucket
377389 s .table .Store (next )
@@ -385,9 +397,15 @@ func (s *MemSampler) RegisterRoute(routeID uint64, start, end []byte) bool {
385397// Counters live next to the metadata but are protected by their own
386398// atomic ops, not metaMu. If Start is lowered, sortedSlots is rebuilt
387399// to preserve Flush's key-order contract.
388- func foldIntoBucket (next * routeTable , bucket * routeSlot , routeID uint64 , start , end []byte ) {
400+ //
401+ // MemberRoutes growth is capped by MaxMemberRoutesPerSlot — beyond
402+ // that cap the bucket counters still absorb the route's traffic, but
403+ // the routeID is not added to the visible member list.
404+ func (s * MemSampler ) foldIntoBucket (next * routeTable , bucket * routeSlot , routeID uint64 , start , end []byte ) {
389405 bucket .metaMu .Lock ()
390- bucket .MemberRoutes = append (bucket .MemberRoutes , routeID )
406+ if len (bucket .MemberRoutes ) < s .opts .MaxMemberRoutesPerSlot {
407+ bucket .MemberRoutes = append (bucket .MemberRoutes , routeID )
408+ }
391409 if len (end ) == 0 || (len (bucket .End ) != 0 && bytesGT (end , bucket .End )) {
392410 bucket .End = cloneBytes (end )
393411 }
@@ -502,7 +520,9 @@ func (s *MemSampler) Flush() {
502520// entries whose grace window has not yet elapsed. Rows are appended
503521// to *rows so the caller sees the slice growth. Entries whose
504522// elapsed time (now - retiredAt) has reached grace are dropped after
505- // this final drain.
523+ // this final drain. The dropped tail of the backing array is zeroed
524+ // so released *routeSlot pointers do not stay GC-reachable through
525+ // the reused capacity.
506526func drainRetiredSlots (retired []retiredSlot , rows * []MatrixRow , now time.Time , grace time.Duration ) []retiredSlot {
507527 keep := retired [:0 ]
508528 for _ , r := range retired {
@@ -511,13 +531,15 @@ func drainRetiredSlots(retired []retiredSlot, rows *[]MatrixRow, now time.Time,
511531 keep = append (keep , r )
512532 }
513533 }
534+ clearTail (retired , len (keep ))
514535 return keep
515536}
516537
517538// advancePendingPrunes lets each pending member-prune live until its
518539// retiredAt+grace passes, then actually prunes the routeID from the
519540// bucket's MemberRoutes. Returns the entries still inside the grace
520- // window.
541+ // window. Like drainRetiredSlots, the dropped tail is zeroed so
542+ // released bucket pointers don't linger via the reused capacity.
521543func advancePendingPrunes (pending []memberPrune , now time.Time , grace time.Duration ) []memberPrune {
522544 keep := pending [:0 ]
523545 for _ , p := range pending {
@@ -527,9 +549,26 @@ func advancePendingPrunes(pending []memberPrune, now time.Time, grace time.Durat
527549 }
528550 pruneMemberRoute (p .bucket , p .routeID )
529551 }
552+ clearPruneTail (pending , len (keep ))
530553 return keep
531554}
532555
556+ // clearTail zeroes the [keepLen, len(s)) range of s so dropped entries
557+ // don't keep their *routeSlot pointers GC-reachable through the
558+ // reused backing array.
559+ func clearTail (s []retiredSlot , keepLen int ) {
560+ for i := keepLen ; i < len (s ); i ++ {
561+ s [i ] = retiredSlot {}
562+ }
563+ }
564+
565+ // clearPruneTail is clearTail for the pendingPrunes queue.
566+ func clearPruneTail (s []memberPrune , keepLen int ) {
567+ for i := keepLen ; i < len (s ); i ++ {
568+ s [i ] = memberPrune {}
569+ }
570+ }
571+
533572// bucketStillReferenced reports whether any RouteID in
534573// virtualForRoute still maps to bucket. Used by RemoveRoute to detect
535574// when a virtual bucket has lost its last member and must be retired
0 commit comments