Skip to content

Commit 145fab5

Browse files
committed
fix(routing): decay quarantine reconviction count over quiet time
1 parent fe452e4 commit 145fab5

2 files changed

Lines changed: 179 additions & 14 deletions

File tree

ip_remote_multi_client.go

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9955,18 +9955,18 @@ type multiClientChannel struct {
99559955
// as quarantineMigrated and the rest of the episode bookkeeping beside
99569956
// it, not new durable state. Guarded by stateLock.
99579957
//
9958-
// KNOWN LIMITATION, tracked separately, not yet fixed: this counter never
9959-
// DECAYS within a session -- a provider that flaps early and then runs
9960-
// clean for hours still escalates straight to the 240s cap on its next
9961-
// bench rather than re-starting at 60s. The blast radius is bounded and
9962-
// self-healing even so: the worst case is a stale-bad exit taking up to
9963-
// 240s instead of 60s to be force-evicted by the expiry escape, and
9964-
// release-on-receive-progress (addReceiveAck's clear, unrelated to this
9965-
// counter) remains a fully independent per-poll acquittal path this
9966-
// cannot delay -- a genuinely recovered exit is never held past the
9967-
// evidence that acquits it. Clean-interval decay analogous to
9968-
// quarantineMemoryDuration should land before QuarantineDampening is
9969-
// ever turned on for real traffic.
9958+
// This raw field is the historical high-water mark and is NEVER
9959+
// decremented in storage -- decay is applied lazily at read time by
9960+
// quarantineReconvictionCount(), which removes one step per whole
9961+
// quarantineReconvictionDecayInterval elapsed since quarantineLiftTime
9962+
// (the same stamp this field advances beside above, so a fresh
9963+
// conviction resets the decay clock for free -- no separate field to
9964+
// keep in sync). See quarantineReconvictionDecay for the pure math.
9965+
// QuarantineDampening off short-circuits the whole computation --
9966+
// quarantineReconvictionCount() returns this field verbatim, no clock
9967+
// read at all -- so a default build's benchDuration, and the
9968+
// StallEvents telemetry this field also feeds (see exitMetricsSnapshot),
9969+
// stay byte-for-byte what they were before decay existed.
99709970
quarantineReconvictions int
99719971

99729972
// pendingSendTime is when the current run of unacked sends began, reset on
@@ -10623,12 +10623,66 @@ func (self *multiClientChannel) quarantineState() (blackholeReason, time.Time) {
1062310623
return self.quarantineReason, self.quarantineStart
1062410624
}
1062510625

10626+
// quarantineReconvictionDecayInterval is the clean interval one reconviction
10627+
// step decays over: quarantineReconvictionCount() removes one step from the
10628+
// raw count for every whole interval elapsed since quarantineLiftTime, the
10629+
// stamp of the LAST completed bench-then-lift cycle (the same instant
10630+
// quarantineReconvictions itself advances on -- see clearQuarantineWithLock
10631+
// -- so a fresh conviction resets this clock for free). Set equal to
10632+
// quarantineMemoryDuration: the existing answer this file already gives for
10633+
// "how long counts as a clean interval" for the sibling survived-quarantine
10634+
// memory, reused rather than inventing a second opinion on the same
10635+
// question -- though the two remain independent constants should they ever
10636+
// need to diverge.
10637+
const quarantineReconvictionDecayInterval = quarantineMemoryDuration
10638+
10639+
// quarantineReconvictionDecay returns reconvictions with one step removed
10640+
// for every whole quarantineReconvictionDecayInterval elapsed is worth,
10641+
// floored at 0. A non-positive reconvictions or a non-positive elapsed (a
10642+
// clock problem, not evidence of a longer clean interval -- the same
10643+
// convention reentryScorePenalty's elapsed<0 clamp uses) both read as "no
10644+
// decay to apply" rather than propagating toward a negative result: a
10645+
// negative count feeding benchDuration or exitScore's StallEvents term is
10646+
// exactly the defect class this branch has already shipped twice (negative
10647+
// StallEvents scoring as a bonus, a zero RTT scoring best), so this must
10648+
// never produce one. Pure: no clock reads, no locks -- mirrors benchDuration
10649+
// and reentryScorePenalty.
10650+
func quarantineReconvictionDecay(reconvictions int, elapsed time.Duration) int {
10651+
if reconvictions <= 0 {
10652+
return 0
10653+
}
10654+
if elapsed <= 0 {
10655+
return reconvictions
10656+
}
10657+
decayed := reconvictions - int(elapsed/quarantineReconvictionDecayInterval)
10658+
if decayed < 0 {
10659+
return 0
10660+
}
10661+
return decayed
10662+
}
10663+
1062610664
// quarantineReconvictionCount reads the completed bench-then-lift cycle
10627-
// count benchDuration escalates on; see the field comment.
10665+
// count benchDuration escalates on; see the field comment. With
10666+
// QuarantineDampening on, the raw count is decayed by elapsed quiet time
10667+
// since the last completed cycle (quarantineReconvictionDecay, above) before
10668+
// being returned, so a channel that reconvicted long ago and has been clean
10669+
// since reads a low count again rather than the historical peak. With the
10670+
// knob at its zero value (off) this returns the raw field verbatim and does
10671+
// not even read the clock -- so a default build's reading, and everything
10672+
// downstream of it (benchDuration, and exitMetricsSnapshot's StallEvents),
10673+
// stays byte-for-byte what it was before this decay existed.
1062810674
func (self *multiClientChannel) quarantineReconvictionCount() int {
10675+
// reliabilitySettings() is a bare atomic load (see its own comment),
10676+
// never blocking, so reading it before the lock adds no new lock
10677+
// ordering and keeps the locked section to the two fields below.
10678+
dampening := self.reliabilitySettings().QuarantineDampening
10679+
1062910680
self.stateLock.Lock()
1063010681
defer self.stateLock.Unlock()
10631-
return self.quarantineReconvictions
10682+
if !dampening || self.quarantineLiftTime.IsZero() {
10683+
return self.quarantineReconvictions
10684+
}
10685+
return quarantineReconvictionDecay(self.quarantineReconvictions, time.Now().Sub(self.quarantineLiftTime))
1063210686
}
1063310687

1063410688
// quarantineReentryElapsed reports how long it has been since this channel's

routing_quarantine_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,117 @@ func TestQuarantineReconvictionsSurviveReceiveProgressRelease(t *testing.T) {
155155
}
156156
}
157157

158+
// TestQuarantineReconvictionDecaySteps is the pure decay math Task 6 adds:
159+
// one step removed per whole quarantineReconvictionDecayInterval of elapsed
160+
// time, floored at 0, with both a negative count and a negative elapsed
161+
// (clock trouble, not evidence of a longer clean interval -- the same
162+
// convention reentryScorePenalty's elapsed<0 clamp already uses) reading as
163+
// "no decay to apply" rather than propagating a negative result. A negative
164+
// reconviction count feeding benchDuration or exitScore's StallEvents term
165+
// is exactly the defect class this branch has already shipped twice
166+
// (negative StallEvents scoring as a bonus, a zero RTT scoring best), so
167+
// this must never produce one.
168+
func TestQuarantineReconvictionDecaySteps(t *testing.T) {
169+
interval := quarantineReconvictionDecayInterval
170+
171+
if got := quarantineReconvictionDecay(5, 0); got != 5 {
172+
t.Fatalf("zero elapsed must not decay: got %d, want 5", got)
173+
}
174+
if got := quarantineReconvictionDecay(5, interval-time.Second); got != 5 {
175+
t.Fatalf("just under one elapsed interval must not decay yet: got %d, want 5", got)
176+
}
177+
if got := quarantineReconvictionDecay(5, interval); got != 4 {
178+
t.Fatalf("exactly one elapsed interval must remove exactly one step: got %d, want 4", got)
179+
}
180+
if got := quarantineReconvictionDecay(5, 2*interval); got != 3 {
181+
t.Fatalf("two elapsed intervals must remove exactly two steps: got %d, want 3", got)
182+
}
183+
if got := quarantineReconvictionDecay(5, 100*interval); got != 0 {
184+
t.Fatalf("many elapsed intervals must floor at 0, not go negative: got %d, want 0", got)
185+
}
186+
if got := quarantineReconvictionDecay(0, 100*interval); got != 0 {
187+
t.Fatalf("a count already at 0 must stay 0: got %d, want 0", got)
188+
}
189+
if got := quarantineReconvictionDecay(-3, interval); got != 0 {
190+
t.Fatalf("a negative count must clamp to 0, not go more negative: got %d, want 0", got)
191+
}
192+
if got := quarantineReconvictionDecay(5, -time.Second); got != 5 {
193+
t.Fatalf("negative elapsed must clamp to no-decay, got %d, want 5", got)
194+
}
195+
}
196+
197+
// TestQuarantineReconvictionCountDecaysOverQuietTime is Task 6's channel-level
198+
// proof: with QuarantineDampening on, quarantineReconvictionCount() decays
199+
// the count by whole quarantineReconvictionDecayInterval steps measured from
200+
// quarantineLiftTime (the last completed lift), floors at 0 once enough
201+
// quiet time has passed, and a fresh conviction is visible immediately
202+
// afterward rather than reading as still-decayed. Deliberately picks
203+
// intervals where the decayed and un-decayed readings differ (1 and 0
204+
// against a raw count of 2) so this cannot pass against the un-fixed
205+
// always-climbing counter.
206+
func TestQuarantineReconvictionCountDecaysOverQuietTime(t *testing.T) {
207+
client := stallTestChannel()
208+
client.settings.QuarantineDampening = true
209+
210+
// two completed bench-then-lift cycles -> raw reconvictions = 2
211+
client.setQuarantined(blackholeNoReceiveAck)
212+
client.clearQuarantine()
213+
client.setQuarantined(blackholeNoReceiveSyn)
214+
client.clearQuarantine()
215+
if got := client.quarantineReconvictionCount(); got != 2 {
216+
t.Fatalf("expected 2 reconvictions right after the second lift, got %d", got)
217+
}
218+
219+
// age the last lift back by just over one decay interval: exactly one
220+
// step must be removed
221+
client.stateLock.Lock()
222+
client.quarantineLiftTime = time.Now().Add(-quarantineReconvictionDecayInterval - time.Second)
223+
client.stateLock.Unlock()
224+
if got := client.quarantineReconvictionCount(); got != 1 {
225+
t.Fatalf("one elapsed decay interval must remove exactly one step, got %d, want 1", got)
226+
}
227+
228+
// age it back far enough for well past both steps: must floor at 0, not
229+
// go negative
230+
client.stateLock.Lock()
231+
client.quarantineLiftTime = time.Now().Add(-5*quarantineReconvictionDecayInterval - time.Second)
232+
client.stateLock.Unlock()
233+
if got := client.quarantineReconvictionCount(); got != 0 {
234+
t.Fatalf("enough quiet time must decay reconvictions to exactly 0, got %d, want 0", got)
235+
}
236+
237+
// a fresh conviction resets the decay clock: the new lift's elapsed time
238+
// is ~0, so the count must be visible right away, not read as decayed
239+
client.setQuarantined(blackholeNoReceiveAck)
240+
client.clearQuarantine()
241+
if got := client.quarantineReconvictionCount(); got == 0 {
242+
t.Fatal("a fresh conviction must reset the decay clock, not read as still-decayed")
243+
}
244+
}
245+
246+
// TestQuarantineReconvictionCountInertWhenDampeningOff pins the zero-value-off
247+
// contract: with QuarantineDampening at its zero value (false), an aged
248+
// quarantineLiftTime must not decay the count at all -- quarantineReconvictionCount()
249+
// must keep returning the raw, ever-climbing reading a default build has
250+
// always returned, with no clock read and no decay computation performed.
251+
func TestQuarantineReconvictionCountInertWhenDampeningOff(t *testing.T) {
252+
client := stallTestChannel()
253+
// QuarantineDampening left at its zero value (false)
254+
255+
client.setQuarantined(blackholeNoReceiveAck)
256+
client.clearQuarantine()
257+
client.setQuarantined(blackholeNoReceiveSyn)
258+
client.clearQuarantine()
259+
260+
client.stateLock.Lock()
261+
client.quarantineLiftTime = time.Now().Add(-10 * quarantineReconvictionDecayInterval)
262+
client.stateLock.Unlock()
263+
264+
if got := client.quarantineReconvictionCount(); got != 2 {
265+
t.Fatalf("QuarantineDampening off must never decay the count, got %d, want 2", got)
266+
}
267+
}
268+
158269
// TestReentryScorePenaltyDecaysToZero is the pure decay curve: full weight
159270
// at the instant of release (elapsed==0), zero once ramp has fully elapsed,
160271
// and strictly decreasing in between. ramp<=0 is the zero-value-off legacy

0 commit comments

Comments
 (0)