Repository navigation
htlcswitch: avoid per-row queries when pruning network result store - #11408
ZZiigguurraatt wants to merge 1 commit into
Conversation
🔴 PR Severity: CRITICAL
🔴 Critical (1 file)
AnalysisThe only changed file, To override, add a |
|
The implementation approach and single-commit scope look good. Before merge, I would only suggest:
The implementation itself and the one-commit PR structure can otherwise remain unchanged. |
| key := make([]byte, len(k)) | ||
| copy(key, k) | ||
| value := make([]byte, len(v)) | ||
| copy(value, v) | ||
| kept = append(kept, keptResult{key: key, value: value}) |
There was a problem hiding this comment.
| key := make([]byte, len(k)) | |
| copy(key, k) | |
| value := make([]byte, len(v)) | |
| copy(value, v) | |
| kept = append(kept, keptResult{key: key, value: value}) | |
| kept = append(kept, keptResult{ | |
| key: bytes.Clone(k), | |
| value: bytes.Clone(v), | |
| }) |
217f4ea to
605ba36
Compare
|
commit amended but I have not yet got this backported into my environment with the node that was struggling to test. |
`cleanStore` prunes the switch's network result store at startup, keeping only the results of payments still in flight. It iterated the bucket with `ForEach` and deleted each stale key with its own `Delete`. On the SQL kvdb backend every `ForEach` step and every `Delete` is a separate database round-trip (`ForEach` issues one `SELECT ... LIMIT 1` per row; `Delete` issues a `SELECT` bucket-check plus a `DELETE`), so pruning a store with N entries costs on the order of N sequential round-trips. The store holds one result per locally-initiated (outbound) payment (`handleLocalResponse`) — not forwarded or received HTLCs — and is only pruned here at startup, so it grows with outbound-payment volume during uptime; on a busy sending node against a networked Postgres this made startup spend hours doing round-trip-bound work. The whole prune also runs in a single transaction, so a node restarted before it finished rolled the deletes back and made no progress, leaving the store oversized for the next startup to repeat. Since the keep set is small, read the keepers with one streaming query via `kvdb.ForAll`, drop the whole bucket in one cascading `DeleteTopLevelBucket`, recreate it, and re-insert the keepers. This removes the O(N) client/server round-trips — the N per-row queries collapse to a single streamed read and a single cascading delete. Postgres still streams and deletes O(N) rows internally, but it does so in one statement each rather than N network round-trips, so the prune commits in well under a second regardless of store size. On bbolt `kvdb.ForAll` falls back to `ForEach` and the drop/recreate are ordinary operations, so behavior there is unchanged. Fixes lightningnetwork#11353.
605ba36 to
b696ddb
Compare
|
fixed a new issue in the test that the linter didn't like |
|
/gateway review |
|
Sorry, |
|
/gateway review |
There was a problem hiding this comment.
Gateway review — 5 findings
🔴 0 Blocker · 🟠 0 Major · 🟡 4 Minor · 🔵 1 Nit
Summary
This PR replaces the per-key ForEach/Delete prune in cleanStore with three steps: one kvdb.ForAll read that snapshots the entries to keep, one DeleteTopLevelBucket, then a recreate and re-insert of the kept entries. On the SQL backends this removes the O(N) client/server round-trips that stalled startup. The rewrite is correct. Kept keys and values are cloned before the bucket is dropped, the removed == 0 early return skips needless churn, errors still propagate, and everything stays in one transaction, so a failure rolls back cleanly. The new table-driven test runs the SQL path when built with the postgres or sqlite tag.
What remains is minor. The release note promises a speed it doesn't measure. The test can't detect a bad value copy. There is no empty-store case. The decode at startup still panics on a malformed key, as it did before this PR.
Lower-severity findings (5)
🟡 F1 (Minor) — Release note overstates the speedup · docs/release-notes/unreleased/11408.md:11
Nothing measures "commits in well under a second", and the reviewer specifically asked for this to be framed as removing O(N) client/server round-trips, since Postgres still scans and cascade-deletes O(N) rows internally. End the sentence at "…instead of a round-trip per entry" and drop the timing claim.
🟡 F2 (Minor) — Malformed key panics node at startup · htlcswitch/payment_result.go:287
binary.BigEndian.Uint64(k) panics on any key shorter than 8 bytes, so one corrupt key in network-result-store-bucket would crash startup instead of returning an error. The removed ForEach code had the same unchecked decode, so this is hardening, not a regression. Checking len(k) != 8 and returning a wrapped error would let startup fail cleanly.
🟡 F3 (Minor) — No test case for an empty store · htlcswitch/payment_result_test.go:234
The table never calls cleanStore on an empty bucket, which is the first-startup path through the removed == 0 early return at payment_result.go:307. Add a case that stores nothing, calls cleanStore with a non-empty keep set, and asserts that it returns no error.
🟡 F4 (Minor) — Identical payloads can't catch value-copy bugs · htlcswitch/payment_result_test.go:252
All five stored results are the same &lnwire.UpdateAddHTLC{}, and the kept-ID check only asserts NoError. If the bytes.Clone(v) at payment_result.go:297 regressed and a backend reused its buffer, every kept entry would still decode and the test would pass. Store a distinct payload per ID (for example, ID: i) and assert that each kept result matches what was stored.
🔵 F5 (Nit) — Small-keep-set assumption is not checked · htlcswitch/payment_result.go:307
The drop-and-reinsert path relies on "the keep set is small" (comment at lines 270–277), but only removed == 0 is special-cased. When few entries are stale and many are kept, it does more work than a per-key delete would. Either state the assumption as a precondition, or fall back to per-key Delete when removed < len(kept).
Reviewed at medium effort.
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)
|
🤖 gateway audit metadata for this PR — auto-generated, please don't edit. |
saubyk
left a comment
There was a problem hiding this comment.
Two comments from my end
| * [Fixed a startup stall on Postgres and SQLite | ||
| nodes](https://github.com/lightningnetwork/lnd/pull/11408): pruning the | ||
| switch's network result store at startup issued one database round-trip per | ||
| stored entry. The store accumulates one entry per locally-initiated | ||
| (outbound) payment, so on a node that sends a lot of payments the prune |
There was a problem hiding this comment.
Was the hours-long stall actually observed on SQLite, or is that inferred from the shared code path? SQLite round-trips are in-process, so the note may be overstating who is affected.
Also, should this read "per HTLC attempt" rather than "per payment"? MPP payments add several entries each, which matches the code comment and the correction in the thread above.
There was a problem hiding this comment.
Was the hours-long stall actually observed on SQLite, or is that inferred from the shared code path? SQLite round-trips are in-process, so the note may be overstating who is affected.
Did not investigate yet on a comparable node.
There was a problem hiding this comment.
Also, should this read "per HTLC attempt" rather than "per payment"? MPP payments add several entries each, which matches the code comment and the correction in the thread above.
Not sure, was using "per payment" to differentiate from receives and forwards. Will have to investigate that and get back.
|
I benchmarked the old implementation against the implementation in #11413 using the same PostgreSQL benchmark and three iterations:
This is approximately a 25-30x improvement on embedded PostgreSQL over localhost. A networked PostgreSQL deployment may see a larger improvement because the old implementation performs multiple client/server round trips per stale entry. PR #11413 includes |
|
I compared this PR at The production
So these PRs should not both merge. I recommend treating #11413 as the superseding version, or porting its validation/benchmark commit into this PR if this one is preferred. I found no correctness or security difference in the production implementation itself. |
|
Here are the concrete changes needed to bring this PR to parity with #11413:
func TestNetworkResultStoreCleanStore(t *testing.T) {
if kvdb.PostgresBackend {
stop, err := kvdb.StartEmbeddedPostgres()
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, stop())
})
} else {
t.Parallel()
}
// ...
}
{
name: "empty store",
numResults: 0,
keep: map[uint64]struct{}{0: {}},
},This requires making
msg := &lnwire.UpdateAddHTLC{ID: i}
n := &networkResult{
msg: msg,
unencrypted: true,
isResolution: true,
}Then, for each retained result: result, err := store.getResult(i)
require.NoError(t, err)
add, ok := result.msg.(*lnwire.UpdateAddHTLC)
require.True(t, ok)
require.Equal(t, i, add.ID)
Keep fixture population outside the timer. This is the benchmark used for the old/new comparison (~25-30x locally).
The production |
@saubyk is there any value in testing with SQLite to satisfy #11408 (comment) , or just forget about that comment? |
|
@ZZiigguurraatt No need for a SQLite benchmark. My comment was about the release-note wording. The release note in #11413 already resolves this: "SQL-backed nodes" with the hours attributed to remote Postgres, and "per locally initiated outbound HTLC attempt" instead of "per payment". If #11413 supersedes this PR, I consider the thread resolved. Regardless SQLite ran the same per-entry statements, so it benefits from the fix too. |
|
closing in favour of #11413 |
cleanStoreprunes the switch's network result store at startup, keeping only the results of still in-flight attempts. It iterated the bucket withForEachand deleted each stale key with its ownDelete. On the native SQL kvdb backend everyForEachstep and everyDeleteis a separate database round-trip (ForEachissues oneSELECT ... LIMIT 1per row;Deleteissues aSELECTbucket-check plus aDELETE), so pruning a store with N entries costs on the order of N sequential round-trips. The store is only pruned here at startup, so it grows with payment and forwarding volume during uptime; on a busy node against a networked Postgres this made startup spend hours doing round-trip-bound work. The whole prune also runs in a single transaction, so a node restarted before it finished rolled the deletes back and made no progress, leaving the store oversized for the next startup to repeat.Since the keep set is small, read the keepers with one streaming query via
kvdb.ForAll, drop the whole bucket in one cascadingDeleteTopLevelBucket, recreate it, and re-insert the keepers. This turns the O(N) read-and-delete loop into one read, one drop, one recreate, and O(keep) inserts, independent of how many stale entries have accumulated, so the prune commits in well under a second regardless of store size. On bboltkvdb.ForAllfalls back toForEachand the drop/recreate are ordinary operations, so behavior there is unchanged.Fixes #11353.