From a12531fd21c6cc3ae66ad7d719ef1a12f6026f75 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:32:41 -0500 Subject: [PATCH 1/2] feat(routing-policy): size the adaptive forward mux on upload (bidirectional sizing) The adaptive preset's on_tick controller sized only the reverse (download) direction: it read RecvBytes deltas and widened/collapsed the mux under sustained download load, while the forward (upload) width was pinned at one leg. SentBytes was never read, so an upload-heavy flow could not grow the send mux. Add a forward controller that mirrors the reverse one, driven by SentBytes: the same EWMA smoothing, peak decay, saturated/idle thresholds, hysteresis and cooldown constants (reused, not duplicated). On sustained upload saturation it emits a new RotationAction.AddForwardLeg that the router actuates forward-only (appendRouteAsymmetric addFwd=true/addRev=false, via addOneAuxSendLeg), so the extra send capacity does not enlarge the reverse/download set; it collapses back to the lean single forward leg when the upload goes idle. A combined desiredActive target folds the forward growth in so the reverse park/drop-recovery rules never tear down a forward-grown leg. When SentBytes is flat the whole forward machine stays dormant, so a download-only or idle flow evolves byte-identically to before. Rebuilt the preset wasm bundle so native and wazero make identical decisions; extended the parity harness with an upload sequence and added engine tests for upload-widen / download-regression / idle-collapse. --- .../routing-policies/wasm/bundle/main.go | 2 + pkg/router/dial_hook.go | 8 + pkg/router/policy/bridge.go | 5 +- pkg/router/policy/hook.go | 1 + pkg/router/policy/preset/preset.go | 5 + pkg/router/policy/preset/preset_test.go | 171 ++++++++++++++++++ pkg/router/policy/preset/tick.go | 139 +++++++++++++- pkg/router/policy/presethook/presethook.go | 1 + pkg/router/policy/types.go | 5 + pkg/router/policy/wasm/abi.go | 1 + pkg/router/policy/wasm/evaluator.go | 1 + pkg/router/policy/wasm/presets/bundle.wasm | Bin 564358 -> 566284 bytes pkg/router/policy/wasm/presets/parity_test.go | 24 ++- pkg/router/route_group.go | 28 ++- pkg/router/router_dial.go | 70 +++++-- 15 files changed, 431 insertions(+), 30 deletions(-) diff --git a/docs/examples/routing-policies/wasm/bundle/main.go b/docs/examples/routing-policies/wasm/bundle/main.go index a13863fb25..c236f49ce2 100644 --- a/docs/examples/routing-policies/wasm/bundle/main.go +++ b/docs/examples/routing-policies/wasm/bundle/main.go @@ -108,6 +108,7 @@ type rotationActionWire struct { ExcludeHops []string `json:"exclude_hops,omitempty"` DemoteToStandby []int `json:"demote_to_standby,omitempty"` PromoteFromStandby []int `json:"promote_from_standby,omitempty"` + AddForwardLeg bool `json:"add_forward_leg,omitempty"` } // engine holds the adaptive tick controllers' per-transport_id state for this @@ -269,6 +270,7 @@ func actionToWire(a preset.RotationAction) rotationActionWire { ExcludeHops: a.ExcludeHops, DemoteToStandby: a.DemoteToStandby, PromoteFromStandby: a.PromoteFromStandby, + AddForwardLeg: a.AddForwardLeg, } } diff --git a/pkg/router/dial_hook.go b/pkg/router/dial_hook.go index 4bcc679a02..21481f68d1 100644 --- a/pkg/router/dial_hook.go +++ b/pkg/router/dial_hook.go @@ -352,6 +352,14 @@ type RotationAction struct { // valid. DemoteToStandby []int PromoteFromStandby []int + + // AddForwardLeg requests one more FORWARD-ONLY aux leg — appended + // addFwd=true / addRev=false so it adds upstream send capacity without + // enlarging the reverse/download set. The rotation loop dials it via the + // forward-only add callback (router.addOneAuxSendLeg). The forward- + // direction mirror of AddLeg; emitted by the adaptive preset under + // sustained upload (SentBytes) saturation. ExcludeHops applies to it too. + AddForwardLeg bool } // RotationHook fires periodically per active route group, giving diff --git a/pkg/router/policy/bridge.go b/pkg/router/policy/bridge.go index 33db906dcd..90ba518b22 100644 --- a/pkg/router/policy/bridge.go +++ b/pkg/router/policy/bridge.go @@ -403,8 +403,9 @@ func parseRotationAction(v starlark.Value) (RotationAction, error) { return RotationAction{}, fmt.Errorf("expected struct, got %s", v.Type()) } out := RotationAction{ - AddLeg: readBoolField(s, "add_leg"), - ExcludeHops: readStrListField(s, "exclude_hops"), + AddLeg: readBoolField(s, "add_leg"), + AddForwardLeg: readBoolField(s, "add_forward_leg"), + ExcludeHops: readStrListField(s, "exclude_hops"), } if drops, err := s.Attr("drop_legs"); err == nil && drops != nil { if _, isNone := drops.(starlark.NoneType); !isNone { diff --git a/pkg/router/policy/hook.go b/pkg/router/policy/hook.go index c5fe1afff4..e891408825 100644 --- a/pkg/router/policy/hook.go +++ b/pkg/router/policy/hook.go @@ -395,6 +395,7 @@ func (h *Hook) OnTick(info router.DialInfo, legs []router.LegInfo) router.Rotati ExcludeHops: append([]string(nil), action.ExcludeHops...), DemoteToStandby: append([]int(nil), action.DemoteToStandby...), PromoteFromStandby: append([]int(nil), action.PromoteFromStandby...), + AddForwardLeg: action.AddForwardLeg, } } diff --git a/pkg/router/policy/preset/preset.go b/pkg/router/policy/preset/preset.go index c7d7c8c211..5c98d61cf9 100644 --- a/pkg/router/policy/preset/preset.go +++ b/pkg/router/policy/preset/preset.go @@ -92,6 +92,11 @@ type RotationAction struct { ExcludeHops []string DemoteToStandby []int PromoteFromStandby []int + // AddForwardLeg requests one more FORWARD-ONLY aux leg (the router appends + // it addFwd=true / addRev=false) — extra upstream send capacity that does + // NOT enlarge the reverse/download set. The adaptive preset emits this on + // sustained upload saturation, the forward-direction mirror of AddLeg. + AddForwardLeg bool } // Decide dispatches to the named preset's decide logic. The name diff --git a/pkg/router/policy/preset/preset_test.go b/pkg/router/policy/preset/preset_test.go index 1e64cbaf1e..3fe6eed266 100644 --- a/pkg/router/policy/preset/preset_test.go +++ b/pkg/router/policy/preset/preset_test.go @@ -620,3 +620,174 @@ func TestEngine_OnTick_CoupledNoGrowUnderLoss(t *testing.T) { t.Errorf("coupled should shed the lossy leg (1) instead; got %+v", got) } } + +// --- adaptive BIDIRECTIONAL sizing (forward/upload widening) --- +// +// These exercise the SentBytes-driven forward controller added alongside the +// existing RecvBytes-driven reverse controller. adaptiveSim is a faithful +// mini-router: it feeds the engine a leg snapshot, applies the returned +// RotationAction to the leg set (append on AddLeg/AddForwardLeg, flip Standby on +// promote/demote, remove on drop), then advances the cumulative byte counters +// for the next tick — so the engine sees leg growth react to its own actions. +type adaptiveSim struct { + e *Engine + legs []LegInfo + nextTID int + sawAddFwd bool + sawAddLeg bool + sawPromote bool + sentPerTick uint64 // bytes added to each ACTIVE leg's SentBytes each tick + recvPerTick uint64 // bytes added to each ACTIVE leg's RecvBytes each tick +} + +func newAdaptiveSim(sentPerTick, recvPerTick uint64) *adaptiveSim { + s := &adaptiveSim{e: New(), sentPerTick: sentPerTick, recvPerTick: recvPerTick, nextTID: 1} + // Steady start: one active forward/primary leg (leg 0). No standby — the + // simplest shape that isolates the sizing controllers. + s.legs = []LegInfo{{Index: 0, TransportID: "t0", Kind: "stcpr", LatencyMs: 40, Alive: true}} + return s +} + +func (s *adaptiveSim) activeCount() int { + n := 0 + for _, l := range s.legs { + if l.Alive && !l.Standby { + n++ + } + } + return n +} + +func (s *adaptiveSim) reindex() { + for i := range s.legs { + s.legs[i].Index = i + } +} + +func (s *adaptiveSim) step() RotationAction { + act := s.e.OnTick("adaptive", s.legs) + if act.AddForwardLeg { + s.sawAddFwd = true + } + if act.AddLeg { + s.sawAddLeg = true + } + if len(act.PromoteFromStandby) > 0 { + s.sawPromote = true + } + // Apply promote/demote first (index-stable), then drops (compact), then adds. + for _, idx := range act.PromoteFromStandby { + if idx >= 0 && idx < len(s.legs) { + s.legs[idx].Standby = false + } + } + for _, idx := range act.DemoteToStandby { + if idx >= 0 && idx < len(s.legs) { + s.legs[idx].Standby = true + } + } + if len(act.DropLegs) > 0 { + drop := map[int]bool{} + for _, idx := range act.DropLegs { + drop[idx] = true + } + var kept []LegInfo + for i, l := range s.legs { + if !drop[i] { + kept = append(kept, l) + } + } + s.legs = kept + s.reindex() + } + if act.AddLeg || act.AddForwardLeg { + tid := "t" + string(rune('a'+s.nextTID)) + s.nextTID++ + s.legs = append(s.legs, LegInfo{Index: len(s.legs), TransportID: tid, Kind: "stcpr", LatencyMs: 40, Alive: true}) + } + // Advance cumulative counters on the ACTIVE legs for the next snapshot. + for i := range s.legs { + if s.legs[i].Alive && !s.legs[i].Standby { + s.legs[i].SentBytes += s.sentPerTick + s.legs[i].RecvBytes += s.recvPerTick + } + } + return act +} + +// TestEngine_OnTick_AdaptiveForwardWidensOnUpload asserts an upload-heavy flow +// (growing SentBytes, flat RecvBytes) widens the FORWARD mux via AddForwardLeg +// while the reverse controller stays completely lean (no AddLeg, reverse target +// unchanged at adaptRevActive). +func TestEngine_OnTick_AdaptiveForwardWidensOnUpload(t *testing.T) { + s := newAdaptiveSim(1_000_000, 0) // heavy upload, zero download + for i := 0; i < 20; i++ { + s.step() + } + if !s.sawAddFwd { + t.Fatalf("upload-heavy flow must widen forward via AddForwardLeg; never saw one") + } + if s.sawAddLeg { + t.Errorf("upload-heavy flow must NOT grow the reverse/full-duplex set (AddLeg)") + } + if s.e.adaptTarget != adaptRevActive { + t.Errorf("reverse target must stay lean at %d; got %d", adaptRevActive, s.e.adaptTarget) + } + if s.e.adaptFwdTarget <= adaptFwdActive { + t.Errorf("forward target must have widened above %d; got %d", adaptFwdActive, s.e.adaptFwdTarget) + } + if s.activeCount() <= 1 { + t.Errorf("forward widening must have added at least one active send leg; active=%d", s.activeCount()) + } +} + +// TestEngine_OnTick_AdaptiveReverseWidensOnDownload is the REGRESSION guard: a +// download-heavy flow (growing RecvBytes, flat SentBytes) must still widen the +// reverse set via AddLeg exactly as before, and must NOT trip the new forward +// controller (no AddForwardLeg; forward target stays at adaptFwdActive). +func TestEngine_OnTick_AdaptiveReverseWidensOnDownload(t *testing.T) { + s := newAdaptiveSim(0, 1_000_000) // zero upload, heavy download + for i := 0; i < 20; i++ { + s.step() + } + if !s.sawAddLeg && !s.sawPromote { + t.Fatalf("download-heavy flow must widen the reverse set (AddLeg/promote); saw neither") + } + if s.sawAddFwd { + t.Errorf("download-heavy flow must NOT trip the forward controller (AddForwardLeg)") + } + if s.e.adaptFwdTarget != adaptFwdActive { + t.Errorf("forward target must stay lean at %d on a download flow; got %d", adaptFwdActive, s.e.adaptFwdTarget) + } + if s.e.adaptTarget <= adaptRevActive { + t.Errorf("reverse target must have widened above %d; got %d", adaptRevActive, s.e.adaptTarget) + } +} + +// TestEngine_OnTick_AdaptiveForwardCollapsesOnIdle asserts a forward-widened +// flow collapses back to the lean single forward leg once the upload goes idle +// (forward target returns to adaptFwdActive and the active set shrinks). +func TestEngine_OnTick_AdaptiveForwardCollapsesOnIdle(t *testing.T) { + s := newAdaptiveSim(1_000_000, 0) + for i := 0; i < 20; i++ { // widen under upload + s.step() + } + if s.e.adaptFwdTarget <= adaptFwdActive { + t.Fatalf("precondition: forward must be widened; got fwdTarget=%d", s.e.adaptFwdTarget) + } + grown := s.activeCount() + // Upload stops: flat SentBytes and RecvBytes from here on. + s.sentPerTick, s.recvPerTick = 0, 0 + for i := 0; i < 40; i++ { + s.step() + } + if s.e.adaptFwdTarget != adaptFwdActive { + t.Errorf("idle must collapse forward target back to %d; got %d", adaptFwdActive, s.e.adaptFwdTarget) + } + if s.activeCount() >= grown { + t.Errorf("idle must shed the extra forward legs; active stayed %d (peak %d)", s.activeCount(), grown) + } + if s.activeCount() != 1 { + t.Errorf("idle steady state must be a single active forward leg; active=%d", s.activeCount()) + } +} diff --git a/pkg/router/policy/preset/tick.go b/pkg/router/policy/preset/tick.go index c435d2861e..7a2ccf058d 100644 --- a/pkg/router/policy/preset/tick.go +++ b/pkg/router/policy/preset/tick.go @@ -50,6 +50,21 @@ type Engine struct { adaptSeeded bool adaptIdleCount int adaptTarget int + // forward (upload / SentBytes) sizing — the exact mirror of the reverse + // (RecvBytes) machine above, driven by SentBytes deltas. adaptFwdTarget is + // the forward active width (seeded to adaptFwdActive); an upload-heavy flow + // widens it under sustained SENT saturation and collapses it back when the + // upload goes idle, using the SAME EWMA/peak/hysteresis/cooldown constants + // as the reverse side. When SentBytes never advances the whole forward + // machine stays dormant (adaptFwdSeeded never trips), so a download-only or + // idle flow behaves byte-identically to the reverse-only controller. + adaptPrevSent map[string]uint64 + adaptFwdThroughputEWMA float64 + adaptFwdPeak float64 + adaptFwdSeeded bool + adaptFwdIdleCount int + adaptFwdTarget int + adaptFwdSatTicks int // health + anti-churn state adaptCooldown int // ticks to hold the active set steady after a reshape adaptSatTicks int // consecutive saturated ticks (grow only on a sustained signal) @@ -85,11 +100,13 @@ func New() *Engine { prevKnownTIDs: map[string]bool{}, adaptLatEWMA: map[string]float64{}, adaptPrevRecv: map[string]uint64{}, + adaptPrevSent: map[string]uint64{}, adaptSeen: map[string]bool{}, adaptAliveIdx: map[string]int{}, adaptUnhealthy: map[string]int{}, adaptStall: map[string]int{}, adaptTarget: adaptRevActive, + adaptFwdTarget: adaptFwdActive, ledbatEWMA: map[string]float64{}, ledbatBase: map[string]float64{}, ledbatSeen: map[string]bool{}, @@ -508,9 +525,13 @@ func (e *Engine) tickProbeAndPrune(legs []LegInfo) RotationAction { // --- adaptive (composite) --- const ( - // adaptFwdActive is the lean forward (upstream / request) leg count: a - // single low-latency route, so the request path never pays mux head-of-line - // cost. adaptRevActive is the STEADY active reverse width — deliberately 1: + // adaptFwdActive is the STEADY lean forward (upstream / request) leg count: a + // single low-latency route, so an interactive / idle request path pays no mux + // head-of-line cost. Like the reverse side it is only a STEADY floor: the + // forward mux WIDENS under sustained UPLOAD (SentBytes) saturation — tickAdaptive + // grows adaptFwdTarget and emits AddForwardLeg (forward-only aux legs) — then + // collapses back to adaptFwdActive when the upload goes idle. adaptRevActive is + // the STEADY active reverse width — deliberately 1: // an interactive / idle flow rides ONE good leg (leg 0) and is never // scattered+reordered across high-variance legs. The reverse mux only WIDENS // under sustained bulk load (tickAdaptive promotes warm spares), then shrinks @@ -577,6 +598,7 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { delete(e.adaptAliveIdx, k) } var rawTotal float64 + var rawSent float64 aliveCount := 0 standbyCount := 0 newestAliveIdx := -1 @@ -618,6 +640,12 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { movedRecv[tid] = true } e.adaptPrevRecv[tid] = l.RecvBytes + // Forward (upload) throughput — the SentBytes mirror of the RecvBytes + // accumulation above. Feeds the independent forward sizing machine. + if prev, ok := e.adaptPrevSent[tid]; ok && l.SentBytes > prev { + rawSent += float64(l.SentBytes - prev) + } + e.adaptPrevSent[tid] = l.SentBytes } for tid := range e.adaptLatEWMA { if !e.adaptSeen[tid] { @@ -629,6 +657,11 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { delete(e.adaptPrevRecv, tid) } } + for tid := range e.adaptPrevSent { + if !e.adaptSeen[tid] { + delete(e.adaptPrevSent, tid) + } + } saturated, idle := false, false if !e.adaptSeeded { @@ -654,6 +687,35 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { e.adaptIdleCount = 0 } + // Forward (upload) saturation/idle — the SentBytes mirror of the block + // above, using the SAME EWMA smoothing, peak decay, and 0.80/0.25 + // saturated/idle thresholds. Stays fully dormant (fwdSaturated / fwdIdle + // both false, adaptFwdSeeded never set) while SentBytes is flat, so a + // download-only or idle flow evolves exactly as before. + fwdSaturated, fwdIdle := false, false + if !e.adaptFwdSeeded { + if rawSent > 0 { + e.adaptFwdThroughputEWMA = rawSent + e.adaptFwdPeak = rawSent + e.adaptFwdSeeded = true + } + } else { + e.adaptFwdThroughputEWMA = adaptAlpha*rawSent + (1-adaptAlpha)*e.adaptFwdThroughputEWMA + e.adaptFwdPeak *= adaptPeakDecay + if e.adaptFwdThroughputEWMA > e.adaptFwdPeak { + e.adaptFwdPeak = e.adaptFwdThroughputEWMA + } + if e.adaptFwdPeak > 0 { + fwdSaturated = e.adaptFwdThroughputEWMA >= 0.80*e.adaptFwdPeak + fwdIdle = e.adaptFwdThroughputEWMA < 0.25*e.adaptFwdPeak + } + } + if fwdIdle { + e.adaptFwdIdleCount++ + } else { + e.adaptFwdIdleCount = 0 + } + // Anti-churn cooldown: after any reshape, hold the active set steady for a // few ticks so a transient signal can't reshape the mux every tick (each // reshape disrupts in-flight flows). @@ -666,6 +728,12 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { } else { e.adaptSatTicks = 0 } + // Forward sustained-saturation streak (upload analog). + if fwdSaturated { + e.adaptFwdSatTicks++ + } else { + e.adaptFwdSatTicks = 0 + } // Active-set latency median (EWMA) for gross-outlier classification, plus the // per-active-leg health streaks. A leg (never leg 0) that reads a @@ -741,6 +809,20 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { } } + // desiredActive is the combined active-leg width the group converges to: + // the reverse steady/grown target PLUS any extra forward legs the upload + // machine has grown (forwardExtra). Folding the forward growth into the + // convergence target is what stops the reverse-side park/drop-recovery + // rules from immediately tearing down a leg the forward machine just added. + // When the forward machine is dormant (SentBytes flat) forwardExtra is 0 + // and desiredActive == adaptTarget, so every rule below behaves exactly as + // the reverse-only controller did. + forwardExtra := e.adaptFwdTarget - adaptFwdActive + desiredActive := e.adaptTarget + forwardExtra + if desiredActive > adaptCap { + desiredActive = adaptCap + } + // (0) No active legs — bring one up, preferring a healthy warm spare. if aliveCount == 0 { if healthyPromotable >= 0 { @@ -756,7 +838,7 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { // (active fell below the steady target): promote a HEALTHY warm spare // INSTANTLY and re-establish a replacement to restore the floor — zero // re-dial dip. Restoring lost capacity outranks every optimization. - if aliveCount < e.adaptTarget { + if aliveCount < desiredActive { if healthyPromotable >= 0 { return RotationAction{PromoteFromStandby: []int{healthyPromotable}, AddLeg: true} } @@ -790,7 +872,7 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { // load). This is what makes an idle / interactive flow settle onto ONE leg. // No cooldown: park is self-limiting (only fires while over-provisioned) and // stops once active == target, so it converges quickly at dial without churn. - if !saturated && aliveCount > e.adaptTarget && standbyCount < adaptStandbyMax && newestAliveIdx > 0 { + if !saturated && !fwdSaturated && aliveCount > desiredActive && standbyCount < adaptStandbyMax && newestAliveIdx > 0 { return RotationAction{DemoteToStandby: []int{newestAliveIdx}} } @@ -799,7 +881,10 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { // wide mux is reserved for real bulk load and never drains the reserve below // adaptStandbyMin. if e.adaptSatTicks >= adaptHysteresis && aliveCount < adaptCap { - e.adaptTarget = aliveCount + 1 + // Grow the REVERSE target by one. Subtract forwardExtra so the reverse + // width tracks only the download legs even when forward growth has + // enlarged aliveCount (a no-op when the forward machine is dormant). + e.adaptTarget = aliveCount - forwardExtra + 1 if e.adaptTarget > adaptCap { e.adaptTarget = adaptCap } @@ -811,12 +896,31 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { return RotationAction{AddLeg: true} } + // (4b) Grow the FORWARD width on SUSTAINED upload saturation — the exact + // mirror of (4), driven by SentBytes. Emits AddForwardLeg so the router + // appends the aux leg forward-only (appendRouteAsymmetric addFwd=true, + // addRev=false): the extra upstream send capacity is added WITHOUT enlarging + // the reverse/download set. Bounded by the same adaptCap as the reverse side. + // A fresh leg (not a warm-standby promote) is used because the standby pool + // is full-duplex — promoting one would also grow the reverse set. + if e.adaptFwdSatTicks >= adaptHysteresis && aliveCount < adaptCap && desiredActive < adaptCap { + e.adaptFwdTarget++ + if e.adaptFwdTarget > adaptCap { + e.adaptFwdTarget = adaptCap + } + e.adaptFwdSatTicks = 0 + e.adaptCooldown = adaptReshapeCooldown + return RotationAction{AddForwardLeg: true} + } + // (5) Shrink on SUSTAINED idle back toward the single-leg steady target, so a // finished bulk transfer releases its extra legs (parked to the reserve, or - // dropped once it is full). Never leg 0. - if e.adaptIdleCount >= adaptHysteresis && aliveCount > adaptRevActive && newestAliveIdx > 0 { + // dropped once it is full). Never leg 0. The reverse-portion width + // (aliveCount-forwardExtra) gates this so forward-grown legs aren't shrunk + // here (rule 5b owns them). + if e.adaptIdleCount >= adaptHysteresis && aliveCount-forwardExtra > adaptRevActive && newestAliveIdx > 0 { e.adaptIdleCount = 0 - e.adaptTarget = aliveCount - 1 + e.adaptTarget = aliveCount - forwardExtra - 1 if e.adaptTarget < adaptRevActive { e.adaptTarget = adaptRevActive } @@ -827,6 +931,23 @@ func (e *Engine) tickAdaptive(legs []LegInfo) RotationAction { return RotationAction{DropLegs: []int{newestAliveIdx}} } + // (5b) Shrink the FORWARD width on SUSTAINED upload idle back toward the lean + // single forward leg — the mirror of (5), driven by SentBytes idle. Parks the + // newest leg to the reserve (or drops it once the reserve is full). Never + // leg 0. + if e.adaptFwdIdleCount >= adaptHysteresis && e.adaptFwdTarget > adaptFwdActive && aliveCount > adaptFwdActive && newestAliveIdx > 0 { + e.adaptFwdIdleCount = 0 + e.adaptFwdTarget-- + if e.adaptFwdTarget < adaptFwdActive { + e.adaptFwdTarget = adaptFwdActive + } + e.adaptCooldown = adaptReshapeCooldown + if standbyCount < adaptStandbyMax { + return RotationAction{DemoteToStandby: []int{newestAliveIdx}} + } + return RotationAction{DropLegs: []int{newestAliveIdx}} + } + return RotationAction{} } diff --git a/pkg/router/policy/presethook/presethook.go b/pkg/router/policy/presethook/presethook.go index 47fdfd9776..a92c26de1c 100644 --- a/pkg/router/policy/presethook/presethook.go +++ b/pkg/router/policy/presethook/presethook.go @@ -139,6 +139,7 @@ func (h *Hook) OnTick(_ router.DialInfo, legs []router.LegInfo) router.RotationA ExcludeHops: action.ExcludeHops, DemoteToStandby: action.DemoteToStandby, PromoteFromStandby: action.PromoteFromStandby, + AddForwardLeg: action.AddForwardLeg, } } diff --git a/pkg/router/policy/types.go b/pkg/router/policy/types.go index f96f2725c3..091b696d1e 100644 --- a/pkg/router/policy/types.go +++ b/pkg/router/policy/types.go @@ -215,4 +215,9 @@ type RotationAction struct { // router.RotationAction and docs/warm_standby_legs_rfc.md. DemoteToStandby []int PromoteFromStandby []int + // AddForwardLeg requests one more FORWARD-ONLY aux leg (router appends it + // addFwd=true / addRev=false): extra upstream send capacity that does not + // enlarge the reverse/download set. The forward-direction mirror of AddLeg, + // emitted by the adaptive preset on sustained upload saturation. + AddForwardLeg bool } diff --git a/pkg/router/policy/wasm/abi.go b/pkg/router/policy/wasm/abi.go index 0733a446de..1002f13e8e 100644 --- a/pkg/router/policy/wasm/abi.go +++ b/pkg/router/policy/wasm/abi.go @@ -125,4 +125,5 @@ type RotationActionWire struct { ExcludeHops []string `json:"exclude_hops,omitempty"` DemoteToStandby []int `json:"demote_to_standby,omitempty"` PromoteFromStandby []int `json:"promote_from_standby,omitempty"` + AddForwardLeg bool `json:"add_forward_leg,omitempty"` } diff --git a/pkg/router/policy/wasm/evaluator.go b/pkg/router/policy/wasm/evaluator.go index 34015485cf..1199faabde 100644 --- a/pkg/router/policy/wasm/evaluator.go +++ b/pkg/router/policy/wasm/evaluator.go @@ -315,6 +315,7 @@ func rotationFromWire(w RotationActionWire) policy.RotationAction { ExcludeHops: append([]string(nil), w.ExcludeHops...), DemoteToStandby: append([]int(nil), w.DemoteToStandby...), PromoteFromStandby: append([]int(nil), w.PromoteFromStandby...), + AddForwardLeg: w.AddForwardLeg, } } diff --git a/pkg/router/policy/wasm/presets/bundle.wasm b/pkg/router/policy/wasm/presets/bundle.wasm index 6f494149fdd837308c237c49648fcb8967617cbf..49934227c74c40e64bf76465e277aad807841c24 100755 GIT binary patch delta 62330 zcmd?ScYIXE_5i+f+oqC0dIIF`CP3(+C81~ORX~b>Q~^PnbWp(rDTb!#0)q$`s-Z~< zcma6=%2PyC5L8g42m&HP5D`?6-#KUQy}Jv+_ulXG{r&aRPndhBoH=vm%xN=ocjhkl z+=K2diE4b4kaZ`^E|bbRJePmcsyZA>c4WQ;&)%A9E&c9KT1AH=Q+DXgqYw2=zv6pM z8|s;R#XCc-*!VZIQlt*mB7-1X(;c4pq1e*lz((*IX}tnScnz{%fBUKM7xkVjUH!>> zH8@EHST2=sov0=jmJeybLKa-pbe17G!X)1RBdN4^)Mm~5N@y*t{$Z#~D*DNLF*Gx1 z!4)mi>4BGWVbv?c%TS%&4RJU^Axle>94=W`n=9G6?7=b*V0kx`3JY_>>ax%S*Qt`~ zIdI+kVfZZxlHDa{K~mT}VkpZI#qN@T1=P@V86HBV>=cF|9q^B5-*wFe7>2^Q`gd@t zcA*;1JX@}7_*W4~=-4zFDCyY=Iq(NLO&DfCtK^~XNRKkXRl>7xhU|8Dl-90NfbDiR z)E(umRx&~=xL(+}WOG(3pHOux8B%Rs(VbAea8aodEKJ~-2yGT$^F~A`NK3DKD@9L{ z_Ra9FiykbkzV0m*Qw@_QF)4mP1_BTV0P#+YsU+oJ_pXXbkv3lU9*lWAx&W|rm_tSq zMj&;j#a2a-+hXt55}3Fo7zIdpQ^PEHp2C zJidjD)LoL))rrKu;Vy$oWXgI>rk0EG9!>dJJjQvqrhYCS&tWpK{4z)iJ+)w?=DF()uQU%^F0GWzu;5>n(=oYTITMq#DtT1x6wg1Y z#Ct!hQXcExs`9Z7G!cP*T&=d0f6aTZTA5P$z?YH-=10W=OP6jC$ zhj|<+ED5=Tc~)=L;NOGSwMd6c!F&&E5gwYTD=x_--Rf(a=T}EP2Xc{_C3=vCx~u|XE{-r?zGBpBwLbgR8$1Y+@x?7aPxkep6F}; zOM0c)hsg#FFM89fC-K8ul#rZISTX)IREimnprRtE81^6)&;1uvq&me>(R;K;ln>#n zHE6)0HIs`c*wu##)~Ti+!Je&2?O(A8{s6&l`VmY_ruXxjegrEL?IUWHX)frd+7njb zBe<9_DO?F4Tu?ED^Nz07(gl!UIRhu$cC-zbJr7Ta_hhZI4gBXIxEN4k4uZuTIQ{1U z)<=51S-4F0XT=q_OlmZ$pKXB$wgssf<_URZ7a@LKsP@Y6_N|}hon5E8H5%D(G_t)a zWn7h~GRs!5y2GwQAK6u?*Oe88h-YN|ulR>O5@^_?@t>2K{@>xR`{N&8Ojq8-ti=C! zh?gh^nwZ-Xk4Ai6-82jF|ACDn9@$ldUssku{BPJu0tw@)pO|V%D1msSAG83D>58k^ z=6w*Ke%y~bouoJjN!9y(twdS@r0IcYNuC+A{pZFb8~HV!-^glQc)ZarsiHYfr@1OX4bwVz;>rlekvd^zfZ(m{z0_1EorSrf zdPK461IenPRza;5oTzIq#ZPN}5-4vSc3+^mz)ZouNOMs=um(VLnR*LLq0bnPR1A5& zLz|HB+}$jRgs;;B+A2uEBW-n!gr|b=+=|dvpEmKM$uCWkTBC5oL5pTISxAvl9X-e&bf>2Na=j^$drs3@<^C&5d(Ssb4WP1G%zy=z)&K9PtQFI#_swSi zGnMZ)^P_Uv=1GJ9�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 Gxc?82;z9uc diff --git a/pkg/router/policy/wasm/presets/parity_test.go b/pkg/router/policy/wasm/presets/parity_test.go index 4271848567..6042ceabb6 100644 --- a/pkg/router/policy/wasm/presets/parity_test.go +++ b/pkg/router/policy/wasm/presets/parity_test.go @@ -84,14 +84,15 @@ type normAct struct { ExcludeHops []string DemoteToStandby []int PromoteFromStandby []int + AddForwardLeg bool } func actFromPolicy(a policy.RotationAction) normAct { - return normAct{DropLegs: nzi(a.DropLegs), AddLeg: a.AddLeg, ExcludeHops: nz(a.ExcludeHops), DemoteToStandby: nzi(a.DemoteToStandby), PromoteFromStandby: nzi(a.PromoteFromStandby)} + return normAct{DropLegs: nzi(a.DropLegs), AddLeg: a.AddLeg, ExcludeHops: nz(a.ExcludeHops), DemoteToStandby: nzi(a.DemoteToStandby), PromoteFromStandby: nzi(a.PromoteFromStandby), AddForwardLeg: a.AddForwardLeg} } func actFromPreset(a preset.RotationAction) normAct { - return normAct{DropLegs: nzi(a.DropLegs), AddLeg: a.AddLeg, ExcludeHops: nz(a.ExcludeHops), DemoteToStandby: nzi(a.DemoteToStandby), PromoteFromStandby: nzi(a.PromoteFromStandby)} + return normAct{DropLegs: nzi(a.DropLegs), AddLeg: a.AddLeg, ExcludeHops: nz(a.ExcludeHops), DemoteToStandby: nzi(a.DemoteToStandby), PromoteFromStandby: nzi(a.PromoteFromStandby), AddForwardLeg: a.AddForwardLeg} } func nz(s []string) []string { @@ -291,6 +292,24 @@ func TestTickParity_NativeMatchesWazero(t *testing.T) { {rl(0, "a", 50, 0), rl(1, "b", 50, 0), rl(3, "d", 50, 0), sb(2, "c", 50)}, } + // adaptive UPLOAD-heavy: sustained SentBytes growth on the primary leg with + // flat RecvBytes drives the forward (upload) controller — exercises the new + // SentBytes path and its AddForwardLeg emission across the wire so native and + // wazero must agree step-for-step on the forward-direction sizing too. + legS := func(idx int, tid string, sent uint64) policy.LegInfo { + return policy.LegInfo{Index: idx, TransportID: tid, Kind: "stcpr", LatencyMs: 40, Alive: true, SentBytes: sent} + } + up := [][]policy.LegInfo{ + {legS(0, "a", 1_000_000)}, + {legS(0, "a", 2_000_000)}, + {legS(0, "a", 3_000_000)}, + {legS(0, "a", 4_000_000)}, + {legS(0, "a", 5_000_000)}, + {legS(0, "a", 6_000_000), legS(1, "b", 0)}, + {legS(0, "a", 7_000_000), legS(1, "b", 1_000_000)}, + {legS(0, "a", 8_000_000), legS(1, "b", 2_000_000)}, + } + cases := []tickCase{ {"rotating-bw", "rotating-bw", rbw}, {"latency-adaptive", "latency-adaptive", la}, @@ -299,6 +318,7 @@ func TestTickParity_NativeMatchesWazero(t *testing.T) { {"coupled", "coupled", cpl}, {"adaptive", "adaptive", ad}, {"ledbat", "ledbat", lb}, + {"adaptive-upload", "adaptive", up}, } for _, tc := range cases { diff --git a/pkg/router/route_group.go b/pkg/router/route_group.go index 2cc99d8c56..50fed229c0 100644 --- a/pkg/router/route_group.go +++ b/pkg/router/route_group.go @@ -212,7 +212,12 @@ type RouteGroup struct { // See pkg/router/dial_hook.go RotationHook. rotationHook RotationHook rotationApplyAdd func(excludeHops []string) - rotationInterval time.Duration + // rotationApplyAddForward dials one FORWARD-ONLY aux leg + // (appendRouteAsymmetric addFwd=true/addRev=false) for a + // RotationAction.AddForwardLeg — extra upstream send capacity that leaves + // the reverse/download set untouched. Nil disables forward widening. + rotationApplyAddForward func(excludeHops []string) + rotationInterval time.Duration // selfHealAdd restores the multiplexed degree in the background when a // leg dies. pruneDeadTransports drops the dead leg (surviving legs @@ -748,11 +753,14 @@ func (rg *RouteGroup) SetLegChangeHook(hook LegChangeHook, info DialInfo) { // forward leg with the policy's ExcludeHops as the disjoint- // intermediate filter. It runs in the rotation goroutine's // own context so a slow setup-node dial doesn't block other -// route groups' rotation. -func (rg *RouteGroup) SetRotation(hook RotationHook, applyAdd func(excludeHops []string), interval time.Duration) { +// route groups' rotation. applyAddForward is the FORWARD-ONLY +// analog (appendRouteAsymmetric addFwd=true/addRev=false), dialed +// for a RotationAction.AddForwardLeg; nil disables forward widening. +func (rg *RouteGroup) SetRotation(hook RotationHook, applyAdd, applyAddForward func(excludeHops []string), interval time.Duration) { rg.mu.Lock() rg.rotationHook = hook rg.rotationApplyAdd = applyAdd + rg.rotationApplyAddForward = applyAddForward rg.rotationInterval = interval rg.mu.Unlock() // Start the rotation goroutine now (startOffServiceLoops fired @@ -1239,6 +1247,7 @@ func (rg *RouteGroup) rotationServiceFn(_ time.Duration) { rg.mu.Lock() hook := rg.rotationHook applyAdd := rg.rotationApplyAdd + applyAddForward := rg.rotationApplyAddForward info := rg.legChangeInfo legs := rg.snapshotLegs() rg.mu.Unlock() @@ -1246,7 +1255,7 @@ func (rg *RouteGroup) rotationServiceFn(_ time.Duration) { return } action := hook.OnTick(info, legs) - if len(action.DropLegs) == 0 && !action.AddLeg && + if len(action.DropLegs) == 0 && !action.AddLeg && !action.AddForwardLeg && len(action.DemoteToStandby) == 0 && len(action.PromoteFromStandby) == 0 { return } @@ -1297,6 +1306,17 @@ func (rg *RouteGroup) rotationServiceFn(_ time.Duration) { if action.AddLeg && applyAdd != nil { applyAdd(action.ExcludeHops) } + + // Forward-only widen: extra upstream send leg that leaves the reverse set + // untouched. Falls back to the full-duplex add when no forward-only callback + // is wired (older dial path) so the widen still happens. + if action.AddForwardLeg { + if applyAddForward != nil { + applyAddForward(action.ExcludeHops) + } else if applyAdd != nil { + applyAdd(action.ExcludeHops) + } + } } // dropLegsByIndex closes the transports at the policy-supplied diff --git a/pkg/router/router_dial.go b/pkg/router/router_dial.go index 445be7ecb3..17c2f73421 100644 --- a/pkg/router/router_dial.go +++ b/pkg/router/router_dial.go @@ -703,7 +703,26 @@ func (r *router) finishDial( // Periodic rotation (policy on_tick) reuses the same callback. if rh, ok := r.conf.DialHook.(RotationHook); ok && rh != nil && opts.RotationIntervalSeconds > 0 { interval := time.Duration(opts.RotationIntervalSeconds) * time.Second - nrg.rg.SetRotation(rh, applyAdd, interval) + // Forward-only add-leg callback: the adaptive preset's AddForwardLeg + // (upload-saturation widen) dials an aux leg addFwd=true/addRev=false + // so the extra upstream send capacity does not enlarge the + // reverse/download set. Distinct from applyAdd (full-duplex) so the + // two directions size independently. + applyAddForward := func(excludeHops []string) { + addCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + excludePKs := make([]cipher.PubKey, 0, len(excludeHops)) + for _, h := range excludeHops { + var pk cipher.PubKey + if err := pk.Set(h); err == nil { + excludePKs = append(excludePKs, pk) + } + } + if err := r.addOneAuxSendLeg(addCtx, nrgCapture, &optsCopy, fwdDescCopy, excludePKs); err != nil { + r.logger.WithError(err).Debug("Mux forward add-leg failed; route group keeps current leg set") + } + } + nrg.rg.SetRotation(rh, applyAdd, applyAddForward, interval) } } @@ -2477,6 +2496,29 @@ func (r *router) establishMuxRoutes( // leg set); the rotation goroutine logs and waits for the next // tick. func (r *router) addOneAuxForwardLeg(ctx context.Context, nrg *NoiseRouteGroup, opts *DialOptions, forwardDesc routing.RouteDescriptor, extraExcludePKs []cipher.PubKey) error { + return r.addOneAuxLeg(ctx, nrg, opts, forwardDesc, extraExcludePKs, true) +} + +// addOneAuxSendLeg dials one additional FORWARD-ONLY aux leg +// (appendRouteAsymmetric addFwd=true / addRev=false) and appends it to nrg. +// Used by the rotation hook for a RotationAction.AddForwardLeg — the adaptive +// preset's upload-saturation widen. It adds upstream send capacity WITHOUT a +// paired reverse rule, so it does not enlarge the reverse/download set. +// +// Caveat (see the full-duplex note in addOneAuxLeg): a leg with no local +// reverse (consume) rule black-holes any DOWNLOAD the far end spreads onto it. +// That is acceptable here precisely because this leg is grown for an +// upload-dominant flow (little reverse traffic) and the leg-dataprogress / +// leg-liveness prunes evict it if the far end mis-spreads bulk download onto +// it. It is the "forward actuation can't fully mirror reverse" corner: the warm +// standby pool is full-duplex, so a forward-only widen must be a fresh leg. +func (r *router) addOneAuxSendLeg(ctx context.Context, nrg *NoiseRouteGroup, opts *DialOptions, forwardDesc routing.RouteDescriptor, extraExcludePKs []cipher.PubKey) error { + return r.addOneAuxLeg(ctx, nrg, opts, forwardDesc, extraExcludePKs, false) +} + +// addOneAuxLeg is the shared implementation behind addOneAuxForwardLeg +// (addRev=true, full-duplex) and addOneAuxSendLeg (addRev=false, forward-only). +func (r *router) addOneAuxLeg(ctx context.Context, nrg *NoiseRouteGroup, opts *DialOptions, forwardDesc routing.RouteDescriptor, extraExcludePKs []cipher.PubKey, addRev bool) error { if nrg == nil || nrg.rg == nil { return fmt.Errorf("route group nil") } @@ -2582,20 +2624,22 @@ func (r *router) addOneAuxForwardLeg(ctx context.Context, nrg *NoiseRouteGroup, if err != nil { return fmt.Errorf("rotation add-leg: setup-node dial: %w", err) } - // Append the replacement leg FULL-DUPLEX (forward + reverse). Forward-only - // deletes the initiator's consume (reverse) rule for this leg — but the - // setup-node dial already installed that rule on the far end, which marks the - // leg ready on our forward handshake and then spreads its bulk (download) - // stream onto it. With the initiator's consume rule gone those packets are - // dropped (errRouteDescNotExist), so the leg black-holes (recv=0) and, because - // the reorder buffer is lossless, the missing sequences head-of-line-stall the - // primary leg too — a net 0-byte transfer. aliveLegCount counts forward legs, - // so a send-only leg still satisfies the degree target while being a download - // blackhole. Keeping the reverse rule lets the aggregated download land here. - if err := r.appendRouteAsymmetric(nrg, muxRules, true, true); err != nil { + // Append the leg. Full-duplex (addRev=true, the default rotation/self-heal + // path): forward-only would delete the initiator's consume (reverse) rule for + // this leg — but the setup-node dial already installed that rule on the far + // end, which marks the leg ready on our forward handshake and then spreads its + // bulk (download) stream onto it. With the initiator's consume rule gone those + // packets are dropped (errRouteDescNotExist), so the leg black-holes (recv=0) + // and, because the reorder buffer is lossless, the missing sequences + // head-of-line-stall the primary leg too — a net 0-byte transfer. So the + // full-duplex path keeps the reverse rule. addRev=false is used ONLY by the + // adaptive preset's forward-only (AddForwardLeg) upload widen, where the flow + // is upload-dominant so little download lands here, and the data-progress / + // liveness prunes evict the leg if the far end mis-spreads download onto it. + if err := r.appendRouteAsymmetric(nrg, muxRules, true, addRev); err != nil { return fmt.Errorf("rotation add-leg: append: %w", err) } - log.Infof("Rotation aux leg established via tp %s", muxRules.Forward.NextTransportID()) + log.Infof("Rotation aux leg established (addRev=%v) via tp %s", addRev, muxRules.Forward.NextTransportID()) return nil } From 3ad12b60093000e2996a9841cc32ca9a713e2749 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:42:26 -0500 Subject: [PATCH 2/2] test(preset): nolint the bounded gosec/unparam in adaptiveSim helper --- pkg/router/policy/preset/preset_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/router/policy/preset/preset_test.go b/pkg/router/policy/preset/preset_test.go index 3fe6eed266..e6da8f97ca 100644 --- a/pkg/router/policy/preset/preset_test.go +++ b/pkg/router/policy/preset/preset_test.go @@ -664,7 +664,7 @@ func (s *adaptiveSim) reindex() { } } -func (s *adaptiveSim) step() RotationAction { +func (s *adaptiveSim) step() RotationAction { //nolint:unparam // test helper: return kept for call-site clarity act := s.e.OnTick("adaptive", s.legs) if act.AddForwardLeg { s.sawAddFwd = true @@ -701,7 +701,7 @@ func (s *adaptiveSim) step() RotationAction { s.reindex() } if act.AddLeg || act.AddForwardLeg { - tid := "t" + string(rune('a'+s.nextTID)) + tid := "t" + string(rune('a'+s.nextTID)) //nolint:gosec // G115: bounded test rune s.nextTID++ s.legs = append(s.legs, LegInfo{Index: len(s.legs), TransportID: tid, Kind: "stcpr", LatencyMs: 40, Alive: true}) }