Skip to content

feat(balancer): spread new connections across share exits by load and latency - #34

Open
Vyacheslav-Tomashevskiy wants to merge 1 commit into
mergeos-bounties:masterfrom
Vyacheslav-Tomashevskiy:feat/exit-load-balancer
Open

feat(balancer): spread new connections across share exits by load and latency#34
Vyacheslav-Tomashevskiy wants to merge 1 commit into
mergeos-bounties:masterfrom
Vyacheslav-Tomashevskiy:feat/exit-load-balancer

Conversation

@Vyacheslav-Tomashevskiy

Copy link
Copy Markdown

Fixes #16

The problem

listExits() can hand back several share exits, but pickExit() sorted them by latency_ms + load * 100 and 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, because load only 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:

before   mock-vn-hcm 500
after    mock-vn-hcm 170   mock-sg-1 128   mock-vn-hn 128   mock-eu-fra 64   mock-us-sfo 10

Two scoring bugs fell out while measuring this:

input on master now
share node reporting load: 22 (percent, 22% busy) charged 2200 ms, never picked again read as 22%, score 95
loopback share node reporting latency_ms: 0 0 || 9999 → worst exit in the catalog score 25, ranks first
exit at load: 1.0, 10 ms away vs free exit 300 ms away picks the full one (10+100 = 110 < 300) picks the free one

What 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:

  • Load is normalized before it is scored. Share nodes report a 0..1 fraction, a percent, or only sessions/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).
  • Stale load decays toward neutral. A node that reported load: 0.05 ten 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 over 2 * balanceLoadStaleMs, so a fresh mediocre report beats a stale flattering one.
  • Saturation is a filter, not a penalty. At or above balanceSaturationLoad (0.9) an exit takes no new connections, same for healthy: 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.
  • Locally placed sessions count. SessionTracker remembers 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.
  • Strategies: 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 (--hold shows the loop closing — mock-us-sfo gets 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 its no exits available throw; connect() passes the config and places/releases on the tracker; configure --balance-strategy|--saturation-load|--latency-weight; GET /api/balance and the balance_* keys on POST /api/config (validated — an unknown strategy is rejected, not silently stored).

Seeing it

$ trucvpn list --balance
Balancer view (6)  strategy=p2c  saturation=0.9
  direct-local        local   score       1      1ms  load   0% (fraction/n/a)  skip:direct
  mock-vn-hcm         vn      score      83     28ms  load  22% (fraction/n/a)  eligible
  mock-sg-1           sg      score      90     45ms  load  18% (fraction/n/a)  eligible
  mock-vn-hn          vn      score     107     32ms  load  30% (fraction/n/a)  eligible
  mock-eu-fra         eu      score   182.5     95ms  load  35% (fraction/n/a)  eligible
  mock-us-sfo         us      score   222.5    120ms  load  41% (fraction/n/a)  eligible

$ trucvpn balance --count 500 --seed 42
Balance plan: 500 connections  strategy=p2c  region=auto
  mock-vn-hcm           179   35.8%  ##############
  mock-sg-1             136   27.2%  ###########
  mock-vn-hn            122   24.4%  ##########
  mock-eu-fra            63   12.6%  #####

--seed runs 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: null must not disqualify every exit).

tests/dashboard.test.js/api/balance ranks cheapest first and marks direct ineligible; POST /api/config rejects an unknown strategy.

Full suite: 50 passed (was 14 on master), node --test "tests/*.test.js" on Node 22. trucvpn demo, doctor, connect/disconnect unchanged.

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.md says so explicitly, so the two do not collide.

… 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
@laurentketterle-hub

Copy link
Copy Markdown

QA Verification — TrucVPN#34

PR: feat(balancer): spread new connections across share exits by load and latency
Author: @Vyacheslav-Tomashevskiy | SHA: 67cb413

CI: no checks

  • No checks

Tests

✅ no tests

Evidence

| Screenshots | ❌ |
| Video/GIF | ❌ |
| Logs | ✅ |

Verdict: ⚠️ EVIDENCE MISSING

Auto-verified via PR Verify Tool

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[200 MRG] Feature: multi-user share load balancer across exits

2 participants