mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-13 12:33:20 +02:00
Adopt the Msg::Option::reject() contract in the Track B dynamic-option handlers, which predate it. Without this, a rejected setconfig for an xrebalance-family option was still acknowledged as success, so lightningd persisted the never-applied value to config.setconfig -- including the junk-string case that then fails lightningd's own option parse on the NEXT start. Every reject branch in XRebalancer, XRebalancePredictor (via its bad_option choke point, which now names the specific constraint instead of 'could not parse value'), XRebalanceHistory and XMoveFunds now reports a quote-free reason. While in the handlers, close the same parse holes Track A's review fixed plus a NaN family specific to these stod-based knobs: - XRebalanceHistory and XRebalancePredictor::parse_u64 parsed via std::stoull, which accepts a leading minus and wraps modulo 2^64: setconfig -1 became ~1.8e19. Signed parse + negative reject, matching the FundsMover/AskreneUpdates handlers. - std::stod accepts 'nan' and 'inf'. A NaN slips through every range/clamping comparison (all false) straight into the stored setting -- a NaN per_hour breaks the cycle timing, a NaN or inf part-wait-secs breaks the part wait, and casting non-finite to uint32 (maxparts) is undefined. parse_double and all direct stod sites now require finite values, and maxparts clamps to [1, 1000000] before its uint32 cast.
512 lines
15 KiB
C++
512 lines
15 KiB
C++
#include"Boss/Mod/AskreneLayer.hpp"
|
|
#include"Boss/Mod/Rpc.hpp"
|
|
#include"Boss/Mod/XRebalancePredictor.hpp"
|
|
#include"Boss/ModG/RebalanceModeProxy.hpp"
|
|
#include"Boss/Msg/DbResource.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/ProvideStatus.hpp"
|
|
#include"Boss/Msg/SolicitStatus.hpp"
|
|
#include"Boss/Msg/XRebalanceLayerAged.hpp"
|
|
#include"Boss/RebalanceMode.hpp"
|
|
#include"Boss/concurrent.hpp"
|
|
#include"Boss/log.hpp"
|
|
#include"Ev/Io.hpp"
|
|
#include"Jsmn/Object.hpp"
|
|
#include"Json/Out.hpp"
|
|
#include"Ln/Amount.hpp"
|
|
#include"Ln/Scid.hpp"
|
|
#include"S/Bus.hpp"
|
|
#include"Sqlite3.hpp"
|
|
#include"Util/make_unique.hpp"
|
|
#include<cinttypes>
|
|
#include<cmath>
|
|
#include<cstdint>
|
|
#include<map>
|
|
#include<string>
|
|
#include<utility>
|
|
#include<vector>
|
|
|
|
namespace {
|
|
|
|
auto const opt_horizon_max =
|
|
std::string("clboss-xrebalance-predict-horizon-max-secs");
|
|
auto const opt_horizon_frac =
|
|
std::string("clboss-xrebalance-predict-horizon-frac");
|
|
auto const opt_min_samples =
|
|
std::string("clboss-xrebalance-predict-min-samples");
|
|
auto const opt_wall_margin =
|
|
std::string("clboss-xrebalance-predict-wall-margin");
|
|
auto const opt_floor_factor =
|
|
std::string("clboss-xrebalance-predict-floor-factor");
|
|
|
|
/* The live defaults deliberately differ from the spot-check
|
|
* defaults (XRebalancePredict::default_params) in two places:
|
|
* horizon-max 0 keeps the predictor OFF until the operator opts in,
|
|
* and floor-factor 0 makes the first enablement walls-only (floors
|
|
* are the riskier half: a too-high floor attracts flow and costs a
|
|
* failed part to self-correct). */
|
|
auto constexpr default_horizon_max = std::uint64_t(0);
|
|
auto constexpr default_horizon_frac = double(2.0);
|
|
auto constexpr default_min_samples = std::uint64_t(2);
|
|
auto constexpr default_wall_margin = double(1.0);
|
|
auto constexpr default_floor_factor = double(0.0);
|
|
|
|
/* Tolerate both a JSON number (startup primitive) and a JSON
|
|
* string (runtime setconfig encoding). */
|
|
bool parse_double(Jsmn::Object const& v, double& out) {
|
|
try {
|
|
if (v.is_number()) {
|
|
out = double(v);
|
|
return std::isfinite(out);
|
|
}
|
|
if (v.is_string()) {
|
|
/* stod accepts "nan" and "inf"; a NaN stored in
|
|
* an option silently defeats every subsequent
|
|
* range check (comparisons are all false). */
|
|
out = std::stod(std::string(v));
|
|
return std::isfinite(out);
|
|
}
|
|
} catch (std::exception const&) { }
|
|
return false;
|
|
}
|
|
bool parse_u64(Jsmn::Object const& v, std::uint64_t& out) {
|
|
try {
|
|
if (v.is_number()) {
|
|
/* Reject negatives and non-finite before the
|
|
* conversion: double->uint64 of either is
|
|
* undefined, and stoull-style wrapping would
|
|
* silently accept -1 as ~1.8e19. */
|
|
auto d = double(v);
|
|
if (!std::isfinite(d) || d < 0)
|
|
return false;
|
|
out = std::uint64_t(d);
|
|
return true;
|
|
}
|
|
if (v.is_string()) {
|
|
/* Signed parse so a negative is rejected rather
|
|
* than wrapped (std::stoull accepts a leading
|
|
* minus and negates modulo 2^64). */
|
|
auto s = std::stoll(std::string(v));
|
|
if (s < 0)
|
|
return false;
|
|
out = std::uint64_t(s);
|
|
return true;
|
|
}
|
|
} catch (std::exception const&) { }
|
|
return false;
|
|
}
|
|
|
|
}
|
|
|
|
namespace Boss { namespace Mod {
|
|
|
|
XRebalancePredictor::Plan
|
|
XRebalancePredictor::plan( std::vector<Row> const& rows
|
|
, std::uint64_t cutoff
|
|
, std::uint64_t now
|
|
, XRebalancePredict::Params const& params
|
|
) {
|
|
struct Group {
|
|
std::vector<XRebalancePredict::Bound> bounds;
|
|
/* Newest observation of ANY kind: a node_fail is not
|
|
* a liquidity bound, but it IS fresh real data, and
|
|
* candidacy is about whether the routed layer still
|
|
* carries live evidence for this direction. */
|
|
std::uint64_t newest = 0;
|
|
};
|
|
auto groups = std::map< std::pair<std::string, std::uint32_t>
|
|
, Group>();
|
|
for (auto const& row : rows) {
|
|
auto& g = groups[{row.scid, row.dir}];
|
|
if (row.time > g.newest)
|
|
g.newest = row.time;
|
|
auto is_fail = false;
|
|
if (XRebalancePredict::kind_is_bound(row.kind, is_fail))
|
|
g.bounds.push_back(
|
|
{row.time, is_fail, row.amount_msat});
|
|
}
|
|
|
|
auto result = Plan();
|
|
result.directions = groups.size();
|
|
result.candidates = 0;
|
|
for (auto const& e : groups) {
|
|
/* Fresh real data: the routed layer already carries
|
|
* live evidence; nothing to synthesize. */
|
|
if (e.second.newest >= cutoff)
|
|
continue;
|
|
++result.candidates;
|
|
auto res = XRebalancePredict::predict(
|
|
e.second.bounds, now, params);
|
|
/* amount 0 would be a degenerate inform (a wall at 0
|
|
* is a full exclusion we did not observe; a floor at
|
|
* 0 is a no-op) -- can arise from margins/factors
|
|
* scaling a tiny bound down. Skip. */
|
|
if (res.wall.would_assert && res.wall.amount_msat > 0)
|
|
result.assertions.push_back(
|
|
{ e.first.first, e.first.second
|
|
, true, res.wall.amount_msat});
|
|
if (res.floor.would_assert && res.floor.amount_msat > 0)
|
|
result.assertions.push_back(
|
|
{ e.first.first, e.first.second
|
|
, false, res.floor.amount_msat});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
class XRebalancePredictor::Impl {
|
|
private:
|
|
S::Bus& bus;
|
|
std::function<double()> get_now;
|
|
Sqlite3::Db db;
|
|
Boss::Mod::Rpc* rpc;
|
|
ModG::RebalanceModeProxy mode_proxy;
|
|
|
|
/* Live (dynamic-option) parameter values. */
|
|
std::uint64_t horizon_max_secs;
|
|
double horizon_frac;
|
|
std::uint64_t min_samples;
|
|
double wall_margin;
|
|
double floor_factor;
|
|
|
|
/* Last-cycle summary, for clboss-status. */
|
|
std::uint64_t last_run_time;
|
|
std::size_t last_directions;
|
|
std::size_t last_candidates;
|
|
std::size_t last_walls;
|
|
std::size_t last_floors;
|
|
|
|
void start() {
|
|
bus.subscribe<Msg::DbResource
|
|
>([this](Msg::DbResource const& r) {
|
|
db = r.db;
|
|
return Ev::lift();
|
|
});
|
|
bus.subscribe<Msg::Init
|
|
>([this](Msg::Init const& init) {
|
|
rpc = &init.rpc;
|
|
return Ev::lift();
|
|
});
|
|
bus.subscribe<Msg::Manifestation
|
|
>([this](Msg::Manifestation const&) {
|
|
return manifest_int_option( opt_horizon_max
|
|
, default_horizon_max,
|
|
"Maximum forecast horizon (seconds) of the "
|
|
"xrebalance persistence forecaster, AND its "
|
|
"master switch: 0 (the default) disables "
|
|
"synthetic re-assertion entirely. When "
|
|
"enabled, after each hourly aging pass the "
|
|
"forecaster re-asserts walls/floors for "
|
|
"channel directions with no live evidence, "
|
|
"for up to min(this, horizon-frac * the "
|
|
"regime's evidence span) past the last "
|
|
"observation. Since an asserted wall is "
|
|
"never contradicted by routing (the router "
|
|
"will not attempt amounts above it), this "
|
|
"cap IS the wall re-test schedule. 86400 "
|
|
"(24h) is the intended enabled value.")
|
|
+ manifest_double_option( opt_horizon_frac
|
|
, default_horizon_frac,
|
|
"Forecast horizon as a multiple of the "
|
|
"regime's evidence span (newest - oldest "
|
|
"consistent observation). 2.0: two "
|
|
"observations an hour apart are asserted "
|
|
"for two hours past the newest.")
|
|
+ manifest_int_option( opt_min_samples
|
|
, default_min_samples,
|
|
"Minimum observations of a side's kind "
|
|
"(failures for walls, successes for "
|
|
"floors) in the current regime before "
|
|
"that side is asserted.")
|
|
+ manifest_double_option( opt_wall_margin
|
|
, default_wall_margin,
|
|
"Multiplier on asserted wall amounts. "
|
|
">= 1.0 biases errors high, which "
|
|
"self-corrects (a too-high wall costs a "
|
|
"failed part that writes a fresh real "
|
|
"bound; a too-low wall is sticky until "
|
|
"the horizon).")
|
|
+ manifest_double_option( opt_floor_factor
|
|
, default_floor_factor,
|
|
"Multiplier on asserted floor amounts; "
|
|
"<= 1.0 is conservative. 0 (the default) "
|
|
"disables floor assertion entirely "
|
|
"(walls-only operation; floors are the "
|
|
"riskier half).");
|
|
});
|
|
bus.subscribe<Msg::Option
|
|
>([this](Msg::Option const& o) {
|
|
return handle_option(o);
|
|
});
|
|
bus.subscribe<Msg::XRebalanceLayerAged
|
|
>([this](Msg::XRebalanceLayerAged const& m) {
|
|
if (horizon_max_secs == 0)
|
|
return Ev::lift();
|
|
if (!db || !rpc)
|
|
return Ev::lift();
|
|
auto cutoff = m.cutoff;
|
|
return Boss::concurrent(run(cutoff));
|
|
});
|
|
bus.subscribe<Msg::SolicitStatus
|
|
>([this](Msg::SolicitStatus const&) {
|
|
return status();
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> manifest_int_option( std::string const& name
|
|
, std::uint64_t dflt
|
|
, std::string desc
|
|
) {
|
|
return bus.raise(Msg::ManifestOption{
|
|
name, Msg::OptionType_Int,
|
|
Json::Out::direct(dflt), std::move(desc),
|
|
true /* dynamic */
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> manifest_double_option( std::string const& name
|
|
, double dflt
|
|
, std::string desc
|
|
) {
|
|
return bus.raise(Msg::ManifestOption{
|
|
name, Msg::OptionType_String,
|
|
Json::Out::direct(dflt), std::move(desc),
|
|
true /* dynamic */
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> handle_option(Msg::Option const& o) {
|
|
if (o.name == opt_horizon_max) {
|
|
auto v = std::uint64_t(0);
|
|
if (!parse_u64(o.value, v))
|
|
return bad_option(o, "must be a "
|
|
"non-negative integer");
|
|
horizon_max_secs = v;
|
|
if (horizon_max_secs == 0)
|
|
return Boss::log( bus, Info
|
|
, "XRebalancePredictor: "
|
|
"disabled (%s = 0)."
|
|
, o.name.c_str());
|
|
return Boss::log( bus, Info
|
|
, "XRebalancePredictor: horizon "
|
|
"cap = %" PRIu64 " seconds."
|
|
, horizon_max_secs);
|
|
}
|
|
if (o.name == opt_horizon_frac) {
|
|
auto v = double(0.0);
|
|
if (!parse_double(o.value, v) || v <= 0)
|
|
return bad_option(o, "must be a number > 0");
|
|
horizon_frac = v;
|
|
return Boss::log( bus, Info
|
|
, "XRebalancePredictor: horizon "
|
|
"frac = %.3f."
|
|
, horizon_frac);
|
|
}
|
|
if (o.name == opt_min_samples) {
|
|
auto v = std::uint64_t(0);
|
|
if (!parse_u64(o.value, v) || v < 1)
|
|
return bad_option(o, "must be an "
|
|
"integer >= 1");
|
|
min_samples = v;
|
|
return Boss::log( bus, Info
|
|
, "XRebalancePredictor: min "
|
|
"samples = %" PRIu64 "."
|
|
, min_samples);
|
|
}
|
|
if (o.name == opt_wall_margin) {
|
|
auto v = double(0.0);
|
|
if (!parse_double(o.value, v) || v <= 0)
|
|
return bad_option(o, "must be a number > 0");
|
|
wall_margin = v;
|
|
return Boss::log( bus, Info
|
|
, "XRebalancePredictor: wall "
|
|
"margin = %.3f."
|
|
, wall_margin);
|
|
}
|
|
if (o.name == opt_floor_factor) {
|
|
auto v = double(0.0);
|
|
if (!parse_double(o.value, v) || v < 0)
|
|
return bad_option(o, "must be a number >= 0");
|
|
floor_factor = v;
|
|
if (floor_factor == 0)
|
|
return Boss::log( bus, Info
|
|
, "XRebalancePredictor: "
|
|
"floors disabled "
|
|
"(walls-only).");
|
|
return Boss::log( bus, Info
|
|
, "XRebalancePredictor: floor "
|
|
"factor = %.3f."
|
|
, floor_factor);
|
|
}
|
|
return Ev::lift();
|
|
}
|
|
|
|
Ev::Io<void> bad_option( Msg::Option const& o
|
|
, char const* reason
|
|
) {
|
|
/* Report the rejection so SetConfigHandler fails the
|
|
* setconfig command and lightningd does not persist the
|
|
* value (no-op at init time). */
|
|
o.reject(o.name + ": " + reason);
|
|
return Boss::log( bus, Warn
|
|
, "XRebalancePredictor: %s: %s; "
|
|
"keeping current setting."
|
|
, o.name.c_str()
|
|
, reason);
|
|
}
|
|
|
|
Ev::Io<void> run(std::uint64_t cutoff) {
|
|
return mode_proxy.get_mode().then([this, cutoff
|
|
](RebalanceMode m) {
|
|
if (m != RebalanceMode::xrebalance)
|
|
return Boss::log( bus, Debug
|
|
, "XRebalancePredictor: "
|
|
"mode is not xrebalance; "
|
|
"skipping cycle.");
|
|
return evaluate(cutoff);
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> evaluate(std::uint64_t cutoff) {
|
|
auto rows = std::make_shared<std::vector<Row>>();
|
|
return db.transact().then([rows](Sqlite3::Tx tx) {
|
|
auto fetch = tx.query(R"QRY(
|
|
SELECT time, scid, dir, kind, amount_msat
|
|
FROM "XRebalanceHistory"
|
|
ORDER BY scid, dir, time;
|
|
)QRY").execute();
|
|
for (auto& r : fetch) {
|
|
auto ndx = std::size_t(0);
|
|
auto row = Row();
|
|
row.time = r.get<std::uint64_t>(ndx++);
|
|
row.scid = r.get<std::string>(ndx++);
|
|
row.dir = r.get<std::uint32_t>(ndx++);
|
|
row.kind = r.get<std::string>(ndx++);
|
|
row.amount_msat =
|
|
r.get<std::uint64_t>(ndx++);
|
|
rows->push_back(std::move(row));
|
|
}
|
|
tx.commit();
|
|
return Ev::lift();
|
|
}).then([this, rows, cutoff]() {
|
|
auto params = XRebalancePredict::Params{
|
|
horizon_frac, horizon_max_secs,
|
|
std::size_t(min_samples), wall_margin,
|
|
floor_factor};
|
|
auto now = std::uint64_t(get_now());
|
|
auto result = plan(*rows, cutoff, now, params);
|
|
|
|
last_run_time = now;
|
|
last_directions = result.directions;
|
|
last_candidates = result.candidates;
|
|
last_walls = 0;
|
|
last_floors = 0;
|
|
|
|
auto act = Ev::lift();
|
|
for (auto const& a : result.assertions) {
|
|
if (a.is_wall) {
|
|
++last_walls;
|
|
act = std::move(act)
|
|
+ Boss::Mod::AskreneLayer::
|
|
inform_channel_constrained(
|
|
*rpc,
|
|
Boss::Mod::AskreneLayer::
|
|
xrebalance_layer_name,
|
|
Ln::Scid(a.scid), a.dir,
|
|
Ln::Amount::msat(
|
|
a.amount_msat));
|
|
} else {
|
|
++last_floors;
|
|
act = std::move(act)
|
|
+ Boss::Mod::AskreneLayer::
|
|
inform_channel_unconstrained(
|
|
*rpc,
|
|
Boss::Mod::AskreneLayer::
|
|
xrebalance_layer_name,
|
|
Ln::Scid(a.scid), a.dir,
|
|
Ln::Amount::msat(
|
|
a.amount_msat));
|
|
}
|
|
}
|
|
if (result.assertions.empty())
|
|
act = std::move(act)
|
|
+ Boss::log( bus, Debug
|
|
, "XRebalancePredictor: nothing "
|
|
"to assert (%zu directions, "
|
|
"%zu candidates)."
|
|
, result.directions
|
|
, result.candidates);
|
|
else
|
|
act = std::move(act)
|
|
+ Boss::log( bus, Info
|
|
, "XRebalancePredictor: asserted "
|
|
"%zu wall(s), %zu floor(s) "
|
|
"(%zu directions, %zu "
|
|
"candidates)."
|
|
, last_walls, last_floors
|
|
, result.directions
|
|
, result.candidates);
|
|
return act;
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> status() {
|
|
auto out = Json::Out();
|
|
auto obj = out.start_object();
|
|
obj
|
|
.field("enabled", horizon_max_secs != 0)
|
|
.field("horizon_max_secs", horizon_max_secs)
|
|
.field("horizon_frac", horizon_frac)
|
|
.field("min_samples", min_samples)
|
|
.field("wall_margin", wall_margin)
|
|
.field("floor_factor", floor_factor)
|
|
.field("last_run_time", last_run_time)
|
|
.field( "last_directions"
|
|
, std::uint64_t(last_directions))
|
|
.field( "last_candidates"
|
|
, std::uint64_t(last_candidates))
|
|
.field("last_walls", std::uint64_t(last_walls))
|
|
.field("last_floors", std::uint64_t(last_floors))
|
|
;
|
|
obj.end_object();
|
|
return bus.raise(Msg::ProvideStatus{
|
|
"xrebalance_predictor",
|
|
std::move(out)
|
|
});
|
|
}
|
|
|
|
public:
|
|
Impl() =delete;
|
|
Impl(Impl&&) =delete;
|
|
Impl(Impl const&) =delete;
|
|
|
|
explicit
|
|
Impl(S::Bus& bus_, std::function<double()> get_now_)
|
|
: bus(bus_)
|
|
, get_now(std::move(get_now_))
|
|
, rpc(nullptr)
|
|
, mode_proxy(bus_)
|
|
, horizon_max_secs(default_horizon_max)
|
|
, horizon_frac(default_horizon_frac)
|
|
, min_samples(default_min_samples)
|
|
, wall_margin(default_wall_margin)
|
|
, floor_factor(default_floor_factor)
|
|
, last_run_time(0)
|
|
, last_directions(0)
|
|
, last_candidates(0)
|
|
, last_walls(0)
|
|
, last_floors(0) { start(); }
|
|
};
|
|
|
|
XRebalancePredictor::XRebalancePredictor(XRebalancePredictor&&) =default;
|
|
XRebalancePredictor::~XRebalancePredictor() =default;
|
|
|
|
XRebalancePredictor::XRebalancePredictor( S::Bus& bus
|
|
, std::function<double()> get_now_
|
|
)
|
|
: pimpl(Util::make_unique<Impl>(bus, get_now_)) { }
|
|
|
|
}}
|