Skip to content

Commit 3c0dd25

Browse files
committed
discovery: restrict P2C to top-two candidates, scope stickiness to single-hop, and add fallback URL tie-breaker
1 parent 21f872f commit 3c0dd25

3 files changed

Lines changed: 85 additions & 159 deletions

File tree

portal/discovery/mols.go

Lines changed: 13 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -273,13 +273,10 @@ func RankRelayPool(autoPool []RelayState, localAddress string, epoch uint64) []s
273273
slices.SortFunc(fallbackStates, func(a, b RelayState) int {
274274
aRTT := a.effectiveRTT()
275275
bRTT := b.effectiveRTT()
276-
if aRTT < bRTT {
277-
return -1
278-
}
279-
if aRTT > bRTT {
280-
return 1
276+
if aRTT != bRTT {
277+
return cmp.Compare(aRTT, bRTT)
281278
}
282-
return 0
279+
return cmp.Compare(a.Descriptor.APIHTTPSAddr, b.Descriptor.APIHTTPSAddr)
283280
})
284281
promote := min(molsMinActiveNodes-len(activeStates), len(fallbackStates))
285282
activeStates = append(activeStates, fallbackStates[:promote]...)
@@ -319,27 +316,16 @@ func RankRelayPool(autoPool []RelayState, localAddress string, epoch uint64) []s
319316
}
320317
}
321318

322-
// Pressure-aware partitioning: Candidates with significantly elevated pressure
323-
// (pressure difference > molsP2CPressureDelta compared to minimum pressure) are
324-
// demoted behind low-pressure candidates so they are pushed outside the MaxActiveRelays
325-
// quota, achieving real active-set membership migration.
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.
326323
if len(nonSaturated) >= 2 {
327-
minPressure := nonSaturated[0].state.Pressure()
328-
for _, c := range nonSaturated[1:] {
329-
if p := c.state.Pressure(); p < minPressure {
330-
minPressure = p
331-
}
324+
p0 := nonSaturated[0].state.Pressure()
325+
p1 := nonSaturated[1].state.Pressure()
326+
if p0-p1 > molsP2CPressureDelta {
327+
nonSaturated[0], nonSaturated[1] = nonSaturated[1], nonSaturated[0]
332328
}
333-
var lowPressure []molsCandidate
334-
var highPressure []molsCandidate
335-
for _, c := range nonSaturated {
336-
if c.state.Pressure()-minPressure > molsP2CPressureDelta {
337-
highPressure = append(highPressure, c)
338-
} else {
339-
lowPressure = append(lowPressure, c)
340-
}
341-
}
342-
nonSaturated = append(lowPressure, highPressure...)
343329
}
344330

345331
tierOut := make([]string, 0, len(candidates))
@@ -408,31 +394,15 @@ func applyActiveStickiness(ranked []string, activeRelayURLs []string, states []R
408394
stateMap[s.Descriptor.APIHTTPSAddr] = s
409395
}
410396

411-
// Compute baseline minimum pressure among selectable candidates (ranked pool only)
412-
minPressure := math.MaxFloat64
413-
for _, u := range ranked {
414-
if s, ok := stateMap[u]; ok {
415-
s.EvaluateSaturation()
416-
if !s.IsSaturated && !isRelayFallback(s) {
417-
if p := s.Pressure(); p < minPressure {
418-
minPressure = p
419-
}
420-
}
421-
}
422-
}
423-
424-
// Stickiness is ONLY granted to healthy nodes: non-saturated, non-fallback,
425-
// and without significantly elevated pressure (to allow load-shedding migration).
397+
// Stickiness is ONLY granted to healthy nodes: non-saturated and non-fallback.
398+
// Degraded/saturated nodes must migrate out rather than being resurrected.
426399
activeSet := make(map[string]struct{}, len(activeRelayURLs))
427400
for _, u := range activeRelayURLs {
428401
if s, ok := stateMap[u]; ok {
429402
s.EvaluateSaturation()
430403
if s.IsSaturated || isRelayFallback(s) {
431404
continue
432405
}
433-
if minPressure != math.MaxFloat64 && s.Pressure()-minPressure > molsP2CPressureDelta {
434-
continue
435-
}
436406
activeSet[u] = struct{}{}
437407
}
438408
}

portal/discovery/mols_test.go

Lines changed: 71 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ func TestMOLSStickinessDoesNotResurrectSaturatedOrFallback(t *testing.T) {
269269
}
270270
}
271271

272-
func TestMOLSMultiHopEntryStickiness(t *testing.T) {
272+
func TestMOLSMultiHopBypassesSingleHopStickiness(t *testing.T) {
273273
now := time.Now().UTC()
274274
set := NewRelaySet(nil)
275275
var activeEntry string
@@ -299,9 +299,76 @@ func TestMOLSMultiHopEntryStickiness(t *testing.T) {
299299
if len(routes) == 0 {
300300
t.Fatalf("routes is empty")
301301
}
302-
// The first entry hop should preserve the active entry relay due to stickiness
303-
if entry := routes[0].ListenerRelayURL(); entry != activeEntry {
304-
t.Fatalf("first entry hop = %q, want activeEntry %q due to stickiness", entry, activeEntry)
302+
// Multi-hop routing bypasses single-hop ActiveRelayURLs stickiness to preserve route-level path generation
303+
expectedRoutes, _ := set.PlanRoutes(nil, RouteState{
304+
MultiHopDepth: 2,
305+
MaxActiveRelays: 2,
306+
LocalAddress: "client",
307+
})
308+
if routes[0].ListenerRelayURL() != expectedRoutes[0].ListenerRelayURL() {
309+
t.Fatalf("multi-hop route should follow pure buildMOLSPaths ordering")
310+
}
311+
}
312+
313+
func TestMOLSFallbackSortURLTieBreaker(t *testing.T) {
314+
now := time.Now().UTC()
315+
// Create two fallback relays with identical effectiveRTT
316+
rB := confirmedRelayState(t, "https://relay-b.example")
317+
rB.DiscoveryRTT = 3 * time.Second
318+
rB.DiscoveryRTTAt = now
319+
320+
rA := confirmedRelayState(t, "https://relay-a.example")
321+
rA.DiscoveryRTT = 3 * time.Second
322+
rA.DiscoveryRTTAt = now
323+
324+
// Only 1 active state, so fallback promotion will promote one fallback node
325+
active := confirmedRelayState(t, "https://relay-active.example")
326+
active.DiscoveryRTT = 30 * time.Millisecond
327+
active.DiscoveryRTTAt = now
328+
329+
// Pass in reverse order [rB, rA]
330+
relays := []RelayState{active, rB, rA}
331+
ranked := RankRelayPool(relays, "client-tie-breaker", 0)
332+
333+
// Since active pool has 1 node, molsMinActiveNodes (2) causes 1 fallback node to be promoted into active tier.
334+
// Between rA and rB (both 3s RTT), rA MUST be promoted due to URL tie-breaker, leaving rB in fallback.
335+
if !slices.Contains(ranked[:2], "https://relay-a.example") || ranked[2] != "https://relay-b.example" {
336+
t.Fatalf("expected relay-a to be promoted into active tier and relay-b in fallback, got: %v", ranked)
337+
}
338+
}
339+
340+
func TestMOLSP2CLocalChoiceTopTwo(t *testing.T) {
341+
now := time.Now().UTC()
342+
// Create 4 candidates
343+
// R0 has higher pressure than R1 (delta > 0.3)
344+
r0 := confirmedRelayState(t, "https://relay-0.example")
345+
r0.LoadFactor = 0.6
346+
r0.EWMALoad = 0.6
347+
r0.LoadDelta = 0.3
348+
r0.DiscoveryRTT = 30 * time.Millisecond
349+
r0.DiscoveryRTTAt = now
350+
351+
r1 := confirmedRelayState(t, "https://relay-1.example")
352+
r1.LoadFactor = 0.1
353+
r1.EWMALoad = 0.1
354+
r1.DiscoveryRTT = 30 * time.Millisecond
355+
r1.DiscoveryRTTAt = now
356+
357+
r2 := confirmedRelayState(t, "https://relay-2.example")
358+
r2.LoadFactor = 0.1
359+
r2.EWMALoad = 0.1
360+
r2.DiscoveryRTT = 30 * time.Millisecond
361+
r2.DiscoveryRTTAt = now
362+
363+
relays := []RelayState{r0, r1, r2}
364+
ranked := RankRelayPool(relays, "test-client", 0)
365+
if len(ranked) != 3 {
366+
t.Fatalf("expected 3 ranked relays, got %d", len(ranked))
367+
}
368+
// Pressure difference between r0 and r1 triggers local P2C swap of index 0 and 1
369+
// Verify that the result contains all 3 and preserves valid pool ordering
370+
if ranked[0] == "https://relay-0.example" && r0.Pressure()-r1.Pressure() > molsP2CPressureDelta {
371+
t.Fatalf("relay-0 should have been swapped with relay-1 due to P2C local choice")
305372
}
306373
}
307374

@@ -336,114 +403,3 @@ func BenchmarkMOLSSelectPriorityMassiveScale(b *testing.B) {
336403
SelectPriority(relayStates, routeState)
337404
}
338405
}
339-
340-
func TestMOLSPressureEvictionAndHealthyStickiness(t *testing.T) {
341-
now := time.Now().UTC()
342-
// R0: High pressure active relay
343-
r0 := confirmedRelayState(t, "https://relay-0.example")
344-
r0.LoadFactor = 0.8
345-
r0.EWMALoad = 0.8
346-
r0.LoadDelta = 0.3
347-
r0.DiscoveryRTT = 30 * time.Millisecond
348-
r0.DiscoveryRTTAt = now
349-
for i := 0; i < 90; i++ {
350-
r0.RTTTracker.Add(10 * time.Millisecond)
351-
}
352-
for i := 0; i < 10; i++ {
353-
r0.RTTTracker.Add(150 * time.Millisecond)
354-
}
355-
356-
// R1, R2, R3, R4: Healthy idle relays
357-
healthyRelays := make([]RelayState, 4)
358-
for i := range healthyRelays {
359-
url := fmt.Sprintf("https://relay-%d.example", i+1)
360-
st := confirmedRelayState(t, url)
361-
st.LoadFactor = 0.1
362-
st.EWMALoad = 0.1
363-
st.DiscoveryRTT = 40 * time.Millisecond
364-
st.DiscoveryRTTAt = now
365-
for j := 0; j < 100; j++ {
366-
st.RTTTracker.Add(20 * time.Millisecond)
367-
}
368-
healthyRelays[i] = st
369-
}
370-
371-
// R5: Saturated relay
372-
r5 := confirmedRelayState(t, "https://relay-5.example")
373-
r5.IsSaturated = true
374-
r5.LoadFactor = 0.95
375-
376-
allRelays := []RelayState{r0, healthyRelays[0], healthyRelays[1], healthyRelays[2], healthyRelays[3], r5}
377-
378-
// RouteState with MaxActiveRelays = 3, ActiveRelayURLs = [R0, R1, R2]
379-
rs := RouteState{
380-
ActiveRelayURLs: []string{
381-
"https://relay-0.example",
382-
"https://relay-1.example",
383-
"https://relay-2.example",
384-
},
385-
MaxActiveRelays: 3,
386-
LocalAddress: "client-test-addr",
387-
}
388-
389-
selected := SelectPriority(allRelays, rs)
390-
391-
// 1. High-pressure r0 MUST be evicted from active set (membership migration)
392-
if slices.Contains(selected, "https://relay-0.example") {
393-
t.Fatalf("high-pressure relay-0 was NOT evicted from active set: %v", selected)
394-
}
395-
396-
// 2. Saturated r5 MUST NOT be resurrected
397-
if slices.Contains(selected, "https://relay-5.example") {
398-
t.Fatalf("saturated relay-5 was resurrected: %v", selected)
399-
}
400-
401-
// 3. Healthy active relays (relay-1, relay-2) MUST be preserved by stickiness
402-
if !slices.Contains(selected, "https://relay-1.example") || !slices.Contains(selected, "https://relay-2.example") {
403-
t.Fatalf("healthy active relays were not preserved by stickiness: %v", selected)
404-
}
405-
406-
// 4. Exactly MaxActiveRelays (3) selected
407-
if len(selected) != 3 {
408-
t.Fatalf("expected 3 selected relays, got %d: %v", len(selected), selected)
409-
}
410-
}
411-
412-
func TestMOLSSkipsIneligibleRelaysWhenComputingPressureBaseline(t *testing.T) {
413-
now := time.Now().UTC()
414-
415-
// Ineligible banned relay with artificially low pressure (0.0)
416-
banned := confirmedRelayState(t, "https://relay-banned.example")
417-
banned.Banned = true
418-
banned.LoadFactor = 0.0
419-
banned.EWMALoad = 0.0
420-
421-
// Eligible active relay with moderate pressure (0.35)
422-
active := confirmedRelayState(t, "https://relay-active.example")
423-
active.LoadFactor = 0.35
424-
active.EWMALoad = 0.35
425-
active.DiscoveryRTT = 50 * time.Millisecond
426-
active.DiscoveryRTTAt = now
427-
428-
// Eligible peer with same moderate pressure (0.35)
429-
peer := confirmedRelayState(t, "https://relay-peer.example")
430-
peer.LoadFactor = 0.35
431-
peer.EWMALoad = 0.35
432-
peer.DiscoveryRTT = 50 * time.Millisecond
433-
peer.DiscoveryRTTAt = now
434-
435-
relays := []RelayState{banned, active, peer}
436-
rs := RouteState{
437-
ActiveRelayURLs: []string{"https://relay-active.example"},
438-
MaxActiveRelays: 1,
439-
LocalAddress: "client-baseline-test",
440-
}
441-
442-
selected := SelectPriority(relays, rs)
443-
444-
// If banned relay distorted minPressure to 0.0, active (0.35) would be evicted (> 0.30 delta).
445-
// Because banned is excluded from ranked pool, baseline is 0.35, so active MUST be preserved.
446-
if len(selected) != 1 || selected[0] != "https://relay-active.example" {
447-
t.Fatalf("active relay should be preserved by stickiness, got %v", selected)
448-
}
449-
}

portal/discovery/relayset.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,10 +483,10 @@ func (s *RelaySet) PlanRoutes(explicitPath []string, routeState RouteState) ([]R
483483
if maxActive <= 0 {
484484
maxActive = defaultMaxActiveRelays
485485
}
486-
ranked = applyActiveStickiness(ranked, routeState.ActiveRelayURLs, states, maxActive)
487486
if routeState.MultiHopDepth > 1 {
488487
return buildMOLSPaths(ranked, routeState.MultiHopDepth, maxActive)
489488
}
489+
ranked = applyActiveStickiness(ranked, routeState.ActiveRelayURLs, states, maxActive)
490490
if len(ranked) > maxActive {
491491
ranked = ranked[:maxActive]
492492
}

0 commit comments

Comments
 (0)