From 3136cd5f2cf5ba174f6af3fef73a362ebfbfe853 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Thu, 21 May 2026 21:26:04 -0700 Subject: [PATCH] =?UTF-8?q?FundsMover:=20clboss=20askrene=20layer=20mainte?= =?UTF-8?q?nance=20=E2=80=94=2024h=20aging=20+=20self-exclude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit askrene records every askrene-inform-channel and askrene-disable-node write into the persistent clboss layer with a timestamp, but that timestamp is never consulted during route scoring -- entries persist indefinitely until something explicitly calls askrene-age . Before this commit, CLBOSS never called askrene-age, so transient capacity dips and one-off node outages would embed permanently in CLBOSS's routing model over weeks and months. FundsMover (the module that already owns clboss-layer creation) now subscribes to Msg::TimerRandomHourly and on each fire issues askrene-age with cutoff = now - 86400 (24 hours). Gated on layer_ready so we never call age before the layer exists. RpcError is swallowed with a Debug log for graceful degradation on CLN < v24.11, matching the existing create_clboss_layer behaviour. The 24h window was chosen to roughly match the RegularActiveProbe natural-refresh cadence -- each peer is probed once every ~24h on average, per the uniform_int_distribution(1, 144) dice roll every 10 min in RegularActiveProbe.cpp -- so most ActiveProber-written entries get refreshed via re-probing before they age out. For FundsMover-written maximum_msat constraints (which never refresh naturally because FundsMover's getroutes call includes the clboss layer and so routes around them), this explicit aging is the only freshness mechanism. For comparison, xpay's own plugin ages its xpay layer with cutoff = now - 3600 (1 hour) every 60 seconds (see plugins/xpay/xpay.c around line 2944). We are deliberately less aggressive because CLBOSS's rebalance and probe cadence is on a multi-hour timescale. --- Boss/Mod/FundsMover/Main.cpp | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/Boss/Mod/FundsMover/Main.cpp b/Boss/Mod/FundsMover/Main.cpp index 275cc7f..fcf0add 100644 --- a/Boss/Mod/FundsMover/Main.cpp +++ b/Boss/Mod/FundsMover/Main.cpp @@ -9,6 +9,7 @@ #include"Boss/Msg/ProvideDeletablePaymentLabelFilter.hpp" #include"Boss/Msg/RequestMoveFunds.hpp" #include"Boss/Msg/SolicitDeletablePaymentLabelFilter.hpp" +#include"Boss/Msg/TimerRandomHourly.hpp" #include"Boss/concurrent.hpp" #include"Boss/log.hpp" #include"Ev/Io.hpp" @@ -19,6 +20,8 @@ #include"S/Bus.hpp" #include"Util/make_unique.hpp" #include"Util/stringify.hpp" +#include +#include #if HAVE_CONFIG_H # include"config.h" @@ -103,6 +106,12 @@ private: &is_our_label }); }); + bus.subscribe([this](Msg::TimerRandomHourly const& _) { + return wait_for_ready().then([this]() { + return age_clboss_layer(); + }); + }); } /* Gate Msg::RequestMoveFunds handling on FundsMover's * startup-time setup: rpc must have arrived via Msg::Init, @@ -122,6 +131,93 @@ private: }); } + /* Trim clboss-layer entries older than aging_window_secs. + * Without this, askrene-inform-channel constraints (and any + * disable_node writes) recorded over the lifetime of CLBOSS + * would persist forever -- askrene does NOT consult the + * per-entry timestamp during route scoring; the timestamp + * is only used by this explicit aging RPC to delete entries + * with timestamp < cutoff. + * + * Without aging, transient capacity dips and one-off node + * outages would embed permanently in CLBOSS's routing model. + * + * Cadence is driven by Msg::TimerRandomHourly (~once per + * hour with jitter) and the 24h window is chosen to roughly + * match the ActiveProber natural-refresh cadence (each peer + * is probed once every ~24h on average -- see + * RegularActiveProbe's uniform(1, 144) per 10 min dice + * roll); the explicit aging is the only freshness mechanism + * for FundsMover-written constraints (since FundsMover + * routes around them and never re-tests). + * + * Compared to xpay's own layer (cutoff 1h, fires every + * 60s) we are much less aggressive because CLBOSS's + * rebalance and probe cadence is on a 24h+ timescale. + * + * RpcError swallowed for graceful degradation, same pattern + * as create_clboss_layer. + */ + Ev::Io age_clboss_layer() { + auto constexpr aging_window_secs = std::uint64_t(86400); + return Ev::lift().then([this]() { + auto cutoff = std::uint64_t(std::time(nullptr)) + - aging_window_secs; + auto parms = Json::Out() + .start_object() + .field("layer", + Boss::Mod::AskreneLayer::clboss_layer_name) + .field("cutoff", cutoff) + .end_object() + ; + return rpc->command( "askrene-age" + , std::move(parms) + ); + }).then([this](Jsmn::Object res) { + auto removed = std::uint64_t(0); + if (res.has("num_removed") + && res["num_removed"].is_number()) + removed = std::uint64_t(double(res["num_removed"])); + return Boss::log( bus, Debug + , "FundsMover: askrene-age (clboss) " + "removed %" PRIu64 " stale entries." + , removed + ); + }).catching([this](RpcError const& e) { + /* Distinguish CLN-too-old (askrene-age RPC + * missing) from other failures. The standard + * JSON-RPC "method not found" code (-32601) is + * the explicit graceful-degradation case + * (CLN < v24.11, no askrene plugin); we keep + * that at Debug so older nodes don't spam logs + * once an hour. Any other RpcError suggests + * something unexpected (transient askrene + * problem, layer corruption, etc.) -- promote + * to Warn since this aging path is the only + * cleanup for FundsMover-written constraints, + * and a sustained failure would let stale + * pessimism accumulate indefinitely. + */ + auto code = int(0); + if (e.error.has("code") && e.error["code"].is_number()) + code = int(double(e.error["code"])); + auto is_method_missing = (code == -32601); + return Boss::log( bus + , is_method_missing ? Debug : Warn + , "FundsMover: askrene-age (clboss) " + "failed: %s%s" + , Util::stringify(e.error).c_str() + , is_method_missing + ? " (RPC missing; aging " + "unavailable on this CLN)." + : " (unexpected; stale " + "entries will accumulate " + "until next successful " + "aging pass)." + ); + }); + } + /* Ensure the persistent "clboss" askrene layer exists. Called * once at startup, fire-and-forget. Idempotent: when persistent * is true, askrene-create-layer succeeds even if the layer @@ -143,6 +239,34 @@ private: , std::move(parms) ); }).then([this](Jsmn::Object _) { + /* Self-exclude from middle hops by adding our + * node_id to the clboss layer's disabled_nodes. + * Without this, askrene-getroutes can return + * paths that loop through us as a middle node + * (us -> source -> us -> destination -> us), + * which appear to succeed but actually drain + * the destination channel in the wrong direction + * while paying fees for zero net progress. + * + * The legacy getroute call had this protection + * via its exclude=[self_id] argument; the + * askrene-getroutes API has no inline equivalent, + * so the persistent layer's disabled_nodes set + * is the only path to express the same intent. + * + * Idempotency: layer_add_disabled_node appends + * without de-dup so successive restarts will + * accumulate duplicate self entries, but the + * layer_disables_node membership check works + * correctly with duplicates -- minor storage + * bloat we accept as cheap. + */ + return Boss::Mod::AskreneLayer::disable_node( + *rpc, + Boss::Mod::AskreneLayer::clboss_layer_name, + self_id + ); + }).then([this]() { layer_ready = true; return Ev::lift(); }).catching([this](RpcError const& e) {