Primary and fallback rebalance routes
May 2026
When several channels are depleted at once, the planner does not fire a single rebalance and hope. It lays out a full list of routes up front — one primary per depleted channel, plus secondary fallbacks — and then lets execution walk that list. This note is about how the list is built and ordered. The budgeting note covers what happens once a route is actually attempted.
Targets and sources
At plan time every channel is bucketed by its balance: a target is depleted (local below 20%) and needs inbound; a source is overfull (local above 80%) and can give. The two lists are then sorted in opposite directions:
targets → most depleted first
sources → most overfull first Primary plans: one best pairing per target
The planner walks the targets, deepest deficit first, and gives each the fullest source not already spoken for. A single source rarely has enough surplus to refill a target on its own, so multiple sources can each contribute their share until the target's deficit is covered:
for target in targets (deepest first):
for source in sources (fullest first):
take min(remaining_deficit, source_surplus)
reserve that surplus, reduce the deficit Each source's committed surplus is tracked so it is never double-spent across targets, and a (source, target) pair is only ever used once. This is deliberately a greedy pass, not a global cost-minimising assignment — the ordering is predictable and easy to read, and the fallbacks below absorb the cases where a greedy pick falls short.
Fallback plans: every other pairing
After the primaries, the planner appends a fallback for every remaining target × source combination that wasn't used as a primary, still in most-overfull order:
for target in targets:
for src in sources not already paired with target:
plans += fallback(target, src) # is_fallback = true
These are not "retry if the primary failed" branches. They are simply
later entries in the same flat list, each tagged is_fallback, and
they are attempted in order only while their target still has a deficit at run
time — typically because the primary pair had no viable route between those two
nodes.
Why a flat list instead of branches
Execution is driven by two running ledgers — each target's remaining deficit and each source's remaining surplus — so the planner needs no conditional logic at all. It emits every plausible route once, primaries first, and the executor's ledger checks decide which actually fire:
- a target filled by its primary → its fallbacks skip themselves;
- a target only partially filled → its next fallback fires;
- a source drained by an earlier plan → its remaining plans skip themselves.
A worked example
Two depleted targets, T1 (deeper) and T2; two sources, A (fuller) and B.
primaries: T1 ← A, T2 ← B
fallbacks: T1 ← B, T2 ← A (the cross pairings) If the route T1 ← A has no path and fails, the fallback T1 ← B tops it up. If B is then short for T2, the fallback T2 ← A picks up whatever surplus A had left. Every sensible combination is available and tried in a sensible order, and not one line of "what if" was needed to arrange it.
What this buys
- Resilience — each target gets several shots at being filled from different sources within a single run.
- Predictability — the order is fully determined by depth and fullness, so you can read the plan and know exactly what will be attempted, and in what sequence.
- Simplicity — the planner is pure list-building; all of the "did it work, what next" logic lives in the ledgers rather than in nested branches.