Skip to content

multi: count forwarding packages without loading them - #11327

Merged
Roasbeef merged 2 commits into
lightningnetwork:masterfrom
ziggie1984:rpc-optional-fwdpkg-count
Oct 19, 2026
Merged

Roasbeef merged 2 commits into
lightningnetwork:masterfrom
ziggie1984:rpc-optional-fwdpkg-count

Conversation

@ziggie1984

@ziggie1984 ziggie1984 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Change

  • Add a count-only CountFwdPkgs method to the forwarding package store
    (FwdPackager, OpenChannelFwdPkgStore, ChannelStateDB, OpenChannel)
    that iterates the per-channel height buckets via kvdb.ForAll without
    loading package contents.
  • Use it in PendingChannels for waiting-close and pending-force-close
    channels to populate num_forwarding_packages.
  • Harden the SQL ForAll implementation so it returns row-iteration errors
    and preserves the distinction between nested buckets (NULL) and empty
    leaf values.

No RPC or CLI changes: num_forwarding_packages is still always populated.

Motivation

PendingChannels previously materialized every forwarding package, including
all of its HTLCs, only to return len(fwdPkgs). On the SQL-backed KV store
this produced a large number of database round trips per channel. Counting the
immediate height buckets with ForAll is a single query per channel.

The SQL iterator must not turn a read interrupted after a subset of rows into
a successful partial count. It must also keep an empty SQLite BLOB distinct
from the SQL NULL used to represent a nested bucket, otherwise a malformed
empty leaf can be counted as a forwarding package.

Counting is more lenient than loading package contents: a height bucket with
missing sub-buckets or filters is still counted. Malformed keys and stray leaf
values in the channel's source bucket return ErrCorruptedFwdPkg.

Testing

  • TestCountFwdPkgs covers add, remove, wipe, non-empty corrupted leaves and
    empty corrupted leaves.
  • SQLite regression tests cover empty-leaf preservation and interrupted row
    iteration.
  • BenchmarkPostgresFwdPkgCount (-tags kvdb_postgres) compares
    LoadFwdPkgs against CountFwdPkgs.
  • Existing itest/lnd_wipe_fwdpkgs_test.go exercises
    num_forwarding_packages end to end.

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Oct 13, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

Classified from changed files | 8 files | 140 lines changed

🔴 Critical (1 file)
  • rpcserver.go - core server coordination (explicitly listed critical path)
🟠 High (3 files)
  • lnrpc/lightning.proto - RPC/API definition change (new optional field)
  • lnrpc/lightning.pb.go - generated from the proto change above
  • lnrpc/lightning.swagger.json - generated swagger spec for the API change
🟡 Medium (1 file)
  • cmd/commands/commands.go - CLI client command update
🟢 Low (3 files)
  • docs/release-notes/release-notes-0.22.0.md - release notes
  • itest/lnd_wipe_fwdpkgs_test.go - integration test
  • lntest/rpc/lnd.go - test harness RPC helper

Analysis

This PR makes forwarding package counts optional in an RPC response, which requires a change to rpcserver.go — a file explicitly designated CRITICAL as part of core server coordination. The accompanying lnrpc/lightning.proto change (plus its generated .pb.go/swagger artifacts) adds a new optional field to the wire-facing API surface. File count and line volume are both well below the thresholds that would trigger an additional severity bump. Given the direct edit to rpcserver.go, this PR warrants expert review of the RPC response-handling logic to confirm correctness and backward compatibility.


To override, add a severity-override-{critical,high,medium,low} label.

@ZZiigguurraatt

Copy link
Copy Markdown
Contributor

Why do we have include_num_forwarding_packages and num_forwarding_packages? Can these be simplified to a single thing?

@ziggie1984

Copy link
Copy Markdown
Collaborator Author

To clarify: only include_num_forwarding_packages is new in this PR.
num_forwarding_packages already exists in PendingChannelsResponse; the
diff only updates its documentation to explain when it is populated.

The two fields cannot be combined because they travel in opposite directions:
the new request field tells the server whether to perform the expensive lookup,
while the existing response field carries the resulting count. My previous
reply about using one handle was too quick.

@ZZiigguurraatt

ZZiigguurraatt commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Just tested and now I'm getting

frank-6f4cf6b596-fj489:/$ time lncli pendingchannels >/dev/null

real	0m0.308s
user	0m0.036s
sys	0m0.017s
frank-6f4cf6b596-fj489:/$ 

which is much better than what is reported in #11289 (comment)

@ZZiigguurraatt

Copy link
Copy Markdown
Contributor

So this PR only fixes part 1 of #11289 (comment) and not part 2?

@ziggie1984

Copy link
Copy Markdown
Collaborator Author

So this PR only fixes part 1 of #11289 (comment) and not part 2?

Yes I am hesitant to fix part 2 with a patch because it will be fixed with the SQL implemenation once it is deployed. Let me know how your testing goes and whether we need to improve some things still to give you a temporary working version for your test-setup.

Comment thread rpcserver.go Outdated
return nil, 0, err
}

numFwdPkgs := int64(len(fwdPkgs))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: numFwdPkgs is a single-use intermediate variable here since channel is already declared above; we can just assign directly.

channel.NumForwardingPackages = int64(len(fwdPkgs))

@TechLateef TechLateef left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Tested and verified that tests and proto generation pass locally. Left one minor nit inline.
Also, don't forget to add your name to the docs/release-notes/release-notes-0.22.0.md

@saubyk saubyk added this to v0.21 Oct 14, 2026
@saubyk saubyk added this to the v0.21.5 milestone Oct 14, 2026
@saubyk saubyk moved this to In review in v0.21 Oct 14, 2026
@saubyk

saubyk commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

/gateway review

@lightninglabs-gateway lightninglabs-gateway Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gateway review — 2 findings

🔴 0 Blocker · 🟠 0 Major · 🟡 2 Minor · 🔵 0 Nit

Summary

This PR stops the PendingChannels RPC from loading forwarding packages for waiting-close and pending-force-close channels unless the caller sets the new include_num_forwarding_packages field. It is a reasonable mitigation for the SQL-kvdb latency reported in #11289, and the requester's timing confirms the improvement. The server-side change is mechanical. When the flag is set, the old LoadFwdPkgs call and its error path are kept exactly. When it is not set, the field is left at its zero value. Release notes and the lncli flag are in place.

There are two issues. First, num_forwarding_packages = 0 now means both "the channel has no forwarding packages" and "the count was not requested", and the response gives callers no way to tell which. Second, the new default-off assertion only covers waiting-close channels. The test harness now always sets the flag, so nothing checks the default path for pending-force-close channels.


Findings (could not anchor inline)

🟡 F1 (Minor) — Zero forwarding packages now also means not requested · lnrpc/lightning.proto:773

Existing callers that don't set the new flag now get num_forwarding_packages = 0 for every pending channel. That is the same value as a real count of zero, so a tool that reads 0 as "forwarding packages have been wiped" now gets a false positive with no error. lnd's own lnd_wipe_fwdpkgs_test reasons this way, and it keeps working only because the harness now always sets the flag.

The response field is a plain proto3 scalar, so it has no field presence. Updating the doc comment and adding a release-notes entry does not reach automated consumers: they keep compiling and quietly read a different meaning. If the ambiguity matters, the field could carry presence. One option is to declare it optional int64 num_forwarding_packages = 10;, which is wire-compatible but changes the Go field to *int64, a compile-time break for Go clients. Another is to add a sibling bool that says whether the count was populated. Either way, REST/JSON would omit the value instead of returning "0". If the team decides the count is diagnostic-only and the release note is enough, this can be left as is. The trade-off should be a deliberate choice.

Lower-severity findings (1)

🟡 F2 (Minor) — Default-off path untested for pending force-close channels · itest/lnd_wipe_fwdpkgs_test.go:63

The new assertion only covers the default-off branch of fetchWaitingCloseChannels. Nothing tests the new if includeNumForwardingPackages guard in fetchPendingForceCloseChannels. Because lntest/rpc/lnd.go now sets IncludeNumForwardingPackages: true on every harness call, no itest calls PendingForceClosingChannels without the flag. A matching PendingChannelsRequest{} check after the channel moves to pending force close would cover it.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

@lightninglabs-gateway

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

@yyforyongyu yyforyongyu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@bitromortac bitromortac left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I think this strategy makes sense, but I think a cheaper count can keep the response unchanged and make the new flag unnecessary, so perhaps it's worth changing the approach. Details are in the inline comment.

Edit: the request is non-blocking, we can also go on with your proposal, just let me know what you think of this

Comment thread rpcserver.go Outdated
"packages for channel:%s, %v",
historical.ShortChannelID, err)
return nil, 0, err
if includeNumForwardingPackages {

@bitromortac bitromortac Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flag exists only to avoid the cost of LoadFwdPkgs, but the RPC needs only the count, which is the number of package heights in the channel's bucket. A count-only read keeps num_forwarding_packages populated, so existing clients do not silently get 0 and no breaking change is necessary. What do you think about adding a CountFwdPkgs method to the forwarding package store and the chanstate interface that reads only the heights with kvdb.ForAll, use it at both call sites, and drop the request flag? In a store-level benchmark with 500 packages of 50 adds per channel, this goes from 244 ms to 263 µs on sqlite, and from 30003 queries to 3. The motivation behind this is that I'd like to avoid adding external knobs that make lnd more complicated.

@ziggie1984
ziggie1984 force-pushed the rpc-optional-fwdpkg-count branch 3 times, most recently from 505ed9c to 6f58a2a Compare October 16, 2026 14:04
@ziggie1984 ziggie1984 changed the title lnrpc: make forwarding package counts optional multi: count forwarding packages without loading them Oct 16, 2026
@ziggie1984
ziggie1984 force-pushed the rpc-optional-fwdpkg-count branch from 6f58a2a to ad12d2c Compare October 16, 2026 14:26
@ziggie1984

Copy link
Copy Markdown
Collaborator Author

@bitromortac updated to your new design proposal

@yyforyongyu yyforyongyu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we address the two count/error-handling cases below before merging?

}

var count int
err := kvdb.ForAll(sourceBkt, func(key, value []byte) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we return rows.Err() from SQL ForAll before using it here? It currently returns nil when rows.Next() stops on an error. I reproduced this through CountFwdPkgs on SQLite: with 3 valid packages, a read failure after 0 or 1 rows returns 0, nil or 1, nil. That gives PendingChannels a successful but incorrect count. Can we add an interrupted-iteration regression test too?

// Each direct child is a forwarding package bucket identified
// by its eight-byte commitment height. Reject malformed leaf
// entries instead of counting them as packages.
if len(key) != 8 || value != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we preserve the SQL NULL/empty-BLOB distinction in ForAll and add an empty-leaf case on SQLite? The iterator exposes an empty BLOB as nil, so this check treats it as a bucket. With 1 valid package plus an empty leaf, CountFwdPkgs returns 2, nil, while LoadFwdPkgs returns ErrCorruptedFwdPkg. This misses the stated guarantee that stray leaf values are rejected.

Add a count-only forwarding package store method that iterates
package height buckets without materializing their HTLC contents.

Use the new method for waiting-close and pending-force-close channels
so PendingChannels keeps populating the existing response field
without a new request flag.

Make SQL ForAll preserve empty leaf values and propagate row iteration
errors, with regression coverage for both cases.

Add forwarding package correctness coverage and a PostgreSQL benchmark
for the count-only path.
@ziggie1984
ziggie1984 force-pushed the rpc-optional-fwdpkg-count branch from ad12d2c to 63a17fc Compare October 16, 2026 17:29
@ziggie1984
ziggie1984 force-pushed the rpc-optional-fwdpkg-count branch from 63a17fc to 62467bb Compare October 16, 2026 17:43
@ZZiigguurraatt

Copy link
Copy Markdown
Contributor

To clarify: only include_num_forwarding_packages is new in this PR. num_forwarding_packages already exists in PendingChannelsResponse; the diff only updates its documentation to explain when it is populated.

The two fields cannot be combined because they travel in opposite directions: the new request field tells the server whether to perform the expensive lookup, while the existing response field carries the resulting count. My previous reply about using one handle was too quick.

OK, so looks like your later force pushes removed include_num_forwarding_packages?

@ZZiigguurraatt

Copy link
Copy Markdown
Contributor

Please rebase so we can get e69112e .

@ZZiigguurraatt

Copy link
Copy Markdown
Contributor

I'm now running 62467bb . Will report back on Monday if it is still stable like the previous version was I was testing earlier in the week.

@bitromortac bitromortac left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates! LGTM 🎉 I left one non-blocking nit on the commit layout.

rows, cancel, err := b.tx.Query(
"SELECT key, value FROM " + b.table + " WHERE " +
parentSelector(b.id) + " ORDER BY key",
"SELECT key, value, " +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The multi: commit mixes a ForAll bug fix in the kvdb module with the new store method and the RPC change. kvdb is its own Go module and other stores share ForAll, so a separate commit is easier to review, bisect and backport. A better split would be three commits that each build: kvdb/sqlbase: for the ForAll fix with its sqlite tests, chanstate: for CountFwdPkgs with its test, the interface, channeldb and the benchmark, and rpcserver: for the PendingChannels change (non-blocking)

@ZZiigguurraatt

Copy link
Copy Markdown
Contributor

I'm now running 62467bb . Will report back on Monday if it is still stable like the previous version was I was testing earlier in the week.

Only thing I've found so far is #11353 , which I think is an unrelated issue.

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👡

@ziggie1984 ziggie1984 added backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. labels Oct 19, 2026
@Roasbeef
Roasbeef merged commit b5de062 into lightningnetwork:master Oct 19, 2026
88 of 91 checks passed
@github-project-automation github-project-automation Bot moved this from In review to Done in v0.21 Oct 19, 2026
@ziggie1984
ziggie1984 deleted the rpc-optional-fwdpkg-count branch October 19, 2026 17:46
@github-actions

Copy link
Copy Markdown

Created backport PR for v0.20.x-branch:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-11327-to-v0.20.x-branch
git worktree add --checkout .worktree/backport-11327-to-v0.20.x-branch backport-11327-to-v0.20.x-branch
cd .worktree/backport-11327-to-v0.20.x-branch
git reset --hard HEAD^
git cherry-pick -x 65b2a54b6a59b009ef056b256b3497898dcb2249 62467bb0cf3788bd6436f8e2038e25216c497000
git push --force-with-lease

@github-actions

Copy link
Copy Markdown

Created backport PR for v0.21.x-branch:

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-11327-to-v0.21.x-branch
git worktree add --checkout .worktree/backport-11327-to-v0.21.x-branch backport-11327-to-v0.21.x-branch
cd .worktree/backport-11327-to-v0.21.x-branch
git reset --hard HEAD^
git cherry-pick -x 65b2a54b6a59b009ef056b256b3497898dcb2249 62467bb0cf3788bd6436f8e2038e25216c497000
git push --force-with-lease

ziggie1984 added a commit that referenced this pull request Oct 20, 2026
…20.x-branch

[v0.20.x-branch] Backport #11327: multi: count forwarding packages without loading them
ziggie1984 added a commit that referenced this pull request Oct 20, 2026
…21.x-branch

[v0.21.x-branch] Backport #11327: multi: count forwarding packages without loading them
ziggie1984 added a commit to ziggie1984/lnd that referenced this pull request Oct 21, 2026
The bug fix entry of lightningnetwork#11327 was folded from a fragment without a link to
its pull request. Link it, like every other entry in the release notes.
ziggie1984 added a commit to ziggie1984/lnd that referenced this pull request Oct 21, 2026
The bug fix entry of lightningnetwork#11327 was folded from a fragment without a link to
its pull request. Link it, like every other entry in the release notes.
ziggie1984 added a commit that referenced this pull request Oct 21, 2026
The bug fix entry of #11327 was folded from a fragment without a link to
its pull request. Link it, like every other entry in the release notes.

(cherry picked from commit 8c7cc1a)
ziggie1984 added a commit that referenced this pull request Oct 21, 2026
The bug fix entry of #11327 was folded from a fragment without a link to
its pull request. Link it, like every other entry in the release notes.

(cherry picked from commit ecd2dbb)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` gateway-active severity-critical Requires expert review - security/consensus critical

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

7 participants