Skip to content

fix(groups): keep persisted group metadata in sync on membership change - #761

Merged
jlucaso1 merged 3 commits into
mainfrom
fix/group-membership-stale-phash
Jun 8, 2026
Merged

fix(groups): keep persisted group metadata in sync on membership change#761
jlucaso1 merged 3 commits into
mainfrom
fix/group-membership-stale-phash

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Closes the features-32 gap.

A participant add/remove updated only the in-memory group cache, never the persisted metadata blob. The in-memory cache expires after ~1h; the next query_info then loads the stale persisted blob and sends an out-of-date participant phash, defeating the not-modified fast-path (forcing a needless full re-query) and diverging from the actual membership. leave() likewise dropped the in-memory entry but left the persisted blob behind.

The fix keeps the persisted blob in sync:

  • Re-serialize and persist the mutated GroupInfo after a successful add/remove in both the API methods (Groups::add_participants/remove_participants) and the inbound group-notification handler (the more common path), via a shared Client::persist_group_metadata (same serde_json format query_info already uses).
  • On leave(), delete the persisted blob through a new Backend::delete_group_metadata (default no-op; implemented for sqlite + in-memory) — we're no longer in the group, so a stale phash from it would only cause a needless re-query if ever read.

Verified against WA Web (WAWebQueryGroupJob persists updated group metadata on membership mutations so the next not-modified comparison stays consistent).

Test: in-memory delete_group_metadata round-trip (put → get → delete → get None).

A participant add/remove updated only the in-memory group cache, never the
persisted metadata blob. The in-memory cache expires after ~1h; the next
query_info then loads the stale persisted blob and sends an out-of-date
participant phash, defeating the not-modified fast-path (forcing a needless full
re-query) and diverging from the actual membership. leave() likewise dropped the
in-memory entry but left the persisted blob behind.

Re-serialize and persist the mutated GroupInfo after a successful add/remove in
both the API methods (Groups::add_participants/remove_participants) and the
inbound notification handler (the common path), via a shared
Client::persist_group_metadata. On leave(), delete the persisted blob through a
new Backend::delete_group_metadata (default no-op; sqlite + in-memory impls).

Verified against WA Web (WAWebQueryGroupJob persists updated group metadata on
membership mutations so the next not-modified comparison stays consistent).
Test: in-memory delete_group_metadata round-trip.
@coderabbitai

coderabbitai Bot commented Jun 8, 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: c1868a6a-92ea-4325-a2fc-a7186bb1405c

📥 Commits

Reviewing files that changed from the base of the PR and between c87a59a and 8695ace.

📒 Files selected for processing (2)
  • src/features/groups.rs
  • src/handlers/notification.rs

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Ensure persisted group metadata is updated or removed when participants are added/removed or when leaving a group to avoid stale state.
  • Chores

    • Storage interfaces and backends updated to support deleting persisted group metadata.
  • Tests

    • Added tests verifying persisted group metadata can be removed and refetched as expected.

Walkthrough

This PR adds ProtocolStore::delete_group_metadata and backend implementations; introduces Client::persist_group_metadata and Client::invalidate_persisted_group_metadata; and updates Groups::leave, add_participants, remove_participants, and the group notification handler to delete or persist group-metadata so persisted phash stays consistent with in-memory GroupInfo.

Changes

Group Metadata Persistence and Cleanup

Layer / File(s) Summary
Storage trait and backend implementations for metadata deletion
wacore/src/store/traits.rs, wacore/src/store/in_memory.rs, storages/sqlite-storage/src/sqlite_store.rs
ProtocolStore adds delete_group_metadata (default no-op). InMemoryBackend removes the map entry; SqliteStore performs a blocking Diesel delete scoped by device_id and group_jid. Tests extended to verify deletion makes get_group_metadata return None.
Client helpers and group mutation wiring
src/features/groups.rs
New Client::persist_group_metadata serializes GroupInfo and calls put_group_metadata; Client::invalidate_persisted_group_metadata deletes via store. Groups::add_participants/remove_participants persist patched GroupInfo on cache hits or call delete on cache misses. Groups::leave deletes persisted group metadata when leaving. Includes a Tokio test for the invalidate helper.
Notification handler persistence calls
src/handlers/notification.rs
handle_group_notification now calls persist_group_metadata after updating cached GroupInfo for Add and Remove; on cache-miss it calls invalidate_persisted_group_metadata so persisted state is cleared.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

You leave a group — we delete the persisted metadata. You mutate participants — we persist or clear the blob so phash comparisons don't force unnecessary re-queries. This must work right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: persisted group metadata is now kept in sync when group membership changes via add/remove/leave operations.
Description check ✅ Passed The description is directly related to the changeset, explaining the bug being fixed, the implementation approach across multiple components, and verification against actual WA Web behavior.
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 fix/group-membership-stale-phash

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.

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

🤖 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 `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 2541-2561: Extend the SQLite round-trip test to cover the new
delete path: after inserting/upserting a group record via the existing
put_group_metadata/upsert_group_metadata calls, call
delete_group_metadata(group_jid) and then call get_group_metadata(group_jid) and
assert it returns None (or equivalent "not found"). Also add a negative case
verifying delete only removes records matching the current device_id by creating
a record for a different device_id, calling delete_group_metadata for the
current device, and asserting the other-device record still exists via
get_group_metadata for that jid/device combo. This ensures delete_group_metadata
and get_group_metadata behaviors are locked down.
🪄 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: 34b94f80-bed0-462f-9a1a-588d0c3c5d92

📥 Commits

Reviewing files that changed from the base of the PR and between ca93294 and 6f69026.

📒 Files selected for processing (5)
  • src/features/groups.rs
  • src/handlers/notification.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread storages/sqlite-storage/src/sqlite_store.rs

@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: 6f69026d62

ℹ️ 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 on lines +1383 to +1385
client
.persist_group_metadata(&notification.group_jid, &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.

P2 Badge Persist membership changes when the cache has expired

When a participant notification arrives after the in-memory group cache has expired (the default TTL is one hour) but a persisted metadata blob still exists, this block is skipped entirely, so the new persist_group_metadata path never updates the stored participant list/phash. The same cache-hit gating exists in the add/remove API paths, which leaves the exact stale persisted blob this change is meant to avoid until a later full query_info refreshes it; consider loading/mutating the persisted GroupInfo or invalidating/deleting it on cache miss.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jun 8, 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,925 2,925 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,446 8,446 +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,485 49,485 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 55,001 55,001 +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,215 112,964 +0.2%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,619 1,656,617 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 651,783 651,846 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 875,662 875,719 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,083,547 2,083,454 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 748,895 748,097 +0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,329,653 1,329,790 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,378,043 4,370,354 +0.2%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 514,018 517,248 -0.6%
binary_benchmark::marshal_group::bench_marshal_allocating 45,381 45,381 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,431 45,431 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,334 66,334 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,492 43,492 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,487 45,487 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,945 4,945 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,976 4,976 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,747 6,747 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,544 528,544 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,165 528,165 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,411 529,411 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,732 5,417,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,047 5,362,047 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,365 13,276,365 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,274 48,274 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,335 48,335 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,659 66,659 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,291 8,291 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,143,503 4,141,091 +0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,133 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,264,189 4,264,189 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 100,399 100,399 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 506,700 505,924 +0.2%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,973,134 11,976,448 -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,887,642 4,902,342 -0.3%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,404 37,414 -0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,617,967 3,617,967 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 230,648 230,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.

…a cold cache

Review follow-ups on the group-metadata sync PR:
- Codex: persisting only on a cache hit missed the exact case this fix targets
  (a membership change arriving after the ~1h in-memory cache expired) — the
  block was skipped and the stale persisted blob was left in place. Add an
  else-branch on cache miss in both the API methods and the notification handler
  that drops the persisted blob via a new
  Client::invalidate_persisted_group_metadata, so the next query re-fetches fresh.
- CodeRabbit: add the delete assertion to the sqlite group-metadata round-trip
  test.
@jlucaso1

jlucaso1 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

🤖 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/features/groups.rs`:
- Around line 462-464: Add a regression test that simulates an expired group
cache and exercises the add/remove code paths that call
self.client.invalidate_persisted_group_metadata(jid) (the cache-miss
invalidation branch shown in features::groups.rs around the add/remove
handlers), then assert that the persisted blob was actually deleted from the
client storage mock (or database) after the operation; specifically, create a
test that (1) seeds a persisted group metadata blob, (2) sets up the GroupCache
to appear expired for the target jid so the code takes the "Cache expired"
branch, (3) performs the add or remove operation that should call
invalidate_persisted_group_metadata, and (4) verifies the mock client received
the invalidation call and that the persisted blob no longer exists. Ensure
equivalent tests cover the other mentioned locations (around lines handling
cache-miss at the other add/remove branches) so this behavior cannot regress.

In `@src/handlers/notification.rs`:
- Around line 1394-1399: The else-branch that calls
client.invalidate_persisted_group_metadata(&notification.group_jid) should log a
debug message before invalidation so the cache-expiration event is observable;
update both the Add and Remove branches that call
invalidate_persisted_group_metadata to emit a process/debug log including the
group identifier (notification.group_jid) and a short reason like "cache expired
- dropping persisted metadata" (and include the action type "Add" or "Remove")
immediately before calling
client.invalidate_persisted_group_metadata(&notification.group_jid) so operators
can trace why persisted group metadata was removed.
🪄 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: e3c26ad3-6cbc-4b3b-b501-01e4fb2670f5

📥 Commits

Reviewing files that changed from the base of the PR and between 6f69026 and c87a59a.

📒 Files selected for processing (3)
  • src/features/groups.rs
  • src/handlers/notification.rs
  • storages/sqlite-storage/src/sqlite_store.rs

Comment thread src/features/groups.rs
Comment thread src/handlers/notification.rs
…invalidation

Review follow-ups on the group-metadata sync PR:
- CodeRabbit: add a regression test for the cache-miss invalidation path
  (invalidate_persisted_group_metadata deletes the persisted blob) so the
  stale-phash fix can't quietly regress.
- CodeRabbit: the notification-handler else-branches invalidated the persisted
  blob silently while the if-branches logged; add matching debug logs so a cache
  expiry + invalidation is observable when troubleshooting stale group data.

@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: 8695ace12e

ℹ️ 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/features/groups.rs
if let Some(info) = group_cache.get(jid).await {
let mut info = Arc::unwrap_or_clone(info);
info.remove_participants(&accepted);
self.client.persist_group_metadata(jid, &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.

P2 Badge Rotate sender keys before persisting removal metadata

When a participant removal succeeds while the group cache is warm, this new awaited persistence write runs before rotate_sender_key_on_participant_remove; with SQLite or a custom backend this can block on disk/network while other outgoing sends are still allowed, so a concurrent send can reuse the old sender key after the server has accepted the removal. Move the rotation ahead of this best-effort metadata persistence (the same ordering exists in the group-notification remove path) so removed members cannot retain decryptable sender-key material during a slow metadata write.

Useful? React with 👍 / 👎.

@jlucaso1
jlucaso1 merged commit 015c99c into main Jun 8, 2026
13 checks passed
@jlucaso1
jlucaso1 deleted the fix/group-membership-stale-phash branch June 8, 2026 13:22
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