FundsMover: gate rebalances below clboss-min-rebalance-ppm
Some checks are pending
Code Base Sanity Check / tests (push) Waiting to run
Code Base Sanity Check / coverage (push) Waiting to run
Code Base Sanity Check / build-clang (push) Waiting to run

Decline a rebalance whose fee budget, as ppm of the amount being moved, is
below a configurable floor, before any work begins. When a RequestMoveFunds
arrives with fee_budget/amount under the floor, FundsMover emits the zero
ResponseMoveFunds that Runner::finish() would have produced after giving up,
without creating a Runner, calling getroutes, or fanning out the split-retry
cascade.

This is the lever the classic rebalancer was missing. Over a week of production
(2026-06-18 onward) only 78 FundsMover deliveries succeeded, and their effective
feerate has a hard floor around 50 ppm: only ~8% cleared below 40 ppm, median
about 150 ppm. Meanwhile the two highest-traffic drains (LNBiG, LQWD-England)
get budgeted at ~32-39 ppm, below that floor, so they delivered nothing while
firing ~1500 doomed JIT rebalances a day, each fanning out into hundreds of
fast-fail / 204 / 205 sub-attempts that pinned askrene at 4/4 for hours. The
gate refuses those moves at the source.

The option is dynamic (setconfig-tunable) so the floor can be swept at runtime,
modeled on clboss-classic-layer-age-secs. Default 50; set to 0 to disable and
attempt every requested move.

This supersedes the two earlier storm band-aids (the per-(source,sink)
Unaffordable cache and the maxfee=0 fast-fail), which tried to make the doomed
storm cheaper instead of stopping it: the cache suppressed only ~22% of moves
(blind to the 204 failure mode, and its amount/ppm domination did not generalize
across the varying request stream), and the fast-fail only trimmed the LNBiG
maxfee=0 splits. The gate subsumes both by stopping sub-floor moves before they
start.
This commit is contained in:
Ken Sedgwick 2026-06-25 14:14:21 -07:00
parent cebe1f86b5
commit 31bbf093d7
No known key found for this signature in database
GPG key ID: DBD2AF0849D711A9
2 changed files with 131 additions and 0 deletions

View file

@ -12,6 +12,7 @@
#include"Boss/Msg/OptionType.hpp"
#include"Boss/Msg/ProvideDeletablePaymentLabelFilter.hpp"
#include"Boss/Msg/RequestMoveFunds.hpp"
#include"Boss/Msg/ResponseMoveFunds.hpp"
#include"Boss/Msg/SolicitDeletablePaymentLabelFilter.hpp"
#include"Boss/Msg/TimerRandomHourly.hpp"
#include"Boss/concurrent.hpp"
@ -55,6 +56,16 @@ private:
* age_clboss_layer() for the aging mechanics and rationale. */
std::uint64_t aging_window_secs = std::uint64_t(21600);
/* Minimum fee budget, as ppm of the moved amount, worth attempting
* a rebalance at. A requested move whose fee_budget/amount is below
* this is declined up front -- no Runner, no getroutes, no split-
* retry storm -- because classic rebalances essentially never clear
* below ~50 ppm: the high-traffic drains get budgeted near 35 ppm and
* deliver nothing while saturating askrene. Dynamic via
* clboss-min-rebalance-ppm; default 50. Set to 0 to disable the gate
* and attempt every requested move. */
std::uint64_t min_rebalance_ppm = std::uint64_t(50);
void start() {
bus.subscribe<Msg::Init>([this](Msg::Init const& init) {
rpc = &init.rpc;
@ -81,6 +92,22 @@ private:
"layer has the analogous "
"clboss-xrebalance-age-secs.",
/* dynamic = */ true
})
+ bus.raise(Msg::ManifestOption{
"clboss-min-rebalance-ppm",
Msg::OptionType_Int,
Json::Out::direct(min_rebalance_ppm),
"Minimum fee budget, in ppm of the moved amount, "
"worth attempting a rebalance at. A move whose "
"requested fee_budget/amount is below this is "
"declined immediately -- no route solve, no "
"split-retry storm -- because classic rebalances "
"essentially never succeed below this rate. "
"Dynamic: settable at runtime via `lightning-cli "
"setconfig clboss-min-rebalance-ppm <ppm>`. "
"Default 50; set to 0 to disable (attempt every "
"requested move).",
/* dynamic = */ true
});
});
bus.subscribe<Msg::Option
@ -130,6 +157,53 @@ private:
, aging_window_secs
);
});
bus.subscribe<Msg::Option
>([this](Msg::Option const& o) {
if (o.name != "clboss-min-rebalance-ppm")
return Ev::lift();
/* Number at startup, string via setconfig -- the same
* dual encoding clboss-classic-layer-age-secs handles.
* Signed so a negative value is rejected below rather
* than wrapping to a huge unsigned. */
long long ppm = 0;
try {
if (o.value.is_number()) {
ppm = static_cast<long long>(double(o.value));
} else if (o.value.is_string()) {
ppm = std::stoll(std::string(o.value));
} else {
return Boss::log( bus, Warn
, "FundsMover: clboss-min-"
"rebalance-ppm: unsupported "
"value type; keeping %"
PRIu64 "."
, min_rebalance_ppm
);
}
} catch (std::exception const& e) {
return Boss::log( bus, Warn
, "FundsMover: clboss-min-rebalance-"
"ppm: parse error '%s'; keeping %"
PRIu64 "."
, e.what()
, min_rebalance_ppm
);
}
if (ppm < 0) {
return Boss::log( bus, Warn
, "FundsMover: clboss-min-rebalance-"
"ppm: must be >= 0; keeping %"
PRIu64 "."
, min_rebalance_ppm
);
}
min_rebalance_ppm = std::uint64_t(ppm);
return Boss::log( bus, Info
, "FundsMover: min rebalance budget = %"
PRIu64 " ppm"
, min_rebalance_ppm
);
});
bus.subscribe<Msg::RequestMoveFunds
>([this](Msg::RequestMoveFunds const& m) {
auto msg = std::make_shared<Msg::RequestMoveFunds>(m);
@ -166,6 +240,46 @@ private:
"Contact " PACKAGE_BUGREPORT
);
}
/* Decline a rebalance whose fee budget is below
* clboss-min-rebalance-ppm: classic rebalances
* essentially never clear below this rate, so skip
* the whole Runner / getroutes / split-retry
* machinery and emit the zero ResponseMoveFunds
* that Runner::finish() would have produced after
* giving up. Cross-multiplied to avoid a divide:
* fee_budget/amount < min_ppm/1e6 iff
* fee_budget*1e6 < min_ppm*amount. min_ppm == 0
* disables the gate (the test is never true). */
if ( min_rebalance_ppm > 0
&& double(msg->fee_budget.to_msat()) * 1000000.0
< double(min_rebalance_ppm)
* double(msg->amount.to_msat())
) {
auto src_pfx =
std::string(msg->source).substr(0, 8);
auto dst_pfx =
std::string(msg->destination).substr(0, 8);
return Boss::log( bus, Debug
, "FundsMover: not moving %s "
"from %s... to %s... -- fee "
"budget %s is below clboss-min-"
"rebalance-ppm=%" PRIu64 "; "
"never clears this cheap."
, std::string(msg->amount).c_str()
, src_pfx.c_str()
, dst_pfx.c_str()
, std::string(msg->fee_budget)
.c_str()
, min_rebalance_ppm
)
+ bus.raise(Msg::ResponseMoveFunds{
msg->requester,
Ln::Amount::sat(0),
Ln::Amount::sat(0),
msg->source,
msg->destination
});
}
auto runner = Runner::create( bus
, *rpc
, self_id

View file

@ -599,6 +599,23 @@ startup default, or at runtime with
lightning-cli setconfig clboss-classic-layer-age-secs <seconds>
### `--clboss-min-rebalance-ppm=<ppm>`
Sets the minimum fee budget, expressed as parts-per-million of the amount
being moved, at which the classic rebalancer will bother attempting a
rebalance. When a requested move's budget (`fee_budget / amount`) is below
this, FundsMover declines it immediately — no route solve, no split-retry —
rather than spending effort on a move that almost never succeeds at that
rate.
The default is `50`. Set it to `0` to disable the gate and attempt every
requested move.
This is a *dynamic* option: set it in the `lightningd` config for the
startup default, or at runtime with
lightning-cli setconfig clboss-min-rebalance-ppm <ppm>
### `--clboss-min-nodes-to-process=<number>`
Sets the minimum number of nodes that CLBOSS must know about before it