Skip to content

feat!: replace history sync events with lazy blob + perf optimizations - #533

Merged
jlucaso1 merged 5 commits into
mainfrom
feat/lazy-history-sync
Apr 14, 2026
Merged

jlucaso1 merged 5 commits into
mainfrom
feat/lazy-history-sync

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replaces Event::JoinedGroup(LazyConversation) (per-conversation, misleading name) and dead Event::HistorySync(HistorySync) with a single Event::HistorySync(Box<LazyHistorySync>) per sync blob, plus performance optimizations.

Consumer API

Event::HistorySync(hs) => {
    // Cheap metadata — no decode
    let sync_type = hs.sync_type();
    let progress = hs.progress();

    // Full proto access on demand (lazy, cached, parse-once)
    if let Some(data) = hs.get() {
        for conv in &data.conversations { /* ... */ }
        for pn in &data.pushnames { /* ... */ }
        // global_settings, past_participants, call_log_records, etc.
    }

    // Or raw bytes for custom partial decoding
    let raw = hs.raw_bytes();
}

Changes

Event refactor

  • Event::JoinedGroup(LazyConversation) removed — name was wrong (carried DMs, groups, newsletters)
  • Event::HistorySync(HistorySync) removed — dead code, never dispatched
  • New: Event::HistorySync(Box<LazyHistorySync>) — single event per sync blob with lazy decode

Performance

  • Channel removed — tctoken candidates collected directly in the streaming loop, no more async_channel/oneshot/secondary spawn
  • Conditional blob retention — decompressed Bytes only kept when event listeners exist
  • Inline decode for small blobs — PushName/Recent syncs (<256KB) skip spawn_blocking
  • Manual pushname parser — checks id before allocating, skips 99% of entries without allocation
  • parse_jid_fast early-exit — groups/newsletters/bots filtered without JID allocation
  • Decompression cap — 8MB → 64MB estimate to avoid reallocs; hard 64MB limit via Read::take() to prevent OOM
  • Box<wa::HistorySync> in OnceLock — avoids ~500 bytes inline reservation
  • Safe Clone — shares Bytes cheaply, creates fresh OnceLock (no accidental deep copy)
  • Zero-clone tctoken storage — candidates consumed by value, token moved directly into TcTokenEntry

Safety

  • Hard 64MB decompression limit via Read::take() prevents OOM on malformed blobs
  • Safe u64 -> usize conversion via try_from (no truncation on 32-bit/WASM)
  • own_user.unwrap() eliminated — moved into if-let chain
  • Serialize is metadata-only and deterministic (no handler-order dependency)

What's preserved

  • TCToken extraction (prost partial decode of fields 1/21/22/28)
  • Own pushname extraction (streaming field 7)
  • NCT salt extraction (streaming field 19)
  • No-listener fast path (has_handlers() gates blob retention and event dispatch)

Breaking changes

  • Event::JoinedGroup removed
  • Event::HistorySync payload changed from wa::HistorySync to Box<LazyHistorySync>
  • LazyConversation removed

Test plan

  • cargo clippy --all --tests — zero warnings
  • cargo fmt --all — clean
  • All tests pass (7 new LazyHistorySync tests + 3 existing history sync tests)
  • No remaining references to JoinedGroup, LazyConversation, or old channel code

…blob

Replace `Event::JoinedGroup(LazyConversation)` (dispatched N times per
history sync) and dead `Event::HistorySync(HistorySync)` with a single
`Event::HistorySync(Box<LazyHistorySync>)` per sync blob.

LazyHistorySync wraps the full decompressed protobuf bytes with lazy
decode via OnceLock. Consumers get:
- Cheap metadata without decode: sync_type(), chunk_order(), progress()
- Full proto access on demand: .get() -> Option<&wa::HistorySync>
- Raw bytes for custom/partial decoding: .raw_bytes()

Internal processing (tctokens, pushname, nct_salt) is preserved —
streaming extraction via channel still runs before event dispatch.

BREAKING: Event::JoinedGroup removed, Event::HistorySync payload changed,
LazyConversation removed.
@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Swaps per-conversation streaming/lazy decode for a single post-parse wrapper: history-sync parsing now returns decompressed bytes and tc-token candidates; tctokens are stored from parsed candidates; a single Event::HistorySync(Box<LazyHistorySync>) is emitted that holds raw bytes + metadata and lazily decodes the full wa::HistorySync.

Changes

Cohort / File(s) Summary
Runtime & dispatch
src/history_sync.rs
Removed the async streaming/receiver loop and per-conversation lazy dispatch; parse now yields a HistorySyncResult, stores collected tc-token candidates, and dispatches a single Event::HistorySync(Box<LazyHistorySync>); conditional spawn_blocking for large compressed payloads retained.
Parsing core
wacore/src/history_sync.rs
HistorySyncResult gains tc_token_candidates: Vec<TcTokenCandidate> and decompressed_bytes: Option<Bytes>; process_history_sync drops the per-conversation callback in favor of retain_blob: bool; decompression enforces MAX_DECOMPRESSED and optionally retains full blob; pushname/tctoken extraction moved to dedicated helpers and accumulated into result.
Event & lazy wrapper
wacore/src/types/events.rs
Replaced LazyConversation with LazyHistorySync that stores raw decompressed bytes plus sync_type, chunk_order, progress and a parse-once cache; Event::HistorySync now carries Box<LazyHistorySync>; Debug/Serialize and tests updated accordingly.
TCToken storage
src/history_sync.rs, wacore/...
Shifted token extraction from inline partial protobuf decode to collecting TcTokenCandidates and calling store_tc_token_candidate; comparison now uses candidate timestamps and removed prior JID-type gating.
Tests & callsites
wacore/...
Unit tests updated to the new process_history_sync signature (no generic callback) and to validate LazyHistorySync metadata, cached decoding, raw-bytes round-trip, and corrupt/empty decode behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Task as HistorySyncTask
  participant Parser as process_history_sync
  participant Store as TCTokenStore
  participant EventBus as EventBus

  Task->>Parser: parse compressed payload (retain_blob flag)
  Parser-->>Task: HistorySyncResult{tc_token_candidates, decompressed_bytes}
  loop store candidates
    Task->>Store: store_tc_token_candidate(candidate)
  end
  Task->>EventBus: dispatch Event::HistorySync(Box::new(LazyHistorySync::new(decompressed_bytes,...)))
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Poem

🐇 I nibble bytes beneath the moon,

Decompress whispers, hum a tune,
One boxed sync carries all the lore,
Tokens tucked, then hopped out the door,
A rabbit clap — parse once, explore.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: replacing history sync events with a lazy blob approach and adding performance optimizations, matching the core refactoring across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 feat/lazy-history-sync

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.

@github-actions

github-actions Bot commented Apr 14, 2026

Copy link
Copy Markdown

Benchmark Results

59 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 3,879 3,879 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 11,855 11,855 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,398 43,398 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 19,365 19,365 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 68,814 68,814 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,785 76,785 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,214 2,214 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,943 5,943 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 177,917 178,334 -0.2%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 192,395 192,481 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 889,630 889,623 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 981,010 981,011 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,466,138 1,466,139 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,673,214 2,666,987 +0.2%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,756,254 9,790,200 -0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 46,481,927 46,486,374 -0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,705,500 12,693,307 +0.1%
binary_benchmark::marshal_group::bench_marshal_allocating 95,742 95,742 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 95,775 95,775 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 114,155 114,155 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 102,854 102,854 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 95,842 95,842 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 15,748 15,748 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 15,792 15,792 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 17,581 17,581 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 533,115 533,115 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 532,681 532,681 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 534,042 534,042 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 13,423,541 13,423,541 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 13,367,806 13,367,806 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 26,668,743 26,668,743 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,498 2,498 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 38,500 38,500 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 785 785 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 556,214 556,214 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 5,024 5,024 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 7,483 7,483 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 90,792 90,792 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 7,510 7,510 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 90,828 90,828 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 8,838 8,838 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 104,662 104,662 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 13,468 13,468 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,261,543 17,296,698 -0.2%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 161,375 161,375 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,511,833 5,511,833 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 162,048 162,112 -0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 298,353 298,353 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 712,883 712,883 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,653,240 12,577,549 +0.6%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,531,801 27,523,601 +0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 3,467,011 3,467,011 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 126,165,933 123,811,173 +1.9%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,474 2,830,474 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 46,003 46,003 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,106,732 5,106,732 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 317,585 317,585 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,247,117 14,247,117 +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: 1

Caution

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

⚠️ Outside diff range comments (1)
wacore/src/types/events.rs (1)

97-107: ⚠️ Potential issue | 🟠 Major

Fix non-deterministic serialization of LazyHistorySync to preserve metadata.

Currently, Event::HistorySync serializes as null until some caller invokes get(), then starts serializing the full protobuf. This violates deterministic serialization, drops sync_type/chunk_order/progress entirely for untouched events, and contradicts the type's documented purpose of providing "cheap metadata for filtering without decoding."

♻️ Always include metadata in serialized form
 impl Serialize for LazyHistorySync {
     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
     where
         S: serde::Serializer,
     {
-        if let Some(Some(hs)) = self.parsed.get() {
-            hs.serialize(serializer)
-        } else {
-            serializer.serialize_none()
-        }
+        #[derive(Serialize)]
+        struct SerializableLazyHistorySync<'a> {
+            sync_type: i32,
+            chunk_order: Option<u32>,
+            progress: Option<u32>,
+            #[serde(skip_serializing_if = "Option::is_none")]
+            history_sync: Option<&'a wa::HistorySync>,
+        }
+
+        SerializableLazyHistorySync {
+            sync_type: self.sync_type,
+            chunk_order: self.chunk_order,
+            progress: self.progress,
+            history_sync: self.get(),
+        }
+        .serialize(serializer)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 97 - 107, The Serialize impl for
LazyHistorySync must always emit the lightweight metadata (sync_type,
chunk_order, progress) instead of returning null when parsed.get() is None;
update the impl for LazyHistorySync so it builds and serializes a struct/object
that always includes those metadata fields and, if let Some(Some(hs)) =
self.parsed.get() then includes the full parsed protobuf payload as an
additional field (or serializes hs inline), otherwise omits or serializes that
payload as None — this preserves deterministic serialization for
Event::HistorySync while still including the full decoded data when available.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@wacore/src/types/events.rs`:
- Around line 97-107: The Serialize impl for LazyHistorySync must always emit
the lightweight metadata (sync_type, chunk_order, progress) instead of returning
null when parsed.get() is None; update the impl for LazyHistorySync so it builds
and serializes a struct/object that always includes those metadata fields and,
if let Some(Some(hs)) = self.parsed.get() then includes the full parsed protobuf
payload as an additional field (or serializes hs inline), otherwise omits or
serializes that payload as None — this preserves deterministic serialization for
Event::HistorySync while still including the full decoded data when available.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e7652ce-44c1-4de3-98f9-022911e887e3

📥 Commits

Reviewing files that changed from the base of the PR and between cf1f0c6 and 932816b.

📒 Files selected for processing (3)
  • src/history_sync.rs
  • wacore/src/history_sync.rs
  • wacore/src/types/events.rs

Comment thread src/history_sync.rs Outdated
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Apr 14, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 3m 18s —— View job


Code Review: Replace per-conversation history sync events with single lazy blob

  • Read changed files and understand the diff
  • Review wacore/src/types/events.rs — new LazyHistorySync type
  • Review wacore/src/history_sync.rsHistorySyncResult changes
  • Review src/history_sync.rs — task processing refactor
  • Post review findings

Verdict: Looks good overall

This is a well-motivated refactor that cleans up three problems at once: a misleadingly-named event (JoinedGroup for all conversation types), dead code (Event::HistorySync(HistorySync) never dispatched), and an over-opinionated per-conversation dispatch model. The replacement Event::HistorySync(Box<LazyHistorySync>) is cleaner and more flexible.


What's done well

Unified code paths (src/history_sync.rs): The old code had two near-identical branches — one for has_listeners and one for no-listeners — both doing the same channel-based tctoken extraction. The only difference was the listeners branch additionally dispatching per-conversation events. The new code collapses this into a single path with a conditional dispatch at the end. This alone cuts ~60 lines of duplication.

LazyHistorySync design (wacore/src/types/events.rs:28-80): OnceLock<Option<wa::HistorySync>> is the right primitive here. With Arc<Event> dispatch, all handlers share the same instance, so OnceLock gives parse-once semantics for free. Cheap metadata (sync_type, chunk_order, progress) exposed without triggering decode is a nice touch for filtering.

Zero-copy chain preserved (wacore/src/history_sync.rs:81): buf.clone() on Bytes is just an Arc refcount bump — the decompressed blob is shared, not copied. The comment on line 81 documents this clearly.

Default removal from HistorySyncResult: Correct — Bytes has no meaningful default in this context, and explicit construction at wacore/src/history_sync.rs:77-82 is clearer.

Test coverage: 7 tests covering the key behaviors (decode, caching via pointer equality, metadata access, raw bytes, empty bytes, corrupt bytes, message preservation).


Issues / suggestions

1. Serialize impl drops metadata when unparsed (minor)

wacore/src/types/events.rs:97-108 — If get() hasn't been called, the entire event serializes as null, losing sync_type, chunk_order, and progress. This contradicts the type's purpose of providing "cheap metadata without decoding."

CodeRabbit flagged this too and I agree it's worth fixing, though it's not a blocker. If any consumer logs or persists serialized events without first calling get(), they'll see null instead of the metadata they'd expect. A struct-based serialization that always includes the three metadata fields and optionally includes the decoded proto would be more correct.

Fix this →

2. Memory trade-off worth documenting (informational)

The old design dispatched N LazyConversation events per sync (each wrapping a zero-copy slice of one conversation). The new design holds the entire decompressed blob in LazyHistorySync.raw_bytes plus the full decoded wa::HistorySync in the OnceLock after first get().

This is a reasonable trade-off (1 event vs N events, simpler consumer API, access to pushnames/global settings/etc.), but the full proto can be large for initial bootstrap syncs. The blob lives until all handlers drop the Arc<Event>. Consumers who only need conversation IDs will decode the entire proto just to iterate them. This is fine in practice — just calling it out for awareness.

3. notification.sync_type().into() (nit)

src/history_sync.rs:250notification.sync_type() returns a prost enum (HistorySyncType), and .into() converts it to i32. This works but as i32 would be more explicit about what the conversion is. Minor nit.

4. wasm32 try_send on bounded channel (pre-existing, not introduced here)

src/history_sync.rs:177 — On wasm32, try_send on a bounded(4) channel silently drops conversations when the receiver lags. CodeRabbit flagged this, but it's a pre-existing issue from the old code, not a regression from this PR. The PR just unified the two identical branches. Worth tracking separately if wasm32 support matters.


Summary

Clean refactor with good motivation and solid test coverage. The only actionable item is the Serialize impl (issue 1), which is minor. Everything else is either informational or pre-existing. LGTM once you decide whether the serialization behavior matters for your use case.

The Serialize impl now always emits sync_type, chunk_order, and
progress fields. The decoded proto is included as "data" when
available, null otherwise. Avoids returning bare null when the
proto hasn't been decoded yet.

@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: 932816bfa1

ℹ️ 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/history_sync.rs
if self.core.event_bus.has_handlers() {
let lazy_hs = LazyHistorySync::new(
sync_result.decompressed_bytes,
notification.sync_type().into(),

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 Preserve unknown history sync type values in metadata

Event::HistorySync metadata is populated with notification.sync_type().into(), but the prost-generated sync_type() accessor returns the default enum when the field is unset or contains an unknown value. That means new/forward-compat server sync types get silently rewritten (typically to InitialBootstrap) before handlers see them, so consumers branching on hs.sync_type() can take the wrong path. Prefer preserving the raw field value (notification.sync_type) and representing it as optional/raw in LazyHistorySync metadata.

Useful? React with 👍 / 👎.

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/types/events.rs`:
- Around line 97-108: The Serialize impl for LazyHistorySync currently calls
self.parsed.get() and may mutate shared OnceLock state, causing
handler-order-dependent outputs; update the Serialize implementation for
LazyHistorySync to avoid invoking parsed.get() (or any method that
initializes/mutates OnceLock) and only emit metadata fields (sync_type,
chunk_order, progress) or a boolean flag like has_data, making inclusion of the
full decoded data an explicit opt-in via a separate method/serializer; locate
the Serialize impl for LazyHistorySync and remove the data field serialization
(or replace with a non-mutating indicator) so serialization is deterministic and
side-effect free.
- Around line 68-74: The current get method runs wa::HistorySync::decode inline
via self.parsed.get_or_init, which can block the calling async Tokio worker;
change this so the initial full decode is performed on a blocking thread: either
replace get with an async offloaded accessor (e.g., async fn
get_offloaded(&self) -> Option<&wa::HistorySync>) that uses
tokio::task::spawn_blocking to run wa::HistorySync::decode and then stores the
result into self.parsed (or provide a separate init_offloaded() that does the
spawn_blocking decode and populates parsed before async handlers call get),
ensuring all references to get in history_sync.rs use the offloaded
initializer/accessor and that parsed and wa::HistorySync::decode are the unique
symbols updated.
- Around line 76-79: The getter raw_bytes currently returns a slice (&[u8])
which drops the cheap ref-counted Bytes ownership and forces copies; update the
events type so the backing field stores bytes::Bytes (or keep it if already
Bytes) and change pub fn raw_bytes(&self) -> &[u8] to a public API that returns
Bytes (i.e., pub fn raw_bytes(&self) -> Bytes) so callers (like
LazyHistorySync::new and HistorySyncResult.decompressed_bytes consumers) can
retain/forward the blob zero-copy via Bytes::clone; ensure the method clones the
internal Bytes for the return and update any callers accordingly to accept
Bytes.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d76bf170-a92f-4b22-a4e2-60ac77c6a6db

📥 Commits

Reviewing files that changed from the base of the PR and between 932816b and 146bf55.

📒 Files selected for processing (1)
  • wacore/src/types/events.rs

Comment thread wacore/src/types/events.rs
Comment on lines +76 to 79
/// Access the raw decompressed protobuf bytes for custom/partial decoding.
pub fn raw_bytes(&self) -> &[u8] {
&self.raw_bytes
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Keep Bytes on the public raw-bytes API.

LazyHistorySync::new() is fed a Bytes from HistorySyncResult.decompressed_bytes (src/history_sync.rs:248-253), but this getter narrows it to &[u8]. Callers that need to retain or forward the blob now lose the cheap ref-counted handoff and have to allocate a copy.

♻️ One zero-copy option
-    pub fn raw_bytes(&self) -> &[u8] {
-        &self.raw_bytes
+    pub fn raw_bytes(&self) -> Bytes {
+        self.raw_bytes.clone()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 76 - 79, The getter raw_bytes
currently returns a slice (&[u8]) which drops the cheap ref-counted Bytes
ownership and forces copies; update the events type so the backing field stores
bytes::Bytes (or keep it if already Bytes) and change pub fn raw_bytes(&self) ->
&[u8] to a public API that returns Bytes (i.e., pub fn raw_bytes(&self) ->
Bytes) so callers (like LazyHistorySync::new and
HistorySyncResult.decompressed_bytes consumers) can retain/forward the blob
zero-copy via Bytes::clone; ensure the method clones the internal Bytes for the
return and update any callers accordingly to accept Bytes.

Comment thread wacore/src/types/events.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)
wacore/src/types/events.rs (3)

82-83: ⚠️ Potential issue | 🟠 Major

Keep Bytes on the public raw-bytes API.

Returning &[u8] drops the cheap ref-counted handoff that Bytes gives you, so any caller that needs to retain or forward the blob now has to copy it. That defeats the zero-copy path for large history-sync payloads.

♻️ Minimal API fix
-    pub fn raw_bytes(&self) -> &[u8] {
-        &self.raw_bytes
+    pub fn raw_bytes(&self) -> Bytes {
+        self.raw_bytes.clone()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 82 - 83, The public accessor
raw_bytes currently returns a &[u8], losing the cheap ref-counted semantics of
Bytes; change the API to return Bytes instead (keep the field name raw_bytes) so
callers can retain/forward without copying — update pub fn raw_bytes(&self) ->
Bytes to return a cloned handle of the internal Bytes (i.e., return the
ref-counted Bytes, not a slice) and adjust any call sites/tests expecting &[u8]
accordingly.

75-78: ⚠️ Potential issue | 🟠 Major

Don't run the first full protobuf decode inline.

wa::HistorySync::decode on these blobs can be expensive enough to stall whichever thread hits get() first, including Tokio workers. Please move the initial decode behind a spawn_blocking-backed initializer/accessor and keep get() as a cached read.

As per coding guidelines, "All I/O uses Tokio; wrap blocking I/O (ureq) and heavy CPU work in tokio::task::spawn_blocking".

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

In `@wacore/src/types/events.rs` around lines 75 - 78, The get() method currently
does a synchronous, potentially expensive wa::HistorySync::decode on first
access via self.parsed.get_or_init; change this so the initial decode is
performed inside a tokio::task::spawn_blocking and stored in the cache
asynchronously, while get() remains a cheap cached read. Concretely: replace the
inline decode in the initializer for parsed with an async-safe path that kicks
off spawn_blocking to decode self.raw_bytes into wa::HistorySync once (or await
a stored JoinHandle/future), then store the decoded value into parsed (or swap
in when ready); ensure subsequent calls to get() return the cached
Option<&wa::HistorySync> without performing blocking work. Use the symbols get,
parsed, raw_bytes, and wa::HistorySync to locate the change.

102-113: ⚠️ Potential issue | 🟠 Major

Make event serialization deterministic.

Because the same LazyHistorySync instance is shared across handlers, this emits either data: null or the full decoded proto depending on whether some earlier consumer already called get(). That makes JSON output handler-order dependent and can unexpectedly dump full sync contents into logs or telemetry. Keep Serialize metadata-only, and make full-payload serialization explicit.

♻️ One deterministic option
-        let mut s = serializer.serialize_struct("LazyHistorySync", 4)?;
+        let mut s = serializer.serialize_struct("LazyHistorySync", 3)?;
         s.serialize_field("sync_type", &self.sync_type)?;
         s.serialize_field("chunk_order", &self.chunk_order)?;
         s.serialize_field("progress", &self.progress)?;
-        s.serialize_field("data", &self.parsed.get().and_then(|o| o.as_ref()))?;
         s.end()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/types/events.rs` around lines 102 - 113, The current Serialize
impl for LazyHistorySync reads self.parsed.get() and conditionally emits the
full decoded payload, making JSON output handler-order dependent; change the
Serialize implementation for LazyHistorySync to only emit metadata (serialize
"sync_type", "chunk_order", "progress") and serialize the "data" field as None
(or omit it) instead of calling self.parsed.get(); keep the existing field names
("sync_type", "chunk_order", "progress", "data") so consumers still see
metadata, and if full payload serialization is needed add an explicit method
(e.g., LazyHistorySync::serialize_full or a separate serializer) that
intentionally accesses self.parsed.get() to return the decoded proto.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@wacore/src/types/events.rs`:
- Around line 82-83: The public accessor raw_bytes currently returns a &[u8],
losing the cheap ref-counted semantics of Bytes; change the API to return Bytes
instead (keep the field name raw_bytes) so callers can retain/forward without
copying — update pub fn raw_bytes(&self) -> Bytes to return a cloned handle of
the internal Bytes (i.e., return the ref-counted Bytes, not a slice) and adjust
any call sites/tests expecting &[u8] accordingly.
- Around line 75-78: The get() method currently does a synchronous, potentially
expensive wa::HistorySync::decode on first access via self.parsed.get_or_init;
change this so the initial decode is performed inside a
tokio::task::spawn_blocking and stored in the cache asynchronously, while get()
remains a cheap cached read. Concretely: replace the inline decode in the
initializer for parsed with an async-safe path that kicks off spawn_blocking to
decode self.raw_bytes into wa::HistorySync once (or await a stored
JoinHandle/future), then store the decoded value into parsed (or swap in when
ready); ensure subsequent calls to get() return the cached
Option<&wa::HistorySync> without performing blocking work. Use the symbols get,
parsed, raw_bytes, and wa::HistorySync to locate the change.
- Around line 102-113: The current Serialize impl for LazyHistorySync reads
self.parsed.get() and conditionally emits the full decoded payload, making JSON
output handler-order dependent; change the Serialize implementation for
LazyHistorySync to only emit metadata (serialize "sync_type", "chunk_order",
"progress") and serialize the "data" field as None (or omit it) instead of
calling self.parsed.get(); keep the existing field names ("sync_type",
"chunk_order", "progress", "data") so consumers still see metadata, and if full
payload serialization is needed add an explicit method (e.g.,
LazyHistorySync::serialize_full or a separate serializer) that intentionally
accesses self.parsed.get() to return the decoded proto.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c96e1dda-482c-4063-894c-b963734a15aa

📥 Commits

Reviewing files that changed from the base of the PR and between 146bf55 and 9916b2d.

📒 Files selected for processing (1)
  • wacore/src/types/events.rs

- Box OnceLock cached value to avoid inline HistorySync-sized hole
- Safe Clone impl that shares Bytes cheaply without deep-copying
  the decoded proto
- Add into_raw_bytes() for zero-copy Bytes ownership transfer
- Remove async_channel from history sync — collect tctoken candidates
  directly in the streaming loop and process after completion
- Conditional blob retention: only keep decompressed Bytes when
  event listeners exist (saves memory for headless bots)
- Raise decompression estimate cap from 8MB to 64MB to avoid
  unnecessary reallocs on large InitialBootstrap blobs
- Skip spawn_blocking for small blobs (<256KB) — decode inline
  to avoid thread hop overhead for PushName/Recent syncs
- Manual pushname parser that checks id before allocating,
  avoiding string allocations for 99% of non-matching entries
- Use prost partial decode for tctokens (DRY, readable) with
  parse_jid_fast early-exit for groups/newsletters/bots
- Add conversations_raw() iterator for zero-copy per-conversation
  access without full proto decode
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review things carefully: performance, unnecessary allocations, what can be dry, also try to find dead code

@jlucaso1 jlucaso1 changed the title feat!: replace per-conversation history sync events with single lazy blob feat!: replace history sync events with lazy blob + perf optimizations Apr 14, 2026
@claude

claude Bot commented Apr 14, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 3m 8s —— View job


Deep Review: Performance, Allocations, DRY & Dead Code

  • Read all changed files and understand the diff
  • Review wacore/src/types/events.rs — allocations, dead code, performance
  • Review wacore/src/history_sync.rs — allocations, DRY, dead code
  • Review src/history_sync.rs — allocations, DRY, dead code, control flow
  • Cross-file analysis: unused imports, dead paths, DRY violations
  • Post findings

Verdict: Good refactor. A few concrete findings below.


1. Dead code: into_raw_bytes() and conversations_raw() are never called

wacore/src/types/events.rs:102-116 — Both into_raw_bytes(self) and conversations_raw(&self) (plus the entire ConversationIterator struct at lines 119-152) are defined but have zero call sites anywhere in the codebase. conversations_raw in particular pulls in read_varint, skip_field, and wire_type from wacore::history_sync as pub(crate) — those visibility promotions only exist to serve this iterator.

If these are intended as public API for downstream consumers, fine — but the ConversationIterator is a meaningful chunk of manually-written protobuf parsing code (30 lines) with no test coverage. If they're speculative, remove them and re-add when actually needed.

Fix this →


2. Unnecessary allocation: tc_token_candidates collects then iterates

wacore/src/history_sync.rs:73 creates a Vec<TcTokenCandidate>, grows it during parsing, then src/history_sync.rs:214-216 iterates it with an async .await per candidate:

// wacore — collects into Vec
result.tc_token_candidates.push(candidate);

// src — iterates sequentially
for candidate in &sync_result.tc_token_candidates {
    self.store_tc_token_candidate(candidate).await;
}

Each TcTokenCandidate owns a String (id) and Vec<u8> (tc_token), so for a 500-conversation initial bootstrap you're making ~500 small heap allocations during the synchronous parse, carrying them across the blocking boundary, and then cloning the Vec<u8> again at src/history_sync.rs:272 (candidate.tc_token.clone()).

The old streaming design processed tokens as they arrived. The new approach is simpler and fine for typical use, but the clone at line 272 is avoidable — store_tc_token_candidate takes &TcTokenCandidate but could take ownership (or at least take the token by value) to avoid the clone on the hot path. Since the candidate is consumed exactly once in the loop, you could iterate into_iter() and move the Vec<u8> into TcTokenEntry directly.

Fix this →


3. Serialize impl: non-deterministic data field

wacore/src/types/events.rs:179:

s.serialize_field("data", &self.parsed.get().and_then(|o| o.as_ref()))?;

This reads the OnceLock without initializing it — so data is null until some handler calls get(), after which it emits the full decoded proto. Since Arc<Event> shares one LazyHistorySync across all handlers, the serialized output depends on handler execution order. This was already flagged by prior reviews, but from a performance angle it has an additional subtlety: if a logging/telemetry handler serializes the event after another handler calls get(), it will unexpectedly serialize the entire decoded proto — potentially megabytes of JSON — into the log sink.

Two clean options:

  • Metadata-only: drop the data field from Serialize entirely (3 fields instead of 4). Consumers who need the payload can call get() explicitly.
  • Has-data flag: replace data with "decoded": bool using self.parsed.get().and_then(|o| o.as_ref()).is_some().

4. Decompression cap raised 8x without comment

wacore/src/history_sync.rs:59 — The pre-allocation cap was changed from 8 * 1024 * 1024 (8 MB) to 64 * 1024 * 1024 (64 MB). This is just a Vec::with_capacity hint (not a hard limit — read_to_end will grow beyond it), but a 64 MB speculative allocation is meaningful on memory-constrained targets. Was the old 8 MB cap hitting real-world problems? If so, worth a comment. If not, the old value was more conservative.


5. extract_own_pushname early-return assumes field ordering

wacore/src/history_sync.rs:231:

if !id_match {
    return None; // wrong user, skip entirely
}

This returns None immediately when field 1 (id) doesn't match. That's correct if prost/protobuf always serializes field 1 before field 2. While this is guaranteed by the proto spec's canonical ordering and by prost's encoder, it could silently break if a non-canonical encoder sends field 2 before field 1. The old code used prost::Message::decode which doesn't care about field order.

This is a minor robustness concern, not a bug — prost and WA servers both use canonical ordering. But the function's doc says "Checks id first", which could mislead someone into thinking the parser handles both orderings.


6. Minor: ConversationTcTokenFields allocates String + Vec<u8> per conversation

wacore/src/history_sync.rs:256-265 — The prost partial-decode struct still allocates a String for id and a Vec<u8> for tc_token on every conversation decode, including groups/newsletters that get filtered out immediately after. The old streaming code had the same cost, so this isn't a regression — but if you wanted to go further, a manual parser (like extract_own_pushname) could check the JID suffix before allocating the token bytes. Not worth doing unless profiling shows this is hot.


7. Pre-existing: message_id.clone() at src/history_sync.rs:80

Not introduced by this PR, but message_id is cloned for the receipt and then used again for logging. Since send_protocol_receipt takes String by value, you could send the receipt last (after all logging that needs message_id) and avoid the clone. Minor.


Summary

# Severity Type Description
1 Medium Dead code into_raw_bytes, conversations_raw, ConversationIterator — 50+ lines with zero callers and no tests
2 Low Allocation tc_token.clone() avoidable by taking ownership in the iteration loop
3 Low Correctness Serialize data field is non-deterministic (handler-order dependent)
4 Informational Config Decompression cap raised from 8 MB to 64 MB — worth a comment if intentional
5 Informational Robustness extract_own_pushname assumes canonical field ordering
6 Informational Allocation Prost partial decode allocates per-conversation even for filtered groups
7 Informational Pre-existing message_id.clone() avoidable by reordering

Items 1-3 are worth addressing. The rest is informational. Overall this is a clean simplification — the removal of the channel-based streaming and per-conversation dispatch is a net win for both code clarity and maintenance.


View job

@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

Caution

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

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

256-277: ⚠️ Potential issue | 🟠 Major

Make the tc-token merge atomic.

This is still a read/modify/write race: a newer post-send put_tc_token can land after get_tc_token() and then be overwritten by this history-sync write, so the newer sender_timestamp is not actually protected. The max-merge needs to happen inside one backend operation or transactional persistence command.

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

In `@src/history_sync.rs` around lines 256 - 277, The current read/modify/write in
history_sync.rs using backend.get_tc_token and backend.put_tc_token allows a
race that can overwrite a newer sender_timestamp; change this to perform the
max-merge inside a single backend operation or transaction. Add or use an atomic
backend method (e.g., backend.merge_tc_token or a transactional put method) that
takes token_key and the incoming candidate/tc values and computes the merged
TcTokenEntry (max of existing.sender_timestamp and
candidate.tc_token_sender_timestamp, and appropriate token_timestamp logic)
inside the backend implementation, then persist it in one step; update the call
site in history_sync.rs to call that atomic method instead of calling
get_tc_token followed by put_tc_token so the sender_timestamp merge is
guaranteed atomic.
wacore/src/history_sync.rs (1)

99-107: 🛠️ Refactor suggestion | 🟠 Major

Drop the unwrap() from the pushname scan.

This branch is already conditioned on own_user, so the unwrap() is avoidable and violates the repo rule for Rust code. A let-chain keeps the arm collapsible and removes the panic-y API entirely.

♻️ One way to keep this collapsible
-            7 if own_user.is_some()
-                && result.own_pushname.is_none()
-                && wire_type_raw == wire_type::LENGTH_DELIMITED =>
-            {
+            7 if wire_type_raw == wire_type::LENGTH_DELIMITED => {
                 let (len, vlen) = read_varint(&buf[pos..])?;
                 pos += vlen;
                 let end = checked_end(pos, len, buf.len(), "pushname")?;
 
-                if let Some(name) = extract_own_pushname(&buf[pos..end], own_user.unwrap()) {
+                if result.own_pushname.is_none()
+                    && let Some(own_user) = own_user
+                    && let Some(name) = extract_own_pushname(&buf[pos..end], own_user)
+                {
                     result.own_pushname = Some(name);
                 }
                 pos = end;
             }

As per coding guidelines, "Use let-chains (if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nested if let blocks to maintain collapsible if patterns" and "never use .unwrap() outside tests".

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

In `@wacore/src/history_sync.rs` around lines 99 - 107, The branch uses
own_user.unwrap() — replace that by moving own_user into the if-let chain so
there is no unwrap; e.g. change the condition to bind the user (if let Some(own)
= own_user && result.own_pushname.is_none() && wire_type_raw ==
wire_type::LENGTH_DELIMITED) then call extract_own_pushname(&buf[pos..end], own)
(keep the existing read_varint and checked_end usage), ensuring the arm remains
collapsible and panics are avoided.
♻️ Duplicate comments (2)
wacore/src/types/events.rs (2)

79-94: ⚠️ Potential issue | 🟠 Major

Don't do the first full HistorySync decode on the caller thread.

get() still runs wa::HistorySync::decode(...) inline inside OnceLock::get_or_init, so the first async handler that touches a large blob pays the full parse cost on a Tokio worker. That keeps the lazy API, but it reintroduces scheduler stalls on exactly the hot path this change is trying to avoid.

As per coding guidelines, "All I/O uses Tokio; wrap blocking I/O (ureq) and heavy CPU work in tokio::task::spawn_blocking".

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

In `@wacore/src/types/events.rs` around lines 79 - 94, get() performs the heavy
wa::HistorySync::decode(...) synchronously inside OnceLock::get_or_init,
blocking the caller thread; change get() to be async (pub async fn get(&self) ->
Option<&wa::HistorySync>) and do a fast-path check of self.parsed.get()
returning if present, otherwise offload the decode to
tokio::task::spawn_blocking (spawn_blocking(move ||
wa::HistorySync::decode(&raw_bytes[..]).ok().map(Box::new))) await the join,
then store the boxed result into the OnceLock using parsed.set(...) (ignore the
Err case if another task beat us) and finally return parsed.get().as_deref();
reference the symbols get(), parsed, OnceLock::set/get(), and
wa::HistorySync::decode when making the change.

169-180: ⚠️ Potential issue | 🟠 Major

Keep Serialize deterministic and metadata-only.

This output still depends on handler order: if another consumer called .get() first, data suddenly contains the full decoded blob; otherwise it serializes as null. On a shared Arc<Event>, that makes logs/telemetry nondeterministic and can unexpectedly dump full history contents into some sinks but not others.

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

In `@wacore/src/types/events.rs` around lines 169 - 180, The Serialize impl for
LazyHistorySync currently calls self.parsed.get() which can trigger decoding and
makes serialization nondeterministic; change the serialization to be
metadata-only and deterministic by not touching parsed.get(): in the Serialize
impl for LazyHistorySync (the serialize method) remove the call to
self.parsed.get().and_then(...) and instead serialize a stable metadata value
(e.g. serialize_field("data", &Option::<()>::None) or a boolean flag that is
obtained via a non-decoding check API if one exists) so the serializer never
invokes decoding and always produces deterministic output.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wacore/src/history_sync.rs`:
- Around line 55-63: The current code only sets initial Vec capacity (estimated)
so ZlibDecoder.read_to_end can still expand beyond 64MB and OOM; change to
enforce a hard decompressed-size limit by (1) capping the reserved capacity with
the hard limit (use estimated.min(MAX_DECOMPRESSED) when building decompressed)
and (2) wrapping the ZlibDecoder with Read::take(MAX_DECOMPRESSED as u64) (e.g.,
let mut limited = decoder.take(MAX); limited.read_to_end(&mut decompressed)?)
and after reading, if the decoder would have more data (or decompressed.len() ==
MAX) return an error; use a named constant like MAX_DECOMPRESSED = 64 * 1024 *
1024 and reference variables/functions estimated, decompressed, ZlibDecoder, and
read_to_end when locating the change.

---

Outside diff comments:
In `@src/history_sync.rs`:
- Around line 256-277: The current read/modify/write in history_sync.rs using
backend.get_tc_token and backend.put_tc_token allows a race that can overwrite a
newer sender_timestamp; change this to perform the max-merge inside a single
backend operation or transaction. Add or use an atomic backend method (e.g.,
backend.merge_tc_token or a transactional put method) that takes token_key and
the incoming candidate/tc values and computes the merged TcTokenEntry (max of
existing.sender_timestamp and candidate.tc_token_sender_timestamp, and
appropriate token_timestamp logic) inside the backend implementation, then
persist it in one step; update the call site in history_sync.rs to call that
atomic method instead of calling get_tc_token followed by put_tc_token so the
sender_timestamp merge is guaranteed atomic.

In `@wacore/src/history_sync.rs`:
- Around line 99-107: The branch uses own_user.unwrap() — replace that by moving
own_user into the if-let chain so there is no unwrap; e.g. change the condition
to bind the user (if let Some(own) = own_user && result.own_pushname.is_none()
&& wire_type_raw == wire_type::LENGTH_DELIMITED) then call
extract_own_pushname(&buf[pos..end], own) (keep the existing read_varint and
checked_end usage), ensuring the arm remains collapsible and panics are avoided.

---

Duplicate comments:
In `@wacore/src/types/events.rs`:
- Around line 79-94: get() performs the heavy wa::HistorySync::decode(...)
synchronously inside OnceLock::get_or_init, blocking the caller thread; change
get() to be async (pub async fn get(&self) -> Option<&wa::HistorySync>) and do a
fast-path check of self.parsed.get() returning if present, otherwise offload the
decode to tokio::task::spawn_blocking (spawn_blocking(move ||
wa::HistorySync::decode(&raw_bytes[..]).ok().map(Box::new))) await the join,
then store the boxed result into the OnceLock using parsed.set(...) (ignore the
Err case if another task beat us) and finally return parsed.get().as_deref();
reference the symbols get(), parsed, OnceLock::set/get(), and
wa::HistorySync::decode when making the change.
- Around line 169-180: The Serialize impl for LazyHistorySync currently calls
self.parsed.get() which can trigger decoding and makes serialization
nondeterministic; change the serialization to be metadata-only and deterministic
by not touching parsed.get(): in the Serialize impl for LazyHistorySync (the
serialize method) remove the call to self.parsed.get().and_then(...) and instead
serialize a stable metadata value (e.g. serialize_field("data",
&Option::<()>::None) or a boolean flag that is obtained via a non-decoding check
API if one exists) so the serializer never invokes decoding and always produces
deterministic output.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ab221560-ee1d-4f54-af46-c197e205c199

📥 Commits

Reviewing files that changed from the base of the PR and between 9916b2d and a718f4b.

📒 Files selected for processing (3)
  • src/history_sync.rs
  • wacore/src/history_sync.rs
  • wacore/src/types/events.rs

Comment thread wacore/src/history_sync.rs Outdated
- Add hard 64MB decompression limit via Read::take() to prevent OOM
- Fix u64->usize truncation in extract_own_pushname (use try_from)
- Remove own_user.unwrap() — move into if-let chain
- Remove dead code: into_raw_bytes, conversations_raw, ConversationIterator
  (zero callers, no tests — re-add when needed)
- Revert pub(crate) on wire helpers back to private
- Take ownership of TcTokenCandidate in storage loop to avoid
  tc_token.clone()
- Make Serialize metadata-only (sync_type, chunk_order, progress) to
  avoid non-deterministic output depending on handler execution order

@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/history_sync.rs (1)

149-176: ⚠️ Potential issue | 🟠 Major

Use decompressed size estimate, not compressed size, for deciding whether to spawn_blocking.

The <256 KiB compressed threshold does not bound the actual decompression work. A highly compressible blob staying under 256 KiB compressed can still decompress to tens of MB and consume significant CPU for zlib decompression and protobuf field scanning on the async runtime. Per coding guidelines, wrap heavy CPU work in tokio::task::spawn_blocking. Instead, key the decision on the decompressed size estimate (already computed via the 4× multiplier) or unconditionally use spawn_blocking for all blobs.

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

In `@src/history_sync.rs` around lines 149 - 176, The decision to call
spawn_blocking currently uses the compressed size (INLINE_THRESHOLD) and can let
expensive decompression/protobuf work run on the async runtime; change the
branching in the block around INLINE_THRESHOLD to instead estimate decompressed
work (use the existing compressed_size_hint or compute a decompressed_estimate
from compressed_data.len() and the 4× multiplier) and call
self.runtime.spawn_blocking for cases where decompressed_estimate >=
INLINE_THRESHOLD (or simply always use spawn_blocking), moving the
process_history_sync invocation into the blocking closure and sending the result
over the oneshot as before; update references to INLINE_THRESHOLD,
compressed_data, compressed_size_hint, process_history_sync, and
self.runtime.spawn_blocking accordingly.
♻️ Duplicate comments (3)
wacore/src/history_sync.rs (1)

65-67: ⚠️ Potential issue | 🟠 Major

Fail closed when the decompressed stream exceeds the cap.

take(MAX_DECOMPRESSED) stops after 64 MiB, but it does not tell you whether the zlib stream actually ended there. A blob that expands past the cap is currently truncated and then parsed as if it were complete. Read one extra byte and reject anything larger than the limit.

🛡️ Minimal fix
-        let decoder = ZlibDecoder::new(compressed_data.as_slice());
-        let mut limited = decoder.take(MAX_DECOMPRESSED);
+        let decoder = ZlibDecoder::new(compressed_data.as_slice());
+        let mut limited = decoder.take(MAX_DECOMPRESSED + 1);
         limited.read_to_end(&mut decompressed)?;
+        if decompressed.len() as u64 > MAX_DECOMPRESSED {
+            return Err(HistorySyncError::MalformedProtobuf(
+                "history sync exceeds decompressed size limit".into(),
+            ));
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/history_sync.rs` around lines 65 - 67, The decompression currently
uses decoder.take(MAX_DECOMPRESSED) and blindly treats the truncated read as a
full stream; fix this by reading into decompressed via the existing limited
reader, then attempt to read one more byte from the underlying decoder to detect
overflow and return an error if any extra data is present. Concretely, after
limited.read_to_end(&mut decompressed) on the ZlibDecoder (decoder) and using
the MAX_DECOMPRESSED cap, try reading one extra byte (e.g., into a single-byte
buffer) from decoder (or from limited if appropriate) and if that read returns
Ok(n) with n > 0 or would block/indicate more data, return an error instead of
proceeding; keep using the same symbols decoder, limited, MAX_DECOMPRESSED, and
decompressed to locate where to insert this check.
wacore/src/types/events.rs (2)

96-99: 🛠️ Refactor suggestion | 🟠 Major

Return Bytes from the public raw-bytes accessor.

Exposing &[u8] forces a copy for any consumer that needs to retain or forward the blob after the event borrow ends, which undermines the zero-copy handoff this type is meant to provide.

♻️ Suggested API shape
-    pub fn raw_bytes(&self) -> &[u8] {
-        &self.raw_bytes
+    pub fn raw_bytes(&self) -> Bytes {
+        self.raw_bytes.clone()
     }

If you still want a borrowed view, add a separate as_raw_bytes(&self) -> &[u8].

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

In `@wacore/src/types/events.rs` around lines 96 - 99, Change the public accessor
raw_bytes(&self) -> &[u8] to return an owned Bytes (from the bytes crate) so
callers can retain/forward the blob without copying; keep a borrowed view by
adding as_raw_bytes(&self) -> &[u8] if needed. Specifically, update the method
signature raw_bytes to return bytes::Bytes (constructing it from the internal
buffer without additional copies if possible) and add an as_raw_bytes(&self) ->
&[u8] that returns &self.raw_bytes for consumers that only need a borrowed
slice.

79-94: ⚠️ Potential issue | 🟠 Major

Offload the first full protobuf decode.

get() runs wa::HistorySync::decode() inline inside OnceLock::get_or_init(). Any async handler that touches a large Event::HistorySync can burn that entire decode on a Tokio worker thread. Please add an offloaded initializer/accessor and keep get() as a cheap read once initialized. As per coding guidelines, "All I/O uses Tokio; wrap blocking I/O (ureq) and heavy CPU work in tokio::task::spawn_blocking".

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

In `@wacore/src/types/events.rs` around lines 79 - 94, The current get() runs
wa::HistorySync::decode() inside OnceLock::get_or_init which performs heavy CPU
work on the caller thread; add an async initializer that offloads decoding via
tokio::task::spawn_blocking and keep get() as a cheap reader. Concretely: leave
pub fn get(&self) -> Option<&wa::HistorySync> as a simple
parsed.get().as_deref(), and add an async method (e.g. pub async fn
ensure_parsed_blocking(&self) -> Option<&wa::HistorySync>) that checks if
self.parsed.get().is_none(), calls tokio::task::spawn_blocking(move ||
wa::HistorySync::decode(&self.raw_bytes[..]).ok().map(Box::new)).await, and if
decoding succeeded uses self.parsed.set(decoded_box) to populate the OnceLock;
finally return self.parsed.get().as_deref(). Use the existing fields parsed,
raw_bytes and the OnceLock API and ensure spawn_blocking handles the heavy
decode so callers can await ensure_parsed_blocking before calling get().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/history_sync.rs`:
- Around line 149-176: The decision to call spawn_blocking currently uses the
compressed size (INLINE_THRESHOLD) and can let expensive decompression/protobuf
work run on the async runtime; change the branching in the block around
INLINE_THRESHOLD to instead estimate decompressed work (use the existing
compressed_size_hint or compute a decompressed_estimate from
compressed_data.len() and the 4× multiplier) and call
self.runtime.spawn_blocking for cases where decompressed_estimate >=
INLINE_THRESHOLD (or simply always use spawn_blocking), moving the
process_history_sync invocation into the blocking closure and sending the result
over the oneshot as before; update references to INLINE_THRESHOLD,
compressed_data, compressed_size_hint, process_history_sync, and
self.runtime.spawn_blocking accordingly.

---

Duplicate comments:
In `@wacore/src/history_sync.rs`:
- Around line 65-67: The decompression currently uses
decoder.take(MAX_DECOMPRESSED) and blindly treats the truncated read as a full
stream; fix this by reading into decompressed via the existing limited reader,
then attempt to read one more byte from the underlying decoder to detect
overflow and return an error if any extra data is present. Concretely, after
limited.read_to_end(&mut decompressed) on the ZlibDecoder (decoder) and using
the MAX_DECOMPRESSED cap, try reading one extra byte (e.g., into a single-byte
buffer) from decoder (or from limited if appropriate) and if that read returns
Ok(n) with n > 0 or would block/indicate more data, return an error instead of
proceeding; keep using the same symbols decoder, limited, MAX_DECOMPRESSED, and
decompressed to locate where to insert this check.

In `@wacore/src/types/events.rs`:
- Around line 96-99: Change the public accessor raw_bytes(&self) -> &[u8] to
return an owned Bytes (from the bytes crate) so callers can retain/forward the
blob without copying; keep a borrowed view by adding as_raw_bytes(&self) ->
&[u8] if needed. Specifically, update the method signature raw_bytes to return
bytes::Bytes (constructing it from the internal buffer without additional copies
if possible) and add an as_raw_bytes(&self) -> &[u8] that returns
&self.raw_bytes for consumers that only need a borrowed slice.
- Around line 79-94: The current get() runs wa::HistorySync::decode() inside
OnceLock::get_or_init which performs heavy CPU work on the caller thread; add an
async initializer that offloads decoding via tokio::task::spawn_blocking and
keep get() as a cheap reader. Concretely: leave pub fn get(&self) ->
Option<&wa::HistorySync> as a simple parsed.get().as_deref(), and add an async
method (e.g. pub async fn ensure_parsed_blocking(&self) ->
Option<&wa::HistorySync>) that checks if self.parsed.get().is_none(), calls
tokio::task::spawn_blocking(move ||
wa::HistorySync::decode(&self.raw_bytes[..]).ok().map(Box::new)).await, and if
decoding succeeded uses self.parsed.set(decoded_box) to populate the OnceLock;
finally return self.parsed.get().as_deref(). Use the existing fields parsed,
raw_bytes and the OnceLock API and ensure spawn_blocking handles the heavy
decode so callers can await ensure_parsed_blocking before calling get().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 74a4653c-ef9c-4448-904f-2c762d49b4ca

📥 Commits

Reviewing files that changed from the base of the PR and between a718f4b and bd71f0f.

📒 Files selected for processing (3)
  • src/history_sync.rs
  • wacore/src/history_sync.rs
  • wacore/src/types/events.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: bd71f0fe17

ℹ️ 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 +66 to +67
let mut limited = decoder.take(MAX_DECOMPRESSED);
limited.read_to_end(&mut decompressed)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect decompression-cap overflow instead of truncating

Using Read::take(MAX_DECOMPRESSED) here silently truncates history-sync payloads above 64 MiB, because take() reports EOF when the limit is hit. That means oversized but otherwise valid blobs can be partially parsed and emitted as if complete (or fail only after the protocol receipt was already sent), causing irreversible data loss for large accounts. Please explicitly detect limit exhaustion and return a hard error for over-cap blobs instead of continuing with truncated bytes.

Useful? React with 👍 / 👎.

Comment on lines +278 to +280
if let Some(parts) = wacore_binary::jid::parse_jid_fast(&conv.id)
&& (parts.server == "g.us" || parts.server == "newsletter" || parts.server == "bot")
{

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 Preserve full bot filtering when extracting tc tokens

This new fast-path filter only excludes server == "bot", but previous logic excluded all jid.is_bot() values, including phone-number bots on s.whatsapp.net (e.g. 1313555* / 131655500*). Because store_tc_token_candidate no longer re-checks is_bot, tc tokens from those bot chats can now be cached and later reused on sends, which changes privacy-token behavior for bot conversations.

Useful? React with 👍 / 👎.

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