Skip to content

wallet: avoid redundant prevout rescans in invoice paid detection#10736

Open
sashazykov wants to merge 4 commits into
spesmilo:masterfrom
sashazykov:invoice-paid-detection-perf
Open

wallet: avoid redundant prevout rescans in invoice paid detection#10736
sashazykov wants to merge 4 commits into
spesmilo:masterfrom
sashazykov:invoice-paid-detection-perf

Conversation

@sashazykov

Copy link
Copy Markdown
Contributor

Follow-up to #10658 (paid-invoice keys cache). Four small commits addressing the remaining prevout-rescan hotspots in invoice paid detection, all measured on a real wallet with 686 outgoing invoices, accumulated from a year of daily payments to 40-90 recipients — many of whom appear on the list every day, so their invoices pile up on the same addresses: 572 invoices share the single most-reused destination scriptpubkey.

scenario master this PR
wallet open (incl. detection pass) ~37 s ~0.7 s
one tx event, invoices confirmed-paid (normal use) ~35 s <1 ms
one tx event, invoices unconfirmed (initial sync) ~35 s ~0.6 s
full sync from master key with invoices present 12+ hours ~5 min

The ~35s cases run synchronously on the asyncio event loop thread — longer than the 30s request timeout, so during initial sync every in-flight request (merkle proofs, txs) was guaranteed to time out, servers kept disconnecting, and the GUI froze. Both threads were observed inside _is_onchain_invoice_paid via py-spy: the event loop under on_event_adb_added_tx, and the GUI thread under InvoiceList.refresh_item.

Commit 1 — since 63ee2f3, the detection pass discards the cache key, scans prevouts (for relevant_txs), then calls get_invoice_status(), which rescans. Re-add the key from the fresh scan result (confirmed-paid only; LN status precedence preserved; reorg demotion unaffected). Halves the wallet-open pass. Side effect: on master the cache was actually empty right after __init__ (register_callbacks() runs after _prepare_onchain_invoice_paid_detection()), so this also makes load-time cache population effective.

Commit 2 — a tx addition can only promote an invoice towards paid; demotion only comes from the removal paths (reorg/conflict), which still rescan unconditionally. So additions skip invoices already cached as PR_PAID, like set_broadcasting does since d2d4251. Fixes the ~35s per tx in a synced wallet. Does not help unconfirmed invoices (conf==0 cannot be cached) — that is addressed by the next commit.

Commit 3 — invoices sharing a scriptpubkey each redid the identical prevouts + get_tx_height lookups; one pass over 572 sharing invoices did 572+ lookups of the same data. Memoize the per-scriptpubkey lookup for the duration of one detection pass; per-invoice height filtering applies on top. No invalidation is needed: the cache exists only while a pass runs, adb state cannot change mid-pass, and all access is serialized under wallet.lock. Note the diff is dominated by a try/finally re-indent — git show -w shows the ~37-line logic change.

Commit 4 — each invoice_status event made the Qt invoice list recompute the status (a prevout scan) on the GUI thread; while syncing these arrive per touched invoice per tx. While not up-to-date, just set need_updateupdate_wallet() already defers update_tabs() until is_up_to_date() and then rebuilds the list once.

Testing: three new unit tests spying on the scan/lookup calls (load pass scans each invoice once; tx additions and verifications don't rescan cached-paid invoices; a shared scriptpubkey is looked up once per pass, with per-invoice height filtering pinned by a height-excluded invoice). The existing reorg test still pins paid->unpaid demotion. tests.test_invoices, tests.test_wallet, tests.test_wallet_vertical: 131 tests pass. Real-world: watch-only wallet restored from the hw wallet's master public key with the invoices imported — full sync from scratch took 12+ hours on master (timeout spam, disconnections, frozen GUI) vs ~5 minutes on this branch.

Known remaining (pre-existing, not introduced here): timer_actions refreshes the visible invoice list every ~0.5s with a per-row status recomputation, so keeping the Send tab open during initial sync still costs GUI-thread scans; the request_status and QML paths have the same per-event pattern. Happy to follow up on those if there's interest.

Happy to privately share the GPG-encrypted watch-only reproduction wallet if useful for testing.

…ection

Follow-up to 63ee2f3 (paid invoice cache de-sloppify):
_update_onchain_invoice_paid_detection discards the invoice from the
paid-keys cache, scans prevouts (needed for relevant_txs), then calls
get_invoice_status(), which redoes the same scan as the cache key is
gone. This doubles the per-invoice prevout work at wallet load
(_prepare_onchain_invoice_paid_detection) on wallets with many
invoices.

Re-add the key to the cache when the fresh scan says paid with >=1
conf, so get_invoice_status() short-circuits on its existing cache
check instead of rescanning. Reorg demotion is unaffected: the re-add
only ever happens from a fresh scan. Lightning invoices are excluded,
as their LN status takes precedence in get_invoice_status (e.g.
PR_INFLIGHT) and must not be masked by a pre-seeded PR_PAID.

On a real wallet with 686 invoices (572 sharing one destination
scriptpubkey), this halves the detection pass at wallet open:
~36s -> ~18s.
on_event_adb_added_tx and on_event_adb_added_verified_tx rerun onchain
paid detection for every invoice sharing a scriptpubkey with the tx.
On a wallet where many invoices pay the same destination, every tx
touching that destination rescans prevouts for all of them,
synchronously on the asyncio event loop thread: ~35s per tx event on a
wallet with 686 invoices, 572 sharing one destination - longer than
the 30s network request timeout.

A tx addition can only promote an invoice towards paid: demotion
(paid->unpaid) can only be caused by a tx/verification removal (e.g.
reorg), and those paths still rescan unconditionally. So skip invoices
already cached as PR_PAID when the update was triggered by a tx
addition, like set_broadcasting already does since d2d4251.

On the wallet above, a tx event touching the shared destination goes
from ~35s (1176 prevout scans) to ~0ms (0 scans) once the invoices are
confirmed-paid. Note this does not help invoices that are not yet
confirmed (conf==0 cannot enter the cache), so during initial sync
from seed, paid detection still reruns per tx; that cost is addressed
in the next commit.
_is_onchain_invoice_paid redoes the prevout lookup and per-prevout
get_tx_height for each invoice, even though invoices often share a
scriptpubkey (repeat payments to the same destination). One
_update_onchain_invoice_paid_detection pass over such invoices does
the identical lookup for each of them, and unconfirmed invoices
(conf==0, e.g. during initial sync before merkle proofs arrive) cannot
enter the paid-keys cache, so during sync every tx addition pays the
full cost again, on the asyncio event loop thread.

Memoize the per-scriptpubkey lookup for the duration of one detection
pass; the per-invoice height filtering still applies on top. The cache
only exists while a pass runs, so it needs no invalidation: adb state
cannot change mid-pass, as both mutate on the asyncio thread. It also
covers the second lookup that get_invoice_status does for invoices
that cannot be cache-seeded (unconfirmed/unpaid).

On a real wallet (686 invoices, 572 sharing one destination):
- detection pass at wallet open: ~18s -> ~0.6s (~36s on master)
- one adb_added_tx event while invoices are unconfirmed (initial
  sync): ~18s -> ~0.6s (~35s on master)
A full sync from seed with these invoices present went from 12+ hours
(GUI frozen, servers timing out) to ~5 minutes.
Each invoice_status event makes InvoiceList.refresh_item recompute the
invoice status, which scans prevouts, on the GUI thread. While the
wallet is syncing these events arrive in bulk - one per touched
invoice per tx addition - so a wallet with many invoices sharing a
destination scriptpubkey freezes the GUI for the duration of the sync
(observed via py-spy: the GUI thread inside _is_onchain_invoice_paid
under refresh_item, while the event loop thread ran a detection pass).

While not up_to_date, just set need_update: update_wallet() already
defers update_tabs() until is_up_to_date(), and then rebuilds the
invoice list once, in one batch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant