feat(balancer): spread new connections across share exits by load and latency - #34
Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
Conversation
… latency pickExit() sorted the catalog by latency + load*100 and returned the first row. The sort is deterministic and the catalog is shared, so every consumer that discovered the same exits landed on the same node, and nothing pushed the herd off again - load only moves when the share node re-reports it. Adds src/balancer.js: load is normalized before it is scored (fraction, percent or sessions/max_sessions), a stale report decays toward neutral, saturated and unhealthy exits are filtered out instead of penalised, and the choice among the eligible ones is made by a strategy (default: power of two choices) rather than pinned to the argmin. Also fixes two scoring bugs on the way: latency_ms: 0 was falsy and scored as 9999, and a share node reporting load as percent was charged 100x. - trucvpn list --balance scored view with the skip reason per exit - trucvpn balance --count N where N new connections would land (--seed to repeat) - GET /api/balance same view over the control daemon - docs/load-balancing.md strategies, scoring, configuration Fixes mergeos-bounties#16
This was referenced Jul 24, 2026
QA Verification — TrucVPN#34PR: feat(balancer): spread new connections across share exits by load and latency CI: no checks
Tests✅ no tests Evidence| Screenshots | ❌ | Verdict:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #16
The problem
listExits()can hand back several share exits, butpickExit()sorted them bylatency_ms + load * 100and returned row zero. The sort is deterministic and the catalog is shared, so every consumer that discovers the same exits picks the same node — and nothing pushes them off again, becauseloadonly changes when the share node re-reports it. There was no balancing, only a preference.500 consumers connecting against the shipped
data/exits.sample.json:Two scoring bugs fell out while measuring this:
load: 22(percent, 22% busy)latency_ms: 00 || 9999→ worst exit in the catalogload: 1.0, 10 ms away vs free exit 300 ms awayWhat changed
src/balancer.js(new) — latency and load still decide, the score is still a millisecond figure (latency + load * latencyWeightMs, default 250 ms end to end). What it adds:0..1fraction, a percent, or onlysessions/max_sessions. All three map to a fraction; counters win over self-reported load; a missing or garbage load reads as unknown (0.5), not as idle (0).load: 0.05ten minutes ago has been collecting everyone else's connections since. With a timestamp (load_updated_at/updated_at/reported_at/ts, ISO or epoch s/ms) the value fades to neutral over2 * balanceLoadStaleMs, so a fresh mediocre report beats a stale flattering one.balanceSaturationLoad(0.9) an exit takes no new connections, same forhealthy: false/status: down|offline|draining. If that empties the pool the balancer widens it — preferred region → any region → saturated → direct — rather than refusing the connection.SessionTrackerremembers what this client just placed (the catalog will not know yet) and charges 5% per placement, so a daemon placing several connections between two refreshes spreads them.p2c(default),least-loaded,lowest-latency(the old behaviour, kept),weighted-random,round-robin.Why power of two choices as the default. Independent clients cannot see each other's picks, so anything that computes a single best exit recreates the herd; uniform random fixes the herd but throws away latency and load. p2c samples two eligible exits and keeps the better one — each client still prefers good exits, but no exit can be picked by everybody, since it must win a draw first. No shared state, one extra comparison. The far exits are not starved either: as the cheap ones fill up their score climbs and the draws start going the other way (
--holdshows the loop closing —mock-us-sfogets nothing until the others load up, then it starts taking traffic).Wiring:
pickExit(exits, region, options, context)delegates to the balancer and keeps its old signature and itsno exits availablethrow;connect()passes the config and places/releases on the tracker;configure --balance-strategy|--saturation-load|--latency-weight;GET /api/balanceand thebalance_*keys onPOST /api/config(validated — an unknown strategy is rejected, not silently stored).Seeing it
--seedruns a deterministic PRNG, so a plan reproduces exactly — that is also what keeps the distribution tests stable.Tests
tests/balancer.test.js— 34 cases over a mock multi-exit catalog: unit normalization (fraction / percent / counters / junk / clamp), staleness decay and timestamp formats, eligibility and the fallback ladder, both scoring bugs above, distribution over 500 and 1000 connections (no exit above 70%, direct never used, cheap exits still favoured, seed-reproducible), each strategy's characteristic behaviour, local session accounting, region preference including "region is full, leave it", and config hardening (saturationLoad: nullmust not disqualify every exit).tests/dashboard.test.js—/api/balanceranks cheapest first and marks direct ineligible;POST /api/configrejects an unknown strategy.Full suite: 50 passed (was 14 on master),
node --test "tests/*.test.js"on Node 22.trucvpn demo,doctor,connect/disconnectunchanged.Deliberately out of scope
Failover after a session is already up — retrying the next exit when a share node dies mid-connection — is issue #7, and I left it alone. This decides where a new connection goes;
connect()keeps its existing direct fallback when the chosen exit does not answer the probe.docs/load-balancing.mdsays so explicitly, so the two do not collide.