Skip to content

perf: granular cache patching instead of invalidate+refetch - #382

Merged
jlucaso1 merged 3 commits into
mainfrom
perf-granular-cache-patching
Mar 17, 2026
Merged

perf: granular cache patching instead of invalidate+refetch#382
jlucaso1 merged 3 commits into
mainfrom
perf-granular-cache-patching

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Replace cache invalidation with in-place patching for device and group notifications, matching WhatsApp Web's addParticipantInfo / removeParticipantInfo / bulkCreateOrReplace patterns from captured JS
  • Eliminates a full IQ round-trip on every participant/device change event

Before → After

Event Before After
Someone joins a group INVALIDATE → IQ refetch patch: O(1) append, 0 IQ
Someone leaves a group INVALIDATE → IQ refetch patch: O(n) filter, 0 IQ
Contact adds a device INVALIDATE → usync IQ patch: O(1) append, 0 IQ
Contact removes a device INVALIDATE → usync IQ patch: O(n) filter, 0 IQ
Device key update INVALIDATE → usync IQ patch: find + update, 0 IQ
You add group members INVALIDATE → IQ refetch patch: O(1) append, 0 IQ
You remove group members INVALIDATE → IQ refetch patch: O(n) filter, 0 IQ

Changes

wacore/src/client/context.rsGroupInfo::add_participants() and remove_participants() mutation methods with LID-PN map maintenance + 7 unit tests

src/client/device_registry.rspatch_device_add(), patch_device_remove(), patch_device_update() methods + 6 unit tests

src/handlers/notification.rs — Device notification handler now patches instead of invalidating; group notification handler patches GroupInfo in-place for add/remove actions

src/features/groups.rsGroups::add_participants() and remove_participants() patch cache inline instead of invalidating

Safety

All patches are no-ops if the cache entry doesn't exist — the next read fetches fresh from the backend. Zero risk of stale data since the notification carries the exact diff.

Test plan

  • 324 wacore tests pass
  • 317 whatsapp-rust tests pass (13 new tests for granular patching)
  • cargo clippy --all-targets clean
  • cargo fmt --all clean

Summary by CodeRabbit

  • New Features

    • Granular device management: targeted add, remove, and update of device records with precise in-memory cache synchronization
    • Improved group participant handling: in-place cache patches for participant adds/removes and new public APIs to manage participants
    • Notifications now apply granular device/group patches instead of broad cache invalidation, while preserving update events
  • Tests

    • Comprehensive tests for device add/remove/update and edge cases
    • Expanded tests for group participant add/remove, mapping, and backfill behaviors

Replace cache invalidation with in-place patching for device and group
notifications, matching WhatsApp Web's addParticipantInfo /
removeParticipantInfo / bulkCreateOrReplace patterns.

Device notifications (type="devices"):
- Add: append device to cached Vec<Jid> + DeviceListRecord
- Remove: filter out device from both caches
- Update: update key_index in DeviceListRecord

Group notifications (type="w:gp2"):
- Add: extend GroupInfo.participants + update LID-PN maps
- Remove: filter GroupInfo.participants + clean LID-PN maps

Client-initiated (Groups::add/remove_participants):
- Patch cached GroupInfo inline instead of invalidating

All patches are no-ops if the cache entry doesn't exist — the next
read fetches fresh from the backend. Zero risk of stale data since
the notification carries the exact diff.

Eliminates a full IQ round-trip on every participant/device change.
@coderabbitai

coderabbitai Bot commented Mar 17, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad070f91-7b7e-4f57-a23c-d389224a3166

📥 Commits

Reviewing files that changed from the base of the PR and between 564a17a and b3a40ad.

📒 Files selected for processing (1)
  • src/features/groups.rs

📝 Walkthrough

Walkthrough

Adds granular in-memory cache patching: three device patch APIs, group participant add/remove mutations on cached GroupInfo, and notification handler updates to call these patch methods instead of invalidating full caches; tests added for device and group behaviors.

Changes

Cohort / File(s) Summary
Device Cache Patching
src/client/device_registry.rs
Added pub(crate) methods patch_device_add, patch_device_remove, patch_device_update to mutate device_cache and device_registry_cache in-place; added tests covering add, dedupe, no-op misses, remove, and key_index updates.
Group Cache Patching
src/features/groups.rs
Replaced full-cache invalidation with best-effort in-place patches: fetch cached GroupInfo, mutate participants (add/remove only server-accepted entries), and reinsert updated entry (non-atomic get→mutate→insert).
Notification Handler Updates
src/handlers/notification.rs
Notification handlers now dispatch per-item cache patches: device Add→patch_device_add, Remove→patch_device_remove, Update→patch_device_update (or fallback invalidate); group participant Add/Remove→patch-and-reinsert logic retained alongside event dispatch.
GroupInfo Participant APIs
wacore/src/client/context.rs
Added public add_participants(&mut self, new: &[(Jid, Option<Jid>)]) and remove_participants(&mut self, users_to_remove: &[&str]) to manage participants with deduplication and lid↔pn map maintenance; tests added for PN/Lid modes, backfill, and cleanup.

Sequence Diagram(s)

sequenceDiagram
    participant Notification as Notification Service
    participant Handler as Notification Handler
    participant Client as Client (patch APIs)
    participant Cache as In-memory Caches
    participant EventBus as Event Dispatcher

    Notification->>Handler: deliver device/group notification
    Handler->>Client: determine per-item action (add / remove / update)
    Client->>Cache: read current cache entry
    Cache-->>Client: return cached DeviceInfo / GroupInfo or miss
    Client->>Cache: write patched entry (add / remove / update)
    Client->>EventBus: emit DeviceListUpdate / GroupUpdate
    EventBus-->>Handler: subscribers receive update
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through caches, light and spry,

Nibbling diffs as they flutter by.
A small patch here, a careful stitch there,
Devices and groups now handled with care —
Tiny hops, big fixes, carrot cheers to spare! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: replacing cache invalidation with granular cache patching for improved performance.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf-granular-cache-patching
📝 Coding Plan
  • Generate coding plan for human review comments

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

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Mar 17, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 1m 26s —— View job


Code Review: Granular Cache Patching

  • Read changed files
  • Review wacore/src/client/context.rs (GroupInfo mutations)
  • Review src/client/device_registry.rs (device patch methods)
  • Review src/handlers/notification.rs (notification handler changes)
  • Review src/features/groups.rs (group feature changes)
  • Post review feedback

Overall this is a clean, well-structured optimization. The approach of patching caches in-place instead of invalidate+refetch is sound and correctly mirrors the WhatsApp Web patterns. The safety property (no-op on cache miss) is the right call. A few observations:

Looks Good

  • Deduplication in add_participants — correctly checks any(|p| p.user == jid.user) before pushing, preventing duplicate entries.
  • Bidirectional map cleanup in remove_participants — handles both LID→PN and PN→LID removal, which is important for correctness.
  • Cache-miss safety — all patch methods are no-ops when the cache entry doesn't exist (if let Some(mut ...) = cache.get(...).await). This means the next read will fetch fresh from backend.
  • Test coverage — 13 new tests covering add/remove/update, deduplication, no-op on miss, and registry patching. Good coverage.
  • patch_device_add dedup in both device_cache (by device_jid.device) and device_registry_cache (by device_id).

Minor Issues

  1. device_registry.rs:224 — potential truncation in device_id as u16

    device: device_id as u16,

    device_id() returns u32 (from jid.device as u32 at the DeviceElement level), and Jid.device is u16, so this round-trip u16 → u32 → u16 is lossless in practice. But the as u16 cast is unchecked — if DeviceElement is ever constructed with a device_id > u16::MAX from a non-JID source, this would silently truncate. Low risk given current usage, but worth a note.

  2. device_registry.rs:263device_id as u16 in patch_device_remove

    Same pattern: devices.retain(|d| d.device != device_id as u16). Same low risk, same note applies.

  3. notification.rs group patching — get_group_cache().await called twice per action

    In handle_group_notification, both Add and Remove arms call client.get_group_cache().await inside the match. Since get_group_cache() is likely cheap (returns an Arc or similar), this is fine for correctness. But if you wanted to micro-optimize, you could hoist it above the for action in notification.actions loop. Very minor.

  4. groups.rs:237add_participants passes None for phone_number

    let new: Vec<_> = participants.iter().map(|p| (p.clone(), None)).collect();

    When adding participants via the Groups::add_participants() API, the phone_number is always None. This means in LID-addressed groups, the LID→PN map won't be updated. This seems intentional (the caller only has JIDs, not phone number mappings), and the next full group query would populate the map. But it's worth noting that after a programmatic add_participants call on a LID group, the cache will have the participant but without the phone number mapping until the next full refresh.

Architecture

The design correctly separates concerns:

  • wacore/context.rs — pure data mutations on GroupInfo, no async, no I/O
  • device_registry.rs — cache-layer patching with LID/PN resolution
  • notification.rs — wiring: notification → patch call
  • groups.rs — wiring: API call result → patch call

This makes the mutation logic easily testable (and indeed well-tested) without needing async runtime or client infrastructure.

Verdict: This PR looks good to merge. The approach is sound, the edge cases are handled correctly, and the test coverage is solid. The minor issues noted above are all low-risk and don't block merging.


@github-actions

github-actions Bot commented Mar 17, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchperf-granular-cache-patching
Testbedubuntu-latest
Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,123.00
(-9.21%)Baseline: 6,743.89
7,081.09
(86.47%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
850,843.00
(+0.00%)Baseline: 850,800.67
893,340.71
(95.24%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,097.00
(-12.82%)Baseline: 23,052.72
24,205.36
(83.03%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
119,321.00
(-6.81%)Baseline: 128,034.65
134,436.38
(88.76%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
119,349.00
(+0.07%)Baseline: 119,264.85
125,228.09
(95.31%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
534,117.00
(+0.01%)Baseline: 534,041.41
560,743.48
(95.25%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
17,408.00
(+0.27%)Baseline: 17,361.90
18,229.99
(95.49%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
17,210,016.00
(+0.39%)Baseline: 17,143,592.64
18,000,772.27
(95.61%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
176,790.00
(+0.03%)Baseline: 176,734.87
185,571.62
(95.27%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
535,527.00
(+0.01%)Baseline: 535,454.13
562,226.83
(95.25%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
19,457.00
(+0.21%)Baseline: 19,415.43
20,386.20
(95.44%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
42,911,885.00
(+0.30%)Baseline: 42,785,165.83
44,924,424.12
(95.52%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
534,556.00
(+0.01%)Baseline: 534,480.41
561,204.43
(95.25%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
17,381.00
(-3.17%)Baseline: 17,949.75
18,847.24
(92.22%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
17,210,857.00
(+0.39%)Baseline: 17,144,438.59
18,001,660.52
(95.61%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
129,222.00
(-2.77%)Baseline: 132,896.99
139,541.84
(92.60%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
119,421.00
(+0.07%)Baseline: 119,336.85
125,303.69
(95.31%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
94,461.00
(-4.61%)Baseline: 99,029.44
103,980.91
(90.84%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-5.34%)Baseline: 7,793.99
8,183.69
(90.15%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
94,492.00
(+0.36%)Baseline: 94,151.76
98,859.34
(95.58%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.71%)Baseline: 7,348.50
7,715.93
(95.92%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
110,277.00
(+0.31%)Baseline: 109,936.76
115,433.59
(95.53%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.59%)Baseline: 8,860.50
9,303.52
(95.80%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
45,476.00
(-4.00%)Baseline: 47,371.46
49,740.04
(91.43%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-6.52%)Baseline: 2,906.51
3,051.84
(89.03%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+3.79%)Baseline: 535,802.74
562,592.88
(98.84%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.46%)Baseline: 774.53
813.26
(94.80%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,527,339.00
(-0.69%)Baseline: 27,717,834.77
29,103,726.51
(94.58%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,540,322.00
(-0.18%)Baseline: 5,550,236.52
5,827,748.35
(95.07%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
178,094.00
(+0.03%)Baseline: 178,041.27
186,943.33
(95.27%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
178,905.00
(+0.03%)Baseline: 178,852.61
187,795.24
(95.27%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,266,638.00
(-0.11%)Baseline: 17,285,006.17
18,149,256.48
(95.14%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
295,884.00
(+0.01%)Baseline: 295,845.56
310,637.84
(95.25%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,552,194.00
(-0.37%)Baseline: 12,598,706.53
13,228,641.85
(94.89%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
715,609.00
(-0.01%)Baseline: 715,710.23
751,495.74
(95.22%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
41,823.00
(+0.03%)Baseline: 41,811.28
43,901.84
(95.26%)
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,707.08
16,339,792.44
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,504,859.00
(-0.12%)Baseline: 5,511,296.74
5,786,861.58
(95.13%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
956,774.00
(-0.23%)Baseline: 958,980.99
1,006,930.04
(95.02%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,822,723.00
(-0.01%)Baseline: 2,822,882.98
2,964,027.13
(95.23%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,444,364.00
(-1.15%)Baseline: 3,484,585.88
3,658,815.17
(94.14%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
127,226,695.00
(+1.46%)Baseline: 125,394,907.88
131,664,653.27
(96.63%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,812.00
(+0.12%)Baseline: 11,797.45
12,387.32
(95.36%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,825.00
(+0.04%)Baseline: 3,823.28
4,014.45
(95.28%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,720.00
(-0.27%)Baseline: 87,959.84
92,357.83
(94.98%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,754.00
(-0.32%)Baseline: 80,008.58
84,009.01
(94.94%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
51,011.00
(-0.02%)Baseline: 51,021.20
53,572.26
(95.22%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,762.00
(+0.27%)Baseline: 5,746.29
6,033.60
(95.50%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,122.00
(+0.19%)Baseline: 2,117.92
2,223.81
(95.42%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.02%)Baseline: 21,915.53
23,011.31
(95.26%)
🐰 View full continuous benchmarking report in Bencher

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

Caution

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

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

230-240: ⚠️ Potential issue | 🟠 Major

Patch only the participants the server actually accepted.

Both branches ignore result and mutate GroupInfo with every requested JID. Partial add/remove failures will leave the cache ahead of the authoritative group state until someone invalidates it manually. Filter the patch to successful ParticipantChangeResponses, or invalidate on any non-success.

Also applies to: 249-259

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/groups.rs` around lines 230 - 240, The current code patches
GroupInfo with every requested JID regardless of the server response; change the
update logic after self.client.execute(AddParticipantsIq::new(jid,
participants)).await? so it only applies participants that the server accepted
(inspect result for successful ParticipantChangeResponse entries) and add only
those to GroupInfo via info.add_participants(&new_accepted). If the result
indicates any non-success for the request, instead avoid a partial patch and
invalidate the cache entry (e.g., remove or refresh the GroupInfo) rather than
applying a subset; apply the same fix for the corresponding remove-participants
branch that also reads result and calls info.remove_participants.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/device_registry.rs`:
- Around line 229-236: patch_device_add and patch_device_remove currently only
update cache entry for from_jid.to_non_ad(), leaving the other alias (PN/LID)
stale; change both functions to resolve all cached aliases for the user (e.g.,
both PN and LID forms) via the same lookup used by invalidate_device_cache and
iterate over those keys from get_device_cache(), applying the same add/remove
logic to every existing cache entry (check devices.iter().any(...), push or
retain accordingly, then insert back) so both aliases are patched consistently.
- Around line 241-249: The current logic mutates the in-memory
device_registry_cache (e.g., the block that gets record from
self.device_registry_cache.get(...) and then pushes a new
wacore::store::traits::DeviceInfo and calls
self.device_registry_cache.insert(...)) but does not persist the updated
DeviceListRecord, causing has_device() to fall back to stale
persistence_manager.backend() after cache
eviction/invalidate_device_cache()/restart. Replace the cache-only insert with a
call to update_device_list() to persist the patched DeviceListRecord (same
change needed for the similar blocks at the other occurrences referenced around
the 272-279 and 294-299 ranges), ensuring the code constructs the updated
DeviceListRecord and invokes update_device_list(key, updated_record) instead of
only calling device_registry_cache.insert(...).

In `@src/features/groups.rs`:
- Around line 235-239: The get→mutate→insert on TypedCache for group updates
(using self.client.get_group_cache(), group_cache.get(jid),
info.add_participants(...) and group_cache.insert(...)) is racy; wrap the
read-modify-write inside the per-chat lock provided by Client::chat_locks (use
the lock keyed by jid) or call the client's atomic mutate helper so updates to
the same group serialize; apply the same change to the similar block around the
other occurrence (the code around lines 254–258) so both notification-driven and
client-initiated add/remove paths perform the mutation while holding the
per-group lock.

In `@src/handlers/notification.rs`:
- Around line 345-351: The Update branch currently loops patching each device
via client.patch_device_update(notification.user(), device).await but doesn't
handle hash-only updates where op.devices may be empty; change the
DeviceNotificationType::Update handling to check if op.devices.is_empty() and,
if so, call invalidate_device_cache(notification.user()).await as the fallback,
otherwise iterate and call client.patch_device_update for each device (keeping
existing notification.user() usage).

In `@wacore/src/client/context.rs`:
- Around line 87-98: The loop in Context (the for (jid, phone_number) in new {
... }) skips already-cached participants before updating
lid_to_pn_map/pn_to_lid_map, so a later phone_number (None -> Some) never
backfills maps; change the logic to update the maps when addressing_mode ==
AddressingMode::Lid and phone_number.is_some() even if the participant already
exists (i.e., move the map-update block ahead of the continue or perform it
unconditionally before the continue), and add a regression test that exercises
Groups::add_participants() where the first insert uses (jid, None) and a
subsequent call provides Some(phone_number) and asserts that
phone_device_jid_to_lid() returns the expected mapping.

---

Outside diff comments:
In `@src/features/groups.rs`:
- Around line 230-240: The current code patches GroupInfo with every requested
JID regardless of the server response; change the update logic after
self.client.execute(AddParticipantsIq::new(jid, participants)).await? so it only
applies participants that the server accepted (inspect result for successful
ParticipantChangeResponse entries) and add only those to GroupInfo via
info.add_participants(&new_accepted). If the result indicates any non-success
for the request, instead avoid a partial patch and invalidate the cache entry
(e.g., remove or refresh the GroupInfo) rather than applying a subset; apply the
same fix for the corresponding remove-participants branch that also reads result
and calls info.remove_participants.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5d73db44-eff8-46fc-8eef-5885b54dd5f3

📥 Commits

Reviewing files that changed from the base of the PR and between 864c909 and 5384cea.

📒 Files selected for processing (4)
  • src/client/device_registry.rs
  • src/features/groups.rs
  • src/handlers/notification.rs
  • wacore/src/client/context.rs

Comment thread src/client/device_registry.rs
Comment thread src/client/device_registry.rs Outdated
Comment thread src/features/groups.rs Outdated
Comment on lines +235 to +239
let group_cache = self.client.get_group_cache().await;
if let Some(mut info) = group_cache.get(jid).await {
let new: Vec<_> = participants.iter().map(|p| (p.clone(), None)).collect();
info.add_participants(&new);
group_cache.insert(jid.clone(), info).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Serialize group cache patching per group.

This is a non-atomic get() → mutate → insert() on TypedCache. If a notification patch races with a client-initiated add/remove, both paths can read the same GroupInfo and the last insert drops the other delta. Use a per-group lock or a dedicated atomic mutate helper here.

As per coding guidelines, Use Client::chat_locks to serialize per-chat operations for concurrency safety.

Also applies to: 254-258

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/groups.rs` around lines 235 - 239, The get→mutate→insert on
TypedCache for group updates (using self.client.get_group_cache(),
group_cache.get(jid), info.add_participants(...) and group_cache.insert(...)) is
racy; wrap the read-modify-write inside the per-chat lock provided by
Client::chat_locks (use the lock keyed by jid) or call the client's atomic
mutate helper so updates to the same group serialize; apply the same change to
the similar block around the other occurrence (the code around lines 254–258) so
both notification-driven and client-initiated add/remove paths perform the
mutation while holding the per-group lock.

Comment thread src/handlers/notification.rs
Comment thread wacore/src/client/context.rs
- Patch all PN/LID aliases in device_cache via jids_for_lookup helper,
  not just from_jid.to_non_ad() (CodeRabbit)
- Persist patched DeviceListRecords to backend via update_device_list
  so changes survive cache eviction/restart (CodeRabbit)
- Fall back to invalidate on hash-only device Update notifications
  where op.devices is empty (CodeRabbit)
- Backfill LID-PN maps even for existing participants: move map
  update before the dedup check so None→Some(phone) fills correctly
  when server notification follows client-initiated add (CodeRabbit)
- Document get→mutate→insert race in group cache patching as
  acceptable for a best-effort cache (CodeRabbit)
- Add regression test for LID map backfill case (CodeRabbit)
Filter ParticipantChangeResponse by status="200" before patching
GroupInfo. Partial failures (403, 409) no longer leave the cache
ahead of the authoritative group state. (CodeRabbit outside-diff)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant