Skip to content

Commit f797df3

Browse files
committed
discovery: enhance MOLS selection with tiered policy, epoch salt, and virtual latency
1 parent 884f086 commit f797df3

5 files changed

Lines changed: 241 additions & 54 deletions

File tree

cmd/portal-loadtest/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func main() {
6464
picks := make(map[string]int, *relays) // relay URL → count of clients that picked it first
6565
for i := 0; i < *clients; i++ {
6666
localAddr := fmt.Sprintf("synthetic-client-%d", i)
67-
outputURLs := discovery.RankRelayPool(relayStates, localAddr)
67+
outputURLs := discovery.RankRelayPool(relayStates, localAddr, 0)
6868
if len(outputURLs) == 0 {
6969
// All relays were filtered; skip this client.
7070
continue

portal/discovery/mols.go

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"cmp"
2222
"math"
2323
"slices"
24+
"strconv"
2425
"time"
2526
)
2627

@@ -31,7 +32,7 @@ const (
3132
molsVariantM2 uint8 = 11
3233

3334
molsCongestionRTTThreshold = 500 * time.Millisecond
34-
molsCVThreshold = 0.5
35+
molsCVThreshold = 0.6
3536
molsFallbackRTTThreshold = 2 * time.Second
3637
molsMinActiveNodes = 2
3738
defaultMaxActiveRelays = 3
@@ -132,7 +133,7 @@ func molsRTTStats(states []RelayState) (mean time.Duration, cv float64) {
132133
continue
133134
}
134135
count++
135-
sum += float64(state.DiscoveryRTT)
136+
sum += float64(state.effectiveRTT())
136137
}
137138
if count == 0 {
138139
return 0, 0
@@ -146,7 +147,7 @@ func molsRTTStats(states []RelayState) (mean time.Duration, cv float64) {
146147
if state.DiscoveryRTTAt.IsZero() {
147148
continue
148149
}
149-
d := float64(state.DiscoveryRTT) - avg
150+
d := float64(state.effectiveRTT()) - avg
150151
sq += d * d
151152
}
152153
stddev := math.Sqrt(sq / float64(count))
@@ -157,7 +158,7 @@ func molsRTTStats(states []RelayState) (mean time.Duration, cv float64) {
157158
}
158159

159160
func isRelayFallback(state RelayState) bool {
160-
return !state.DiscoveryRTTAt.IsZero() && state.DiscoveryRTT > molsFallbackRTTThreshold
161+
return !state.DiscoveryRTTAt.IsZero() && state.effectiveRTT() > molsFallbackRTTThreshold
161162
}
162163

163164
type molsCandidate struct {
@@ -201,9 +202,9 @@ func selectConfirmed(states []RelayState) []RelayState {
201202
return out
202203
}
203204

204-
// RankRelayPool ranks the autoPool of relay states using MOLS selection for the given local address.
205+
// RankRelayPool ranks the autoPool of relay states using MOLS selection for the given local address and epoch.
205206
// The returned slice contains relay URLs ordered by MOLS-derived priority with saturation partitioning.
206-
func RankRelayPool(autoPool []RelayState, localAddress string) []string {
207+
func RankRelayPool(autoPool []RelayState, localAddress string, epoch uint64) []string {
207208
if len(autoPool) == 0 {
208209
return nil
209210
}
@@ -214,7 +215,11 @@ func RankRelayPool(autoPool []RelayState, localAddress string) []string {
214215

215216
order := len(autoPool)
216217
m1, m2, _ := molsMultipliers(order, nonLinear)
217-
ingressRow := int(hashToGridIndex(localAddress) % uint32(order))
218+
ingressKey := localAddress
219+
if epoch > 0 {
220+
ingressKey = localAddress + "#" + strconv.FormatUint(epoch, 10)
221+
}
222+
ingressRow := int(hashToGridIndex(ingressKey) % uint32(order))
218223

219224
type relayHash struct {
220225
url string
@@ -259,10 +264,12 @@ func RankRelayPool(autoPool []RelayState, localAddress string) []string {
259264

260265
if len(activeStates) < molsMinActiveNodes && len(fallbackStates) > 0 {
261266
slices.SortFunc(fallbackStates, func(a, b RelayState) int {
262-
if a.DiscoveryRTT < b.DiscoveryRTT {
267+
aRTT := a.effectiveRTT()
268+
bRTT := b.effectiveRTT()
269+
if aRTT < bRTT {
263270
return -1
264271
}
265-
if a.DiscoveryRTT > b.DiscoveryRTT {
272+
if aRTT > bRTT {
266273
return 1
267274
}
268275
return 0
@@ -305,14 +312,27 @@ func RankRelayPool(autoPool []RelayState, localAddress string) []string {
305312
}
306313
}
307314

308-
// P2C pressure optimization: If candidate 0 has significantly higher
309-
// pressure than candidate 1, swap them to relieve node pressure without herd effect.
315+
// Pressure-aware partitioning: Candidates with significantly elevated pressure
316+
// (pressure difference > molsP2CPressureDelta compared to minimum pressure) are
317+
// demoted behind low-pressure candidates so they are pushed outside the MaxActiveRelays
318+
// quota, achieving real active-set membership migration.
310319
if len(nonSaturated) >= 2 {
311-
p0 := nonSaturated[0].state.Pressure()
312-
p1 := nonSaturated[1].state.Pressure()
313-
if p0-p1 > molsP2CPressureDelta {
314-
nonSaturated[0], nonSaturated[1] = nonSaturated[1], nonSaturated[0]
320+
minPressure := nonSaturated[0].state.Pressure()
321+
for _, c := range nonSaturated[1:] {
322+
if p := c.state.Pressure(); p < minPressure {
323+
minPressure = p
324+
}
315325
}
326+
var lowPressure []molsCandidate
327+
var highPressure []molsCandidate
328+
for _, c := range nonSaturated {
329+
if c.state.Pressure()-minPressure > molsP2CPressureDelta {
330+
highPressure = append(highPressure, c)
331+
} else {
332+
lowPressure = append(lowPressure, c)
333+
}
334+
}
335+
nonSaturated = append(lowPressure, highPressure...)
316336
}
317337

318338
tierOut := make([]string, 0, len(candidates))
@@ -347,29 +367,55 @@ func SelectPriority(states []RelayState, routeState RouteState) []string {
347367
}
348368
explicit = append(explicit, relayURL)
349369
}
350-
auto := RankRelayPool(filterCandidatePool(states, routeState, now, false), routeState.LocalAddress)
370+
auto := RankRelayPool(filterCandidatePool(states, routeState, now, false), routeState.LocalAddress, routeState.SelectionEpoch)
351371
maxActive := routeState.MaxActiveRelays
352372
if maxActive <= 0 {
353373
maxActive = defaultMaxActiveRelays
354374
}
355-
auto = applyActiveStickiness(auto, routeState.ActiveRelayURLs, maxActive)
375+
auto = applyActiveStickiness(auto, routeState.ActiveRelayURLs, states, maxActive)
376+
if len(auto) > maxActive {
377+
auto = auto[:maxActive]
378+
}
356379
return append(explicit, auto...)
357380
}
358381

359-
// applyActiveStickiness preserves currently active relays that remain in the
360-
// ranked eligible pool to avoid connection churn.
361-
func applyActiveStickiness(ranked []string, activeRelayURLs []string, maxActive int) []string {
362-
if len(activeRelayURLs) == 0 || len(ranked) <= maxActive {
363-
if len(ranked) > maxActive {
364-
return ranked[:maxActive]
365-
}
382+
// applyActiveStickiness reorders the ranked candidates so that eligible healthy sticky
383+
// relays occupy the first maxActive positions without dropping remaining pool candidates.
384+
//
385+
// Priority cascade:
386+
//
387+
// Layer 1: Sticky relays — preserves currently active connections ONLY if they remain in
388+
// the healthy tier (not saturated and not in fallback) to avoid zombie resurrection.
389+
// Layer 2: Warm healthy candidates — fills remaining slots using top-ranked MOLS candidates.
390+
// Trailing: Remaining ranked candidates are preserved to support multi-hop path building.
391+
func applyActiveStickiness(ranked []string, activeRelayURLs []string, states []RelayState, maxActive int) []string {
392+
if len(ranked) == 0 || maxActive <= 0 {
393+
return ranked
394+
}
395+
if len(activeRelayURLs) == 0 {
366396
return ranked
367397
}
398+
399+
stateMap := make(map[string]RelayState, len(states))
400+
for _, s := range states {
401+
stateMap[s.Descriptor.APIHTTPSAddr] = s
402+
}
403+
404+
// Stickiness is ONLY granted to healthy nodes: non-saturated and non-fallback.
405+
// Degraded/saturated nodes must migrate out rather than being resurrected.
368406
activeSet := make(map[string]struct{}, len(activeRelayURLs))
369407
for _, u := range activeRelayURLs {
370-
activeSet[u] = struct{}{}
408+
if s, ok := stateMap[u]; ok {
409+
s.EvaluateSaturation()
410+
if s.IsSaturated || isRelayFallback(s) {
411+
continue
412+
}
413+
activeSet[u] = struct{}{}
414+
}
371415
}
372-
selected := make([]string, 0, maxActive)
416+
417+
selected := make([]string, 0, len(ranked))
418+
// Layer 1: Retain currently active sticky relays that remain healthy (capped at maxActive)
373419
for _, u := range ranked {
374420
if _, isActive := activeSet[u]; isActive {
375421
selected = append(selected, u)
@@ -378,14 +424,10 @@ func applyActiveStickiness(ranked []string, activeRelayURLs []string, maxActive
378424
}
379425
}
380426
}
381-
if len(selected) < maxActive {
382-
for _, u := range ranked {
383-
if !slices.Contains(selected, u) {
384-
selected = append(selected, u)
385-
if len(selected) == maxActive {
386-
break
387-
}
388-
}
427+
// Layer 2 & Trailing: Append remaining candidates preserving their relative MOLS ranking
428+
for _, u := range ranked {
429+
if !slices.Contains(selected, u) {
430+
selected = append(selected, u)
389431
}
390432
}
391433
return selected

portal/discovery/mols_test.go

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ func TestMOLSP2CPressurePromotion(t *testing.T) {
145145
t.Fatalf("relayA pressure (%.2f) should exceed relayB pressure (%.2f) + delta (%.2f)",
146146
relayA.Pressure(), relayB.Pressure(), molsP2CPressureDelta)
147147
}
148+
149+
// Behavioral assertion: In SelectPriority with MaxActiveRelays=1, low-pressure relayB
150+
// must be promoted over high-pressure relayA regardless of initial MOLS order.
151+
selected := SelectPriority([]RelayState{relayA, relayB}, RouteState{MaxActiveRelays: 1})
152+
if len(selected) != 1 || selected[0] != "https://relay-b.example" {
153+
t.Fatalf("SelectPriority with pressure delta = %v, want [%q]", selected, "https://relay-b.example")
154+
}
148155
}
149156

150157
func TestMOLSSelectPriorityActiveStickiness(t *testing.T) {
@@ -174,6 +181,130 @@ func TestMOLSSelectPriorityActiveStickiness(t *testing.T) {
174181
}
175182
}
176183

184+
func TestMOLSSelectPriorityEpochRotation(t *testing.T) {
185+
relays := make([]RelayState, 10)
186+
for i := range relays {
187+
relays[i] = confirmedRelayState(t, fmt.Sprintf("https://relay-epoch-%d.example", i))
188+
}
189+
190+
routeStateEpoch0 := RouteState{LocalAddress: "192.168.1.50:5000", SelectionEpoch: 0}
191+
routeStateEpoch1 := RouteState{LocalAddress: "192.168.1.50:5000", SelectionEpoch: 1}
192+
193+
rank0a := SelectPriority(relays, routeStateEpoch0)
194+
rank0b := SelectPriority(relays, routeStateEpoch0)
195+
// Deterministic for same epoch
196+
if !slices.Equal(rank0a, rank0b) {
197+
t.Fatalf("rank0a != rank0b: %v vs %v", rank0a, rank0b)
198+
}
199+
200+
rank1 := SelectPriority(relays, routeStateEpoch1)
201+
// Rotation should yield a different primary ranking for non-trivial pool
202+
if slices.Equal(rank0a, rank1) {
203+
t.Fatalf("rank1 should differ from rank0, got identical %v", rank1)
204+
}
205+
}
206+
207+
func TestMOLSVirtualLatencyPenalty(t *testing.T) {
208+
now := time.Now().UTC()
209+
relayA := confirmedRelayState(t, "https://relay-a.example")
210+
relayA.DiscoveryRTT = 50 * time.Millisecond
211+
relayA.DiscoveryRTTAt = now
212+
// 7 failures * 300ms = 2.1s penalty => EffectiveRTT = 2.15s (> 2s fallback threshold)
213+
relayA.activeFailures = 7
214+
215+
relayB := confirmedRelayState(t, "https://relay-b.example")
216+
relayB.DiscoveryRTT = 100 * time.Millisecond
217+
relayB.DiscoveryRTTAt = now
218+
219+
relayC := confirmedRelayState(t, "https://relay-c.example")
220+
relayC.DiscoveryRTT = 120 * time.Millisecond
221+
relayC.DiscoveryRTTAt = now
222+
223+
relays := []RelayState{relayA, relayB, relayC}
224+
selected := SelectPriority(relays, RouteState{MaxActiveRelays: 2})
225+
226+
// relayA should be demoted to fallback tier due to virtual latency, so active picks should be B and C
227+
if slices.Contains(selected, "https://relay-a.example") {
228+
t.Fatalf("selected %v should not contain relayA in top 2 due to virtual latency penalty", selected)
229+
}
230+
if len(selected) != 2 {
231+
t.Fatalf("len(selected) = %d, want 2", len(selected))
232+
}
233+
}
234+
235+
func TestMOLSStickinessDoesNotResurrectSaturatedOrFallback(t *testing.T) {
236+
now := time.Now().UTC()
237+
activeSaturated := confirmedRelayState(t, "https://active-sat.example")
238+
activeSaturated.IsSaturated = true
239+
activeSaturated.LoadFactor = 0.95
240+
241+
activeFallback := confirmedRelayState(t, "https://active-fb.example")
242+
activeFallback.DiscoveryRTT = 3 * time.Second
243+
activeFallback.DiscoveryRTTAt = now
244+
245+
healthyA := confirmedRelayState(t, "https://healthy-a.example")
246+
healthyA.DiscoveryRTT = 50 * time.Millisecond
247+
healthyA.DiscoveryRTTAt = now
248+
249+
healthyB := confirmedRelayState(t, "https://healthy-b.example")
250+
healthyB.DiscoveryRTT = 60 * time.Millisecond
251+
healthyB.DiscoveryRTTAt = now
252+
253+
relays := []RelayState{activeSaturated, activeFallback, healthyA, healthyB}
254+
255+
// ActiveRelayURLs includes the saturated and fallback relays.
256+
// Stickiness MUST NOT resurrect them over the healthy candidates.
257+
selected := SelectPriority(relays, RouteState{
258+
ActiveRelayURLs: []string{"https://active-sat.example", "https://active-fb.example"},
259+
MaxActiveRelays: 2,
260+
})
261+
262+
if len(selected) != 2 {
263+
t.Fatalf("len(selected) = %d, want 2", len(selected))
264+
}
265+
for _, u := range selected {
266+
if u == "https://active-sat.example" || u == "https://active-fb.example" {
267+
t.Fatalf("selected %v should not contain demoted relays despite stickiness", selected)
268+
}
269+
}
270+
}
271+
272+
func TestMOLSMultiHopEntryStickiness(t *testing.T) {
273+
now := time.Now().UTC()
274+
set := NewRelaySet(nil)
275+
var activeEntry string
276+
for i := 0; i < 5; i++ {
277+
url := fmt.Sprintf("https://relay-mh-%d.example", i)
278+
st := confirmedRelayState(t, url)
279+
st.Descriptor.SupportsOverlay = true
280+
st.Descriptor.ExpiresAt = now.Add(time.Hour)
281+
st.Descriptor.WireGuardPublicKey = "wg-key"
282+
st.Descriptor.WireGuardPort = 51820
283+
st.LastSeenAt = now
284+
set.relays[url] = st
285+
if i == 4 {
286+
activeEntry = url
287+
}
288+
}
289+
290+
routes, err := set.PlanRoutes(nil, RouteState{
291+
ActiveRelayURLs: []string{activeEntry},
292+
MultiHopDepth: 2,
293+
MaxActiveRelays: 2,
294+
LocalAddress: "client",
295+
})
296+
if err != nil {
297+
t.Fatalf("PlanRoutes failed: %v", err)
298+
}
299+
if len(routes) == 0 {
300+
t.Fatalf("routes is empty")
301+
}
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)
305+
}
306+
}
307+
177308
func BenchmarkMOLSRankRelayPool(b *testing.B) {
178309
localAddr := "test-client-address"
179310
relays := make([]RelayState, 100)
@@ -188,7 +319,7 @@ func BenchmarkMOLSRankRelayPool(b *testing.B) {
188319

189320
b.ResetTimer()
190321
for i := 0; i < b.N; i++ {
191-
RankRelayPool(relays, localAddr)
322+
RankRelayPool(relays, localAddr, 0)
192323
}
193324
}
194325

portal/discovery/relayset.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -481,20 +481,18 @@ func (s *RelaySet) PlanRoutes(explicitPath []string, routeState RouteState) ([]R
481481
}
482482
}
483483

484-
ranked := RankRelayPool(filterCandidatePool(states, routeState, now, routeState.MultiHopDepth > 1), routeState.LocalAddress)
485-
if routeState.MultiHopDepth > 1 {
486-
maxActive := routeState.MaxActiveRelays
487-
if maxActive <= 0 {
488-
maxActive = defaultMaxActiveRelays
489-
}
490-
return buildMOLSPaths(ranked, routeState.MultiHopDepth, maxActive)
491-
}
492-
484+
ranked := RankRelayPool(filterCandidatePool(states, routeState, now, routeState.MultiHopDepth > 1), routeState.LocalAddress, routeState.SelectionEpoch)
493485
maxActive := routeState.MaxActiveRelays
494486
if maxActive <= 0 {
495487
maxActive = defaultMaxActiveRelays
496488
}
497-
ranked = applyActiveStickiness(ranked, routeState.ActiveRelayURLs, maxActive)
489+
ranked = applyActiveStickiness(ranked, routeState.ActiveRelayURLs, states, maxActive)
490+
if routeState.MultiHopDepth > 1 {
491+
return buildMOLSPaths(ranked, routeState.MultiHopDepth, maxActive)
492+
}
493+
if len(ranked) > maxActive {
494+
ranked = ranked[:maxActive]
495+
}
498496
routes := make([]Route, 0, len(ranked)+len(routeState.ExplicitRelayURLs))
499497
for _, relayURL := range routeState.ExplicitRelayURLs {
500498
eligible := true

0 commit comments

Comments
 (0)