Skip to content

perf(send): memoize the per-group device list behind a topology generation - #824

Merged
jlucaso1 merged 4 commits into
mainfrom
perf/group-devices-memo
Jun 10, 2026
Merged

perf(send): memoize the per-group device list behind a topology generation#824
jlucaso1 merged 4 commits into
mainfrom
perf/group-devices-memo

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every group send resolves the full participant device set (it feeds the phash and the SKDM filter), paying the per-member registry fan-out each time: with the directed probe from #823 that is still 2 cache lookups per participant, ~514us per warm send to an 800-member group, repeated on every message to that group.

Change

The resolved (and LID-converted) device list is memoized per group, with the correctness machinery designed so its classic failure modes cannot happen:

  • Recording is enforced by construction, not convention. The topology tracker (generation + changed-users log) is fused into the write chokepoints: DeviceRegistryCache (the registry cache newtype whose only write entry points record the change; the DB-promote path is a separate, documented promote since it copies what the fallback already answered) and LidPnCache::add (mapping changes alter which canonical record either key resolves to; this also covers the startup warm-up, which loops add). A future write path cannot forget a bump because there is no unrecorded write API.
  • GroupInfo identity via Weak. The memo validates by pointer identity (any metadata refresh or membership change produces a new Arc, invalidating for free), ABA-safe because the Weak keeps the allocation alive, while the old snapshot's heavy data (participants, maps) is released as soon as the metadata cache refreshes. The status path builds a fresh GroupInfo per send (no stable identity), so it keeps the unmemoized resolution.
  • Scoped invalidation. Each change records WHICH users it touched, in both namespaces. A memo whose generation went stale first checks the log: when every change since its stamp touched users outside the group (tested against a per-snapshot member set covering participant users, mapped counterparts, and resolved device users), it re-stamps instead of recomputing, so write storms on unrelated groups do not flush every memo. Any doubt (log overflow, global events like a mapping-cache clear) recomputes. The generation is loaded before resolving so a racing write always invalidates the snapshot it raced.
  • Hits share the snapshot. The memo returns Arc<Vec<Jid>> end to end (the phash consumer only reads), so a hit is a refcount bump, not a 1600-JID Vec clone.

resolve_skdm_targets was split into shared helpers with two thin variants (status unmemoized, cached-group memoized), and the tracing spans sit on the resolvers.

Benchmark

Release, warm caches, 800 members x 2 devices, back-to-back on the same machine:

warm send resolution
per-member fan-out (after #823) 514 us
memo hit 161 ns (~3200x)
stale stamp from a storm of unrelated-group writes (1 write between every send) 1.0 us re-stamp instead of a 514 us recompute

Tests

  • group_devices_memo_hits_and_invalidates: a raw cache change without a recorded write is served stale (proving hits are hits), a member change recomputes, and a refreshed GroupInfo Arc recomputes by identity.

  • group_devices_memo_scoped_invalidation: unrelated-user changes re-stamp without recomputing; a member change, a global event, and a log overflow each recompute.

  • group_devices_memo_invalidated_by_member_mapping_change: a LID mapping learned for a member invalidates even though the group only knows the PN namespace (writes log both keys).

  • topology_mutators_bump_the_generation: every mutator (including the migration with a real PN-keyed row) records a change.

  • cargo fmt --all

  • cargo clippy --all-targets -- -D warnings

  • cargo test -p whatsapp-rust --lib (763 passing)

  • cargo test -p wacore (995 passing)

  • wasm32 lib build with the CI invocation

Breaking

None. Public API unchanged; the status send path behavior is untouched.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Performance
    • Faster group sends via memoized per-group device resolution and reduced redundant device lookups; caches now respect topology for safer promotions.
  • Reliability
    • More accurate recipient selection for encrypted group messages (skips hosted/self/forgotten devices); topology-aware invalidation reduces stale device lists.
  • Tests
    • Expanded tests covering memo hits/invalidations, topology generation bumps, scoped invalidation and cache seeding behavior.

Walkthrough

Adds a DeviceTopology tracker and topology-aware DeviceRegistryCache, memoizes fully resolved group device lists validated by topology generation, records topology touches from registry and LID↔PN mutations, refactors SKDM target resolution to use per-group cached sender-key maps and filtered recipient subsets, and shares resolved device lists via Arc for phash.

Changes

Device topology memoization and SKDM caching

Layer / File(s) Summary
Device topology generation tracking
src/client/device_topology.rs
New DeviceTopology (monotonic generation + bounded touched-user log) and DeviceRegistryCache wrapper that records topology touches on insert/invalidate; promote is topology-silent; test/raw-insert helpers included.
Client fields and lifecycle wiring
src/client.rs, src/client/lifecycle.rs
Adds device_topology, topology-aware device_registry_cache, and group_devices_memo to Client; initializes DeviceTopology and binds caches during Client construction; attaches topology to LidPnCache.
Group device memoization with generation validation
src/client/device_registry.rs
Adds GroupDevicesMemo and resolve_group_devices_memoized which validates memo by GroupInfo Arc identity and captured topology generation, recomputes with LID↔PN conversion when stale, and stores scoped member set for invalidation; tests added/expanded.
Device registry cache aliasing & promote semantics
src/client/device_registry.rs
DB-hit cache warms use promote (no topology record); update_device_list(s) and migrations insert cached record under canonical and alias lookup keys (LID/PN) so alias updates invalidate scoped memos; invalidate_device_cache adds post-delete invalidation; test seed helpers switched to raw_insert_for_tests.
LID-PN cache topology integration
src/lid_pn_cache.rs
LidPnCache gains an attachable DeviceTopology; add() records LID/phone touches, clear() records global topology change, and constructor wires topology for both modes.
SKDM target resolution refactor & caching
src/send.rs
Adds skdm_device_map (per-group cached SenderKeyDeviceMap), filter_skdm_targets to compute needs-SKDM subset, refactors resolve_skdm_targets/memoized path to use cached map and filtering; threads Arc-wrapped resolved devices for phash; test seeding updated.
Wacore phash parameter type update
wacore/src/send/group.rs, wacore/benches/send_receive_benchmark.rs
Switches all_devices_for_phash to Option<Arc<Vec<Jid>>> to allow sharing resolved device lists without cloning; callers/bench updated accordingly.
Handler test cache seeding
src/handlers/notification/mod.rs
Identity-change tests now seed device_registry_cache via raw_insert_for_tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#170: Overlaps on group-send SKDM device-resolution and per-group sender-key recipient tracking used by this refactor.
  • oxidezap/whatsapp-rust#603: Related fixes to resolve_skdm_targets and empty SenderKeyDeviceMap handling that intersect with this change.
  • oxidezap/whatsapp-rust#428: Changes to device-registry caching and migration/invalidation paths that this PR builds on.

Suggested labels

breaking-change

Suggested reviewers

  • Ari4ka

You wanted this to work right — review the topology + memoization layers and SKDM filtering paths first; they carry the most behavioral risk.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'perf(send): memoize the per-group device list behind a topology generation' accurately captures the primary change—caching resolved group device lists with topology-aware invalidation.
Description check ✅ Passed The description comprehensively explains the problem (repeated fan-out computation per group send), the solution (memoization with topology tracking), correctness guarantees, benchmarks showing ~3200x improvement, and testing coverage—all directly relevant to the changeset.
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/group-devices-memo

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: 891ea38a96

ℹ️ 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/client/device_registry.rs
@github-actions

github-actions Bot commented Jun 10, 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,300 113,060 +0.2%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,728 1,656,619 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 641,923 641,400 +0.1%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 852,948 852,647 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,989,875 1,989,485 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 734,144 734,172 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,288,199 1,284,115 +0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,147,912 4,147,729 +0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 519,428 518,126 +0.3%
binary_benchmark::marshal_group::bench_marshal_allocating 40,690 40,690 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 40,743 40,743 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 61,909 61,909 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 38,953 38,953 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 40,796 40,796 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 5,144 5,144 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 5,174 5,174 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,954 6,954 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,339 528,339 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 527,963 527,963 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,211 529,211 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 4,769,980 4,769,980 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 4,769,621 4,769,621 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 12,643,701 12,643,701 +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() 28,069 28,069 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,888 672,888 +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,845 3,845 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 47,180 47,180 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,871 3,871 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 47,241 47,241 +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() 65,610 65,610 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 234,591 234,591 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,579 8,579 +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,141,005 4,140,746 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,131 +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,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,908 496,921 -0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 504,889 509,350 -0.9%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,978,116 11,979,064 -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,920,912 4,877,572 +0.9%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,351 2,043,351 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,414 37,404 +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,658 230,658 +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.

@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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/send.rs (1)

1420-1425: ⚠️ Potential issue | 🟠 Major

Fix SKDM warm-path memo misses when self is synthesized (pointer-equality breaks the cache)
In src/send.rs, group_info = ensure_self_in_group(...) is passed into resolve_skdm_targets_memoized(...). When the server omits our sender JID, ensure_self_in_group returns a fresh Arc<GroupInfo>; resolve_group_devices_memoized(...) only reuses its memo when Arc::ptr_eq(&memo.group_info, &group_info) (and generation matches). So the “warm repeat send” memo will miss every time we synthesize self, forcing the expensive device-resolution path and killing the optimization right when you need it.

🤖 Prompt for 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.

In `@src/send.rs` around lines 1420 - 1425, The memo lookup misses when
ensure_self_in_group returns a fresh Arc<GroupInfo> because
resolve_group_devices_memoized only accepts memo hits via
Arc::ptr_eq(&memo.group_info, &group_info) and generation match; change the memo
key/comparison to use a stable identity rather than pointer equality — e.g.,
have resolve_skdm_targets_memoized / resolve_group_devices_memoized compare
memo.group_info.generation and a stable group identifier (or group_info.id or
computed structural key) against the incoming group_info, or normalize
group_info to a canonical Arc before lookup so ptr_eq succeeds; update the memo
struct and lookup logic (references: ensure_self_in_group,
resolve_skdm_targets_memoized, resolve_group_devices_memoized, memo.group_info,
Arc::ptr_eq, generation) accordingly.
🤖 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 `@src/client/device_registry.rs`:
- Around line 83-146: The resolve_group_devices_memoized function reads
device_topology_generation before calling get_user_devices which intentionally
allows a racing write to bump generation and cause wasted work but prevents
serving stale memos; update the doc comment above resolve_group_devices_memoized
(or add an inline comment next to the device_topology_generation.load) to
explicitly state this race semantics, explain why the generation must be loaded
before get_user_devices and that storing a memo stamped with the older
generation is deliberate (optimistic cache tradeoff), and explicitly instruct
future maintainers not to move the load-after-resolve since that would break
invalidation; reference resolve_group_devices_memoized,
device_topology_generation, get_user_devices, group_devices_memo, and generation
in the comment so the intent is unambiguous.

In `@src/client/lifecycle.rs`:
- Line 223: The hard-coded 64 in the Cache::builder().max_capacity(64) call (for
group_devices_memo) should be extracted to a named constant to make the
cache-size decision explicit and easy to tune; define a descriptive constant
(e.g. GROUP_DEVICES_CACHE_CAPACITY) near the top of this file or in a cache
constants module, replace the literal 64 in the group_devices_memo
initialization with that constant, and ensure any tests or docs referencing the
value are updated to use the constant.

In `@src/send.rs`:
- Around line 784-790: The tracing span attribute #[cfg_attr(feature =
"tracing", tracing::instrument(name = "wa.send.resolve_skdm_targets", ...)) was
placed on skdm_device_map but needs to annotate the actual resolver
resolve_skdm_targets so spans reflect real work; move the cfg_attr
tracing::instrument from the skdm_device_map function declaration to the
resolve_skdm_targets function declaration (keeping the same name, level,
skip_all and fields configuration) and remove it from skdm_device_map so traces
correctly attribute resolution time to resolve_skdm_targets.

---

Outside diff comments:
In `@src/send.rs`:
- Around line 1420-1425: The memo lookup misses when ensure_self_in_group
returns a fresh Arc<GroupInfo> because resolve_group_devices_memoized only
accepts memo hits via Arc::ptr_eq(&memo.group_info, &group_info) and generation
match; change the memo key/comparison to use a stable identity rather than
pointer equality — e.g., have resolve_skdm_targets_memoized /
resolve_group_devices_memoized compare memo.group_info.generation and a stable
group identifier (or group_info.id or computed structural key) against the
incoming group_info, or normalize group_info to a canonical Arc before lookup so
ptr_eq succeeds; update the memo struct and lookup logic (references:
ensure_self_in_group, resolve_skdm_targets_memoized,
resolve_group_devices_memoized, memo.group_info, Arc::ptr_eq, generation)
accordingly.
🪄 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: a99807bd-35c6-47e4-814c-550759913ff3

📥 Commits

Reviewing files that changed from the base of the PR and between 324afb7 and 891ea38.

📒 Files selected for processing (5)
  • src/client.rs
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/client/lifecycle.rs
  • src/send.rs

Comment thread src/client/device_registry.rs
Comment thread src/client/lifecycle.rs Outdated
Comment thread src/send.rs Outdated
… invalidation

Addresses every cost of the memo design plus the review findings:

- The generation now lives inside DeviceRegistryCache (cache + tracker
  fused) and LidPnCache::add, so a write path cannot forget to record a
  change; manual bumps are gone. This also covers the Codex P2: the
  LID-PN warm-up records through add() now.
- The memo stores a Weak<GroupInfo>, keeping pointer-identity validation
  (ABA-safe: the Weak holds the allocation) while releasing the old
  snapshot's data as soon as the metadata cache refreshes.
- Changes log WHICH users they touched (both namespaces); a stale memo
  re-stamps itself when every change since its stamp hit other groups'
  users, so unrelated write storms no longer flush every group. Doubt
  (log overflow, global events) recomputes.
- Memo hits share the snapshot Arc instead of cloning the device Vec,
  and the tracing instrument moved back onto the resolvers.

Measured (release, 800 members x 2 devices): hit 161ns; re-stamp under a
storm of unrelated writes 1.0us (vs the 514us recompute the coarse
design paid); raw hit before this change was 8us (Vec clone).

@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: eac657c15a

ℹ️ 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/client/device_registry.rs Outdated
A LID-keyed update (canonical == original == LID) only recorded the LID,
so a PN-addressed group's memo, whose member set knows the member by PN,
re-stamped its stale snapshot instead of recomputing. Updates now record
all resolved lookup keys. Regression test reproduces the exact scenario
and fails without the alias rule (verified by reverting the fix).

@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: f3abd7ebd6

ℹ️ 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/client/device_topology.rs

@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: 4

🤖 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 `@src/client/device_registry.rs`:
- Around line 932-943: The code currently calls self.device_topology.record([pn,
lid]) unconditionally before checking backend.update_device_list result, causing
duplicate topology records on success; change flow so the manual record is only
done in the backend error branch: await
backend.update_device_list(record.clone()) into backend_write, then if let
Err(e) = backend_write { call self.device_topology.record([pn, lid]); warn!(...)
and return; } and remove the prior unconditional call so successful writes rely
on DeviceRegistryCache::insert (device_registry_cache.insert) to record the
change once.
- Around line 164-172: The loop uses a nested if/if-let pattern; replace it with
a let-chain to satisfy the repo style and Clippy's collapsible_if rule: after
inserting participant.user into members, combine the is_lid check with the phone
lookup using a let-chain (e.g., if participant.is_lid() && let Some(pn) =
group_info.phone_jid_for_lid_user(&participant.user) {
members.insert(pn.user.clone()); } else if let Some(lid) =
group_info.lid_user_for_phone_user(&participant.user) {
members.insert(lid.clone()); } so keep the same logic but collapse the nested
if-let into a single conditional using participant.is_lid(),
group_info.phone_jid_for_lid_user, and group_info.lid_user_for_phone_user.
- Around line 95-105: The current code reads generation via
device_topology.current() before fetching group_devices_memo, which can let a
concurrent write make memo stale while still passing memo.generation ==
generation; to fix, remove the early generation read and instead: call
self.group_devices_memo.get(group).await first, then immediately read let
generation = self.device_topology.current() and compare memo.generation ==
generation and pointer equality (memo.group_info vs Arc::as_ptr(group_info)) for
a valid hit; if it's a miss, when you go to resolve/get_user_devices, take a
separate pre-resolve stamp by calling device_topology.current() right before
resolving so the memo you store uses that fresh stamp. Ensure the code
references the existing symbols group_devices_memo.get,
device_topology.current(), memo.generation, and group_info as shown.

In `@src/send.rs`:
- Around line 1425-1427: The memo cache misses because
resolve_skdm_targets_memoized/resolve_group_devices_memoized uses Arc::ptr_eq on
the GroupInfo snapshot but ensure_self_in_group may clone a new GroupInfo (when
self was missing) before the memo lookup; normalize the GroupInfo prior to the
memo identity check by ensuring self inclusion before calling
resolve_skdm_targets_memoized (or alternatively pass the original Arc<GroupInfo>
as the cache key instead of the possibly-cloned one). Update the call site
around resolve_skdm_targets_memoized/resolve_group_devices_memoized so the same
Arc<GroupInfo> instance (or a stable key derived from GroupInfo id/version) is
used for memoization, and reference the functions resolve_skdm_targets_memoized,
resolve_group_devices_memoized, ensure_self_in_group, query_info, GroupInfo, and
Arc::ptr_eq when making the change.
🪄 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: cc77ae35-fac6-41f7-b79b-1c70de9a73f6

📥 Commits

Reviewing files that changed from the base of the PR and between 891ea38 and eac657c.

📒 Files selected for processing (9)
  • src/client.rs
  • src/client/device_registry.rs
  • src/client/device_topology.rs
  • src/client/lifecycle.rs
  • src/handlers/notification/mod.rs
  • src/lid_pn_cache.rs
  • src/send.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/src/send/group.rs

Comment thread src/client/device_registry.rs
Comment thread src/client/device_registry.rs
Comment thread src/client/device_registry.rs Outdated
Comment thread src/send.rs

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

♻️ Duplicate comments (3)
src/client/device_registry.rs (3)

100-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate memo hits against a fresh topology generation.

Lines 100-123 use the pre-resolve stamp for hit validation too. If a topology write lands after Line 100 but before the memo read, this call can still return the old memo once, so a live group send can miss a new device or keep targeting a removed one. Split the reads: use a fresh current_generation for the hit/re-stamp path, then take the pre-resolve generation only right before get_user_devices.

🔧 Minimal fix
-        let generation = self.device_topology.current();
-
         if let Some(memo) = self.group_devices_memo.get(group).await
             && std::ptr::eq(memo.group_info.as_ptr(), Arc::as_ptr(group_info))
         {
-            if memo.generation == generation {
+            let current_generation = self.device_topology.current();
+            if memo.generation == current_generation {
                 // Refcount bump: the snapshot is immutable, so a hit shares
                 // it instead of cloning the device Vec.
                 return Ok(Arc::clone(&memo.devices));
             }
@@
                         group.clone(),
                         Arc::new(GroupDevicesMemo {
                             group_info: memo.group_info.clone(),
-                            generation,
+                            generation: current_generation,
                             members: Arc::clone(&memo.members),
                             devices: Arc::clone(&memo.devices),
                         }),
                     )
                     .await;
                 return Ok(Arc::clone(&memo.devices));
             }
         }
+
+        // Load the generation BEFORE resolving (do NOT move this after
+        // get_user_devices): a write racing the resolve bumps it afterwards,
+        // so the memo we store is already stale by its own stamp and the next
+        // read revalidates. Loading after would stamp racing writes as seen
+        // and serve their effects stale.
+        let generation = self.device_topology.current();
🤖 Prompt for 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.

In `@src/client/device_registry.rs` around lines 100 - 123, The memo hit logic
uses the same pre-resolve generation for both checking cache validity and later
resolving devices, which can return a stale memo if topology advanced between
those reads; change the ordering in the group device lookup (the code paths
around device_topology.current(), group_devices_memo.get(group).await, the
memo.generation check, unchanged_for(...), group_devices_memo.insert(...), and
the later get_user_devices call) so you first read a fresh current_generation
and use that for the memo hit/unchanged_for validation/hit-return paths, and
only after deciding to compute devices read the pre-resolve generation used
immediately before calling get_user_devices and storing a new GroupDevicesMemo;
ensure you compare memo.generation against the fresh current_generation for hits
and reserve the original pre-resolve generation only for the subsequent
compute/insert step.

942-953: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Record this migration once on the success path.

Lines 942-953 double-record the same topology change on success. DeviceRegistryCache::insert already owns the recording write path, so the unconditional record([pn, lid]) burns two generations for one migration and shortens the scoped re-stamp window for no gain.

🔧 Minimal fix
-                let backend_write = backend.update_device_list(record.clone()).await;
-                // The backend row may have changed even when the write errors,
-                // so the change is recorded before the early return.
-                self.device_topology.record([pn, lid]);
-                if let Err(e) = backend_write {
+                if let Err(e) = backend.update_device_list(record.clone()).await {
+                    // The backend row may have changed even when the write errors,
+                    // so record the touched aliases before returning.
+                    self.device_topology.record([pn, lid]);
                     warn!("Failed to migrate device registry to LID: {}", e);
                     return;
                 }
🤖 Prompt for 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.

In `@src/client/device_registry.rs` around lines 942 - 953, The code currently
calls self.device_topology.record([pn, lid]) before checking
backend.update_device_list's result, causing the same topology change to be
recorded twice because DeviceRegistryCache::insert also records on success; fix
this by removing the unconditional self.device_topology.record call and only
record once on the successful path (i.e., after backend.update_device_list
returns Ok or rely on device_registry_cache.insert's recording), updating the
block around backend.update_device_list, the error branch that logs the warn,
and the subsequent self.device_registry_cache.insert call to ensure a single
record occurs.

166-170: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Collapse this nested if let into a let-chain.

This is still the one changed Rust branch that falls back to the nested form. Keep it consistent with the repo rule and Clippy here.

♻️ Minimal rewrite
-            if participant.is_lid() {
-                if let Some(pn) = group_info.phone_jid_for_lid_user(&participant.user) {
-                    members.insert(pn.user.clone());
-                }
+            if participant.is_lid()
+                && let Some(pn) = group_info.phone_jid_for_lid_user(&participant.user)
+            {
+                members.insert(pn.user.clone());
             } else if let Some(lid) = group_info.lid_user_for_phone_user(&participant.user) {
                 members.insert(lid.clone());
             }

As per coding guidelines, **/*.rs: “Always use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks to maintain consistency with Clippy's collapsible_if lint”.

🤖 Prompt for 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.

In `@src/client/device_registry.rs` around lines 166 - 170, The nested if-let
should be collapsed into a let-chain: replace the inner nested form starting
with if participant.is_lid() { if let Some(pn) =
group_info.phone_jid_for_lid_user(&participant.user) {
members.insert(pn.user.clone()); } } with an if using a let-chain (if
participant.is_lid() && let Some(pn) =
group_info.phone_jid_for_lid_user(&participant.user) {
members.insert(pn.user.clone()); }), keeping the existing else-if branch that
uses group_info.lid_user_for_phone_user(&participant.user); reference symbols:
participant.is_lid(), group_info.phone_jid_for_lid_user,
group_info.lid_user_for_phone_user, and members.insert.

Source: Coding guidelines

🤖 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.

Duplicate comments:
In `@src/client/device_registry.rs`:
- Around line 100-123: The memo hit logic uses the same pre-resolve generation
for both checking cache validity and later resolving devices, which can return a
stale memo if topology advanced between those reads; change the ordering in the
group device lookup (the code paths around device_topology.current(),
group_devices_memo.get(group).await, the memo.generation check,
unchanged_for(...), group_devices_memo.insert(...), and the later
get_user_devices call) so you first read a fresh current_generation and use that
for the memo hit/unchanged_for validation/hit-return paths, and only after
deciding to compute devices read the pre-resolve generation used immediately
before calling get_user_devices and storing a new GroupDevicesMemo; ensure you
compare memo.generation against the fresh current_generation for hits and
reserve the original pre-resolve generation only for the subsequent
compute/insert step.
- Around line 942-953: The code currently calls self.device_topology.record([pn,
lid]) before checking backend.update_device_list's result, causing the same
topology change to be recorded twice because DeviceRegistryCache::insert also
records on success; fix this by removing the unconditional
self.device_topology.record call and only record once on the successful path
(i.e., after backend.update_device_list returns Ok or rely on
device_registry_cache.insert's recording), updating the block around
backend.update_device_list, the error branch that logs the warn, and the
subsequent self.device_registry_cache.insert call to ensure a single record
occurs.
- Around line 166-170: The nested if-let should be collapsed into a let-chain:
replace the inner nested form starting with if participant.is_lid() { if let
Some(pn) = group_info.phone_jid_for_lid_user(&participant.user) {
members.insert(pn.user.clone()); } } with an if using a let-chain (if
participant.is_lid() && let Some(pn) =
group_info.phone_jid_for_lid_user(&participant.user) {
members.insert(pn.user.clone()); }), keeping the existing else-if branch that
uses group_info.lid_user_for_phone_user(&participant.user); reference symbols:
participant.is_lid(), group_info.phone_jid_for_lid_user,
group_info.lid_user_for_phone_user, and members.insert.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6c7c334d-d4ae-44e1-a642-61313cb3d727

📥 Commits

Reviewing files that changed from the base of the PR and between eac657c and f3abd7e.

📒 Files selected for processing (1)
  • src/client/device_registry.rs

…ings

- Key the memo off the pre-ensure GroupInfo Arc and append self inside the
  derivation: groups whose server snapshot omits self used to get a fresh
  Arc per send from ensure_self_in_group, making the memo permanently miss
  (CodeRabbit + internal adversarial review).
- Disable the memo when the device registry or LID-PN caches are
  store-backed: a shared external store can be written by other processes
  the in-process topology tracker cannot observe, so the freshness
  contract does not hold there (internal adversarial review).
- Second invalidate in invalidate_device_cache after the DB delete,
  mirroring the canonical-flip guard: a concurrent reader could promote()
  the doomed DB row back between the invalidate and the delete commit.
- Record the migration only once on success (the fused insert already
  records); the manual record stays on the backend-error early return
  (CodeRabbit).
- Fix the member-set comment (the alias coverage comes from writes
  recording lookup.all_keys(), not from device users) and a let-chain.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/device_registry.rs (1)

384-392: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop double-recording the original lookup key.

lookup.all_keys() already includes the incoming identifier, so chaining original_user records the same user twice on every write. DeviceTopology::record() stores one log entry per touched user, so this shrinks the bounded log horizon and pushes unchanged_for() into full recomputes earlier than necessary under write bursts.

♻️ Minimal fix
         self.device_registry_cache
             .insert(
                 canonical_key.clone(),
                 Arc::new(record_for_cache),
                 lookup
                     .all_keys()
-                    .into_iter()
-                    .chain(std::iter::once(original_user.as_str())),
+                    .into_iter(),
             )
             .await;

Apply the same change in update_device_lists(...).

Also applies to: 451-459

🤖 Prompt for 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.

In `@src/client/device_registry.rs` around lines 384 - 392, The insert call into
device_registry_cache is double-recording the incoming identifier because
lookup.all_keys() already contains original_user; remove the chained
std::iter::once(original_user.as_str()) from the iterator passed to
device_registry_cache.insert so only lookup.all_keys() (plus any truly extra
keys) are recorded; apply the same removal in the corresponding
update_device_lists(...) insertion site to avoid duplicating entries that cause
DeviceTopology::record() to log the same user twice.
🤖 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.

Outside diff comments:
In `@src/client/device_registry.rs`:
- Around line 384-392: The insert call into device_registry_cache is
double-recording the incoming identifier because lookup.all_keys() already
contains original_user; remove the chained
std::iter::once(original_user.as_str()) from the iterator passed to
device_registry_cache.insert so only lookup.all_keys() (plus any truly extra
keys) are recorded; apply the same removal in the corresponding
update_device_lists(...) insertion site to avoid duplicating entries that cause
DeviceTopology::record() to log the same user twice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 782b2917-5ef3-4128-86a9-934f481e5479

📥 Commits

Reviewing files that changed from the base of the PR and between f3abd7e and 756178c.

📒 Files selected for processing (4)
  • src/client.rs
  • src/client/device_registry.rs
  • src/client/lifecycle.rs
  • src/send.rs

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