Add AskreneVolatileLayer: a wiped layer so disable_node/channel_update blocks can heal
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled

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.
This commit is contained in:
Ken Sedgwick 2026-06-30 11:39:54 -07:00
parent 55b8f67ede
commit 6f4eccf0b2
No known key found for this signature in database
GPG key ID: DBD2AF0849D711A9
7 changed files with 304 additions and 15 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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<cinttypes>
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<Msg::Init>([this](Msg::Init const& init) {
rpc = &init.rpc;
last_wipe = Ev::now();
return Boss::concurrent(create_layer());
});
bus.subscribe<Msg::Manifestation
>([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 <secs>`. "
"Default 10800 (3h).",
/* dynamic = */ true
});
});
bus.subscribe<Msg::Option>([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<long long>(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<Msg::TimerRandomHourly
>([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<void> 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<RpcError>([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<void> 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>([](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<Impl>(bus)) { }
}}

View file

@ -0,0 +1,43 @@
#ifndef BOSS_MOD_ASKRENEVOLATILELAYER_HPP
#define BOSS_MOD_ASKRENEVOLATILELAYER_HPP
#include<memory>
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<Impl> pimpl;
public:
AskreneVolatileLayer() =delete;
AskreneVolatileLayer(AskreneVolatileLayer&&);
~AskreneVolatileLayer();
explicit
AskreneVolatileLayer(S::Bus&);
};
}}
#endif /* !defined(BOSS_MOD_ASKRENEVOLATILELAYER_HPP) */

View file

@ -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 "

View file

@ -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<void> all( std::ostream& cout
/* Channel balancing. */
all->install<RebalanceModeManager>(bus);
all->install<AskreneVolatileLayer>(bus);
all->install<FundsMover::Main>(bus);
all->install<MoveFundsCommand>(bus);
all->install<EarningsTracker>(bus);

View file

@ -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 \