Skip to content

perf(lid-pn): skip PN->LID session migration for peers with no PN state - #677

Merged
jlucaso1 merged 1 commit into
mainfrom
perf/skip-lid-migration-fresh-peer
Jun 1, 2026
Merged

perf(lid-pn): skip PN->LID session migration for peers with no PN state#677
jlucaso1 merged 1 commit into
mainfrom
perf/skip-lid-migration-fresh-peer

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

What

Skip the per-device PN->LID Signal-session migration when the peer has no PN state to migrate.

migrate_signal_sessions_on_lid_discovery runs whenever a new PN<->LID mapping is learned. It loops MIGRATION_DEVICE_RANGE (100) device slots, and for each takes two per-address session locks plus a session and identity lookup. For a freshly-resolved peer (every member of a large group on the first send) none of those slots have any state, so the entire 100-slot scan is wasted.

  • New SignalStore::has_signal_state_for_user(user) (default true, so a backend that doesn't implement it keeps the full scan). Implemented for the in-memory backend (key prefix scan) and SQLite (EXISTS over sessions and identities, filtered by the owning device id).
  • New SignalStoreCache::has_state_for_user(user, backend) that checks the in-memory cache first (conservatively: any matching key counts) then the durable backend.
  • The migration early-returns when neither has any session or identity for the PN user. The 100-slot loop is unchanged for peers that do have state.

Why

Profiling a cold group send to an ~800-member group (new group-send bench scenario) showed migrate_signal_sessions_on_lid_discovery as 95+ percent of all allocation: 800 members x 100 slots = 80k lock+lookup iterations, all finding nothing because the members were freshly resolved. This is invisible in latency logs but is real malloc/CPU churn (and likely part of the report's "~4s session setup").

Results

dhat on the cold first group send to 800 fresh members:

  • migrate_signal_sessions_on_lid_discovery: 10.04 GB to 0 (-100 percent), ~5.15M allocations to ~800.
  • Total run: 10.07 GB to 0.02 GB (about -99.8 percent), allocations -97.3 percent.
  • 12/12 group replies delivered, 0 failed (behavior unchanged).

Correctness / protocol

The guard only skips when there is provably nothing to migrate, which is identical to what the 100-slot loop did when every slot was empty. For peers with existing PN sessions (the migration's actual purpose, e.g. a prior DM contact who later reveals a LID) the full migration runs unchanged. This matches whatsmeow, which migrates only the sessions that exist for the user rather than a fixed device range; the guard is a conservative subset of that (skip when none).

Tests

  • has_signal_state_for_user_matches_by_user_prefix (in-memory): false when empty, true after a device-0 session, no false-positive for a longer user that this one prefixes, identity-only also counts.
  • migrate_skips_when_no_pn_signal_state: a stateless peer migration creates no LID session.
  • migration_blocks_on_per_address_session_lock now seeds a PN session so it still exercises the lock the loop takes (the guard would otherwise skip a stateless migration). Existing migration_preserves_working_session_when_both_namespaces_present and migration_lock_dance_* still pass.
  • cargo clippy --all-targets -- -D warnings clean; cargo test -p wacore -p whatsapp-rust -p whatsapp-rust-sqlite-storage (829 + 658 + 31 passing).

migrate_signal_sessions_on_lid_discovery scanned MIGRATION_DEVICE_RANGE (100)
device slots per newly-learned mapping, taking two per-address locks plus a
session+identity lookup each. For a freshly-resolved peer (every member of a
large group on first send) all 100 slots are empty, so the whole scan is wasted.

Add SignalStore::has_signal_state_for_user (sqlite EXISTS, in-memory prefix
scan; default true so unimplemented backends keep the full scan) and a
SignalStoreCache::has_state_for_user that checks the in-memory cache then the
backend. Guard the migration loop on it: skip entirely when the PN side has no
session/identity. Behavior-identical for peers with state (the loop already
migrated nothing when empty), and the full migration still runs otherwise.

dhat (group-send 800 fresh members, cold first send): migrate frame 10.04 GB
-> 0, total run 10.07 GB -> 0.02 GB (-99.8%); 12/12 group replies delivered.
@coderabbitai

coderabbitai Bot commented Jun 1, 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: 3c169f94-0681-4344-91a7-e16a4b9c4005

📥 Commits

Reviewing files that changed from the base of the PR and between c5bef74 and 6181e2b.

📒 Files selected for processing (5)
  • src/client/lid_pn.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/signal_cache.rs
  • wacore/src/store/traits.rs

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Session migration now skips unnecessary processing when prerequisites aren't met, reducing system overhead and improving performance.
  • Tests

    • Added comprehensive test coverage for optimized migration behavior and updated existing tests to better validate edge cases across multiple storage backends.

Walkthrough

This PR adds a has_signal_state_for_user query method across SignalStore trait, backend implementations (SQLite and in-memory), and cache layer, then uses it to short-circuit LID migration when a peer has no Signal state, avoiding unnecessary per-device scanning.

Changes

Signal State Existence Query

Layer / File(s) Summary
SignalStore trait extension
wacore/src/store/traits.rs
New async method has_signal_state_for_user added to the SignalStore trait with a conservative default implementation returning Ok(true).
Backend implementations with tests
storages/sqlite-storage/src/sqlite_store.rs, wacore/src/store/in_memory.rs
SQLite backend uses EXISTS queries with LIKE patterns on sessions.address and identities.address to check for user state; in-memory backend uses prefix-matching on stored keys. In-memory implementation includes comprehensive test coverage for prefix-safety and session/identity key formats.
Cache-first query wrapper
wacore/src/store/signal_cache.rs
SignalStoreCache::has_state_for_user checks in-memory session and identity caches by prefix, then defers to backend for authoritative lookup when no cache match exists.
LID migration short-circuit
src/client/lid_pn.rs
migrate_signal_sessions_on_lid_discovery now returns early when signal_cache.has_state_for_user(pn, backend) reports no PN Signal state, skipping per-device migration loops for fresh peers. New test migrate_skips_when_no_pn_signal_state validates short-circuit behavior; existing migration_blocks_on_per_address_session_lock test updated to seed PN session state so the per-device loop is entered.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#482: Also modifies migrate_signal_sessions_on_lid_discovery, adding cache-based read/write routing that works with this new short-circuit check.
  • oxidezap/whatsapp-rust#475: Introduces PN→LID migration expansion that directly benefits from the short-circuit optimization added in this PR.
  • oxidezap/whatsapp-rust#312: Initial SignalStoreCache implementation that this PR builds upon with the new existence-check capability.

Suggested labels

api-design

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: skipping PN->LID session migration for peers with no PN state, which is the core performance optimization this PR delivers.
Description check ✅ Passed The description is directly related to the changeset, clearly explaining the what, why, results, and correctness reasoning behind the migration skip optimization.
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/skip-lid-migration-fresh-peer

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.

@jlucaso1

jlucaso1 commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Jun 1, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 55s —— View job


Claude Code Review — perf(lid-pn): skip PN->LID session migration for peers with no PN state

  • Read changed files
  • Analyze trait/store changes
  • Analyze migration guard logic
  • Analyze tests
  • Post review

Verdict

LGTM — small, well-justified perf win with the right shape. The guard only short-circuits when there is provably nothing to migrate, so it is a strict subset of the existing 100-slot scan's behavior, and the SQLite/in-memory implementations are consistent with the in-memory key prefix semantics. Two minor notes below, neither blocking.

What I checked

Correctness of the early return (src/client/lid_pn.rs:411-417)

  • if let Ok(false) = … is explicit: only Ok(false) skips, both Ok(true) and Err(_) fall through to the full scan. Matches the comment. On DB error this degrades safely (the loop's get_session would also fail, same observable outcome).
  • Trait default returns Ok(true) (wacore/src/store/traits.rs:135-138), so a backend that doesn't override it keeps the prior behavior. Good conservative default.

Cache-first lookup (wacore/src/store/signal_cache.rs:284-302)

  • Necessary to avoid false negatives from dirty/uncommitted writes — if a session was put_session'd but not flushed, only the cache knows.
  • SessionEntry::Absent markers will register as "state present" → fall through to the full scan. That is a missed perf opportunity, never a missed migration, so the bias is correct. In practice fresh peers won't have Absent markers since we wouldn't have looked them up yet.
  • Mutexes are released between the sessions and identities checks — fine for this use, the race window is irrelevant since the loop would catch any concurrent write either way.

Prefix-collision correctness

  • matches() strips the prefix and requires the rest to start with @ or :. Covers both device-0 keys (user@server) and user:dev@server. The in-memory test has_signal_state_for_user_matches_by_user_prefix covers prefix collision ("55119999900001" vs "5511999990000") and identity-only matches.

SQLite implementation (storages/sqlite-storage/src/sqlite_store.rs:1270-1309)

  • LIKE 'user@%' / 'user:%' is the SQL equivalent of the in-memory matches().
  • Index usage: PK is (address, device_id) from 2025-09-23-032232-0000_add_multi_account_support/up.sql, and address TEXT has default BINARY collation, so SQLite's LIKE optimization should turn this into an index range scan, not a table scan. Two EXISTS queries (one per table) are necessary since they live in separate tables; can't be folded.

Tests

  • migrate_skips_when_no_pn_signal_state confirms a stateless migration produces no LID session. It doesn't directly distinguish "early return taken" from "loop ran and found nothing" (both produce the same observable state). That's hard to assert without instrumentation and the perf claim is justified by the dhat numbers in the PR description, so I think it's acceptable. (See note 2.)
  • migration_blocks_on_per_address_session_lock correctly seeds a PN session so the loop is actually entered; existing test coverage for the lock dance and the conflict-resolution policy is preserved.

Notes (non-blocking)

  1. LIKE wildcard hygiene in the SQLite impl — the comment at sqlite_store.rs:1273-1274 notes user is numeric so % / _ can't appear, which is true for all current callers (pn from add_lid_pn_mapping). If a future caller ever passes a non-numeric user string, the guard could over-report (fall through to the full scan — still safe, just no perf win). If you want belt-and-suspenders, escape %/_ in user and add ESCAPE to the LIKE, but I wouldn't push for that now.

  2. Test could prove the early-return path is takenmigrate_skips_when_no_pn_signal_state would pass equally well if the guard didn't exist (since a stateless 100-slot loop also produces no LID session). One way to make it tighter without intrusive instrumentation: pre-acquire the PN address lock at device 0 with a try_lock-style probe after the call, or assert via a side counter that no per-address lock was ever created. Optional. The dhat numbers in the PR body are the real proof; I'd merge as-is.

  3. Doc nit on SignalStoreCache::has_state_for_user — the doc says "Conservative on the cache side: any matching key counts (even a stale/checked-out marker)". Absent cache entries also count (they're keys in the map), which is the more conservative direction (false-positive → wasted scan, not missed migration), so it's consistent — just slightly understated. Mentioning Absent alongside CheckedOut would be more complete.

Summary

Behavior-preserving perf change with a tight guard, conservative defaults, and good tests. The reported -100% allocation reduction in the guarded function and -97.3% total allocations on the 800-member cold-send scenario is plausible given 800 × 100 = 80k lock+lookup iterations are now avoided. Nothing I'd block on.
· perf/skip-lid-migration-fresh-peer

@github-actions

github-actions Bot commented Jun 1, 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() 3,933 3,933 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 12,038 12,038 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,514 43,514 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,579 68,579 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,679 76,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,230 2,230 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,988 5,988 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 182,961 182,961 +0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 2,204,889 2,204,889 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 888,726 888,628 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 1,027,069 1,027,204 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,760,975 1,760,951 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 1,131,742 1,131,157 +0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 2,142,530 2,132,304 +0.5%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 7,306,386 7,290,936 +0.2%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,607,581 12,659,313 -0.4%
binary_benchmark::marshal_group::bench_marshal_allocating 71,296 71,296 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 71,349 71,349 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 98,416 98,416 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 78,796 78,796 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 71,396 71,396 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 7,599 7,599 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 7,642 7,642 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 9,354 9,354 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 530,581 530,581 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 530,149 530,149 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 531,504 531,504 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 8,506,104 8,506,104 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 8,450,452 8,450,452 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 19,677,941 19,677,941 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,468 2,468 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 33,558 33,558 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 787 787 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 526,830 526,830 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 4,990 4,990 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 5,315 5,315 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 61,874 61,874 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 5,347 5,347 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 61,942 61,942 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 6,734 6,734 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 85,585 85,585 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 11,563 11,563 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 396 396 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 120 120 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 439 439 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 153 153 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 499 499 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 162 162 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 44,624 44,624 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 16,424 16,424 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,260,179 17,261,611 -0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 157,179 157,179 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,513,975 5,513,975 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 157,539 157,539 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 296,767 296,699 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 706,282 706,282 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,577,050 12,543,205 +0.3%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,382,453 27,559,847 -0.6%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 126,221,043 124,677,373 +1.2%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,566 46,566 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,197,012 5,197,012 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 360,648 360,648 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,255,917 14,255,917 +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