Reclaim forwarded-payment replay markers instead of leaking them - #1107
ajaysehwal wants to merge 3 commits into
Conversation
|
👋 Thanks for assigning @benthecarman as a reviewer! |
8b94cde to
1839828
Compare
1839828 to
caad407
Compare
|
Thanks! Approach looks right, two structural things claude and I discussed on how to do this best:
Nits: the doc comment on |
91421f3 to
eb251c4
Compare
Thanks for the review — both fixed in the latest commit: eb251c4
Also fixed the stale doc comment and made forwarded_at_timestamp required. Separately, juliusjulyp caught that the old early-exit logic could stop the loop forever on an idle/fresh node. Removed that logic entirely — the loop now just runs for the node's lifetime, which is negligible cost when idle. Covered by updated/new tests. Let me know if anything still looks off. |
There was a problem hiding this comment.
Thanks, close now.
One major thing from claude, run_forwarded_payment_aggregation needs to cover the paired removal too. In Detailed mode, the startup aggregation pass after a long outage deletes both the detail and its marker, and the background processor then replays the unpersisted events against an empty marker store. The doc comment says this path carries no such risk, but it does. Skipping marker removal on the startup pass (leave them for the sweep) would fix it.
5525df4 to
e0e504e
Compare
|
@benthecarman All three are fixed now:
While I was in there, I also swapped the remove_markers: bool parameter threaded through the aggregation functions for a small MarkerRemoval::{Retain, Remove} enum, since the bare true/false at call sites (especially in tests) wasn't self-explanatory. |
e0e504e to
c3f39e4
Compare
Summary
ForwardingStore::record_forwardwrites a permanentForwardedPaymentReplayMarkerfor every forwarded HTLC, used to guard against LDK replaying an event whose side effects we already recorded. Nothing ever removed these markers -- the existing aggregation pass reclaims detail records but never touches the marker store, so it grows without bound for the life of the node.This is worse in the default
Statstracking mode: no detail record is ever written there, so aggregation has nothing to key cleanup off. Worse still,run_forwarded_payment_aggregation's background loop exits for good the first time it observes an empty details store underStatsmode -- which is immediately, since that mode never populates one -- so the reclamation task stops running entirely after its first pass on a routing node's very first startup, in the default config.Any peer with a channel to the node can trigger growth for the cost of their own routing fee (circular routing refunds it to the attacker). The write also happens synchronously inside the LDK event handler, so on a remote KV backend the accumulating read cost adds to the same hot path already shown to head-of-line block the event queue.
Fix
forwarded_at_timestampfield to the marker (TLV-optional, defaults to0on read, so pre-existing leaked markers are swept on the first pass after upgrading).prune_expired_replay_markers, run every aggregation cycle independent ofretention_secs, soStatsmode reclaims markers too. A marker is only removed once it's past a fixed one-bucket-wide age cutoff and has no corresponding detail record left --- age alone isn't sufficient: the aggregation pass defers an entire bucket (every sibling detail in it, not just the record missing a marker) whenever any one detail in that bucket is still missing its own marker. An age-only version of this fix passed every existing test but silently corrupted exactly that scenario; caught it with a dedicated regression test before landing this version.Stats.mode, where a detail can never exist to check for.Testing
Six new tests, covering: markers leaking across both tracking modes, the exact bucket-width age cutoff, the sibling-bucket corruption case above, and the background loop no longer exiting while markers remain.
For both substantive changes (the sibling-bucket guard, and the details-empty fast path), I also manually reverted just that piece, confirmed the corresponding test fails with the predicted symptom, and then restored it and confirmed green -- not just written, verified to actually catch the regression it's meant to catch.
cargo fmt --all -- --check,cargo clippy --lib -- -D clippy::unwrap_used(the repo's CI lint), and the fullcargo test --libsuite (200/200) all pass.