mirror of
https://github.com/ZmnSCPxj/clboss.git
synced 2026-08-13 12:33:20 +02:00
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.
286 lines
9.5 KiB
C++
286 lines
9.5 KiB
C++
#include"Boss/Mod/AskreneLayer.hpp"
|
|
#include"Boss/Mod/Rpc.hpp"
|
|
#include"Ev/Io.hpp"
|
|
#include"Ev/now.hpp"
|
|
#include"Jsmn/Object.hpp"
|
|
#include"Json/Out.hpp"
|
|
#include"Util/stringify.hpp"
|
|
#include<assert.h>
|
|
#include<cstdint>
|
|
#include<map>
|
|
|
|
namespace Boss { namespace Mod { namespace AskreneLayer {
|
|
|
|
std::string const clboss_volatile_layer_name = "clboss-volatile";
|
|
std::string const clboss_layer_name = "clboss";
|
|
|
|
namespace {
|
|
|
|
/* Coalescing state, keyed by "layer|scid-dir|inform-kind" -> the
|
|
* tightest bound emitted in the current time bucket (see InformObs in
|
|
* the header). Safe as a file-static: the whole plugin runs on a
|
|
* single Ev event-loop thread, so there is no concurrent access. */
|
|
std::map<std::string, InformObs> inform_cache;
|
|
|
|
/* The coalescing bucket length, derived as a fixed fraction
|
|
* (1 / coalesce_window_divisor) of the layer aging window
|
|
* (clboss-classic-layer-age-secs). Making it a fraction of the aging
|
|
* window means the keep-alive re-emit (once per bucket) always refreshes
|
|
* a constraint well before it can age out, and the aging window is
|
|
* always exactly coalesce_window_divisor buckets long whatever its
|
|
* value -- so the prune and the depth floor are scale-invariant.
|
|
* FundsMover feeds the live aging value via set_aging_window_secs();
|
|
* this default matches aging/12 at the default 12h aging (1h bucket). */
|
|
std::uint64_t constexpr coalesce_window_divisor = 12;
|
|
double coalesce_window_secs = 43200.0 / double(coalesce_window_divisor);
|
|
|
|
/* Drop coalescing entries idle past the aging window, so the cache does
|
|
* not grow with the set of channel-dirs seen over the process lifetime.
|
|
* Amortised -- swept once per 4096 emits, not per call. */
|
|
void prune_inform_cache(std::uint64_t now_bucket) {
|
|
static std::uint64_t emits = 0;
|
|
if ((++emits & 0xFFF) != 0)
|
|
return;
|
|
/* The aging window is always coalesce_window_divisor buckets, so a
|
|
* few more than that covers any still-active dir at any aging value. */
|
|
auto constexpr keep_buckets = std::uint64_t(coalesce_window_divisor + 4);
|
|
for (auto it = inform_cache.begin(); it != inform_cache.end(); ) {
|
|
if (it->second.bucket + keep_buckets < now_bucket)
|
|
it = inform_cache.erase(it);
|
|
else
|
|
++it;
|
|
}
|
|
}
|
|
|
|
/* Common machinery for the two inform_channel variants. askrene
|
|
* accepts inform=succeeded / constrained / unconstrained as the
|
|
* only difference between them; everything else (scid_dir,
|
|
* amount_msat, layer) is identical.
|
|
*/
|
|
Ev::Io<void>
|
|
inform_channel( Boss::Mod::Rpc& rpc
|
|
, std::string const& layer
|
|
, Ln::Scid scid
|
|
, std::uint32_t direction
|
|
, Ln::Amount amount
|
|
, char const* inform
|
|
) {
|
|
/* askrene only accepts direction 0 or 1 in
|
|
* short_channel_id_dir. All callers feed values from
|
|
* CLN's getroutes/sendpay responses, which are
|
|
* guaranteed to be 0/1, but guard explicitly: a bad
|
|
* direction would produce a syntactically valid but
|
|
* semantically wrong RPC param that askrene rejects, and
|
|
* the silent-swallow RpcError handler below would drop
|
|
* the learning update without a trace.
|
|
*/
|
|
assert(direction <= 1);
|
|
if (direction > 1)
|
|
return Ev::lift();
|
|
auto sdir = std::string(scid) + "/" + Util::stringify(direction);
|
|
|
|
/* Coalesce redundant writes (see InformObs in the header): keep the
|
|
* tightest bound per (layer, scid-dir, kind) within one aging-derived
|
|
* bucket and emit only on a new bucket (keep-alive against the layer
|
|
* aging) or a tightening. Dropping a dominated write is lossless --
|
|
* get_constraints folds the dir down to one tightest [min,max], so the
|
|
* dropped entry would not have changed any route. */
|
|
auto const bucket = std::uint64_t(Ev::now() / coalesce_window_secs);
|
|
auto const is_lower_bound = (std::string(inform) != "constrained");
|
|
auto const key = layer + "|" + sdir + "|" + inform;
|
|
auto const cache_it = inform_cache.find(key);
|
|
auto const* prior = (cache_it == inform_cache.end())
|
|
? nullptr : &cache_it->second;
|
|
if (!inform_coalesce_emit( prior, bucket
|
|
, std::uint64_t(amount.to_msat())
|
|
, is_lower_bound
|
|
))
|
|
return Ev::lift();
|
|
inform_cache[key] = InformObs{ bucket, std::uint64_t(amount.to_msat()) };
|
|
prune_inform_cache(bucket);
|
|
|
|
auto parms = Json::Out()
|
|
.start_object()
|
|
.field("layer", layer)
|
|
.field("short_channel_id_dir", sdir)
|
|
.field("amount_msat", amount.to_msat())
|
|
.field("inform", std::string(inform))
|
|
.end_object()
|
|
;
|
|
return rpc.command( "askrene-inform-channel"
|
|
, std::move(parms)
|
|
).then([](Jsmn::Object _) {
|
|
return Ev::lift();
|
|
}).catching<RpcError>([](RpcError const&) {
|
|
/* Non-fatal -- if the layer is missing (e.g. layer-
|
|
* create failed at startup on CLN < v24.11),
|
|
* subsequent getroutes calls simply will not benefit
|
|
* from the constraint. Better to degrade learning
|
|
* than to crash the caller.
|
|
*/
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
/* The coalescing decision (see InformObs in the header). Defined out
|
|
* here rather than in the anonymous namespace so the unit test can call
|
|
* it directly; inform_channel reaches it via the header declaration. */
|
|
bool
|
|
inform_coalesce_emit( InformObs const* prior
|
|
, std::uint64_t bucket
|
|
, std::uint64_t amount_msat
|
|
, bool is_lower_bound
|
|
) {
|
|
if (!prior)
|
|
return true; /* first observation for this key */
|
|
if (prior->bucket != bucket)
|
|
return true; /* new bucket: keep-alive emit */
|
|
/* Same bucket: emit only if this observation tightens the bound. */
|
|
return is_lower_bound ? amount_msat > prior->tightest_msat
|
|
: amount_msat < prior->tightest_msat;
|
|
}
|
|
|
|
/* Set the coalescing bucket length from the current layer aging window
|
|
* (clboss-classic-layer-age-secs), as aging / coalesce_window_divisor.
|
|
* Called by FundsMover when that option loads or changes, so the
|
|
* coalescing window tracks the aging window live. Floored at 1s so a
|
|
* pathological aging value can never produce a zero-length bucket. */
|
|
void set_aging_window_secs(std::uint64_t aging_secs) {
|
|
auto const w = double(aging_secs) / double(coalesce_window_divisor);
|
|
coalesce_window_secs = (w >= 1.0) ? w : 1.0;
|
|
}
|
|
|
|
Ev::Io<void>
|
|
inform_channel_constrained( Boss::Mod::Rpc& rpc
|
|
, std::string const& layer
|
|
, Ln::Scid scid
|
|
, std::uint32_t direction
|
|
, Ln::Amount amount
|
|
) {
|
|
return inform_channel(rpc, layer, scid, direction, amount, "constrained");
|
|
}
|
|
|
|
Ev::Io<void>
|
|
inform_channel_unconstrained( Boss::Mod::Rpc& rpc
|
|
, std::string const& layer
|
|
, Ln::Scid scid
|
|
, std::uint32_t direction
|
|
, Ln::Amount amount
|
|
) {
|
|
return inform_channel(rpc, layer, scid, direction, amount, "unconstrained");
|
|
}
|
|
|
|
Ev::Io<void>
|
|
update_channel( Boss::Mod::Rpc& rpc
|
|
, std::string const& layer
|
|
, Ln::Scid scid
|
|
, std::uint32_t direction
|
|
, bool enabled
|
|
, Ln::Amount htlc_minimum_msat
|
|
, Ln::Amount htlc_maximum_msat
|
|
, Ln::Amount fee_base_msat
|
|
, std::uint32_t fee_proportional_millionths
|
|
, std::uint16_t cltv_expiry_delta
|
|
) {
|
|
/* Same direction-validity guard as inform_channel: askrene
|
|
* only accepts 0 or 1 in short_channel_id_dir.
|
|
*/
|
|
assert(direction <= 1);
|
|
if (direction > 1)
|
|
return Ev::lift();
|
|
auto sdir = std::string(scid) + "/" + Util::stringify(direction);
|
|
auto parms = Json::Out()
|
|
.start_object()
|
|
.field("layer", layer)
|
|
.field("short_channel_id_dir", sdir)
|
|
.field("enabled", enabled)
|
|
.field("htlc_minimum_msat", htlc_minimum_msat.to_msat())
|
|
.field("htlc_maximum_msat", htlc_maximum_msat.to_msat())
|
|
.field("fee_base_msat", fee_base_msat.to_msat())
|
|
.field( "fee_proportional_millionths"
|
|
, fee_proportional_millionths
|
|
)
|
|
.field( "cltv_expiry_delta"
|
|
, cltv_expiry_delta
|
|
)
|
|
.end_object()
|
|
;
|
|
return rpc.command( "askrene-update-channel"
|
|
, std::move(parms)
|
|
).then([](Jsmn::Object _) {
|
|
return Ev::lift();
|
|
}).catching<RpcError>([](RpcError const&) {
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
Ev::Io<bool>
|
|
is_node_disabled( Boss::Mod::Rpc& rpc
|
|
, std::string const& layer
|
|
, Ln::NodeId node
|
|
) {
|
|
auto target = std::string(node);
|
|
auto parms = Json::Out()
|
|
.start_object()
|
|
.field("layer", layer)
|
|
.end_object()
|
|
;
|
|
return rpc.command( "askrene-listlayers"
|
|
, std::move(parms)
|
|
).then([target = std::move(target)
|
|
](Jsmn::Object res) {
|
|
try {
|
|
auto layers = res["layers"];
|
|
if (!layers.is_array() || layers.size() == 0)
|
|
return Ev::lift(false);
|
|
auto layer_obj = layers[0];
|
|
if (!layer_obj.has("disabled_nodes"))
|
|
return Ev::lift(false);
|
|
auto disabled = layer_obj["disabled_nodes"];
|
|
if (!disabled.is_array())
|
|
return Ev::lift(false);
|
|
for (auto entry : disabled) {
|
|
if (std::string(entry) == target)
|
|
return Ev::lift(true);
|
|
}
|
|
} catch (std::exception const&) {
|
|
/* Malformed response shape -- fall through to
|
|
* false so the caller continues without
|
|
* deduping rather than crashing.
|
|
*/
|
|
}
|
|
return Ev::lift(false);
|
|
}).catching<RpcError>([](RpcError const&) {
|
|
/* Conservative on RPC error: returning false lets
|
|
* the caller fall through to its disable_node call
|
|
* (which also swallows RpcError). Worst case is
|
|
* an accumulating duplicate, same as the pre-dedup
|
|
* behaviour.
|
|
*/
|
|
return Ev::lift(false);
|
|
});
|
|
}
|
|
|
|
Ev::Io<void>
|
|
disable_node( Boss::Mod::Rpc& rpc
|
|
, std::string const& layer
|
|
, Ln::NodeId node
|
|
) {
|
|
auto parms = Json::Out()
|
|
.start_object()
|
|
.field("layer", layer)
|
|
.field("node", std::string(node))
|
|
.end_object()
|
|
;
|
|
return rpc.command( "askrene-disable-node"
|
|
, std::move(parms)
|
|
).then([](Jsmn::Object _) {
|
|
return Ev::lift();
|
|
}).catching<RpcError>([](RpcError const&) {
|
|
return Ev::lift();
|
|
});
|
|
}
|
|
|
|
}}}
|