Skip to content

perf(send): establish sessions before taking the sender-key chain lock - #807

Merged
jlucaso1 merged 2 commits into
mainfrom
perf/group-send-sessions-before-chain-lock
Jun 9, 2026
Merged

perf(send): establish sessions before taking the sender-key chain lock#807
jlucaso1 merged 2 commits into
mainfrom
perf/group-send-sessions-before-chain-lock

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

prepare_group_stanza takes the per-(group, sender) chain lock at the top and holds it through the entire SKDM path. That puts two network phases inside the critical section:

  1. cold-path device resolution (resolver.resolve_devices), and
  2. the prekey fetch + parallel X3DH inside encrypt_for_devices (fetch_prekeys_for_identity_check).

Any send to a group with at least one session-less target device holds the chain lock across a server round-trip, so concurrent sends to the same group serialize behind that RTT (and its failure/retry time). The invariant the lock exists for — the SKDM and the skmsg must describe the same chain iteration — only requires it to cover SKDM creation and the skmsg encrypt, both CPU-bound.

Change

encrypt_for_devices is split into its two existing halves:

  • ensure_sessions_for_devices — the network half: LID-first session lookup, batch prekey fetch, parallel X3DH. Touches only session/identity state, never a sender-key chain. Returns a SessionPlan (the per-device LID encryption overrides + the 406 flag).
  • encrypt_for_devices_with_sessions — the CPU half: the bounded pairwise encrypt fan-out, consuming the SessionPlan. Safe to run under locks that must not span I/O.

encrypt_for_devices remains as the composition of the two, so the DM path is unchanged. The group path now resolves devices and ensures sessions before taking the chain lock; the lock covers only SKDM creation + the pairwise fan-out + the skmsg encrypt. This also matches WA Web, where ensureE2ESessions is a separate step before GroupSkmsgJob's encrypt.

Session-setup serialization (review follow-up): hoisting setup out of the chain lock would have let two concurrent cold sends to the same group race prekey fetch + X3DH writes to the same per-device sessions — serialization the chain lock previously provided as a side effect. A new SenderKeyStore::session_setup_lock (per-group, default-uncontended like sender_key_lock; the signal cache shares the chain-lock map under a disjoint ::setup key) is held only across ensure_sessions_for_devices. Same-group cold sends serialize their setup exactly as on main; warm sends never take it, so the chain lock stays network-free. Cross-group/-path races over a shared device are unchanged from main (the chain lock never covered those).

Failure semantics are preserved: a session-setup error logs and continues without SKDM distribution (WA Web's try/catch-without-rethrow rule), is_device_unregistered_error still flips the stale-device flag, and the sender-key record is still created under the chain lock even when distribution is skipped — so the skmsg always encrypts, exactly as before (previously the record creation happened before the failing encrypt call; skipping it would have turned a distribution failure into a hard NoSenderKeyState send failure).

Verification

  • Regression test prekey_fetch_runs_outside_chain_lock: the mock resolver probes the store's actual lock instances from inside fetch_prekeys_for_identity_check and asserts both directions — the fetch runs not under the chain lock and under the per-group setup lock — plus end-to-end that the pre-established session produces B's pairwise SKDM under the chain lock. The not-under-chain-lock assertion fails against the previous group.rs.
  • Test mem stores now share state across clones (Arc<Mutex<…>>), matching production stores ("the shared cache provides interior mutability") — without this, sessions established inside the spawned X3DH tasks were silently lost in tests, which made the end-to-end half impossible to express.
  • cargo test -p wacore --lib: 947 passed. cargo test -p whatsapp-rust --lib: 744 passed. cargo clippy -p wacore -p wacore-libsignal -p whatsapp-rust --tests clean; fmt clean.

Breaking

None for callers: encrypt_for_devices and prepare_group_stanza keep their signatures. ensure_sessions_for_devices / encrypt_for_devices_with_sessions / SessionPlan and the defaulted trait method SenderKeyStore::session_setup_lock are new pub items. Tracing topology shifts slightly: the DM path now emits wa.send.ensure_sessions + wa.send.encrypt_fanout instead of one span covering both.

prepare_group_stanza held the per-(group, sender) chain lock across the
whole SKDM path: device resolution and the prekey fetch + X3DH inside
encrypt_for_devices — network round-trips — ran inside the critical
section, so concurrent sends to the same group serialized behind an RTT
(or its retries) whenever any target device lacked a session.

Split encrypt_for_devices into its two halves: ensure_sessions_for_devices
(network: LID-first lookup, prekey fetch, parallel X3DH; touches only
session/identity state) and encrypt_for_devices_with_sessions (CPU: the
pairwise fan-out). The combined function remains as a composition for the
DM path. The group path now resolves devices and ensures sessions before
the lock; the lock covers only SKDM creation + pairwise encrypt + skmsg —
the chain-consistency invariant it exists for.

Failure semantics preserved: session-setup errors log and continue
without distribution (WA Web GroupSkmsgJob), and the sender-key record
is still created under the lock so the skmsg always encrypts.

The regression test probes the actual chain lock from inside the mock
resolver's fetch; it fails against the previous code. Test mem stores now
share state across clones (Arc), matching production store semantics so
spawned-task session writes are visible.
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR refactors group message encryption to serialize per-group session setup (prekey fetch and X3DH) separately from the sender-key chain lock. The changes split encryption into session-preparation and encryption phases, move session establishment outside the chain critical section, and add comprehensive lock-ordering tests to validate the concurrency improvements.

Changes

SKDM Session Setup Concurrency

Layer / File(s) Summary
Session setup lock trait and cache implementation
wacore/libsignal/src/protocol/storage/traits.rs, wacore/src/store/signal_cache.rs, src/store/signal_adapter.rs
Added session_setup_lock method to SenderKeyStore trait with a default uncontended implementation. SignalStoreCache implements it via a new shared_named_lock helper that reuses existing locks and opportunistically prunes idle locks. SenderKeyAdapter delegates to the cache layer.
Two-phase encryption refactoring
wacore/src/send/encrypt.rs
Split encrypt_for_devices into ensure_sessions_for_devices (returns SessionPlan carrying per-device overrides and unregistered-device flag) and encrypt_for_devices_with_sessions (consumes the plan for encryption). Updated override access patterns to use safer .first() / .get(idx) instead of direct indexing.
Group SKDM flow integration
wacore/src/send/group.rs
prepare_group_stanza now builds a SessionPlan via ensure_sessions_for_devices under a dedicated session-setup lock (outside the sender-key chain critical section), with graceful fallback if session setup fails. SKDM encryption uses encrypt_for_devices_with_sessions to consume the prepared plan. Sender-key chain lock is now taken only for SKDM message creation and skmsg encryption.
Lock ordering validation and test infrastructure
wacore/src/send/tests.rs
Added ChainLockProbe to track lock state during operations and enhanced MockSendContextResolver to instrument prekey fetch with lock-ordering assertions. Refactored MemSessionStore, MemIdentityStore, and MemSenderKeyStore to use shared Arc<Mutex<...>> for concurrent task visibility. New test prekey_fetch_runs_outside_chain_lock verifies expected lock ordering.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#678: Both PRs modify the group SKDM/send pipeline around prepare_group_stanza and SKDM encryption—main PR changes how per-device sessions are prepared/locked and how SKDM encryption fan-out is executed, while the related PR changes what device set is used for phash computation/skdm_devices marking—so the changes are tightly connected at the same SKDM/group-send code path.
  • oxidezap/whatsapp-rust#109: Both PRs modify the SenderKeyStore plumbing (notably wacore/libsignal/src/protocol/storage/traits.rs and src/store/signal_adapter.rs)—main PR extends the trait with session_setup_lock, while the related PR refactors SenderKeyStore/keying by replacing GroupSenderKeyStore—so the changes are directly connected to the same trait and implementations.
  • oxidezap/whatsapp-rust#657: The main PR extends the same SenderKeyStore/SignalStoreCache locking scheme for group sends by adding a new session_setup_lock and wiring it into prepare_group_stanza/SKDM setup, complementing the related PR's sender_key_lock serialization of the sender-key chain advance.

Suggested labels

api-design

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately captures the main performance optimization: moving session establishment outside the sender-key chain lock to reduce network I/O contention in group sends.
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 description clearly explains the problem (chain lock held across network I/O), the solution (splitting encrypt_for_devices into two phases), and verification approach with concrete test coverage.

✏️ 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/group-send-sessions-before-chain-lock

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: fad388adea

ℹ️ 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 wacore/src/send/group.rs Outdated
@github-actions

github-actions Bot commented Jun 9, 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,927 2,927 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,448 8,448 +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,487 49,487 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 55,003 55,003 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,679 1,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,393 4,393 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 113,198 113,076 +0.1%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,625 1,656,623 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 643,853 644,542 -0.1%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 870,139 868,490 +0.2%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,075,814 2,076,353 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 742,279 741,854 +0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,320,812 1,323,032 -0.2%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,379,404 4,379,115 +0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 517,866 516,650 +0.2%
binary_benchmark::marshal_group::bench_marshal_allocating 45,381 45,381 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,431 45,431 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,334 66,334 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,492 43,492 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,487 45,487 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,945 4,945 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,976 4,976 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,747 6,747 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,544 528,544 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,165 528,165 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,411 529,411 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,732 5,417,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,047 5,362,047 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,365 13,276,365 +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,274 48,274 +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,335 48,335 +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,659 66,659 +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,291 8,291 +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,142,719 4,144,449 -0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,131 100,133 -0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,264,189 4,264,189 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 100,399 100,399 +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() 504,953 510,389 -1.1%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,980,648 11,982,555 -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,922,352 4,868,062 +1.1%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,351 2,043,397 -0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,414 37,414 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,617,967 3,617,967 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 230,648 230,648 +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.

Review follow-up: hoisting ensure_sessions_for_devices out of the chain
lock let two concurrent cold sends to the same group race prekey fetch +
X3DH writes to the same per-device sessions (previously serialized as a
side effect of the chain lock).

Add SenderKeyStore::session_setup_lock — a per-group lock held only
across session setup, never the chain critical section. Cold same-group
sends serialize their setup again; warm sends never take it, so the
chain lock stays network-free. Default impl is uncontended (mirrors
sender_key_lock); the signal cache shares the chain-lock map under a
disjoint '::setup'-suffixed key.

The regression test now also asserts the fetch runs UNDER the setup
lock, alongside the existing not-under-chain-lock assertion.
@jlucaso1
jlucaso1 merged commit 89f5487 into main Jun 9, 2026
11 of 12 checks passed
@jlucaso1
jlucaso1 deleted the perf/group-send-sessions-before-chain-lock branch June 9, 2026 19:13
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