Skip to content

Commit d01b901

Browse files
committed
TDD
1 parent 2ced893 commit d01b901

7 files changed

Lines changed: 535 additions & 28 deletions

File tree

src/vizier/services/adaptive_export/internal/activeset/activeset.go

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ func New() *ActiveSet {
8989
// Upsert sets or extends a pod's t_end. Idempotent — if the pod is
9090
// already present with a >= t_end, no delta is emitted (caller-side
9191
// dedup of trivial extensions; saves debouncer churn).
92+
//
93+
// `version` is advanced ONLY on membership changes (new pod added).
94+
// A pure t_end extension does NOT bump version — subscribers use
95+
// version skips as their "membership might have changed" signal, and
96+
// spurious bumps force unnecessary re-snapshots.
9297
func (s *ActiveSet) Upsert(k Key, tEnd time.Time) {
9398
s.mu.Lock()
9499
prev, existed := s.members[k]
@@ -97,16 +102,16 @@ func (s *ActiveSet) Upsert(k Key, tEnd time.Time) {
97102
return // no-op extension; quietly skip
98103
}
99104
s.members[k] = tEnd
105+
if existed {
106+
// Pure t_end extension: store new value, no version bump,
107+
// no delta. Subscribers see no membership change.
108+
s.mu.Unlock()
109+
return
110+
}
100111
s.version++
101112
v := s.version
102113
s.mu.Unlock()
103-
104-
if !existed {
105-
s.broadcast(Delta{Added: []Key{k}, Version: v})
106-
}
107-
// extension (existed && tEnd > prev) doesn't change membership;
108-
// no delta needed — subscribers don't care about t_end shifts of
109-
// already-present pods.
114+
s.broadcast(Delta{Added: []Key{k}, Version: v})
110115
}
111116

112117
// Remove drops a pod. No-op if not present. Always emits a delta on
@@ -172,6 +177,10 @@ func (s *ActiveSet) Size() int {
172177
// overflow and subscribers MUST re-snapshot if they detect a version
173178
// gap. Channel is closed when ctx-equivalent shutdown is signalled
174179
// via Unsubscribe.
180+
//
181+
// Race hazard: a caller that does `Snapshot()` then `Subscribe()`
182+
// can miss any membership change that lands between the two calls.
183+
// Prefer `SubscribeAndSnapshot()` which is atomic.
175184
func (s *ActiveSet) Subscribe(buffer int) <-chan Delta {
176185
if buffer < 1 {
177186
buffer = 1
@@ -183,6 +192,40 @@ func (s *ActiveSet) Subscribe(buffer int) <-chan Delta {
183192
return ch
184193
}
185194

195+
// SubscribeAndSnapshot atomically captures the current membership
196+
// AND registers the subscription, so the consumer is guaranteed to
197+
// see EVERY change that lands at or after the returned version
198+
// without losing changes in the race window between the two.
199+
//
200+
// Returned tuple:
201+
//
202+
// keys — current membership at snapshot time
203+
// deltas — channel that will receive every future delta
204+
// version — the version of `keys`; consumers can filter the
205+
// channel by `delta.Version > version`
206+
//
207+
// This is the recommended consumer API for bootstrapping.
208+
func (s *ActiveSet) SubscribeAndSnapshot(buffer int) ([]Key, <-chan Delta, uint64) {
209+
if buffer < 1 {
210+
buffer = 1
211+
}
212+
ch := make(chan Delta, buffer)
213+
// Hold BOTH mutexes for the duration of {snapshot, register}.
214+
// Order: s.mu first (membership), then s.subsMu (subscriber list).
215+
// broadcast() takes only s.subsMu, so there's no ordering risk.
216+
s.mu.Lock()
217+
keys := make([]Key, 0, len(s.members))
218+
for k := range s.members {
219+
keys = append(keys, k)
220+
}
221+
version := s.version
222+
s.subsMu.Lock()
223+
s.subs = append(s.subs, ch)
224+
s.subsMu.Unlock()
225+
s.mu.Unlock()
226+
return keys, ch, version
227+
}
228+
186229
// Unsubscribe removes and closes a previously-returned channel.
187230
// Idempotent (no error on unknown chan).
188231
func (s *ActiveSet) Unsubscribe(ch <-chan Delta) {

src/vizier/services/adaptive_export/internal/activeset/activeset_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,31 @@ func TestPruneExpiredBatchesRemovals(t *testing.T) {
9494
}
9595
}
9696

97+
func TestUpsertExtendDoesNotAdvanceVersion(t *testing.T) {
98+
// Per CR feedback (activeset.go:110): pure extension shouldn't
99+
// bump version, because the version is the consumer's "did
100+
// membership change?" signal. Spurious bumps make subscribers
101+
// re-snapshot for nothing.
102+
s := New()
103+
k := Key{Pod: "p"}
104+
s.Upsert(k, time.Now().Add(time.Minute))
105+
_, v1 := s.Snapshot()
106+
// Extend the SAME pod's t_end repeatedly.
107+
for i := 0; i < 10; i++ {
108+
s.Upsert(k, time.Now().Add(time.Duration(i+2)*time.Minute))
109+
}
110+
_, v2 := s.Snapshot()
111+
if v2 != v1 {
112+
t.Fatalf("version advanced on pure extension: v1=%d v2=%d", v1, v2)
113+
}
114+
// But a new pod DOES advance.
115+
s.Upsert(Key{Pod: "q"}, time.Now().Add(time.Minute))
116+
_, v3 := s.Snapshot()
117+
if v3 == v2 {
118+
t.Fatalf("version did NOT advance on new pod add: v=%d", v3)
119+
}
120+
}
121+
97122
func TestSnapshotReturnsCurrentMembers(t *testing.T) {
98123
s := New()
99124
s.Upsert(Key{Namespace: "n1", Pod: "p1"}, time.Now().Add(time.Minute))
@@ -131,6 +156,48 @@ func TestSubscriberOverflowDropsOldest(t *testing.T) {
131156
}
132157
}
133158

159+
// TestSubscribeAndSnapshot_RaceFreeBootstrap — per CR (activeset.go:183):
160+
// a consumer that wants both "initial state" + "all future deltas"
161+
// must be able to do so without missing changes between Snapshot()
162+
// and Subscribe(). Verify the combined helper.
163+
func TestSubscribeAndSnapshot_RaceFreeBootstrap(t *testing.T) {
164+
s := New()
165+
s.Upsert(Key{Pod: "preexisting"}, time.Now().Add(time.Minute))
166+
167+
// Simulate a hostile interleaving: between when we'd call Snapshot
168+
// and when we'd call Subscribe, a concurrent Upsert lands.
169+
// Without a combined helper, we'd miss it. The combined helper
170+
// must report the new pod EITHER in the initial set OR in the
171+
// first delta — never lost.
172+
keys, ch, version := s.SubscribeAndSnapshot(4)
173+
// Concurrent upsert AFTER subscription.
174+
go func() {
175+
s.Upsert(Key{Pod: "racy"}, time.Now().Add(time.Minute))
176+
}()
177+
178+
if len(keys) != 1 || keys[0].Pod != "preexisting" {
179+
t.Fatalf("initial snapshot wrong: %+v", keys)
180+
}
181+
// Drain delta.
182+
select {
183+
case d := <-ch:
184+
if d.Version <= version {
185+
t.Fatalf("delta version %d <= snapshot version %d", d.Version, version)
186+
}
187+
seen := false
188+
for _, k := range d.Added {
189+
if k.Pod == "racy" {
190+
seen = true
191+
}
192+
}
193+
if !seen {
194+
t.Fatalf("racy pod not in delta added=%v", d.Added)
195+
}
196+
case <-time.After(500 * time.Millisecond):
197+
t.Fatalf("no delta within 500ms")
198+
}
199+
}
200+
134201
func TestConcurrentUpsertsAreSafe(t *testing.T) {
135202
s := New()
136203
var wg sync.WaitGroup

src/vizier/services/adaptive_export/internal/controller/controller.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -647,20 +647,44 @@ func (c *Controller) PruneExpired() int {
647647
grace := 2 * c.cfg.After
648648
// Collect under the lock; fire callbacks AFTER releasing so we
649649
// don't hold the controller mutex across user code.
650-
type prunedKey struct{ namespace, pod string }
651-
var fired []prunedKey
650+
//
651+
// IMPORTANT (rev-3 streaming correctness): c.active is keyed by
652+
// anomaly hash, but the streaming layer (ActiveSet) is keyed by
653+
// (namespace, pod). One pod can host multiple distinct hashes
654+
// (e.g. pgsql-server has hashes for postgres, pg_isready, runc:
655+
// [2:INIT] processes). Firing OnPrune for every evicted hash
656+
// would prematurely stop streaming for a pod that still has
657+
// other active hashes. So: compute the set of pods that have
658+
// NO remaining active hashes after this prune, and only fire
659+
// OnPrune for those.
660+
type podKey struct{ namespace, pod string }
661+
prunedHashes := 0
662+
var pruned []podKey
652663
c.mu.Lock()
664+
// Pass 1: delete expired hashes and remember which pods THEY
665+
// belonged to.
666+
candidatePods := map[podKey]struct{}{}
653667
for h, row := range c.active {
654668
if !row.TEnd.Add(grace).After(now) {
655-
fired = append(fired, prunedKey{row.Namespace, row.Pod})
669+
candidatePods[podKey{row.Namespace, row.Pod}] = struct{}{}
656670
delete(c.active, h)
671+
prunedHashes++
657672
}
658673
}
674+
// Pass 2: from candidatePods, remove any pod that STILL has at
675+
// least one surviving hash in c.active. What's left is the set
676+
// of pods that lost their LAST hash — these get OnPrune.
677+
for _, row := range c.active {
678+
delete(candidatePods, podKey{row.Namespace, row.Pod})
679+
}
680+
for pk := range candidatePods {
681+
pruned = append(pruned, pk)
682+
}
659683
c.mu.Unlock()
660684
if c.cfg.OnPrune != nil {
661-
for _, k := range fired {
685+
for _, k := range pruned {
662686
c.cfg.OnPrune(k.namespace, k.pod)
663687
}
664688
}
665-
return len(fired)
689+
return prunedHashes
666690
}

src/vizier/services/adaptive_export/internal/controller/controller_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,125 @@ func TestController_OnPrune_NilIsNoop(t *testing.T) {
469469
// No panic = pass.
470470
}
471471

472+
// TestController_OnPrune_OnlyFiresWhenLastHashOnPodGone — multiple
473+
// anomaly hashes can share a single (namespace, pod) when distinct
474+
// PID×comm combinations on the same pod each get their own
475+
// kubescape rule firing. Real-world example (sweep observation):
476+
// pgsql-server has hashes for processes `postgres`, `pg_isready`,
477+
// and `runc:[2:INIT]` — three hashes, one pod.
478+
//
479+
// The streaming layer is pod-keyed, so OnPrune(ns, pod) must only
480+
// fire when the LAST hash for that pod is evicted. Premature firing
481+
// would stop the per-pod stream while other hashes are still active.
482+
// CR feedback (controller.go:156) caught this; see comment thread.
483+
func TestController_OnPrune_OnlyFiresWhenLastHashOnPodGone(t *testing.T) {
484+
trig := newFakeTrigger()
485+
snk := &fakeSink{}
486+
clk := &fakeClock{t: canonicalEventTime}
487+
488+
var mu sync.Mutex
489+
var prunedPods []string
490+
cfg := Config{
491+
Hostname: "node-1", Before: time.Minute, After: time.Minute,
492+
OnPrune: func(ns, pod string) {
493+
mu.Lock()
494+
defer mu.Unlock()
495+
prunedPods = append(prunedPods, ns+"/"+pod)
496+
},
497+
}
498+
c := New(trig, snk, cfg, clk)
499+
stop := runController(t, c, trig)
500+
defer stop()
501+
502+
// Two events on the SAME pod but with different (PID, Comm) so
503+
// anomaly.Hash returns two distinct hashes.
504+
mkEvent := func(pid uint64, comm string) kubescape.Event {
505+
return kubescape.Event{
506+
Target: anomaly.Target{
507+
PID: pid, Comm: comm, Pod: "pgsql-server-x", Namespace: "px",
508+
},
509+
EventTime: uint64(canonicalEventTime.UnixNano()),
510+
RuleID: "R1", Hostname: "node-1",
511+
}
512+
}
513+
trig.push(mkEvent(100, "postgres"))
514+
trig.push(mkEvent(200, "pg_isready"))
515+
waitFor(t, "two distinct hashes active", 300*time.Millisecond, func() bool {
516+
return c.Active() == 2
517+
})
518+
519+
// Advance past TEnd + 2*After so BOTH hashes are evictable.
520+
clk.advance(3*time.Minute + time.Second)
521+
if r := c.PruneExpired(); r != 2 {
522+
t.Fatalf("PruneExpired removed %d, want 2 hashes", r)
523+
}
524+
mu.Lock()
525+
defer mu.Unlock()
526+
if len(prunedPods) != 1 {
527+
t.Fatalf("OnPrune fired %d times for one pod with 2 hashes; want 1. Calls: %v",
528+
len(prunedPods), prunedPods)
529+
}
530+
if prunedPods[0] != "px/pgsql-server-x" {
531+
t.Fatalf("wrong pod pruned: %q", prunedPods[0])
532+
}
533+
}
534+
535+
// TestController_OnPrune_DoesNotFireWhileOtherHashesActive — inverse
536+
// case: only ONE hash on a pod expires; OnPrune must NOT fire for
537+
// that pod because other hashes for the same pod remain active.
538+
func TestController_OnPrune_DoesNotFireWhileOtherHashesActive(t *testing.T) {
539+
trig := newFakeTrigger()
540+
snk := &fakeSink{}
541+
clk := &fakeClock{t: canonicalEventTime}
542+
543+
var mu sync.Mutex
544+
var prunedPods []string
545+
cfg := Config{
546+
Hostname: "node-1", Before: time.Minute, After: time.Minute,
547+
OnPrune: func(ns, pod string) {
548+
mu.Lock()
549+
defer mu.Unlock()
550+
prunedPods = append(prunedPods, ns+"/"+pod)
551+
},
552+
}
553+
c := New(trig, snk, cfg, clk)
554+
stop := runController(t, c, trig)
555+
defer stop()
556+
557+
mkEvent := func(pid uint64) kubescape.Event {
558+
return kubescape.Event{
559+
Target: anomaly.Target{
560+
PID: pid, Comm: "c", Pod: "samepod", Namespace: "ns",
561+
},
562+
EventTime: uint64(canonicalEventTime.UnixNano()),
563+
RuleID: "R1", Hostname: "node-1",
564+
}
565+
}
566+
trig.push(mkEvent(100))
567+
waitFor(t, "1 hash", 300*time.Millisecond, func() bool { return c.Active() == 1 })
568+
569+
// Advance time so first hash's TEnd is in the past but not yet
570+
// past the 2*After grace. Then push second hash on the same pod.
571+
clk.advance(2 * time.Minute)
572+
trig.push(mkEvent(200))
573+
waitFor(t, "2 hashes", 300*time.Millisecond, func() bool { return c.Active() == 2 })
574+
575+
// Advance to where the FIRST hash is past grace (3m after its
576+
// creation) but the SECOND is still alive (its TEnd is at
577+
// canonical+3m; grace would be +5m). Total clock progression
578+
// from canonical: 2m + 1m + 1s = 3m1s.
579+
clk.advance(time.Minute + time.Second)
580+
removed := c.PruneExpired()
581+
if removed != 1 {
582+
t.Fatalf("PruneExpired removed %d, want 1 (only the old hash)", removed)
583+
}
584+
mu.Lock()
585+
defer mu.Unlock()
586+
if len(prunedPods) != 0 {
587+
t.Fatalf("OnPrune fired for a pod that still has 1 active hash; calls: %v", prunedPods)
588+
}
589+
}
590+
472591
// TestController_OnAttribution_NotHeldUnderMutex — a slow callback
473592
// must NOT block PruneExpired's progress (the controller must not
474593
// be holding its own mutex while invoking user code).

0 commit comments

Comments
 (0)