Skip to content

perf(signal): cache Arc<SenderKeyRecord> to avoid deep-cloning the message-key backlog - #713

Merged
jlucaso1 merged 1 commit into
mainfrom
perf/sender-key-cache-arc
Jun 4, 2026
Merged

perf(signal): cache Arc<SenderKeyRecord> to avoid deep-cloning the message-key backlog#713
jlucaso1 merged 1 commit into
mainfrom
perf/sender-key-cache-arc

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

The sender-key cache stored SenderKeyRecord by value, so get_sender_key returned cached.clone() on every hit. A SenderKeyRecord is a VecDeque<SenderKeyState> where each state holds a message-key backlog that grows up to MAX_MESSAGE_KEYS (2000), so the clone reallocates the deque plus a vector of up to ~2000 entries per call.

get_sender_key is hit on several paths that only need to read:

  • the per-send rotation check (send.rs) cloned the entire record just to read the current chain iteration,
  • the key_exists checks on the send path,
  • device-registry and notification lookups,

and on the per-decrypt load via the load_sender_key trait.

This mirrors an anti-pattern already fixed elsewhere for the device-registry and group caches: cache under Arc.

Change

Cache Arc<SenderKeyRecord>. get_sender_key now returns Option<Arc<SenderKeyRecord>>, so a warm hit is a refcount bump instead of a deep clone.

  • Read-only callers get the shared Arc. The rotation check now borrows the current state (sender_key_state()) to read the iteration instead of sender_key_state_mut().cloned(), so it no longer clones the record.
  • The load_sender_key trait impl still returns an owned record (via Arc::unwrap_or_clone), because group_decrypt mutates the loaded record (catch-up + ratchet advance) and stores it back. The cache keeps its Arc, so this clone is unchanged from the prior behavior. Net effect: the decrypt path is neutral, the read-only paths stop cloning.
  • flush serializes through the Arc (auto-deref), unchanged.

Safety

An earlier draft considered a checkout/take pattern (move the record out of the cache for the mutating decrypt path). That was rejected: group_decrypt has several early return Err paths between the load and the store, so taking the record out and erroring would drop unflushed message keys from the cache, which can make out-of-order messages undecryptable. Caching Arc avoids that entirely: the cache always retains the record, and the mutating path clones exactly as before.

Tests

  • warm_sender_key_hit_shares_arc_not_deep_clone (new): two get_sender_key calls on the same key return the same allocation (Arc::ptr_eq).
  • Full lib suites green; cargo clippy --all-targets -- -D warnings clean.

The deep-clone cost the read-only paths now avoid scales with the message-key backlog (about 1.3M instructions at the 2000-key cap); the Arc clone is an O(1) refcount bump.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7af7128f-8182-49c4-88ec-4015f61531c7

📥 Commits

Reviewing files that changed from the base of the PR and between b7a2fc9 and 6478697.

📒 Files selected for processing (3)
  • src/send.rs
  • src/store/signal_adapter.rs
  • wacore/src/store/signal_cache.rs

📝 Walkthrough

Summary by CodeRabbit

  • Refactor
    • Optimized internal memory handling for sender-key caching to reduce unnecessary duplication and improve performance efficiency in group messaging operations.

Walkthrough

This PR refactors the sender-key cache to store records as reference-counted pointers (Arc<SenderKeyRecord>), reducing expensive deep clones on warm cache hits. The public API and downstream call sites—the adapter layer and send path—adapt to unwrap the Arc and borrow the resulting owned copy as needed.

Changes

Sender-Key Arc Ownership Refactor

Layer / File(s) Summary
Cache storage and public API as Arc
wacore/src/store/signal_cache.rs
SignalStoreCache::get_sender_key now returns Option<Arc<SenderKeyRecord>> instead of owned records. Cache population wraps deserialized records in Arc, and warm hits clone the Arc pointer rather than deep-cloning the record payload. Updated comments reflect Arc refcount behavior, and a new test asserts warm hits return pointer-equal Arc values via Arc::ptr_eq.
Adapter unwraps Arc for mutation
src/store/signal_adapter.rs
SenderKeyAdapter::load_sender_key maps the cached Option<Arc<SenderKeyRecord>> through Arc::unwrap_or_clone to return an owned, mutable record suitable for group_decrypt to mutate. Comments clarify ownership requirements.
Send borrows from owned record
src/send.rs
Sender-key rotation detection in the group-send path refactored to borrow the owned record via record.as_ref() and call sender_key_state() without cloning or mutating the record, while preserving the chain-key iteration comparison against the rotation threshold.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#109: Modifies sender-key handling in src/send.rs and src/store/signal_adapter.rs; this PR's Arc/ownership changes build on that refactor.
  • oxidezap/whatsapp-rust#604: Adjusts group-send sender-key rotation decision in src/send.rs, directly related to the needs_rotation refactor here.
  • oxidezap/whatsapp-rust#375: Restructures wacore/src/store/signal_cache.rs sender-key cache internals with Arc-backed keys, complementary to this PR's Arc-wrapped values.

Suggested labels

api-design, breaking-change


Look, here's what matters. You're moving sender-key records behind Arc pointers in the cache layer. That's smart—no deep clones on warm hits, better memory efficiency. But I need to see that every call site handles this correctly.

The adapter is doing the right thing: it's unwrapping the Arc into an owned copy for mutation in group_decrypt. That works. The send path is borrowing from the owned record—correct approach, no unnecessary clones.

But this is a breaking API change. get_sender_key now returns Option<Arc<SenderKeyRecord>> instead of Option<SenderKeyRecord>. Every downstream caller needs to adapt. Your test proves the Arc behavior is correct—pointer equality checks verify no deep clones. That's solid.

The risk here is subtle: Arc gives you safe sharing, but shared state can hide mutations. Make sure the owner of that Arc—the adapter—is the only place mutating the record. If other code paths grab the Arc and try to mutate through it, you've got a problem. The test doesn't catch that.

Check the full call graph. Make sure no one else is holding an Arc and mutating in parallel. If this is the sender-key decrypt path and it's single-threaded per record, you're fine. If there's concurrent access, you need Arc<Mutex<>> or Arc<RwLock<>>, not just Arc<>.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main performance optimization: caching Arc to avoid deep-cloning large message-key backlogs.
Description check ✅ Passed The description thoroughly explains the problem, solution, safety considerations, and testing approach—all directly related to the changeset's optimization of sender-key caching behavior.
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.

✏️ 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/sender-key-cache-arc

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.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,838 2,838 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,272 8,272 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,398 49,398 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 54,827 54,827 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,592 1,592 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,219 4,219 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,946 113,082 -0.1%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,232 1,656,236 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 650,311 650,272 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 874,108 874,073 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,081,954 2,082,144 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 747,471 747,467 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,328,509 1,328,298 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,378,722 4,394,012 -0.3%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 518,968 518,994 -0.0%
binary_benchmark::marshal_group::bench_marshal_allocating 45,401 45,401 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,451 45,451 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,354 66,354 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,512 43,512 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,507 45,507 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,930 4,930 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,961 4,961 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,732 6,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,529 528,529 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,150 528,150 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,396 529,396 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,786 5,417,786 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,043 5,362,043 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,336 13,276,336 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,283 48,283 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,344 48,344 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,668 66,668 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,286 8,286 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,136,611 4,142,662 -0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,133 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,263,702 4,263,702 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 99,803 99,790 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,249 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 508,633 510,169 -0.3%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,977,166 11,973,641 +0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,910,902 4,903,242 +0.2%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,950 37,950 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,616,036 3,616,076 -0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 223,150 223,150 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

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.

1 participant