Reclaim forwarded-payment replay markers instead of leaking them - #1107
ajaysehwal wants to merge 3 commits into
Conversation
|
👋 I see @tnull was un-assigned. |
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.
| /// already covers the sweep itself eventually reclaiming a marker this old; this test is | ||
| /// narrowly about the startup pass not being the one to do it. | ||
| #[tokio::test] | ||
| async fn startup_pass_does_not_sweep_an_old_marker() { |
There was a problem hiding this comment.
Adding a Detailed version of what I described above would catch the issue.
| // The marker's job ends with its detail: this bucket was only aggregated because the | ||
| // marker confirmed the detail was durably recorded, so nothing needs it anymore. Removing | ||
| // it here, rather than leaving it to the separate age-based sweep, means that sweep rarely | ||
| // has to fall back to its per-marker existence check -- almost every Detailed-mode marker | ||
| // is gone by the time it would otherwise become a candidate. |
There was a problem hiding this comment.
this is way too much docs for this line, how about:
The bucket is committed and the detail is gone, so the marker has nothing left to guard. Remove it here so the sweep only has to deal with markers that have no detail. If this fails the marker is orphaned and the sweep reclaims it.
| /// Sweeps markers the aggregation pass above didn't already remove alongside their detail -- | ||
| /// `Stats` mode, which writes no detail to pair a removal with, and crash orphans. See | ||
| /// [`prune_expired_replay_markers`] for why age alone isn't a safe criterion here. | ||
| pub(crate) async fn prune_stale_replay_markers(&self) -> Result<u64, Error> { |
There was a problem hiding this comment.
nit: this doesn't need to be pub(crate)
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.