Skip to content

perf(sp): eliminate per-tx DB iterator reads in tweaks.subscribe - #3

Open
sethforprivacy wants to merge 3 commits into
cake-update-v2from
sp-scan-fast-path
Open

perf(sp): eliminate per-tx DB iterator reads in tweaks.subscribe#3
sethforprivacy wants to merge 3 commits into
cake-update-v2from
sp-scan-fast-path

Conversation

@sethforprivacy

@sethforprivacy sethforprivacy commented Aug 15, 2026

Copy link
Copy Markdown

Problem

Cake Wallet's on-device ("hardcore") Silent Payments sync is dominated by server-side per-candidate-transaction work in blockchain.tweaks.subscribe. For every tweak row streamed, the hot loop:

  1. resolved the spend-cache height with a fresh RocksDB iterator scan per transaction (get_tweak_cached_height), while the main range scan runs with fill_cache(false) so these random reads never warm the block cache. A full-history scan streams ~4.03M candidate txs (~115.6M in historical mode); at 1–3 ms per cold random SST read this alone accounts for hours of scan time;
  2. rebuilt and split the full script_pubkey.to_asm() string per vout just to recover the taproot key;
  3. cloned tweak.vout_data per tx even when the spend cache was current.

Live measurements against electrs.cakewallet.com (cake-update-v1 @ cf9e03b): ~3–4 ms marginal cost per candidate tx cold vs ~0.2 ms warm (~20×); 0.44 s/block over a 300-block cold dense window, which projects to a 5–17 h serial full-history sync.

Fix

  • Resolve the spend-cache state once per block via a memoized HashMap entry instead of once per transaction (~60× fewer reads on dense blocks).
  • Replace the prefix iter_scan(...).next() in get_tweak_cached_height with a direct point get() — same fixed-length 5-byte key the write path (store_tweak_cache_heightput_sync) uses, so lookups are exact-match equivalent.
  • Extract P2TR x-only keys straight from the script bytes (OP_1 <32 bytes> = 0x51 0x20 …), falling back to the previous asm-split path for anything non-P2TR.
  • Iterate vout_data by reference; clone only on the stale-spend-cache path.

The row_height < last_blockchain_height - 5 guard is rewritten as row_height + 5 < last_blockchain_height — equivalent for all real tips, but no longer underflows u32 on very short (regtest) chains.

Second commit: streaming + last per-tx clone

An independent optimization pass added commit 2:

  • Stream rows straight off the snapshot-consistent RocksDB iterator instead of collect()ing the entire requested range into a Vec before streaming — a dense historical-mode request otherwise holds hundreds of thousands of fully-deserialized rows in memory per connection and delays the first streamed block until the whole range has been read. The query handle is Arc-cloned so the iterator doesn't borrow self.
  • Drop the remaining per-tx deep clone: get_tweak_data() cloned every vout script and spend record once per transaction row; the tweak data is now borrowed in place.

Wire compatibility

Output is byte-for-byte unchanged, chain-validated twice on regtest:

  • Commit 1 vs the unpatched parent (aae16bf): identical normalized tweaks.subscribe payloads for all SP transactions, including a genuine BIP-352 payment (built with rust-silentpayments send math) that both the production sp_scanner path and the session scanner found identically.
  • Commit 2 vs commit 1 (independent run, 2026-08-15): fresh regtest chain, index settled across restarts, both binaries serving the same DB → byte-identical payloads in both non-historical and historical modes.

Notes

  • Builds clean with cargo build --release --features silent-payments. Reminder: the tweaks RPC only exists when the silent-payments feature is enabled — worth pinning in the deployment CI/Dockerfile.
  • sp-scan-fast-path-v1 is the commit-1 change rebased onto cake-update-v1 (base cf9e03b, the binary currently serving production) for a drop-in redeploy of the live box if we want the win before the v2 index cutover; commit 2 can be cherry-picked there too if needed.
  • Follow-up candidate (not in this PR): raise the count cap (1000) so clients can amortize the ~0.3 s fixed per-request overhead over larger ranges.

🤖 Generated with Claude Code

@sethforprivacy

Copy link
Copy Markdown
Author

CI triage:

  • test-liquid (fail → fixed in 381c927): p2tr_pubkey_hex took &bitcoin::Script, but liquid builds use elements::Script for script_pubkey. Now takes raw script bytes — identical logic for both types; cargo check --features liquid and cargo build --release --features silent-payments both verified locally. Cherry-picked to sp-scan-fast-path-v1 as well.
  • nix (fail, pre-existing): the same job fails identically on cake-update-v2 itself (every run since 2026-02-25, bech32 ^0.9 version-selection error during dep vendoring) — unrelated to this PR.

🤖 Generated with Claude Code

@sethforprivacy

Copy link
Copy Markdown
Author

The pre-existing nix failure is root-caused and fixed in #4 (Cargo.lock was internally inconsistent since the 9dffb84 merge — the nix job's strict offline vendoring was the only one that noticed). Once #4 merges, rebasing this branch on cake-update-v2 will turn the nix check green here too.

🤖 Generated with Claude Code

sethforprivacy and others added 3 commits August 15, 2026 15:58
The scan hot loop resolved the tweak spend-cache height with a fresh RocksDB
iterator scan once per transaction row, which dominates scan latency on dense
blocks (thousands of iterator creations per block).

- Resolve cache state once per block height with a memoized HashMap entry.
- Switch the cache-height lookup from iter_scan to a direct point read.
- Extract P2TR xonly keys straight from the script bytes (OP_1 <32 bytes>)
  instead of building + splitting the full script-to-asm string per vout.
- Iterate stored vout data by reference; only clone when a stale spend cache
  requires a lookup_spend refresh (rare, self-healing path).

Output wire format is byte-for-byte unchanged.
- Iterate the tweaks range scan straight off the snapshot-consistent
  RocksDB iterator instead of collecting the entire requested range into
  a Vec before streaming. A dense historical-mode request can hold
  hundreds of thousands of fully-deserialized rows in memory and delays
  the first streamed block until the whole range has been read.
  Arc-cloning the query handle keeps `self` free for send_values().
- Borrow TweakData in place: get_tweak_data() deep-cloned every vout
  script and spend record once per transaction row.

Wire output is unchanged: same rows, same order, same JSON. The RocksDB
iterator is snapshot-consistent, so the mid-scan spend-cache writebacks
observe the same view the collected Vec did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
p2tr_pubkey_hex took &bitcoin::Script, but under the liquid feature
script_pubkey is elements::Script, breaking the test-liquid CI build.
Take the raw script bytes instead — identical logic for both types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants