mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-13 12:33:20 +02:00
setconfig was acknowledged with unconditional success after broadcasting Msg::Option, while owning modules reject bad values by log-and-keep. lightningd persists a setconfig value (configvar_save) only on a success response, so the blanket ack recorded values clboss never applied: listconfigs and config.setconfig diverged from the running configuration, and a non-numeric value persisted for an int-typed option fails lightningd's own option parse on the next start -- lightningd refuses to boot. Add a rejection back-channel to Msg::Option: SetConfigHandler allocates a shared reject_reason (null on init-time raises, so aggregate initialization at existing sites is unaffected), the owning subscriber reports rejection via the new Msg::Option::reject() helper (a no-op at init time, where quietly keeping the default is right), and SetConfigHandler -- whose bus.raise() returns only after all subscribers ran -- fails the command with invalid-params when a reason was set. All dynamic-option owners in this tree report their rejections: RebalanceModeManager (unrecognized mode), FundsMover's three numeric handlers, and AskreneUpdates' shared age/retain handler.
806 lines
25 KiB
C++
806 lines
25 KiB
C++
#include"Boss/Mod/AskreneUpdates.hpp"
|
|
#include"Boss/Mod/AskreneLayer.hpp"
|
|
#include"Boss/Mod/Rpc.hpp"
|
|
#include"Boss/Msg/AskreneChannelUpdate.hpp"
|
|
#include"Boss/Msg/AskreneNodeDisableUpdate.hpp"
|
|
#include"Boss/Msg/CommandFail.hpp"
|
|
#include"Boss/Msg/CommandRequest.hpp"
|
|
#include"Boss/Msg/CommandResponse.hpp"
|
|
#include"Boss/Msg/DbResource.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/OptionType.hpp"
|
|
#include"Boss/Msg/ProvideStatus.hpp"
|
|
#include"Boss/Msg/RequestAskreneUpdates.hpp"
|
|
#include"Boss/Msg/ResponseAskreneUpdates.hpp"
|
|
#include"Boss/Msg/SolicitStatus.hpp"
|
|
#include"Boss/Msg/TimerRandomHourly.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/NodeId.hpp"
|
|
#include"Ln/Scid.hpp"
|
|
#include"S/Bus.hpp"
|
|
#include"Sqlite3.hpp"
|
|
#include"Util/make_unique.hpp"
|
|
#include"Uuid.hpp"
|
|
#include<cinttypes>
|
|
#include<cstdint>
|
|
#include<string>
|
|
#include<vector>
|
|
|
|
namespace {
|
|
|
|
/* How long after its last occurrence a learned update is still projected
|
|
* into the per-request layer. Separate knobs -- a down node and a
|
|
* re-priced channel may deserve different half-lives. */
|
|
auto constexpr default_node_disable_age_secs = std::uint64_t(3600); /* 1h */
|
|
auto constexpr default_channel_update_age_secs = std::uint64_t(3600); /* 1h */
|
|
/* How long a row survives in the log at all -- long, because the log
|
|
* doubles as a mineable history of what got disabled / re-priced. */
|
|
auto constexpr default_retain_secs = std::uint64_t(2592000); /* 30d */
|
|
|
|
}
|
|
|
|
namespace Boss { namespace Mod {
|
|
|
|
class AskreneUpdates::Impl {
|
|
private:
|
|
S::Bus& bus;
|
|
std::function<double()> get_now;
|
|
Sqlite3::Db db;
|
|
Boss::Mod::Rpc* rpc;
|
|
|
|
std::uint64_t node_disable_age_secs;
|
|
std::uint64_t channel_update_age_secs;
|
|
std::uint64_t retain_secs;
|
|
|
|
void start() {
|
|
bus.subscribe<Msg::DbResource
|
|
>([this](Msg::DbResource const& r) {
|
|
db = r.db;
|
|
return init();
|
|
});
|
|
bus.subscribe<Msg::Init
|
|
>([this](Msg::Init const& init) {
|
|
rpc = &init.rpc;
|
|
return Boss::concurrent(sweep_stale_layers());
|
|
});
|
|
bus.subscribe<Msg::Manifestation
|
|
>([this](Msg::Manifestation const&) {
|
|
return bus.raise(Msg::ManifestCommand{
|
|
"clboss-askrene-updates",
|
|
"[hours]",
|
|
"Show the learned askrene updates CLBOSS is "
|
|
"applying: the node disables and channel_update "
|
|
"overrides still within their projection window "
|
|
"(what a rebalance getroutes gets right now), "
|
|
"each with its age, occurrence count and -- for "
|
|
"channels -- the overridden policy. Optional "
|
|
"{hours} widens the view to the last {hours} "
|
|
"hours of the retained log, so aged-out entries "
|
|
"appear too (projected=false). Read-only.",
|
|
false
|
|
}) + bus.raise(Msg::ManifestOption{
|
|
"clboss-node-disable-age-secs",
|
|
Msg::OptionType_Int,
|
|
Json::Out::direct(default_node_disable_age_secs),
|
|
"How long (seconds) after the most recent "
|
|
"NODE-level routing failure CLBOSS keeps "
|
|
"disabling that node in rebalance route "
|
|
"searches. Once this elapses with no fresh "
|
|
"failure the node is no longer projected and "
|
|
"becomes routable again. Dynamic via "
|
|
"`lightning-cli setconfig`. Default 3600 (1h).",
|
|
/* dynamic = */ true
|
|
}) + bus.raise(Msg::ManifestOption{
|
|
"clboss-channel-update-age-secs",
|
|
Msg::OptionType_Int,
|
|
Json::Out::direct(default_channel_update_age_secs),
|
|
"How long (seconds) after the most recent "
|
|
"failure-learned channel_update CLBOSS keeps "
|
|
"applying that policy override (fees, htlc "
|
|
"bounds, enabled flag) in rebalance route "
|
|
"searches. Once this elapses with no fresh "
|
|
"update the channel reverts to gossip policy. "
|
|
"Dynamic via `lightning-cli setconfig`. "
|
|
"Default 3600 (1h).",
|
|
/* dynamic = */ true
|
|
}) + bus.raise(Msg::ManifestOption{
|
|
"clboss-update-retain-secs",
|
|
Msg::OptionType_Int,
|
|
Json::Out::direct(default_retain_secs),
|
|
"How long (seconds) learned node-disable and "
|
|
"channel-update rows are kept in the CLBOSS "
|
|
"database before pruning. Independent of the "
|
|
"projection windows above: the log is retained "
|
|
"well past when an update stops being applied, "
|
|
"so it can be mined (which nodes churn, which "
|
|
"channels re-price). Dynamic via `lightning-cli "
|
|
"setconfig`. Default 2592000 (30d).",
|
|
/* dynamic = */ true
|
|
});
|
|
});
|
|
bus.subscribe<Msg::Option
|
|
>([this](Msg::Option const& o) {
|
|
if (o.name == "clboss-node-disable-age-secs")
|
|
return handle_option( o, node_disable_age_secs
|
|
, "clboss-node-disable-age-secs");
|
|
if (o.name == "clboss-channel-update-age-secs")
|
|
return handle_option( o, channel_update_age_secs
|
|
, "clboss-channel-update-age-secs");
|
|
if (o.name == "clboss-update-retain-secs")
|
|
return handle_option( o, retain_secs
|
|
, "clboss-update-retain-secs");
|
|
return Ev::lift();
|
|
});
|
|
bus.subscribe<Msg::AskreneNodeDisableUpdate
|
|
>([this](Msg::AskreneNodeDisableUpdate const& m) {
|
|
if (!db)
|
|
return Ev::lift();
|
|
return record_node(m.node);
|
|
});
|
|
bus.subscribe<Msg::AskreneChannelUpdate
|
|
>([this](Msg::AskreneChannelUpdate const& m) {
|
|
if (!db)
|
|
return Ev::lift();
|
|
return record_channel(m);
|
|
});
|
|
bus.subscribe<Msg::RequestAskreneUpdates
|
|
>([this](Msg::RequestAskreneUpdates const& req) {
|
|
auto requester = req.requester;
|
|
if (!db)
|
|
return bus.raise(Msg::ResponseAskreneUpdates{
|
|
requester, {}, {}
|
|
});
|
|
return provide(requester);
|
|
});
|
|
bus.subscribe<Msg::TimerRandomHourly
|
|
>([this](Msg::TimerRandomHourly const&) {
|
|
if (!db)
|
|
return Ev::lift();
|
|
return prune();
|
|
});
|
|
bus.subscribe<Msg::SolicitStatus
|
|
>([this](Msg::SolicitStatus const&) {
|
|
if (!db)
|
|
return Ev::lift();
|
|
return status();
|
|
});
|
|
bus.subscribe<Msg::CommandRequest
|
|
>([this](Msg::CommandRequest const& req) {
|
|
if (req.command != "clboss-askrene-updates")
|
|
return Ev::lift();
|
|
if (!db)
|
|
return bus.raise(Msg::CommandResponse{
|
|
req.id, Json::Out::empty_object()
|
|
});
|
|
return report(req);
|
|
});
|
|
}
|
|
|
|
/* Parse and apply one *-age-secs / retain-secs option, tolerating
|
|
* both the number-at-startup and string-via-setconfig encodings. */
|
|
Ev::Io<void> handle_option( Msg::Option const& o
|
|
, std::uint64_t& target
|
|
, char const* name
|
|
) {
|
|
/* Signed so a negative value is rejected below rather than
|
|
* wrapping to a huge unsigned: std::stoull accepts a leading
|
|
* minus and negates modulo 2^64, and the double->uint64
|
|
* conversion of a negative is undefined. Matches the
|
|
* FundsMover option handlers. A wrapped-huge window would
|
|
* silently project every retained row into every rebalance
|
|
* layer. */
|
|
auto secs = std::int64_t(0);
|
|
try {
|
|
if (o.value.is_number()) {
|
|
secs = std::int64_t(double(o.value));
|
|
} else if (o.value.is_string()) {
|
|
secs = std::stoll(std::string(o.value));
|
|
} else {
|
|
o.reject( std::string(name)
|
|
+ ": unsupported value type");
|
|
return Boss::log( bus, Warn
|
|
, "AskreneUpdates: %s: "
|
|
"unsupported value type; "
|
|
"keeping %" PRIu64 "."
|
|
, name, target
|
|
);
|
|
}
|
|
} catch (std::exception const& e) {
|
|
o.reject( std::string(name)
|
|
+ ": not a valid number");
|
|
return Boss::log( bus, Warn
|
|
, "AskreneUpdates: %s: parse error "
|
|
"'%s'; keeping %" PRIu64 "."
|
|
, name, e.what(), target
|
|
);
|
|
}
|
|
if (secs <= 0) {
|
|
o.reject(std::string(name) + ": must be > 0");
|
|
return Boss::log( bus, Warn
|
|
, "AskreneUpdates: %s: must be > 0; "
|
|
"keeping %" PRIu64 "."
|
|
, name, target
|
|
);
|
|
}
|
|
target = std::uint64_t(secs);
|
|
return Boss::log( bus, Info
|
|
, "AskreneUpdates: %s = %" PRIu64 " seconds."
|
|
, name, target
|
|
);
|
|
}
|
|
|
|
Ev::Io<void> init() {
|
|
return db.transact().then([](Sqlite3::Tx tx) {
|
|
tx.query_execute(R"QRY(
|
|
CREATE TABLE IF NOT EXISTS "AskreneNodeDisableUpdates"
|
|
( time INTEGER NOT NULL -- unix seconds
|
|
, node TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS
|
|
idx_askrenenodedisableupdates_node_time
|
|
ON "AskreneNodeDisableUpdates" (node, time);
|
|
CREATE INDEX IF NOT EXISTS
|
|
idx_askrenenodedisableupdates_time
|
|
ON "AskreneNodeDisableUpdates" (time);
|
|
CREATE TABLE IF NOT EXISTS "AskreneChannelUpdates"
|
|
( time INTEGER NOT NULL -- unix seconds
|
|
, scid TEXT NOT NULL
|
|
, dir INTEGER NOT NULL -- askrene direction 0/1
|
|
, enabled INTEGER NOT NULL
|
|
, htlc_min_msat INTEGER NOT NULL
|
|
, htlc_max_msat INTEGER NOT NULL
|
|
, base_fee_msat INTEGER NOT NULL
|
|
, prop_fee_ppm INTEGER NOT NULL
|
|
, cltv_delta INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS
|
|
idx_askrenechannelupdates_scid_dir_time
|
|
ON "AskreneChannelUpdates" (scid, dir, time);
|
|
CREATE INDEX IF NOT EXISTS
|
|
idx_askrenechannelupdates_time
|
|
ON "AskreneChannelUpdates" (time);
|
|
)QRY");
|
|
tx.commit();
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
/* Remove stale private per-request layers left over by a previous
|
|
* run. Runner (FundsMover) and XMoveFunds normally remove their
|
|
* clboss-updates-tmp-<uuid> layers when a request finishes, but an
|
|
* exception escaping mid-request skips those continuations -- in
|
|
* particular, a plugin stop with moves in flight throws
|
|
* Boss::Shutdown into every pending RPC, and cleanup at shutdown
|
|
* cannot work anyway since the Rpc module is already rejecting new
|
|
* commands by then. The layers are non-persistent, so a lightningd
|
|
* restart clears them; a clboss-only restart does not, and repeated
|
|
* redeploys accumulate junk in askrene-listlayers. Sweeping at
|
|
* init restores the invariant that only in-flight requests hold
|
|
* private layers. */
|
|
Ev::Io<void> sweep_stale_layers() {
|
|
return rpc->command( "askrene-listlayers"
|
|
, Json::Out::empty_object()
|
|
).then([this](Jsmn::Object res) {
|
|
auto stale = std::vector<std::string>();
|
|
try {
|
|
auto layers = res["layers"];
|
|
for (auto l : layers) {
|
|
auto name = std::string(l["layer"]);
|
|
if (name.rfind("clboss-updates-tmp-", 0) == 0)
|
|
stale.push_back(name);
|
|
}
|
|
} catch (std::exception const&) {
|
|
return Boss::log( bus, Warn
|
|
, "AskreneUpdates: unexpected "
|
|
"askrene-listlayers response; "
|
|
"skipping stale-layer sweep."
|
|
);
|
|
}
|
|
if (stale.empty())
|
|
return Ev::lift();
|
|
auto act = Ev::lift();
|
|
for (auto const& name : stale)
|
|
act = std::move(act)
|
|
+ AskreneUpdates::close_layer(*rpc, name);
|
|
return std::move(act)
|
|
+ Boss::log( bus, Info
|
|
, "AskreneUpdates: removed %zu stale "
|
|
"clboss-updates-tmp layer(s) left "
|
|
"by a previous run."
|
|
, stale.size()
|
|
);
|
|
}).catching<RpcError>([](RpcError const&) {
|
|
/* CLN without askrene: nothing to sweep. */
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> record_node(Ln::NodeId node) {
|
|
auto now = std::uint64_t(get_now());
|
|
auto node_s = std::string(node);
|
|
return db.transact().then([now, node_s](Sqlite3::Tx tx) {
|
|
tx.query(R"QRY(
|
|
INSERT INTO "AskreneNodeDisableUpdates"
|
|
VALUES(:time, :node);
|
|
)QRY")
|
|
.bind(":time", now)
|
|
.bind(":node", node_s)
|
|
.execute()
|
|
;
|
|
tx.commit();
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> record_channel(Msg::AskreneChannelUpdate cu) {
|
|
auto now = std::uint64_t(get_now());
|
|
return db.transact().then([now, cu](Sqlite3::Tx tx) {
|
|
tx.query(R"QRY(
|
|
INSERT INTO "AskreneChannelUpdates"
|
|
VALUES( :time, :scid, :dir, :enabled, :hmin, :hmax
|
|
, :base, :prop, :cltv);
|
|
)QRY")
|
|
.bind(":time", now)
|
|
.bind(":scid", std::string(cu.scid))
|
|
.bind(":dir", cu.direction)
|
|
.bind(":enabled", cu.enabled)
|
|
.bind(":hmin", cu.htlc_minimum_msat.to_msat())
|
|
.bind(":hmax", cu.htlc_maximum_msat.to_msat())
|
|
.bind(":base", cu.fee_base_msat.to_msat())
|
|
.bind(":prop", cu.fee_proportional_millionths)
|
|
.bind(":cltv", cu.cltv_expiry_delta)
|
|
.execute()
|
|
;
|
|
tx.commit();
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> provide(void* requester) {
|
|
auto now = std::uint64_t(get_now());
|
|
auto node_cutoff = (now > node_disable_age_secs)
|
|
? now - node_disable_age_secs : std::uint64_t(0);
|
|
auto chan_cutoff = (now > channel_update_age_secs)
|
|
? now - channel_update_age_secs : std::uint64_t(0);
|
|
return db.transact().then([this, requester, node_cutoff, chan_cutoff
|
|
](Sqlite3::Tx tx) {
|
|
auto resp = Msg::ResponseAskreneUpdates{requester, {}, {}};
|
|
|
|
/* INDEXED BY the bare (time) index: the planner
|
|
* otherwise picks the (node, time) index and
|
|
* full-scans it with time>= demoted to a per-row
|
|
* filter -- the whole retained table (30d) walked
|
|
* per projection instead of just the window (1h).
|
|
*/
|
|
auto nq = tx.query(R"QRY(
|
|
SELECT DISTINCT node
|
|
FROM "AskreneNodeDisableUpdates"
|
|
INDEXED BY idx_askrenenodedisableupdates_time
|
|
WHERE time >= :cutoff;
|
|
)QRY");
|
|
nq.bind(":cutoff", node_cutoff);
|
|
for (auto& r : nq.execute())
|
|
resp.node_disables.push_back(
|
|
Ln::NodeId(r.get<std::string>(0)));
|
|
|
|
/* Latest override per (scid, dir) still in window.
|
|
* sqlite fills the bare columns from the MAX(time)
|
|
* row of each group (preserved under INDEXED BY).
|
|
* The bare (time) index is forced for the same
|
|
* reason as the node query above: the planner's
|
|
* choice, (scid, dir, time), degrades to a
|
|
* full-index scan with time>= as a per-row filter.
|
|
*/
|
|
auto cq = tx.query(R"QRY(
|
|
SELECT scid, dir, enabled, htlc_min_msat, htlc_max_msat
|
|
, base_fee_msat, prop_fee_ppm, cltv_delta, MAX(time)
|
|
FROM "AskreneChannelUpdates"
|
|
INDEXED BY idx_askrenechannelupdates_time
|
|
WHERE time >= :cutoff
|
|
GROUP BY scid, dir;
|
|
)QRY");
|
|
cq.bind(":cutoff", chan_cutoff);
|
|
for (auto& r : cq.execute()) {
|
|
auto ndx = 0;
|
|
auto scid = r.get<std::string>(ndx++);
|
|
auto dir = r.get<std::uint32_t>(ndx++);
|
|
auto enabled = r.get<int>(ndx++);
|
|
auto hmin = r.get<std::uint64_t>(ndx++);
|
|
auto hmax = r.get<std::uint64_t>(ndx++);
|
|
auto base = r.get<std::uint64_t>(ndx++);
|
|
auto prop = r.get<std::uint64_t>(ndx++);
|
|
auto cltv = r.get<std::uint64_t>(ndx++);
|
|
resp.channel_updates.push_back(
|
|
Msg::AskreneChannelUpdate{
|
|
Ln::Scid(scid),
|
|
dir,
|
|
enabled != 0,
|
|
Ln::Amount::msat(hmin),
|
|
Ln::Amount::msat(hmax),
|
|
Ln::Amount::msat(base),
|
|
std::uint32_t(prop),
|
|
std::uint16_t(cltv)
|
|
});
|
|
}
|
|
tx.commit();
|
|
|
|
return bus.raise(std::move(resp));
|
|
});
|
|
}
|
|
|
|
Ev::Io<void> prune() {
|
|
auto now = std::uint64_t(get_now());
|
|
auto cutoff = (now > retain_secs) ? now - retain_secs
|
|
: std::uint64_t(0);
|
|
return db.transact().then([cutoff](Sqlite3::Tx tx) {
|
|
tx.query(R"QRY(
|
|
DELETE FROM "AskreneNodeDisableUpdates"
|
|
WHERE time < :cutoff;
|
|
)QRY")
|
|
.bind(":cutoff", cutoff)
|
|
.execute()
|
|
;
|
|
tx.query(R"QRY(
|
|
DELETE FROM "AskreneChannelUpdates"
|
|
WHERE time < :cutoff;
|
|
)QRY")
|
|
.bind(":cutoff", cutoff)
|
|
.execute()
|
|
;
|
|
tx.commit();
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
/* clboss-status block: counts of what is stored and, within the
|
|
* projection windows, what is being applied right now. */
|
|
Ev::Io<void> status() {
|
|
auto now = std::uint64_t(get_now());
|
|
auto ncut = (now > node_disable_age_secs)
|
|
? now - node_disable_age_secs : std::uint64_t(0);
|
|
auto ccut = (now > channel_update_age_secs)
|
|
? now - channel_update_age_secs : std::uint64_t(0);
|
|
return db.transact().then([this, now, ncut, ccut
|
|
](Sqlite3::Tx tx) {
|
|
auto out = Json::Out();
|
|
auto obj = out.start_object();
|
|
|
|
/* Scalar subqueries instead of one aggregate pass:
|
|
* MIN/MAX become O(1) index seeks, the projected
|
|
* count reads only the window via the (time)
|
|
* index, and the distinct counts scan a covering
|
|
* index without materializing per-row values. The
|
|
* previous whole-table aggregate form cost
|
|
* clboss-status a full-table scan (with a string
|
|
* concatenation per row on the channel side). */
|
|
auto nf = tx.query(R"QRY(
|
|
SELECT (SELECT COUNT(*)
|
|
FROM "AskreneNodeDisableUpdates")
|
|
, (SELECT COUNT(*)
|
|
FROM (SELECT DISTINCT node
|
|
FROM "AskreneNodeDisableUpdates"))
|
|
, COALESCE((SELECT MIN(time)
|
|
FROM "AskreneNodeDisableUpdates"), 0)
|
|
, COALESCE((SELECT MAX(time)
|
|
FROM "AskreneNodeDisableUpdates"), 0)
|
|
, (SELECT COUNT(DISTINCT node)
|
|
FROM "AskreneNodeDisableUpdates"
|
|
INDEXED BY idx_askrenenodedisableupdates_time
|
|
WHERE time >= :cut);
|
|
)QRY");
|
|
nf.bind(":cut", ncut);
|
|
for (auto& r : nf.execute()) {
|
|
auto nd = obj.start_object("node_disables");
|
|
nd
|
|
.field("rows", r.get<std::uint64_t>(0))
|
|
.field( "distinct_nodes"
|
|
, r.get<std::uint64_t>(1))
|
|
.field( "projected_nodes"
|
|
, r.get<std::uint64_t>(4))
|
|
.field("window_secs", node_disable_age_secs)
|
|
.field( "oldest_time"
|
|
, r.get<std::uint64_t>(2))
|
|
.field( "newest_time"
|
|
, r.get<std::uint64_t>(3))
|
|
;
|
|
nd.end_object();
|
|
}
|
|
|
|
auto cf = tx.query(R"QRY(
|
|
SELECT (SELECT COUNT(*)
|
|
FROM "AskreneChannelUpdates")
|
|
, (SELECT COUNT(*)
|
|
FROM (SELECT DISTINCT scid, dir
|
|
FROM "AskreneChannelUpdates"))
|
|
, COALESCE((SELECT MIN(time)
|
|
FROM "AskreneChannelUpdates"), 0)
|
|
, COALESCE((SELECT MAX(time)
|
|
FROM "AskreneChannelUpdates"), 0)
|
|
, (SELECT COUNT(*)
|
|
FROM (SELECT DISTINCT scid, dir
|
|
FROM "AskreneChannelUpdates"
|
|
INDEXED BY idx_askrenechannelupdates_time
|
|
WHERE time >= :cut));
|
|
)QRY");
|
|
cf.bind(":cut", ccut);
|
|
for (auto& r : cf.execute()) {
|
|
auto cu = obj.start_object("channel_updates");
|
|
cu
|
|
.field("rows", r.get<std::uint64_t>(0))
|
|
.field( "distinct_channel_dirs"
|
|
, r.get<std::uint64_t>(1))
|
|
.field( "projected_channel_dirs"
|
|
, r.get<std::uint64_t>(4))
|
|
.field( "window_secs"
|
|
, channel_update_age_secs)
|
|
.field( "oldest_time"
|
|
, r.get<std::uint64_t>(2))
|
|
.field( "newest_time"
|
|
, r.get<std::uint64_t>(3))
|
|
;
|
|
cu.end_object();
|
|
}
|
|
|
|
obj.field("retain_secs", retain_secs);
|
|
obj.field("now", now);
|
|
obj.end_object();
|
|
tx.commit();
|
|
|
|
return bus.raise(Msg::ProvideStatus{
|
|
"askrene_updates", std::move(out)
|
|
});
|
|
});
|
|
}
|
|
|
|
/* clboss-askrene-updates command: list the updates being applied
|
|
* now (default) or, with {hours}, everything in the last {hours}
|
|
* hours of the retained log (with projected=false for aged-out
|
|
* rows). Node disables grouped per node; channel updates grouped
|
|
* per (scid, dir) with the latest overridden policy. */
|
|
Ev::Io<void> report(Msg::CommandRequest const& req) {
|
|
auto id = req.id;
|
|
auto paramfail = [this, id]() {
|
|
return bus.raise(Msg::CommandFail{
|
|
id, -32602, "Parameter failure",
|
|
Json::Out::empty_object()
|
|
});
|
|
};
|
|
|
|
auto hours = double(0.0);
|
|
auto hours_j = Jsmn::Object();
|
|
auto params = req.params;
|
|
if (params.is_object()) {
|
|
auto known = std::size_t(0);
|
|
if (params.has("hours")) {
|
|
hours_j = params["hours"];
|
|
++known;
|
|
}
|
|
if (params.size() != known)
|
|
return paramfail();
|
|
} else if (params.is_array()) {
|
|
if (params.size() > 1)
|
|
return paramfail();
|
|
for (auto p : params)
|
|
hours_j = p;
|
|
}
|
|
if (!hours_j.is_null()) {
|
|
if (!hours_j.is_number())
|
|
return paramfail();
|
|
hours = double(hours_j);
|
|
if (hours <= 0)
|
|
return paramfail();
|
|
/* Clamp to the retention window: beyond it a
|
|
* wider view adds nothing (rows are pruned), and
|
|
* an unbounded value would make the
|
|
* double->uint64 conversion of hours*3600 below
|
|
* undefined. Clamping rather than failing --
|
|
* "everything retained" is the obvious intent of
|
|
* a huge value. */
|
|
if (hours * 3600.0 > double(retain_secs))
|
|
hours = double(retain_secs) / 3600.0;
|
|
}
|
|
|
|
auto now = std::uint64_t(get_now());
|
|
/* With {hours}, both kinds use that window; otherwise each
|
|
* uses its own projection window (the applied-now view). */
|
|
auto ncut = std::uint64_t(0);
|
|
auto ccut = std::uint64_t(0);
|
|
if (hours > 0) {
|
|
auto w = std::uint64_t(hours * 3600.0);
|
|
ncut = (w < now) ? now - w : std::uint64_t(0);
|
|
ccut = ncut;
|
|
} else {
|
|
ncut = (now > node_disable_age_secs)
|
|
? now - node_disable_age_secs : std::uint64_t(0);
|
|
ccut = (now > channel_update_age_secs)
|
|
? now - channel_update_age_secs : std::uint64_t(0);
|
|
}
|
|
|
|
return db.transact().then([this, id, now, ncut, ccut
|
|
](Sqlite3::Tx tx) {
|
|
auto out = Json::Out();
|
|
auto obj = out.start_object();
|
|
|
|
auto nf = tx.query(R"QRY(
|
|
SELECT node, COUNT(*), MAX(time)
|
|
FROM "AskreneNodeDisableUpdates"
|
|
WHERE time >= :cut
|
|
GROUP BY node
|
|
ORDER BY MAX(time) DESC;
|
|
)QRY");
|
|
nf.bind(":cut", ncut);
|
|
auto narr = obj.start_array("node_disables");
|
|
for (auto& r : nf.execute()) {
|
|
auto node = r.get<std::string>(0);
|
|
auto occ = r.get<std::uint64_t>(1);
|
|
auto last = r.get<std::uint64_t>(2);
|
|
auto age = (now >= last) ? now - last
|
|
: std::uint64_t(0);
|
|
auto o = narr.start_object();
|
|
o
|
|
.field("node", node)
|
|
.field("occurrences", occ)
|
|
.field("last_time", last)
|
|
.field("age_secs", age)
|
|
.field( "projected"
|
|
, age <= node_disable_age_secs)
|
|
;
|
|
o.end_object();
|
|
}
|
|
narr.end_array();
|
|
|
|
auto cf = tx.query(R"QRY(
|
|
SELECT scid, dir, enabled, htlc_min_msat, htlc_max_msat
|
|
, base_fee_msat, prop_fee_ppm, cltv_delta
|
|
, COUNT(*), MAX(time)
|
|
FROM "AskreneChannelUpdates"
|
|
WHERE time >= :cut
|
|
GROUP BY scid, dir
|
|
ORDER BY MAX(time) DESC;
|
|
)QRY");
|
|
cf.bind(":cut", ccut);
|
|
auto carr = obj.start_array("channel_updates");
|
|
for (auto& r : cf.execute()) {
|
|
auto ndx = 0;
|
|
auto scid = r.get<std::string>(ndx++);
|
|
auto dir = r.get<std::uint32_t>(ndx++);
|
|
auto enabled = r.get<int>(ndx++);
|
|
auto hmin = r.get<std::uint64_t>(ndx++);
|
|
auto hmax = r.get<std::uint64_t>(ndx++);
|
|
auto base = r.get<std::uint64_t>(ndx++);
|
|
auto prop = r.get<std::uint64_t>(ndx++);
|
|
auto cltv = r.get<std::uint64_t>(ndx++);
|
|
auto occ = r.get<std::uint64_t>(ndx++);
|
|
auto last = r.get<std::uint64_t>(ndx++);
|
|
auto age = (now >= last) ? now - last
|
|
: std::uint64_t(0);
|
|
auto o = carr.start_object();
|
|
o
|
|
.field("scid", scid)
|
|
.field("dir", dir)
|
|
.field("enabled", enabled != 0)
|
|
.field("htlc_min_msat", hmin)
|
|
.field("htlc_max_msat", hmax)
|
|
.field("base_fee_msat", base)
|
|
.field("prop_fee_ppm", prop)
|
|
.field("cltv_delta", cltv)
|
|
.field("occurrences", occ)
|
|
.field("last_time", last)
|
|
.field("age_secs", age)
|
|
.field( "projected"
|
|
, age <= channel_update_age_secs)
|
|
;
|
|
o.end_object();
|
|
}
|
|
carr.end_array();
|
|
|
|
obj.field("now", now);
|
|
obj.end_object();
|
|
tx.commit();
|
|
|
|
return bus.raise(Msg::CommandResponse{
|
|
id, 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)
|
|
, node_disable_age_secs(default_node_disable_age_secs)
|
|
, channel_update_age_secs(default_channel_update_age_secs)
|
|
, retain_secs(default_retain_secs) { start(); }
|
|
};
|
|
|
|
/* ~~~~ static projection helpers ~~~~ */
|
|
|
|
Ev::Io<std::string>
|
|
AskreneUpdates::open_layer( Boss::Mod::Rpc& rpc
|
|
, Boss::Msg::ResponseAskreneUpdates const& updates
|
|
) {
|
|
auto layer = std::string("clboss-updates-tmp-")
|
|
+ std::string(Uuid::random());
|
|
auto parms = Json::Out()
|
|
.start_object()
|
|
.field("layer", layer)
|
|
.field("persistent", false)
|
|
.end_object()
|
|
;
|
|
return rpc.command( "askrene-create-layer"
|
|
, std::move(parms)
|
|
).then([&rpc, layer, updates](Jsmn::Object) {
|
|
auto chain = Ev::lift();
|
|
for (auto const& node : updates.node_disables)
|
|
chain = std::move(chain)
|
|
+ Boss::Mod::AskreneLayer::disable_node(
|
|
rpc, layer, node);
|
|
for (auto const& cu : updates.channel_updates)
|
|
chain = std::move(chain)
|
|
+ Boss::Mod::AskreneLayer::update_channel(
|
|
rpc, layer,
|
|
cu.scid, cu.direction, cu.enabled,
|
|
cu.htlc_minimum_msat,
|
|
cu.htlc_maximum_msat,
|
|
cu.fee_base_msat,
|
|
cu.fee_proportional_millionths,
|
|
cu.cltv_expiry_delta);
|
|
return std::move(chain).then([layer]() {
|
|
return Ev::lift(layer);
|
|
});
|
|
}).catching<RpcError>([](RpcError const&) {
|
|
/* create-layer failed (e.g. CLN < v24.11 has no askrene):
|
|
* return an empty name so the caller omits it from its
|
|
* getroutes layers array rather than naming a layer that
|
|
* does not exist -- the exact condition that aborts the
|
|
* (important) askrene plugin. Degraded (no update
|
|
* projection this call), never a crash. */
|
|
return Ev::lift(std::string());
|
|
});
|
|
}
|
|
|
|
Ev::Io<void>
|
|
AskreneUpdates::close_layer( Boss::Mod::Rpc& rpc
|
|
, std::string const& layer
|
|
) {
|
|
auto parms = Json::Out()
|
|
.start_object()
|
|
.field("layer", layer)
|
|
.end_object()
|
|
;
|
|
return rpc.command( "askrene-remove-layer"
|
|
, std::move(parms)
|
|
).then([](Jsmn::Object) {
|
|
return Ev::lift();
|
|
}).catching<RpcError>([](RpcError const&) {
|
|
/* Best-effort: the layer is non-persistent and private to
|
|
* this finished request; a failed remove leaks nothing that
|
|
* a restart would not clear. */
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
AskreneUpdates::AskreneUpdates(AskreneUpdates&&) =default;
|
|
AskreneUpdates::~AskreneUpdates() =default;
|
|
|
|
AskreneUpdates::AskreneUpdates( S::Bus& bus
|
|
, std::function<double()> get_now_
|
|
)
|
|
: pimpl(Util::make_unique<Impl>(bus, get_now_)) { }
|
|
|
|
}}
|