From 7fa87c01e7d9c8d8d06653a2be3832a9858bd71c Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Mon, 1 Jun 2026 09:31:50 -0700 Subject: [PATCH] XMoveFunds: age the clboss-xrebalance layer, runtime-tunable via setconfig Adds periodic askrene-age against the persistent clboss-xrebalance layer so capacity constraints written by inform_channel_constrained do not accumulate forever. Mirrors FundsMover's age_clboss_layer: fires on Msg::TimerRandomHourly, logs num_removed at Debug, and catches RpcError with -32601 stayed at Debug (graceful degradation on CLN without askrene-age) while other codes promote to Warn so sustained aging failure is visible. The aging cutoff is controlled by a new plugin option, clboss-xrebalance-age-secs, default 3600 (1h) to match FundsMover's production value. Operators on networks with slower flows (signet) are expected to widen this; the right value is empirical and will be tuned after observation. The option is registered dynamic=true so the window is mutable at runtime via: lightning-cli setconfig clboss-xrebalance-age-secs No clboss / lightningd restart required. To support that, three small infrastructure pieces: * Boss::Msg::ManifestOption gains a bool dynamic field (default false; preserves existing behavior). * Boss::Mod::Manifester emits the per-option dynamic flag in the getmanifest response, so lightningd knows to forward setconfig for that option. * New Boss::Mod::SetConfigHandler module records (name -> dynamic flag) from Msg::ManifestOption events, then handles incoming Msg::CommandRequest where command == "setconfig" by validating the named option is registered + dynamic and re-raising a fresh Msg::Option on the bus. Existing option handlers re-apply the new value transparently. Contract for any future opt-in to dynamic: at startup lightningd encodes Int / Bool / Flag option values as JSON primitives, but at setconfig time it encodes them as JSON strings. Handlers for dynamic options must accept both Jsmn shapes. The XMoveFunds option handler does this; the contract is documented in SetConfigHandler's header comment so future modules can opt in safely. --- Boss/Mod/AmountSettingsHandler.cpp | 21 +++- Boss/Mod/Manifester.cpp | 3 + Boss/Mod/SetConfigHandler.cpp | 100 ++++++++++++++++++ Boss/Mod/SetConfigHandler.hpp | 53 ++++++++++ Boss/Mod/XMoveFunds/Main.cpp | 161 +++++++++++++++++++++++++++++ Boss/Mod/all.cpp | 2 + Boss/Msg/ManifestOption.hpp | 8 ++ Boss/Msg/Option.hpp | 17 ++- Makefile.am | 2 + 9 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 Boss/Mod/SetConfigHandler.cpp create mode 100644 Boss/Mod/SetConfigHandler.hpp diff --git a/Boss/Mod/AmountSettingsHandler.cpp b/Boss/Mod/AmountSettingsHandler.cpp index c7eaae9..9cc17a4 100644 --- a/Boss/Mod/AmountSettingsHandler.cpp +++ b/Boss/Mod/AmountSettingsHandler.cpp @@ -78,7 +78,26 @@ private: bus.subscribe([this](Msg::Option o) { - assert(settings); + /* Msg::Option was originally a startup-only signal + * (delivered once per registered option between + * Manifestation and EndOfOptions), and EndOfOptions + * moves `settings` away on line below. Now that + * SetConfigHandler can re-raise Msg::Option at + * runtime to deliver setconfig updates, this handler + * may receive events for unrelated names long after + * `settings` has been moved -- e.g. a runtime + * `setconfig clboss-xrebalance-age-secs ...` triggers + * a Msg::Option that hits every subscriber. + * + * Drop those silently: none of the options this + * handler owns is registered dynamic, so by the time + * we see a post-EndOfOptions event it cannot + * legitimately apply. This also covers a latent + * assertion crash that pre-existed dynamic options + * (any post-EOO Msg::Option for any name would have + * tripped the old assert(settings) here). */ + if (!settings) + return Ev::lift(); auto const& name = o.name; if (name == "clboss-min-onchain") { settings->reserve = parse_sats(o.value); diff --git a/Boss/Mod/Manifester.cpp b/Boss/Mod/Manifester.cpp index 1a3c53d..088d333 100644 --- a/Boss/Mod/Manifester.cpp +++ b/Boss/Mod/Manifester.cpp @@ -90,6 +90,9 @@ void Manifester::start() { .field( "description" , o.description ) + .field( "dynamic" + , o.dynamic + ) .end_object() ); } diff --git a/Boss/Mod/SetConfigHandler.cpp b/Boss/Mod/SetConfigHandler.cpp new file mode 100644 index 0000000..5cc5ec6 --- /dev/null +++ b/Boss/Mod/SetConfigHandler.cpp @@ -0,0 +1,100 @@ +#include"Boss/Mod/SetConfigHandler.hpp" +#include"Boss/Msg/CommandFail.hpp" +#include"Boss/Msg/CommandRequest.hpp" +#include"Boss/Msg/CommandResponse.hpp" +#include"Boss/Msg/ManifestOption.hpp" +#include"Boss/Msg/Option.hpp" +#include"Boss/log.hpp" +#include"Ev/Io.hpp" +#include"Jsmn/Object.hpp" +#include"Json/Out.hpp" +#include"S/Bus.hpp" + +namespace { + +/* JSON-RPC error code for malformed parameters, matching the + * JSONRPC2 invalid-params constant used elsewhere in clboss. */ +constexpr int RPC_INVALID_PARAMS = -32602; + +} + +namespace Boss { namespace Mod { + +void SetConfigHandler::start() { + bus.subscribe([this](Boss::Msg::ManifestOption const& o) { + options[o.name] = o.dynamic; + return Ev::lift(); + }); + bus.subscribe([this](Boss::Msg::CommandRequest const& m) { + if (m.command != "setconfig") + return Ev::lift(); + + auto id = m.id; + auto const& params = m.params; + + /* Extract `config` (required string). */ + if (!params.is_object() || !params.has("config") + || !params["config"].is_string()) { + return bus.raise(Boss::Msg::CommandFail{ + id, RPC_INVALID_PARAMS, + "setconfig: missing or non-string 'config' " + "parameter", + Json::Out::empty_object() + }); + } + auto name = std::string(params["config"]); + + /* Verify the option is one we registered, and is + * declared dynamic. Lightningd should never forward + * setconfig for a non-dynamic option (libplugin would + * refuse it on the receiving side too), but defending + * here keeps the error surface clear and prevents a + * surprise Msg::Option re-raise for an option whose + * handler may not expect runtime updates. */ + auto it = options.find(name); + if (it == options.end()) { + return bus.raise(Boss::Msg::CommandFail{ + id, RPC_INVALID_PARAMS, + "setconfig: unknown option '" + name + "'", + Json::Out::empty_object() + }); + } + if (!it->second) { + return bus.raise(Boss::Msg::CommandFail{ + id, RPC_INVALID_PARAMS, + "setconfig: option '" + name + + "' is not dynamic", + Json::Out::empty_object() + }); + } + + /* Forward the value as-is. Lightningd encodes the new + * value as a JSON string (see plugin_set_dynamic_opt in + * cln/lightningd/plugin.c), so handlers will see a + * Jsmn::Object with is_string() == true here even for + * numeric option types. The contract documented on + * SetConfigHandler covers this. + * + * Note: bus.raise(Msg::Option) broadcasts to every + * Msg::Option subscriber, not just the one that owns + * this option. Subscribers must filter by name and + * no-op on non-matches -- see the doc comment on + * Boss::Msg::Option for the full contract. */ + auto value = params.has("val") + ? params["val"] + : Jsmn::Object(); + return Boss::log( bus, Debug + , "SetConfigHandler: dispatching setconfig " + "'%s'" + , name.c_str() + ) + + bus.raise(Boss::Msg::Option{name, std::move(value)}) + + bus.raise(Boss::Msg::CommandResponse{ + id, Json::Out::empty_object() + }); + }); +} + +}} diff --git a/Boss/Mod/SetConfigHandler.hpp b/Boss/Mod/SetConfigHandler.hpp new file mode 100644 index 0000000..51def94 --- /dev/null +++ b/Boss/Mod/SetConfigHandler.hpp @@ -0,0 +1,53 @@ +#ifndef BOSS_MOD_SETCONFIGHANDLER_HPP +#define BOSS_MOD_SETCONFIGHANDLER_HPP + +#include +#include + +namespace S { class Bus; } + +namespace Boss { namespace Mod { + +/** class Boss::Mod::SetConfigHandler + * + * @brief Dispatches `setconfig` JSON-RPC calls from lightningd + * for options that were registered with `dynamic = true` on their + * Msg::ManifestOption. + * + * Lightningd routes `setconfig ` to the plugin that + * owns the option, as a JSON-RPC method call. We turn that into + * a fresh Msg::Option on the bus, so existing option handlers + * re-apply the new value without a plugin restart. + * + * Contract for module authors who mark an option `dynamic = true`: + * at startup lightningd delivers Int / Bool / Flag option values + * as JSON primitives, but at setconfig time lightningd encodes the + * value as a JSON string. Any module that opts in to dynamic + * updates MUST tolerate both shapes in its Msg::Option handler -- + * inspect `o.value.is_string()` and parse from the string form + * when appropriate. + */ +class SetConfigHandler { +private: + S::Bus& bus; + /* Name -> dynamic flag, populated from Msg::ManifestOption + * events during the Manifestation phase. Non-dynamic + * options are recorded too so we can return a clearer error + * than "unknown option" if lightningd ever forwards a + * setconfig for a non-dynamic name (which it should not). */ + std::map options; + + void start(); + +public: + SetConfigHandler() =delete; + SetConfigHandler(SetConfigHandler&&) =delete; + SetConfigHandler(SetConfigHandler const&) =delete; + + explicit + SetConfigHandler(S::Bus& bus_) : bus(bus_) { start(); } +}; + +}} + +#endif /* !defined(BOSS_MOD_SETCONFIGHANDLER_HPP) */ diff --git a/Boss/Mod/XMoveFunds/Main.cpp b/Boss/Mod/XMoveFunds/Main.cpp index 08d79ef..37572ea 100644 --- a/Boss/Mod/XMoveFunds/Main.cpp +++ b/Boss/Mod/XMoveFunds/Main.cpp @@ -7,7 +7,10 @@ #include"Boss/Msg/CommandResponse.hpp" #include"Boss/Msg/Init.hpp" #include"Boss/Msg/ManifestCommand.hpp" +#include"Boss/Msg/ManifestOption.hpp" #include"Boss/Msg/Manifestation.hpp" +#include"Boss/Msg/Option.hpp" +#include"Boss/Msg/TimerRandomHourly.hpp" #include"Boss/concurrent.hpp" #include"Boss/log.hpp" #include"Ev/Io.hpp" @@ -232,6 +235,14 @@ private: * wait_for_ready() so that command handling never tries to * use the layer before askrene is told about it. */ bool layer_ready; + /* Window for periodic askrene-age on the xrebalance layer. + * Tunable via the `clboss-xrebalance-age-secs` option (dynamic + * -- runtime mutable via `lightning-cli setconfig`). Default + * 3600 mirrors FundsMover's production value. Operators on + * networks with slower flows (signet) typically widen this + * via setconfig to keep accumulated capacity knowledge longer + * before constraints expire. */ + std::uint64_t aging_window_secs; /* For generating MPP groupids -- a u64 random value shared * across all parts of one xmovefunds invocation. */ std::mt19937_64 rng; @@ -379,6 +390,79 @@ private: }); } + /* Trim xrebalance-layer constraints older than + * aging_window_secs. Modeled on FundsMover's age_clboss_layer + * (Boss/Mod/FundsMover/Main.cpp). No self-loop guard refresh + * here -- xrebalance layer does not carry a self disable_node + * entry (its ephemeral per-request masking already excludes + * non-source/non-dest us-channels). + * + * RpcError taxonomy matches FundsMover: JSON-RPC -32601 + * (method not found) stays Debug for graceful degradation on + * CLN < v24.11 where askrene-age is absent; any other code is + * promoted to Warn since a sustained aging failure lets stale + * pessimism accumulate. + * + * channel_updates left to refresh-on-failure overwrite: askrene- + * age intentionally skips layer->local_updates, so this RPC + * only trims `constraints` written by inform_channel_*. Policy + * overrides written via askrene-update-channel refresh + * themselves whenever a fresh failure carries a new + * channel_update payload (gossmap_local_updatechan merges). + */ + Ev::Io age_xrebalance_layer() { + 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:: + xrebalance_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 + , "XMoveFunds: askrene-age (%s) " + "removed %" PRIu64 " stale entries." + , Boss::Mod::AskreneLayer:: + xrebalance_layer_name + .c_str() + , removed + ); + }).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 + , "XMoveFunds: askrene-age (%s) " + "failed: %s%s" + , Boss::Mod::AskreneLayer:: + xrebalance_layer_name + .c_str() + , 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)." + ); + }); + } + /* Gate command handling on startup completion: rpc must have * arrived via Msg::Init, and create_xrebalance_layer() must * have completed (either successfully or via the logged- @@ -1380,6 +1464,7 @@ public: , rpc(nullptr) , claimer(bus_) , layer_ready(false) + , aging_window_secs(3600) , rng(static_cast( std::chrono::system_clock::now() .time_since_epoch().count())) { @@ -1416,6 +1501,82 @@ public: "xrebalance algorithm; the caller " "specifies the explicit channel set.", false + }) + bus.raise(Msg::ManifestOption{ + "clboss-xrebalance-age-secs", + Msg::OptionType_Int, + Json::Out::direct(aging_window_secs), + "Cutoff (seconds) for periodic askrene-age " + "on the persistent clboss-xrebalance " + "layer. Constraints older than this are " + "trimmed once per TimerRandomHourly tick " + "so stale capacity pessimism does not " + "accumulate forever. Dynamic: settable " + "at runtime via `lightning-cli setconfig " + "clboss-xrebalance-age-secs `. " + "Default 3600 (1h); operators on slower " + "networks (signet) typically widen this.", + /* dynamic = */ true + }); + }); + bus.subscribe([this](Msg::Option const& o) { + if (o.name != "clboss-xrebalance-age-secs") + return Ev::lift(); + /* At startup lightningd sends Int options as a + * JSON number primitive (Initiator forwards the + * value verbatim from the init request); at + * runtime lightningd's setconfig path encodes + * the value as a JSON string (see + * cln/lightningd/plugin.c + * plugin_set_dynamic_opt). Tolerate both. */ + auto secs = std::uint64_t(0); + try { + if (o.value.is_number()) { + secs = std::uint64_t(double(o.value)); + } else if (o.value.is_string()) { + secs = std::stoull(std::string(o.value)); + } else { + return Boss::log( bus, Warn + , "XMoveFunds: " + "clboss-xrebalance-" + "age-secs: " + "unsupported value " + "type; keeping " + "%" PRIu64 "." + , aging_window_secs + ); + } + } catch (std::exception const& e) { + return Boss::log( bus, Warn + , "XMoveFunds: clboss-" + "xrebalance-age-secs: " + "parse error '%s'; " + "keeping %" PRIu64 "." + , e.what() + , aging_window_secs + ); + } + if (secs == 0) { + return Boss::log( bus, Warn + , "XMoveFunds: clboss-" + "xrebalance-age-secs: " + "must be > 0; keeping " + "%" PRIu64 "." + , aging_window_secs + ); + } + aging_window_secs = secs; + return Boss::log( bus, Info + , "XMoveFunds: xrebalance layer " + "aging window = %" PRIu64 + " seconds" + , aging_window_secs + ); + }); + bus.subscribe([this](Msg::TimerRandomHourly const&) { + return wait_for_ready().then([this]() { + return age_xrebalance_layer(); }); }); bus.subscribe all( std::ostream& cout /* Startup. */ all->install(bus); all->install(bus, threadpool, std::move(open_rpc_socket)); + all->install(bus); /* General settings. */ all->install(bus); diff --git a/Boss/Msg/ManifestOption.hpp b/Boss/Msg/ManifestOption.hpp index 222e8e4..4883c58 100644 --- a/Boss/Msg/ManifestOption.hpp +++ b/Boss/Msg/ManifestOption.hpp @@ -17,6 +17,14 @@ struct ManifestOption { OptionType type; Json::Out default_value; std::string description; + /* If true, lightningd will accept `setconfig ` at + * runtime for this option and forward the new value to clboss + * via the `setconfig` JSON-RPC method. SetConfigHandler turns + * that into a fresh Msg::Option on the bus, so existing option + * handlers re-apply the new value without a plugin restart. + * Default false preserves the original startup-only contract. + */ + bool dynamic = false; }; }} diff --git a/Boss/Msg/Option.hpp b/Boss/Msg/Option.hpp index 8b4d946..6ab22b9 100644 --- a/Boss/Msg/Option.hpp +++ b/Boss/Msg/Option.hpp @@ -8,8 +8,21 @@ namespace Boss { namespace Msg { /** struct Boss::Msg::Option * - * @brief emitted during `init` handling, providing the value - * of an option that we registered. + * @brief providing the value of an option we registered. + * + * Emitted during `init` handling (one Msg::Option per option + * lightningd actually carried in the init request), AND re- + * emitted by Boss::Mod::SetConfigHandler at runtime when + * lightningd forwards a `setconfig` JSON-RPC call for an option + * we registered as `dynamic = true`. + * + * Subscribers MUST filter by `name` and no-op for names they do + * not own (the bus broadcasts to all Msg::Option subscribers, so + * a dynamic option update for module A will be delivered to + * module B as well). Subscribers MUST also tolerate post-init + * arrival -- any local invariants that were valid only "between + * Manifestation and EndOfOptions" must be re-checked rather than + * asserted. */ struct Option { std::string name; diff --git a/Makefile.am b/Makefile.am index 6d98cec..4197c88 100644 --- a/Makefile.am +++ b/Makefile.am @@ -258,6 +258,8 @@ libclboss_la_SOURCES = \ Boss/Mod/SelfUptimeMonitor.hpp \ Boss/Mod/SendpayResultMonitor.cpp \ Boss/Mod/SendpayResultMonitor.hpp \ + Boss/Mod/SetConfigHandler.cpp \ + Boss/Mod/SetConfigHandler.hpp \ Boss/Mod/StatusCommand.cpp \ Boss/Mod/StatusCommand.hpp \ Boss/Mod/SwapManager.cpp \