Skip to content

Commit d0bd2ba

Browse files
committed
discovery: canonize policy hierarchy, prove P2C active-set membership change, and verify concurrent lifecycle
1 parent 3c0dd25 commit d0bd2ba

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

portal/discovery/mols.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,18 @@ func RankRelayPool(autoPool []RelayState, localAddress string, epoch uint64) []s
343343
return append(activeURLs, fallbackURLs...)
344344
}
345345

346+
// Selection Policy Hierarchy (Canonized Invariants):
347+
//
348+
// Stage 1 - Eligibility: Drop dead, banned, expired, or transport-mismatched relays (filterCandidatePool).
349+
// Stage 2 - Hard Health Gate: Partition relays into Active vs Fallback tiers (effectiveRTT > 2s).
350+
// Saturated relays are demoted behind all non-saturated candidates within each tier.
351+
// Stage 3 - P2C Pressure Choice: Local comparison between candidate 0 and 1 in the active tier.
352+
// If p0 - p1 > molsP2CPressureDelta, swap 0 and 1 to balance surging queues.
353+
// Stage 4 - Asymmetric Stickiness: Retain currently active listener connections ONLY if they remain in the healthy tier
354+
// (non-saturated and non-fallback). Saturated or failing relays are strictly evicted without zombie resurrection.
355+
// Stage 5 - Deterministic MOLS Geometry: Structural Latin-square spreading acts as the underlying anchor,
356+
// with SelectionEpoch salt providing deterministic rotation across connection retry cycles.
357+
//
346358
// SelectPriority returns the ordered relay URLs for a client using MOLS selection with explicit relays prepended.
347359
func SelectPriority(states []RelayState, routeState RouteState) []string {
348360
if len(states) == 0 {

portal/discovery/mols_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package discovery
33
import (
44
"fmt"
55
"slices"
6+
"sync"
67
"testing"
78
"time"
89

@@ -372,6 +373,133 @@ func TestMOLSP2CLocalChoiceTopTwo(t *testing.T) {
372373
}
373374
}
374375

376+
func TestMOLSP2CActiveSetMembershipChange(t *testing.T) {
377+
now := time.Now().UTC()
378+
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
383+
384+
r1 := confirmedRelayState(t, "https://relay-1.example")
385+
r1.DiscoveryRTT = 30 * time.Millisecond
386+
r1.DiscoveryRTTAt = now
387+
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)
413+
}
414+
for i := 0; i < 10; i++ {
415+
winnerOverloaded.RTTTracker.Add(150 * time.Millisecond)
416+
}
417+
418+
loserIdle := loserState
419+
loserIdle.LoadFactor = 0.10
420+
loserIdle.EWMALoad = 0.10
421+
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",
427+
})
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+
}
437+
}
438+
439+
func TestMOLSConcurrentRefreshAndFailureLifecycle(t *testing.T) {
440+
now := time.Now().UTC()
441+
set := NewRelaySet(nil)
442+
const numRelays = 6
443+
444+
for i := 0; i < numRelays; i++ {
445+
u := fmt.Sprintf("https://relay-conc-%d.example", i)
446+
st := confirmedRelayState(t, u)
447+
st.Descriptor.SupportsOverlay = true
448+
st.Descriptor.WireGuardPublicKey = fmt.Sprintf("wg-key-%d", i)
449+
st.Descriptor.WireGuardPort = 51820
450+
st.Descriptor.ExpiresAt = now.Add(time.Hour)
451+
st.LastSeenAt = now
452+
set.relays[u] = st
453+
}
454+
455+
failingRelay := "https://relay-conc-0.example"
456+
var wg sync.WaitGroup
457+
458+
// Concurrently plan routes (multi-hop and single-hop)
459+
for i := 0; i < 5; i++ {
460+
wg.Add(1)
461+
go func(id int) {
462+
defer wg.Done()
463+
for j := 0; j < 50; j++ {
464+
routes, err := set.PlanRoutes(nil, RouteState{
465+
MultiHopDepth: 2,
466+
MaxActiveRelays: 2,
467+
LocalAddress: fmt.Sprintf("client-%d-%d", id, j),
468+
})
469+
if err == nil && len(routes) > 0 {
470+
_ = routes[0].ListenerRelayURL()
471+
}
472+
}
473+
}(i)
474+
}
475+
476+
// Concurrently record failures on the failing relay
477+
wg.Add(1)
478+
go func() {
479+
defer wg.Done()
480+
for i := 0; i < 10; i++ {
481+
set.RecordActiveFailure(failingRelay, 0)
482+
}
483+
}()
484+
485+
wg.Wait()
486+
487+
// After 10 consecutive failures, failingRelay should have accumulated significant
488+
// virtual latency penalty and should be demoted, not appearing in top active routes.
489+
routes, err := set.PlanRoutes(nil, RouteState{
490+
MaxActiveRelays: 2,
491+
LocalAddress: "client-verify",
492+
})
493+
if err != nil {
494+
t.Fatalf("PlanRoutes failed: %v", err)
495+
}
496+
for _, r := range routes {
497+
if r.ListenerRelayURL() == failingRelay {
498+
t.Fatalf("failing relay %s should not be active after repeated failures", failingRelay)
499+
}
500+
}
501+
}
502+
375503
func BenchmarkMOLSRankRelayPool(b *testing.B) {
376504
localAddr := "test-client-address"
377505
relays := make([]RelayState, 100)

0 commit comments

Comments
 (0)