← Tech docs

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.
Like the thinking? Run it on your own node.