diff --git a/advanced/signal-protocol.mdx b/advanced/signal-protocol.mdx index 0695f6b1..5cb4c86c 100644 --- a/advanced/signal-protocol.mdx +++ b/advanced/signal-protocol.mdx @@ -616,7 +616,15 @@ The group send path no longer serializes encryption behind a client-level lock. This is an internal performance change — no public method on `Client::send_message` was renamed, and the order of `` children in the resulting stanza is unchanged. If you implemented a custom `SignalStore`, note that `update_device_lists(records: Vec)` is now part of the trait so the fan-out can batch its writes. -While *per-device* encryption runs concurrently, the sender-key chain itself is serialized **per `(group, sender)` pair** (v0.6). SKDM creation and `skmsg` encryption on the same chain can't race, so two concurrent sends to the same group ratchet the sender key deterministically instead of corrupting the chain. Different groups (or different senders) still encrypt fully in parallel. +While *per-device* encryption runs concurrently, the sender-key chain is protected by **two separate locks per `(group, sender)` pair**: + +1. **Session-setup lock** (`SenderKeyStore::session_setup_lock`) — held only across `ensure_sessions_for_devices` (prekey fetch + X3DH). May span network I/O. Warm sends (no SKDM needed) never take it, so they are never blocked by a cold send's network round-trip. + +2. **Chain lock** (`SenderKeyStore::sender_key_lock`) — held across SKDM creation + pairwise encrypt fan-out + `skmsg` encrypt. Pure CPU; never spans network I/O. This is the invariant that prevents two concurrent sends from splitting the key between the SKDM and the `skmsg`. + +Prior to [#807](https://github.com/oxidezap/whatsapp-rust/pull/807), a single chain lock covered both phases, causing concurrent group sends to serialize behind a server round-trip whenever a new session needed to be established. Now only the CPU phase is in the critical section. Different groups (or different senders) encrypt fully in parallel, unchanged. + +`encrypt_for_devices` is composed of two public halves: `ensure_sessions_for_devices` (network, returns `SessionPlan`) and `encrypt_for_devices_with_sessions` (CPU, consumes `SessionPlan`). The DM path calls `encrypt_for_devices` unchanged; the group path calls them separately with the chain lock taken only around the second. ### In-memory sender key device cache diff --git a/changelog/2026-06-09-group-send-perf.mdx b/changelog/2026-06-09-group-send-perf.mdx new file mode 100644 index 00000000..a09923b8 --- /dev/null +++ b/changelog/2026-06-09-group-send-perf.mdx @@ -0,0 +1,31 @@ +--- +title: "June 9, 2026 — Group send: session setup hoisted out of chain lock" +description: "Concurrent sends to the same group no longer serialize behind prekey fetch I/O. The sender-key chain lock now covers only the CPU-bound SKDM creation and skmsg encrypt. A new per-group session-setup lock serializes cold prekey fetch and X3DH without blocking warm sends." +--- + +## Performance + +**Group send: session setup hoisted out of the sender-key chain lock ([#807](https://github.com/oxidezap/whatsapp-rust/pull/807))** + +Previously, `prepare_group_stanza` took the per-(group, sender) chain lock at the top and held it through the entire SKDM path — including cold-path device resolution and prekey fetch + X3DH session establishment. Any group send that needed to establish a new pairwise session held the chain lock across a server round-trip, so concurrent sends to the same group serialized behind that RTT. + +**Two-phase split.** `encrypt_for_devices` is now composed of two separately callable halves: + +- **`ensure_sessions_for_devices`** — network phase: LID-first session lookup, batch prekey fetch, parallel X3DH. Returns a `SessionPlan` (per-device LID overrides + 406 flag). Touches only session/identity state, never a sender-key chain. May span network I/O. +- **`encrypt_for_devices_with_sessions`** — CPU phase: the bounded pairwise encrypt fan-out, consuming the `SessionPlan`. Safe to run under a lock that must not span I/O. + +`encrypt_for_devices` remains as the composition of both, so the DM path is unchanged. + +The group path now runs `ensure_sessions_for_devices` before taking the chain lock; the chain lock covers only SKDM creation + pairwise fan-out + `skmsg` encrypt. This matches WA Web, where `ensureE2ESessions` is a separate step before `GroupSkmsgJob`'s encrypt. + +**Session-setup lock.** Hoisting session 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. A new `SenderKeyStore::session_setup_lock` (per-group, default-uncontended; backed by the same `SignalStoreCache` lock map under a `::setup` key suffix) is held only during `ensure_sessions_for_devices`. Same-group cold sends serialize their setup exactly as before; warm sends (no SKDM needed) never take it, so the chain lock stays network-free. + +**New public items:** +- `wacore::send::encrypt::ensure_sessions_for_devices` +- `wacore::send::encrypt::encrypt_for_devices_with_sessions` +- `wacore::send::encrypt::SessionPlan` +- `SenderKeyStore::session_setup_lock` — defaulted trait method (returns a fresh uncontended mutex by default); production stores override via `SignalStoreCache::session_setup_lock` + +**Tracing.** Both the DM and group paths now emit `wa.send.ensure_sessions` + `wa.send.encrypt_fanout` instead of a single combined `wa.send.encrypt_fanout` span. + +**No breaking changes** — `encrypt_for_devices` and `prepare_group_stanza` keep their existing signatures. diff --git a/docs.json b/docs.json index f3d8cd43..a5f928c1 100644 --- a/docs.json +++ b/docs.json @@ -144,6 +144,7 @@ "changelog/2026-06-09-device-list-remove", "changelog/2026-06-09-usync-empty-record", "changelog/2026-06-09-retry-resync", + "changelog/2026-06-09-group-send-perf", "changelog/2026-06-08", "changelog/2026-06-06", "changelog/2026-06-05-abprops",