From 6f4eccf0b288c838046d97da276e8978ab5d0a4a Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 30 Jun 2026 11:39:54 -0700 Subject: [PATCH] Add AskreneVolatileLayer: a wiped layer so disable_node/channel_update blocks can heal askrene-age only trims timestamped inform-channel constraints; it never removes disabled_nodes or channel_update overrides, which carry no timestamp. So the blocks the rebalancers write accumulate forever and a node or channel can never recover -- a disabled peer stays unroutable for the life of the layer. Separate those blocks from the aged constraints into their own shared, non-persistent layer, clboss-volatile, wiped wholesale on a timer (default 3h, clboss-volatile-layer-wipe-secs, dynamic). Blocks re-accumulate from fresh failures, so a recovered node or channel becomes routable again within one wipe interval. The constraint layers (clboss / clboss-xrebalance) are untouched and still aged by askrene-age. The new AskreneVolatileLayer module owns the layer: it creates it (non-persistent) at startup and removes+recreates it once the interval elapses on a TimerRandomHourly tick. A getroutes landing in the brief wipe gap just misses the blocks for that one call -- benign and self-correcting. Non-persistent means a CLN restart is itself a free wipe. Classic wiring (FundsMover/Attempter): the two block writes -- disable_node for NODE-level failures and update_channel for policy overrides -- now go to clboss-volatile, which is added to the classic getroutes layers array. The inform-channel constraints stay in clboss. The classic self-exclude also stays in clboss; the shared volatile layer carries no self entry, since xrebalance must never disable self for its circular routing. The xrebalance engine (XMoveFunds) is intentionally not redirected here; that is a follow-on change. --- Boss/Mod/AskreneLayer.cpp | 1 + Boss/Mod/AskreneLayer.hpp | 18 +++ Boss/Mod/AskreneVolatileLayer.cpp | 216 ++++++++++++++++++++++++++++++ Boss/Mod/AskreneVolatileLayer.hpp | 43 ++++++ Boss/Mod/FundsMover/Attempter.cpp | 37 ++--- Boss/Mod/all.cpp | 2 + Makefile.am | 2 + 7 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 Boss/Mod/AskreneVolatileLayer.cpp create mode 100644 Boss/Mod/AskreneVolatileLayer.hpp diff --git a/Boss/Mod/AskreneLayer.cpp b/Boss/Mod/AskreneLayer.cpp index bbb3a6f..07f4b91 100644 --- a/Boss/Mod/AskreneLayer.cpp +++ b/Boss/Mod/AskreneLayer.cpp @@ -11,6 +11,7 @@ namespace Boss { namespace Mod { namespace AskreneLayer { +std::string const clboss_volatile_layer_name = "clboss-volatile"; std::string const clboss_layer_name = "clboss"; namespace { diff --git a/Boss/Mod/AskreneLayer.hpp b/Boss/Mod/AskreneLayer.hpp index c87dd74..f574aa8 100644 --- a/Boss/Mod/AskreneLayer.hpp +++ b/Boss/Mod/AskreneLayer.hpp @@ -12,6 +12,24 @@ namespace Boss { namespace Mod { class Rpc; } } namespace Boss { namespace Mod { namespace AskreneLayer { +/* Name of the shared, non-persistent askrene layer that holds the + * "blocks" both rebalancers learn -- node disables (NODE-level + * failures) and channel_update policy overrides. These are not + * timestamped and askrene-age never removes them, so unlike the + * inform-channel constraints they cannot heal by aging. Instead this + * layer is wiped wholesale on a timer (see Boss::Mod::AskreneVolatileLayer) + * and re-accumulates from fresh failures, so a recovered node/channel + * becomes routable again within one wipe interval. + * + * Shared by both modes because a down node / a forwarder's policy is a + * mode-independent fact; both classic and xrebalance getroutes include + * this layer alongside their per-mode constraint layer. Deliberately + * carries NO self_id entry -- the classic self-exclude lives in + * clboss_layer_name (xrebalance must never disable self for its circular + * routing). + */ +extern std::string const clboss_volatile_layer_name; + /* Name of the persistent askrene layer that CLBOSS subsystems * write failure-feedback and (optionally) success-observations * into. Following the xpay convention -- the layer is named diff --git a/Boss/Mod/AskreneVolatileLayer.cpp b/Boss/Mod/AskreneVolatileLayer.cpp new file mode 100644 index 0000000..2d9bb52 --- /dev/null +++ b/Boss/Mod/AskreneVolatileLayer.cpp @@ -0,0 +1,216 @@ +#include"Boss/Mod/AskreneVolatileLayer.hpp" +#include"Boss/Mod/AskreneLayer.hpp" +#include"Boss/Mod/Rpc.hpp" +#include"Boss/Msg/Init.hpp" +#include"Boss/Msg/ManifestOption.hpp" +#include"Boss/Msg/Manifestation.hpp" +#include"Boss/Msg/Option.hpp" +#include"Boss/Msg/OptionType.hpp" +#include"Boss/Msg/TimerRandomHourly.hpp" +#include"Boss/concurrent.hpp" +#include"Boss/log.hpp" +#include"Ev/Io.hpp" +#include"Ev/now.hpp" +#include"Jsmn/Object.hpp" +#include"Json/Out.hpp" +#include"S/Bus.hpp" +#include"Util/make_unique.hpp" +#include"Util/stringify.hpp" +#include + +namespace { + +/* Default seconds between wholesale wipes of the clboss-volatile layer. */ +auto constexpr default_wipe_secs = std::uint64_t(10800); /* 3h */ + +} + +namespace Boss { namespace Mod { + +class AskreneVolatileLayer::Impl { +private: + S::Bus& bus; + Boss::Mod::Rpc* rpc; + + /* Seconds between wipes; dynamic via clboss-volatile-layer-wipe-secs. */ + std::uint64_t wipe_secs; + /* Ev::now() at the last (re)create. The first wipe happens one + * interval after startup, so the freshly-created layer is given time + * to accumulate before being wiped. */ + double last_wipe; + + void start() { + bus.subscribe([this](Msg::Init const& init) { + rpc = &init.rpc; + last_wipe = Ev::now(); + return Boss::concurrent(create_layer()); + }); + + bus.subscribe([this](Msg::Manifestation const&) { + return bus.raise(Msg::ManifestOption{ + "clboss-volatile-layer-wipe-secs", + Msg::OptionType_Int, + Json::Out::direct(wipe_secs), + "Seconds between wholesale wipes of the shared " + "clboss-volatile askrene layer (the never-aged " + "node disables and channel-update overrides that " + "the rebalancers write). Once the interval " + "elapses the layer is removed and recreated on a " + "TimerRandomHourly tick, so blocks re-accumulate " + "from fresh failures and a recovered node or " + "channel becomes routable again within one " + "interval. Dynamic: settable at runtime via " + "`lightning-cli setconfig " + "clboss-volatile-layer-wipe-secs `. " + "Default 10800 (3h).", + /* dynamic = */ true + }); + }); + bus.subscribe([this](Msg::Option const& o) { + if (o.name != "clboss-volatile-layer-wipe-secs") + return Ev::lift(); + /* Number at startup, string via setconfig -- the same + * dual encoding the other dynamic options handle. + * Signed so a negative value is rejected below rather + * than wrapping to a huge unsigned. */ + long long secs = 0; + try { + if (o.value.is_number()) { + secs = static_cast(double(o.value)); + } else if (o.value.is_string()) { + secs = std::stoll(std::string(o.value)); + } else { + return Boss::log( bus, Warn + , "AskreneVolatileLayer: " + "clboss-volatile-layer-wipe-" + "secs: unsupported value " + "type; keeping %" PRIu64 "." + , wipe_secs + ); + } + } catch (std::exception const& e) { + return Boss::log( bus, Warn + , "AskreneVolatileLayer: " + "clboss-volatile-layer-wipe-secs: " + "parse error '%s'; keeping " + "%" PRIu64 "." + , e.what() + , wipe_secs + ); + } + if (secs <= 0) { + return Boss::log( bus, Warn + , "AskreneVolatileLayer: " + "clboss-volatile-layer-wipe-secs: " + "must be > 0; keeping %" PRIu64 "." + , wipe_secs + ); + } + wipe_secs = std::uint64_t(secs); + return Boss::log( bus, Info + , "AskreneVolatileLayer: wipe interval " + "set to %" PRIu64 "s." + , wipe_secs + ); + }); + + bus.subscribe([this](Msg::TimerRandomHourly const&) { + if (!rpc) + return Ev::lift(); + auto now = Ev::now(); + if (now - last_wipe < double(wipe_secs)) + return Ev::lift(); + last_wipe = now; + return Boss::concurrent(wipe_layer()); + }); + } + + /* (Re)create the non-persistent volatile layer. Non-fatal on + * RpcError: on CLN < v24.11 (no askrene) the layer simply does not + * exist and the rebalancers' block-writes silently no-op. */ + Ev::Io create_layer() { + auto parms = Json::Out() + .start_object() + .field( "layer" + , Boss::Mod::AskreneLayer::clboss_volatile_layer_name + ) + .field("persistent", false) + .end_object() + ; + return rpc->command( "askrene-create-layer" + , std::move(parms) + ).then([](Jsmn::Object) { + return Ev::lift(); + }).catching([this](RpcError const& e) { + 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 + , "AskreneVolatileLayer: " + "askrene-create-layer (%s) failed: " + "%s%s" + , Boss::Mod::AskreneLayer::clboss_volatile_layer_name + .c_str() + , Util::stringify(e.error).c_str() + , is_method_missing + ? " (RPC missing; volatile layer " + "unavailable on this CLN)." + : " (unexpected)." + ); + }); + } + + /* Wipe = remove + recreate. Single fixed-name layer, so a getroutes + * landing in the brief gap simply misses the blocks for that one call + * -- benign and self-correcting (the next failure re-records them). + * The remove is allowed to fail (the layer may not exist yet). */ + Ev::Io wipe_layer() { + auto parms = Json::Out() + .start_object() + .field( "layer" + , Boss::Mod::AskreneLayer::clboss_volatile_layer_name + ) + .end_object() + ; + return rpc->command( "askrene-remove-layer" + , std::move(parms) + ).then([](Jsmn::Object) { + return Ev::lift(); + }).catching([](RpcError const&) { + /* Layer absent (first wipe, or a prior create failed); + * the recreate below establishes it regardless. */ + return Ev::lift(); + }).then([this]() { + return create_layer(); + }).then([this]() { + return Boss::log( bus, Debug + , "AskreneVolatileLayer: wiped %s " + "(blocks re-accumulate from fresh " + "failures)." + , Boss::Mod::AskreneLayer::clboss_volatile_layer_name + .c_str() + ); + }); + } + +public: + explicit + Impl(S::Bus& bus_ + ) : bus(bus_) + , rpc(nullptr) + , wipe_secs(default_wipe_secs) + , last_wipe(0.0) + { start(); } +}; + +AskreneVolatileLayer::AskreneVolatileLayer(AskreneVolatileLayer&&) =default; +AskreneVolatileLayer::~AskreneVolatileLayer() =default; + +AskreneVolatileLayer::AskreneVolatileLayer(S::Bus& bus) + : pimpl(Util::make_unique(bus)) { } + +}} diff --git a/Boss/Mod/AskreneVolatileLayer.hpp b/Boss/Mod/AskreneVolatileLayer.hpp new file mode 100644 index 0000000..c4c916b --- /dev/null +++ b/Boss/Mod/AskreneVolatileLayer.hpp @@ -0,0 +1,43 @@ +#ifndef BOSS_MOD_ASKRENEVOLATILELAYER_HPP +#define BOSS_MOD_ASKRENEVOLATILELAYER_HPP + +#include + +namespace S { class Bus; } + +namespace Boss { namespace Mod { + +/** class Boss::Mod::AskreneVolatileLayer + * + * @brief owns the lifecycle of the shared, non-persistent + * `clboss-volatile` askrene layer: creates it at startup and wipes it + * (remove + recreate) on a periodic timer. + * + * @desc the volatile layer holds the "blocks" both rebalancers learn -- + * node disables and channel_update overrides. askrene-age never removes + * those (they carry no aging timestamp), so without this they would + * accumulate forever and a node/channel could never heal. Wiping the + * whole layer on a cadence lets the blocks re-accumulate from fresh + * failures, so a recovered node becomes routable again within one wipe + * interval. Cadence is clboss-volatile-layer-wipe-secs (dynamic; default + * 3h). The layer is non-persistent, so a CLN restart is itself a free + * wipe. + */ +class AskreneVolatileLayer { +private: + class Impl; + std::unique_ptr pimpl; + +public: + AskreneVolatileLayer() =delete; + + AskreneVolatileLayer(AskreneVolatileLayer&&); + ~AskreneVolatileLayer(); + + explicit + AskreneVolatileLayer(S::Bus&); +}; + +}} + +#endif /* !defined(BOSS_MOD_ASKRENEVOLATILELAYER_HPP) */ diff --git a/Boss/Mod/FundsMover/Attempter.cpp b/Boss/Mod/FundsMover/Attempter.cpp index 8f5a386..79c8349 100644 --- a/Boss/Mod/FundsMover/Attempter.cpp +++ b/Boss/Mod/FundsMover/Attempter.cpp @@ -545,6 +545,7 @@ private: * to cover that plus its real fee. */ la.entry(Boss::Mod::AskreneLayer::clboss_layer_name); + la.entry(Boss::Mod::AskreneLayer::clboss_volatile_layer_name); la.end_array(); obj.field("maxfee_msat", route_maxfee.to_msat()); obj.field("final_cltv", cltv_delta + 14); @@ -1339,22 +1340,24 @@ private: ; /* 0x2000 == NODE level error. */ if ((fail & 0x2000)) { - /* Persistent disable_node is correct - * for NODE-level failures and is also - * consulted by this Attempter's own - * subsequent getroutes calls (the - * clboss layer is in the layers - * array), so no separate transient - * write is needed for this case. + /* disable_node goes to the shared + * clboss-volatile layer: it is a block + * (askrene-age never removes it), so it + * lives in the wiped layer where it can + * heal rather than accumulating forever. + * That layer is also in this Attempter's + * own getroutes layers array, so the + * disable steers subsequent routes with + * no separate transient write needed. */ feedback = Boss::Mod::AskreneLayer::disable_node( rpc, - Boss::Mod::AskreneLayer::clboss_layer_name, + Boss::Mod::AskreneLayer::clboss_volatile_layer_name, enode ) + Boss::log( bus, Debug , "FundsMover[%s]: feedback: " - "disable_node %s on clboss" + "disable_node %s on clboss-volatile" , attempt_tag().c_str() , std::string(enode).c_str() ); @@ -1560,16 +1563,20 @@ private: * channel_update. Cache it * for apply_policy_overrides * on the next retry, and - * mirror to the clboss - * layer (for other CLBOSS - * subsystems that consult - * the layer). + * mirror to the shared + * clboss-volatile layer: a + * policy override is a block + * (askrene-age never removes + * it), so it lives in the + * wiped layer where it can + * heal, and is consulted by + * both modes' getroutes. */ policy_overrides[key] = cu; feedback = std::move(feedback) + Boss::Mod::AskreneLayer::update_channel( rpc, - Boss::Mod::AskreneLayer::clboss_layer_name, + Boss::Mod::AskreneLayer::clboss_volatile_layer_name, echan, std::uint32_t(edir), cu.enabled, @@ -1581,7 +1588,7 @@ private: ) + Boss::log( bus, Debug , "FundsMover[%s]: " - "feedback: clboss " + "feedback: volatile " "update_channel %s/%d " "enabled=%d " "base=%umsat prop=%uppm " diff --git a/Boss/Mod/all.cpp b/Boss/Mod/all.cpp index 16b96ba..72e66aa 100644 --- a/Boss/Mod/all.cpp +++ b/Boss/Mod/all.cpp @@ -1,5 +1,6 @@ #include"Boss/Mod/ActiveProber.hpp" #include"Boss/Mod/AmountSettingsHandler.hpp" +#include"Boss/Mod/AskreneVolatileLayer.hpp" #include"Boss/Mod/AutoDisconnector.hpp" #include"Boss/Mod/AvailableRpcCommandsAnnouncer.hpp" #include"Boss/Mod/BlockTracker.hpp" @@ -209,6 +210,7 @@ std::shared_ptr all( std::ostream& cout /* Channel balancing. */ all->install(bus); + all->install(bus); all->install(bus); all->install(bus); all->install(bus); diff --git a/Makefile.am b/Makefile.am index 9a3d6f3..dd93ee1 100644 --- a/Makefile.am +++ b/Makefile.am @@ -71,6 +71,8 @@ libclboss_la_SOURCES = \ Boss/Mod/AmountSettingsHandler.hpp \ Boss/Mod/AskreneLayer.cpp \ Boss/Mod/AskreneLayer.hpp \ + Boss/Mod/AskreneVolatileLayer.cpp \ + Boss/Mod/AskreneVolatileLayer.hpp \ Boss/Mod/AutoDisconnector.cpp \ Boss/Mod/AutoDisconnector.hpp \ Boss/Mod/AvailableRpcCommandsAnnouncer.cpp \