Skip to content

perf(prekeys): avoid full record decode on the pre-key upload path - #900

Merged
jlucaso1 merged 3 commits into
mainfrom
perf/prekey-upload-skip-full-decode
Jun 18, 2026
Merged

perf(prekeys): avoid full record decode on the pre-key upload path#900
jlucaso1 merged 3 commits into
mainfrom
perf/prekey-upload-skip-full-decode

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

What

A profile-guided allocation trim on the connect/registration path. upload_pre_keys_pass was running a full PreKeyRecordStructure::decode (prost) on every stored record just to read the public key for the upload IQ — and a full decode also allocates a throwaway copy of the private key the upload never touches. On a fresh registration that means 812 records decoded straight back out of the store, immediately after being written to it.

Now the pass carries the public keys straight out of generation: it already mints the keypairs in its blocking offload, so it keeps their public keys in hand and uploads those directly — the freshly generated keys are never written-then-reloaded-and-decoded. Only the rare leftover (already-stored, not-yet-uploaded) window keys are read back, and those go through the full PreKeyRecordStructure::decode — the exact same call the consume path (Device::load_prekey) uses — with any record that fails to decode skipped (warn), not uploaded.

Why

Profiling connect_to_ready on CodSpeed (the run from #898) shows connect is dominated by generating + uploading the one-time pre-key batch on a fresh registration. Two parts:

  • The X25519 keygen (~63% of the simulation) — intentional WA Web fidelity (DEFAULT_WANTED_PRE_KEY_COUNT = 812, mirroring WAWebUploadPreKeysJob). Left untouched — real, required crypto, not waste.
  • The store round-trip + full-decode on upload — pure overhead on a fresh registration, since the records being reloaded and decoded are the ones the same call just generated. Eliminated: the fresh public keys are carried in memory, so for the common fresh-registration case there is no store reload and no decode at all (~2 throwaway Vec allocations per record × 812 ≈ 1,600 fewer allocations, plus 812 fewer store reads).

Safety / behavior

The upload and consume paths now parse stored records with the same PreKeyRecordStructure::decode, so they can no longer disagree about which records are valid — a record that fails to decode is skipped on upload exactly as Device::load_prekey would reject it on consume.

An earlier revision of this PR read the public key with a hand-rolled byte scan (extract_prekey_public_key); that helper is now off the upload path entirely. It survives only on the digestKey path (validate_digest_key, a local hash compare that skips on mismatch and never uploads), where this PR also tightens its framing validation (rejects truncated length-delimited/fixed fields and unsupported wire types) and adds malformed-varint + wire-type 3/4 test coverage.

Ordering is preserved: leftovers (sorted) precede the fresh keys, and the plan guarantees every fresh id is greater than every leftover id, so the assembled batch stays globally sorted and last_id stays correct.

Verification

  • cargo fmt --all / cargo clippy --all --tests — clean
  • cargo test -p whatsapp-rust --lib prekeys — 16 tests pass, including the window_tests that drive upload_pre_keys_pass against a mock backend (upload-window, retry-single-key, collapse/regenerate paths)
  • cargo test -p wacore prekeys — extractor framing tests, including the malformed-record / full-decode-parity cases
  • CodSpeed will quantify the connect_to_ready Memory delta on this PR.

Scope / follow-ups (intentionally not here)

  • This targets allocation count/volume, not peak memory; peak is dominated by the 812 keygens plus holding the batch + encoded buffer, and would need streaming to move. No peak-memory claim until CodSpeed measures it.

upload_pre_keys_pass re-decoded every stored PreKeyRecordStructure with prost
just to read the public key for the upload IQ. A full decode also allocates a
copy of the private key the upload never uses; at the default batch of 812
one-time pre-keys (WA Web fidelity) that is ~2 throwaway Vec allocations per
record on the connect/registration path.

Read the public-key field straight from the encoded record via the existing
wacore::prekeys::extract_prekey_public_key helper instead. Behavior-preserving:
same public-key bytes, same skip-with-warning handling for a record missing the
field. Trims the allocation count/volume CodSpeed attributes to connect_to_ready
(the dominant cost there remains the intentional 812 X25519 keygens, untouched).
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Extract_prekey_public_key is tightened to validate protobuf framing end-to-end: it now rejects truncated length-delimited/fixed fields and unsupported wire types (returning None), matching full PreKeyRecordStructure::decode behavior. A test verifies consistency across malformed cases. Upload_pre_keys_pass is then refactored to collect generated pre-key public keys during the generation offload, compute only leftover IDs from the plan, load and decode only those leftovers (skipping undecodable records), then assemble the batch from leftovers plus fresh keys in sorted order.

Changes

Pre-key Extraction Validation Alignment and Upload Refactoring

Layer / File(s) Summary
Stricter protobuf framing validation
wacore/src/prekeys.rs
extract_prekey_public_key now rejects truncated length-delimited and fixed fields (returning None instead of partial results) and rejects unsupported/invalid wire types (instead of skipping gracefully). Documentation updated to specify end-to-end framing validation and last-one-wins semantics. Test cross-validates against full PreKeyRecordStructure::decode on valid and malformed records.
Fresh key collection during generation
src/prekeys.rs
upload_pre_keys_pass modifies the blocking generation closure to collect each generated pre-key's public key alongside the encoded protobuf batch, accumulating them in fresh_pre_keys before the closure completes. This eliminates the need to read/decipher freshly generated keys from the store downstream.
Leftover-only loading and batch assembly
src/prekeys.rs
upload_pre_keys_pass replaces loading the entire upload window with logic that computes leftover IDs from plan.available, loads only those leftover store rows, decodes leftover protobuf records (skipping undecodable ones with warn logs), then appends the pre-collected fresh_pre_keys to form the final pre_key_pairs in sorted order. Fully missing leftover + gen_count == 0 triggers collapse and optional retry.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes


Look, this is non-negotiable. We can't have the upload path accepting pre-key records that the consume path would later reject—that's a silent failure at scale, and it breaks the entire security model. The validation tightening is the linchpin here. I need to see that the extractor truly rejects everything malformed the same way full decode does, that the test covers truncation and wire-type edge cases, and that there's no path where we upload a key we wouldn't have accepted on the other end. The refactoring itself is solid—pulling fresh keys during generation instead of reading them back from storage is the right move—but it only works if validation is airtight. If the validation isn't bulletproof, we don't ship this.


Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely captures the main optimization: avoiding full record decoding on the pre-key upload path, which is the primary change across both modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description directly addresses the changeset: it explains the allocation optimization in upload_pre_keys_pass, the stricter validation in extract_prekey_public_key, and the rationale for these changes based on profiling data.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/prekey-upload-skip-full-decode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b51b20373e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/prekeys.rs Outdated
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.59 MiB 10.59 MiB +640 B (+0.01%) 🔺
bin .text 8.70 MiB 8.70 MiB +704 B (+0.01%) 🔺
bin allocated (text+data+bss) 10.59 MiB 10.59 MiB +24 B (+0.00%) 🔺
llvm-lines wacore 639,574 639,594 +20 (+0.00%) 🔺
llvm-lines wacore copies 17,666 17,666 0
llvm-lines whatsapp-rust lib 654,850 655,265 +415 (+0.06%) 🔺
llvm-lines whatsapp-rust lib copies 20,155 20,171 +16 (+0.08%) 🔺
deps crates (Cargo.lock) 347 347 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.44 MiB 1.44 MiB +152 B (+0.01%) 🔺
.text wacore 517.65 KiB 518.09 KiB +452 B (+0.09%) 🔺
.text wacore_binary 156.73 KiB 156.73 KiB 0
.text wacore_libsignal 166.63 KiB 166.63 KiB 0
.text wacore_appstate 36.98 KiB 36.98 KiB 0
.text wacore_noise 27.71 KiB 27.71 KiB 0
.text waproto 876.21 KiB 876.21 KiB 0
.text whatsapp_rust_sqlite_storage 207.48 KiB 207.48 KiB 0
.text whatsapp_rust_tokio_transport 32.49 KiB 32.49 KiB 0
.text whatsapp_rust_ureq_http_client 5.93 KiB 5.93 KiB 0
.text std 1.13 MiB 1.13 MiB +59 B (+0.00%) 🔺
.text other deps 4.07 MiB 4.07 MiB 0
Top movers (cargo-bloat attribution)
Crate main PR Δ
regex_automata 1.67 KiB 3.06 KiB +1.39 KiB (+83.44%)
prost 472.66 KiB 471.26 KiB -1.39 KiB (-0.29%)

Baseline: 931a5d69c (latest main run) · Head: a74f1a274 · Graphs

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 1 file

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/prekeys.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
❌ 1 (👁 1) regressed benchmark
✅ 185 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation send_message[20] 5.6 ms 5 ms +10.95%
Simulation send_and_receive[1] 871.5 µs 788.4 µs +10.53%
👁 Simulation send_message[1] 651.1 µs 847 µs -23.13%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/prekey-upload-skip-full-decode (5f69f98) with main (931a5d6)

Open in CodSpeed

Review on the upload-path optimization (#900) flagged that
extract_prekey_public_key kept the last-seen public key even when the
record's protobuf tail was truncated or used an invalid wire type. The
upload path would then ship a key the consume path (get_pre_key's full
PreKeyRecordStructure::decode) later rejects — handing a peer a key this
device can't use to decrypt their first message.

Validate the record framing end-to-end: a malformed varint, a truncated
length-delimited/fixed field, or an unsupported wire type now yields None,
matching what a full prost decode rejects. The upload and digestKey callers
already skip on None. Add a test asserting parity with prost decode on a
record with a valid publicKey but a malformed tail.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e437100a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/prekeys.rs Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wacore/src/prekeys.rs`:
- Around line 422-446: The test
extract_prekey_public_key_matches_full_decode_validation currently only covers
truncated trailing field scenarios but does not test the edge cases that the
extractor claims to reject: malformed varints and unsupported wire types. Add
additional test cases within this test function to construct and verify
rejection of data containing malformed varints (e.g., 10+ continuation bytes)
and unsupported wire types (wire type 3 or 4). For each edge case, assert that
extract_prekey_public_key returns None and that the full prost decode also
rejects the data, ensuring consistent behavior between the two paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 443dc581-3ea2-4729-b605-140fa1b4b5de

📥 Commits

Reviewing files that changed from the base of the PR and between b51b203 and 8e43710.

📒 Files selected for processing (2)
  • src/prekeys.rs
  • wacore/src/prekeys.rs

Comment thread wacore/src/prekeys.rs
…y leftovers

Review on #900 kept finding parity gaps in the hand-rolled extractor used on the
upload path: it skips fields by wire type without validating protobuf keys the
way prost does (e.g. field number 0, or a known field with the wrong wire type),
so a record the consume path's full PreKeyRecordStructure::decode would reject
could still be uploaded — handing a peer a key this device can't decrypt with.

Sidestep the parser entirely on the hot path. The batch generates 812 fresh
keypairs and already holds their public keys, so keep them from generation and
upload them directly instead of re-reading and re-parsing what was just written.
Only the rare leftover (already-stored) window keys still need a read-back, and
those use the full, correct PreKeyRecordStructure::decode — identical to the
consume path, so a record accepted for upload is always one this device can
later decrypt with.

This also drops the redundant store reload of the freshly generated window: on
the common connect/registration path (an all-fresh window) the upload now reads
and decodes nothing, trimming the allocations CodSpeed attributes to
connect_to_ready beyond what the extractor approach achieved.

extract_prekey_public_key stays for the digestKey check (a local hash compare
that only skips on mismatch, never uploads); its strict-framing tests gain
malformed-varint and unsupported-wire-type cases.
@jlucaso1
jlucaso1 merged commit 9d7e6de into main Jun 18, 2026
17 checks passed
@jlucaso1
jlucaso1 deleted the perf/prekey-upload-skip-full-decode branch June 18, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants