Skip to content

perf(receipt): aggregate offline delivery receipts per chat like WA Web - #820

Merged
jlucaso1 merged 2 commits into
mainfrom
perf/aggregate-offline-delivery-receipts
Jun 10, 2026
Merged

jlucaso1 merged 2 commits into
mainfrom
perf/aggregate-offline-delivery-receipts

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Problem

Every received message emits its own delivery <receipt>: one node build, one marshal, one Noise frame, one socket write. During an offline-sync reconnect the server drains the backlog in batches of 200, so a few hundred queued messages produce a few hundred receipt stanzas in a burst. WA Web optimizes exactly this case: sendAggregateOfflineReceipts groups the backlog's receipts by chat and author and emits ONE <receipt> per group, with the first message id as the id attr and the rest as <list><item id=.../></list> children, chunked at 256 ids. Our ingest already parses that aggregated shape (collect_simple_message_ids); we just never produced it.

Change

Offline-drained messages (info.is_offline, set from the stanza's offline attr) buffer their delivery receipt instead of sending it 1:1, and complete_offline_sync flushes the buffer as aggregate receipts. Live messages keep the existing 1:1 path behind a single bool check, matching WA Web sending live receipts individually.

  • The buffer stores Arc<MessageInfo> clones (refcount bumps, nothing copied). Grouping keys are fully borrowed (&Jid + a static type str), and message ids enter the nodes as &str borrows, so the flush allocates only the per-group vectors and the nodes themselves.
  • Groups split by (chat, author, receipt type, self-fanout recipient): everything that varies the receipt-level attrs. delivery_receipt_type is the single source of truth for the type attr in both the builder and the grouping key, so they cannot drift. Splitting finer than WA Web (e.g. on recipient device) is always wire-safe; merging across any of these would corrupt the receipt.
  • Wire shape mirrors sendAggregateReceipts: id = first id, remaining ids in <list><item>, the type attr dropped for plain delivery, t carrying the flush timestamp, participant only for group/status chats, 256-id chunks.
  • No receipt can strand in the buffer: complete_offline_sync flips the completed flag before taking the buffer lock, and producers re-check the flag under the lock, so a late offline receipt either lands in the flush or falls back to 1:1. disconnect/reconnect/reconnect_immediately drain the buffer into the outbound_flush window before closing it, preserving the issue Delivery receipts may be dropped if disconnect() is called shortly after a message is received #571 flush semantics.
  • The drain uses mem::take, so outside the offline window the buffer is an empty Vec with zero capacity (asserted in a test). No memory is held between offline windows.

Benchmark

Temporary measurement (release, 100 runs, bench not committed): a 200-message offline backlog from one chat.

stanzas wire bytes build + marshal
individual (before) 200 6000 113 us
aggregate (after) 1 3428 (-43%) 86 us (-24%)

The dominant saving is not in the table: 199 fewer Noise frame encryptions and 199 fewer socket writes per 200-message chat backlog, plus the matching reduction on the server side.

Tests

  • aggregate_delivery_receipts_group_by_chat_author_and_type: DM receipts coalesce, group authors split, peer_msg never merges into the plain delivered group.

  • aggregate_delivery_receipt_node_shape_and_ingest_roundtrip: asserts the WA Web wire shape and round-trips the produced node through our own ingest parser (collect_simple_message_ids).

  • aggregate_delivery_receipt_chunks_at_256_ids: 257 ids split into 256 + 1, and a single-id chunk carries no empty <list>.

  • offline_receipt_buffer_protocol: end-to-end buffer lifecycle including the late-receipt fallback after completion and the zero-capacity assertion after the drain.

  • cargo fmt --all

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

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

  • cargo test -p wacore (994 passing)

Breaking

None. The public API is unchanged; live receipts keep the exact same wire shape and timing.

@coderabbitai

coderabbitai Bot commented Jun 10, 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: 91c63f28-5d68-4e8d-a872-71e84b1df0c5

📥 Commits

Reviewing files that changed from the base of the PR and between 77aceb6 and 789429f.

📒 Files selected for processing (2)
  • src/client/lifecycle.rs
  • src/receipt.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Delivery receipts buffered during offline sync are now flushed as aggregated receipts when sync completes.
  • Bug Fixes

    • Buffered receipts are reliably flushed on disconnect/reconnect and during connection lifecycle transitions to avoid lost or duplicated receipts.
  • Documentation

    • Clarified offline receipt buffering and aggregation semantics.
  • Tests

    • Added tests covering aggregation behavior, chunking, and buffer flush/race scenarios.

Walkthrough

This PR buffers delivery receipts during offline sync, computes an optional receipt type, groups buffered messages by receipt-level attributes (including type), chunks groups to 256 IDs, and flushes them as aggregate <receipt> stanzas on offline-sync completion or during disconnect/reconnect; live receipts remain 1:1.

Changes

Offline Receipt Buffering and Aggregation

Layer / File(s) Summary
Buffer Field Definition and Initialization
src/client.rs, src/client/lifecycle.rs
Client gains offline_receipt_buffer as Mutex<Vec<Arc<MessageInfo>>> and initializes it during construction.
Disconnect and Reconnect Lifecycle Integration
src/client/lifecycle.rs
disconnect, reconnect, and reconnect_immediately call flush_offline_receipts() before closing the outbound flush window; connect() and cleanup_connection_state() call clear_offline_receipt_buffer() to reset state.
Ordering and Flushing Semantics Documentation
src/client/sessions.rs, src/message/dispatch.rs
Docs clarify offlinesync completion happens-before flushing and that offline-buffered messages are aggregated while live messages are sent individually.
Receipt Type Computation and Shared Builder
src/receipt.rs
Adds delivery_receipt_type helper and delivery_receipt_builder that conditionally emits type attribute for receipts.
Aggregate Grouping, Chunking, and Node Construction
src/receipt.rs
Introduces DeliveryReceiptGroup, groups buffered messages including optional type in the grouping key, and emits aggregate <receipt> nodes chunked to 256 message IDs each.
Buffer Protocol: try_buffer and flush_offline_receipts Methods
src/receipt.rs
Implements Client::try_buffer_offline_receipt (race-safe check against offline_sync_completed), Client::flush_offline_receipts (drains, groups, chunks, and schedules outbound flush), and clear_offline_receipt_buffer.
Tests: Grouping, Chunking, and Buffer Protocol
src/receipt.rs
Adds tests for grouping semantics (preventing peer_msg coalescing), 256-ID chunking, wire-shape round-trip parsing, completion-flag race fallback, and drain/reset behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main optimization: aggregating offline delivery receipts to match WA Web's behavior, improving performance.
Description check ✅ Passed The description is thorough and directly related to the changeset, explaining the problem, implementation details, benchmarks, and testing.
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/aggregate-offline-delivery-receipts

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: 77aceb62b9

ℹ️ 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/receipt.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,049 113,049 +0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,620 1,656,622 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 644,423 644,372 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 870,461 870,330 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,076,052 2,076,296 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 737,417 737,389 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,304,940 1,304,945 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,239,484 4,219,185 +0.5%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 511,423 520,020 -1.7%
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,143,519 4,144,385 -0.0%
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() 511,184 511,983 -0.2%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,977,071 11,979,075 -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,913,132 4,877,892 +0.7%
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,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,658 230,648 +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: 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/client/lifecycle.rs`:
- Around line 492-495: Update the comment in disconnect() to accurately describe
buffering behavior: explain that disconnect() calls flush_offline_receipts()
which drains current buffered receipts but does not flip offline_sync_completed,
so try_buffer_offline_receipt() can still add new receipts to
offline_receipt_buffer after the drain (because outbound_flush.close() only
stops spawning outbound tasks), and those later receipts will be handled on the
next flush (e.g., reconnect()/reconnect_immediately()) rather than within the
disconnect call; reference disconnect(), flush_offline_receipts(),
complete_offline_sync(), try_buffer_offline_receipt(), outbound_flush.close(),
offline_sync_completed, and offline_receipt_buffer in the updated comment.

In `@src/receipt.rs`:
- Around line 561-574: The try_buffer_offline_receipt function currently calls
.expect("offline receipt buffer poisoned") on offline_receipt_buffer which
panics on mutex poison and violates the no-.expect/.unwrap rule; replace that
panic with graceful handling: acquire the lock with .lock() and handle Err by
either returning false (fallback to 1:1 send) or mapping the poisoned guard via
.into_inner() to continue, or change the signature to return a Result<bool,
YourError> and propagate the locking error; update the code paths around
offline_receipt_buffer, try_buffer_offline_receipt, and checks of
offline_sync_completed accordingly, or if you intentionally want fail-fast,
replace the .expect with a short comment on why poison should panic and keep
behavior consistent.
🪄 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: 25a95eff-6612-4dcd-8fb7-7fa3b17dba0a

📥 Commits

Reviewing files that changed from the base of the PR and between b65fd0d and 77aceb6.

📒 Files selected for processing (5)
  • src/client.rs
  • src/client/lifecycle.rs
  • src/client/sessions.rs
  • src/message/dispatch.rs
  • src/receipt.rs

Comment thread src/client/lifecycle.rs
Comment thread src/receipt.rs
A receipt buffered after disconnect()'s drain (close() only stops outbound
spawns, not buffering) would otherwise leak into the next connection's
aggregate flush. The connection-state resets now clear the buffer; the
server redelivers those unacked messages on the next connect. Also use the
poison-recovery lock pattern instead of expect(), and make the disconnect
drain comment precise.
@jlucaso1
jlucaso1 merged commit fc6157b into main Jun 10, 2026
11 checks passed
@jlucaso1
jlucaso1 deleted the perf/aggregate-offline-delivery-receipts branch June 10, 2026 04:13
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