Skip to content

Commit f7a9723

Browse files
committed
discovery: demote overloaded candidate outside active quota and prove anti-cascade dispersion
1 parent d0bd2ba commit f7a9723

2 files changed

Lines changed: 135 additions & 60 deletions

File tree

portal/discovery/mols.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,15 +316,18 @@ func RankRelayPool(autoPool []RelayState, localAddress string, epoch uint64) []s
316316
}
317317
}
318318

319-
// P2C pressure optimization: compare candidate 0 and 1, swap only when
320-
// p0 - p1 > molsP2CPressureDelta, and preserve the rest of the MOLS order.
321-
// This keeps the pressure correction local to the client's distinct candidate pair
322-
// rather than collapsing into a global pressure ordering.
319+
// P2C pressure optimization:
320+
// Compare candidate 0 and 1. If p0 - p1 > molsP2CPressureDelta, candidate 0
321+
// is significantly overloaded. To achieve real load-shedding under active listener
322+
// quotas (such as the default MaxActiveRelays = 3), candidate 0 yields its active slot
323+
// and is demoted behind non-saturated candidates, enabling warm reserve candidates
324+
// to enter the active set while preserving local client-specific MOLS ordering.
323325
if len(nonSaturated) >= 2 {
324326
p0 := nonSaturated[0].state.Pressure()
325327
p1 := nonSaturated[1].state.Pressure()
326328
if p0-p1 > molsP2CPressureDelta {
327-
nonSaturated[0], nonSaturated[1] = nonSaturated[1], nonSaturated[0]
329+
overloaded := nonSaturated[0]
330+
nonSaturated = append(nonSaturated[1:], overloaded)
328331
}
329332
}
330333

portal/discovery/mols_test.go

Lines changed: 127 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -366,74 +366,146 @@ func TestMOLSP2CLocalChoiceTopTwo(t *testing.T) {
366366
if len(ranked) != 3 {
367367
t.Fatalf("expected 3 ranked relays, got %d", len(ranked))
368368
}
369-
// Pressure difference between r0 and r1 triggers local P2C swap of index 0 and 1
370-
// Verify that the result contains all 3 and preserves valid pool ordering
369+
// Pressure difference between r0 and r1 triggers local P2C demotion of overloaded candidate 0
371370
if ranked[0] == "https://relay-0.example" && r0.Pressure()-r1.Pressure() > molsP2CPressureDelta {
372-
t.Fatalf("relay-0 should have been swapped with relay-1 due to P2C local choice")
371+
t.Fatalf("relay-0 should have yielded its top slot due to P2C local choice")
373372
}
374373
}
375374

376-
func TestMOLSP2CActiveSetMembershipChange(t *testing.T) {
375+
func TestMOLSP2CActiveSetMembershipChangeDefaultQuota(t *testing.T) {
377376
now := time.Now().UTC()
377+
const numRelays = 4 // 3 active + 1 reserve under default MaxActiveRelays = 3
378+
relays := make([]RelayState, numRelays)
379+
for i := 0; i < numRelays; i++ {
380+
st := confirmedRelayState(t, fmt.Sprintf("https://relay-quota-%d.example", i))
381+
st.DiscoveryRTT = 25 * time.Millisecond
382+
st.DiscoveryRTTAt = now
383+
st.LoadFactor = 0.10
384+
st.EWMALoad = 0.10
385+
relays[i] = st
386+
}
387+
388+
// Baseline: under balanced loads, MOLS determines initial order
389+
clientAddr := "client-quota-test"
390+
basePicks := SelectPriority(relays, RouteState{
391+
MaxActiveRelays: defaultMaxActiveRelays, // 3
392+
LocalAddress: clientAddr,
393+
})
394+
if len(basePicks) != defaultMaxActiveRelays {
395+
t.Fatalf("expected %d base picks, got %d", defaultMaxActiveRelays, len(basePicks))
396+
}
397+
398+
rankedBase := RankRelayPool(relays, clientAddr, 0)
399+
topCandidateURL := rankedBase[0]
400+
reserveCandidateURL := rankedBase[3] // 4th candidate (reserve slot)
401+
402+
// Overload the top candidate with surging load and tail inflation
403+
loadedRelays := make([]RelayState, len(relays))
404+
for i, r := range relays {
405+
loadedRelays[i] = r
406+
if r.Descriptor.APIHTTPSAddr == topCandidateURL {
407+
loadedRelays[i].LoadFactor = 0.75
408+
loadedRelays[i].EWMALoad = 0.75
409+
loadedRelays[i].LoadDelta = 0.35
410+
for j := 0; j < 90; j++ {
411+
loadedRelays[i].RTTTracker.Add(10 * time.Millisecond)
412+
}
413+
for j := 0; j < 10; j++ {
414+
loadedRelays[i].RTTTracker.Add(150 * time.Millisecond)
415+
}
416+
}
417+
}
378418

379-
// r0 is initially preferred by MOLS over r1
380-
r0 := confirmedRelayState(t, "https://relay-0.example")
381-
r0.DiscoveryRTT = 25 * time.Millisecond
382-
r0.DiscoveryRTTAt = now
419+
// Under default MaxActiveRelays = 3, overloaded top candidate MUST be evicted from active set
420+
newPicks := SelectPriority(loadedRelays, RouteState{
421+
MaxActiveRelays: defaultMaxActiveRelays, // 3
422+
LocalAddress: clientAddr,
423+
})
424+
if len(newPicks) != defaultMaxActiveRelays {
425+
t.Fatalf("expected %d new picks, got %d", defaultMaxActiveRelays, len(newPicks))
426+
}
383427

384-
r1 := confirmedRelayState(t, "https://relay-1.example")
385-
r1.DiscoveryRTT = 30 * time.Millisecond
386-
r1.DiscoveryRTTAt = now
428+
// Invariant 1: Overloaded relay is evicted outside the active listener quota
429+
if slices.Contains(newPicks, topCandidateURL) {
430+
t.Fatalf("overloaded relay %s was NOT evicted from active set: %v", topCandidateURL, newPicks)
431+
}
387432

388-
// Baseline: under balanced loads, MOLS order decides the initial winner and loser
389-
relaysBaseline := []RelayState{r0, r1}
390-
basePicks := RankRelayPool(relaysBaseline, "client-addr", 0)
391-
if len(basePicks) < 2 {
392-
t.Fatalf("expected at least 2 ranked picks, got %d", len(basePicks))
393-
}
394-
initialWinner := basePicks[0]
395-
initialLoser := basePicks[1]
396-
397-
var winnerState, loserState RelayState
398-
if r0.Descriptor.APIHTTPSAddr == initialWinner {
399-
winnerState = r0
400-
loserState = r1
401-
} else {
402-
winnerState = r1
403-
loserState = r0
404-
}
405-
406-
// Overload the initial winner with surging load and tail inflation
407-
winnerOverloaded := winnerState
408-
winnerOverloaded.LoadFactor = 0.75
409-
winnerOverloaded.EWMALoad = 0.75
410-
winnerOverloaded.LoadDelta = 0.35
411-
for i := 0; i < 90; i++ {
412-
winnerOverloaded.RTTTracker.Add(10 * time.Millisecond)
433+
// Invariant 2: Reserve candidate steps into the active listener set
434+
if !slices.Contains(newPicks, reserveCandidateURL) {
435+
t.Fatalf("reserve relay %s did not enter active set: %v", reserveCandidateURL, newPicks)
413436
}
414-
for i := 0; i < 10; i++ {
415-
winnerOverloaded.RTTTracker.Add(150 * time.Millisecond)
437+
}
438+
439+
func TestMOLSAntiCascadeDispersion(t *testing.T) {
440+
testDispersion := func(t *testing.T, numRelays int) {
441+
now := time.Now().UTC()
442+
relays := make([]RelayState, numRelays)
443+
for i := 0; i < numRelays; i++ {
444+
relays[i] = confirmedRelayState(t, fmt.Sprintf("https://relay-%d-%d.example", numRelays, i))
445+
relays[i].DiscoveryRTT = 20 * time.Millisecond
446+
relays[i].DiscoveryRTTAt = now
447+
}
448+
449+
const numClients = 700
450+
clientsByPrimary := make(map[string][]string)
451+
for i := 0; i < numClients; i++ {
452+
clientAddr := fmt.Sprintf("client-%d-%d.example", numRelays, i)
453+
ranked := RankRelayPool(relays, clientAddr, 0)
454+
primary := ranked[0]
455+
clientsByPrimary[primary] = append(clientsByPrimary[primary], clientAddr)
456+
}
457+
458+
// Find the primary relay serving the largest group of clients
459+
var targetPrimary string
460+
var targetClients []string
461+
for p, cs := range clientsByPrimary {
462+
if len(cs) > len(targetClients) {
463+
targetPrimary = p
464+
targetClients = cs
465+
}
466+
}
467+
468+
// Simulate primary relay failure (drop from candidate pool)
469+
survivingRelays := make([]RelayState, 0, numRelays-1)
470+
for _, r := range relays {
471+
if r.Descriptor.APIHTTPSAddr != targetPrimary {
472+
survivingRelays = append(survivingRelays, r)
473+
}
474+
}
475+
476+
// Measure replacement distribution for displaced clients
477+
replacementCounts := make(map[string]int)
478+
for _, clientAddr := range targetClients {
479+
ranked := RankRelayPool(survivingRelays, clientAddr, 0)
480+
replacement := ranked[0]
481+
replacementCounts[replacement]++
482+
}
483+
484+
// Anti-cascade Invariant 1: Displaced clients MUST NOT collapse onto a single secondary
485+
if len(replacementCounts) <= 1 {
486+
t.Fatalf("N=%d: All displaced clients collapsed onto a single replacement: %v", numRelays, replacementCounts)
487+
}
488+
489+
// Anti-cascade Invariant 2: No single surviving relay should absorb an overwhelming monopoly (>50%)
490+
for repl, count := range replacementCounts {
491+
fraction := float64(count) / float64(len(targetClients))
492+
if fraction > 0.50 {
493+
t.Fatalf("N=%d: Replacement relay %s absorbed %.1f%% (>50%%) of displaced traffic: %v", numRelays, repl, fraction*100, replacementCounts)
494+
}
495+
}
416496
}
417497

418-
loserIdle := loserState
419-
loserIdle.LoadFactor = 0.10
420-
loserIdle.EWMALoad = 0.10
498+
t.Run("PrimeOrder_N7", func(t *testing.T) {
499+
testDispersion(t, 7)
500+
})
501+
502+
t.Run("EvenOrder_N8", func(t *testing.T) {
503+
testDispersion(t, 8)
504+
})
421505

422-
relaysLoaded := []RelayState{winnerOverloaded, loserIdle}
423-
// Under MaxActiveRelays = 1, P2C swap MUST replace the active-set member from winner to loser
424-
newPicks := SelectPriority(relaysLoaded, RouteState{
425-
MaxActiveRelays: 1,
426-
LocalAddress: "client-addr",
506+
t.Run("EvenOrder_N6", func(t *testing.T) {
507+
testDispersion(t, 6)
427508
})
428-
if len(newPicks) != 1 {
429-
t.Fatalf("expected 1 pick, got %d", len(newPicks))
430-
}
431-
if newPicks[0] == initialWinner {
432-
t.Fatalf("P2C failed to change active set membership: overloaded relay %s remained active", initialWinner)
433-
}
434-
if newPicks[0] != initialLoser {
435-
t.Fatalf("expected initial loser %s to take the active slot, got %s", initialLoser, newPicks[0])
436-
}
437509
}
438510

439511
func TestMOLSConcurrentRefreshAndFailureLifecycle(t *testing.T) {

0 commit comments

Comments
 (0)