Skip to content

perf!: reduce startup allocations by ~21% and peak heap by ~31% - #525

Merged
jlucaso1 merged 1 commit into
mainfrom
perf/reduce-startup-allocations
Apr 13, 2026
Merged

perf!: reduce startup allocations by ~21% and peak heap by ~31%#525
jlucaso1 merged 1 commit into
mainfrom
perf/reduce-startup-allocations

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Heaptrack-guided optimizations targeting connection startup hot paths. Measured on a 1-message benchmark against the bartender mock server.

Results:

Metric Before After Delta
Total allocations 29,869 23,622 -20.9%
Peak heap 3.17 MB 2.19 MB -30.9%
Peak RSS 22.02 MB 20.06 MB -8.9%
Memory leaked at exit 2.26 MB 908 KB -60.8%

Changes:

  • build_iq_node take-by-value — eliminates deep Node tree clones through the entire IQ request pipeline (was 17K alloc calls)
  • build_upload_prekeys_request iterator — removes intermediate Vec collect + per-key public_bytes.clone()
  • AbPropConfig discriminator-first parsing — checks config_code/event_code attribute before parsing, avoiding double-parse waste
  • has_session() existence checkInMemoryBackend uses contains_key(), SqliteStore uses SELECT EXISTS instead of loading full session blobs
  • Batch prekey operationsload_prekeys_batch + store_prekeys_batch overrides eliminate ~2,400 per-key #[async_trait] box allocs
  • validate_digest_key batch load — single load_prekeys_batch call replaces 812 individual load_prekey calls
  • ureq buffer tuning — 16 KB buffers (down from 128 KB default), reduced idle pool from 10 to 3 connections

Breaking changes

  • RequestUtils::build_iq_node takes InfoQuery by value instead of &InfoQuery
  • PreKeyUtils::build_upload_prekeys_request takes impl IntoIterator<Item = (u32, Vec<u8>)> instead of &[(u32, Vec<u8>)]

Test plan

  • cargo clippy --all --tests — zero warnings
  • cargo test -p wacore --lib — 503 passed
  • cargo test -p whatsapp-rust --lib — 345 passed
  • cargo test -p whatsapp-rust-sqlite-storage --lib — 21 passed
  • Heaptrack benchmark (1-msg bartender mock) — verified reductions
  • E2E test with real mock server connection

Summary by CodeRabbit

  • Performance

    • Faster, lower-memory prekey batch loading and upload; reduced eager allocations.
    • Optimized HTTP client buffers and connection pooling for leaner networking.
  • Bug Fixes

    • More robust prekey validation with clearer handling of missing/invalid keys.
    • Consistent request timeout handling to avoid duplicated timeout logic.
    • Added session existence checks to improve session-related behaviors.

@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Batch prekey load/store APIs and implementations were added; prekey validation now uses batch loading. Request builders and IQ construction were refactored to take owned values/iterators to avoid clones. HTTP client agent builder now uses smaller buffers and reduced connection pooling across all code paths.

Changes

Cohort / File(s) Summary
SignalStore trait & in-memory/sqlite implementations
wacore/src/store/traits.rs, wacore/src/store/in_memory.rs, storages/sqlite-storage/src/sqlite_store.rs
Added load_prekeys_batch(&[u32]) -> Vec<(u32, Vec<u8>)> to SignalStore (default impl); implemented batch load/store in InMemory and Sqlite backends and added has_session() to stores.
Client prekey validation
src/prekeys.rs
Client::validate_digest_key switched from per-key loads to a single backend.load_prekeys_batch(...) call, validates counts upfront, and simplifies missing/decoding/error handling and early returns.
Prekey upload & request construction
wacore/src/iq/prekeys.rs, wacore/src/prekeys.rs, wacore/src/request.rs
Eliminated eager intermediate prekey Vec by accepting iterators in PreKeyUtils::build_upload_prekeys_request; changed RequestUtils::build_iq_node and related builders to take owned InfoQuery/content to consume without cloning.
Request timeout handling
src/request.rs
Consolidated default timeout definition (75s) and removed redundant recomputation of iq_timeout; adjusted call to build_iq_node to pass query by value.
HTTP client agent config
http_clients/ureq-client/src/lib.rs
Unified build_agent() to always use a shared Config builder with reduced I/O buffers (16 KiB) and lower idle connection caps (3 total, 2 per host); applied TLS skip-mutator on the existing builder.
Property parsing
wacore/src/iq/props.rs
Refined AbPropConfig::try_from_node_ref to a discriminated parse path (check config_code vs event_code) and minor change from .to_string() to .into_owned().

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant SignalStore as SignalStore (trait impl)
    participant DB as Storage (Sqlite / InMemory)

    rect rgba(100,150,240,0.5)
    Client->>SignalStore: validate_digest_key(requested_ids)
    end

    rect rgba(120,200,80,0.5)
    SignalStore->>DB: load_prekeys_batch(ids)
    DB-->>SignalStore: Vec<(id, bytes)>
    SignalStore-->>Client: Vec<(id, bytes)> or empty/missing info
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through bytes and keys today,
Bundled them up, no clones to pay,
Batches racing, lean and spry,
Buffers small, connections shy,
A rabbit hops — prekeys carried away!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the primary objectives: performance optimization with quantified improvements (startup allocations -21%, peak heap -31%), which is directly reflected in the changeset across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/reduce-startup-allocations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jlucaso1
jlucaso1 force-pushed the perf/reduce-startup-allocations branch 2 times, most recently from f027018 to 5b7d82a Compare April 13, 2026 05:31

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

ℹ️ 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/send.rs Outdated
Comment on lines +1084 to +1086
Some(mut devices) => {
devices.retain(|j| !j.is_hosted());
devices

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 Fallback to bare recipient when cached DM list is empty

If recipient_cached exists but is empty after hosted-device filtering, this branch returns an empty recipient target set and never adds recipient_bare. In that case prepare_dm_stanza can build a stanza that encrypts only for own devices (or no devices), so the remote user may not receive the message even though send returns success. This is a regression from the previous behavior that always included bare recipient fanout, and it can happen with stale/partial registry records (including hosted-only entries).

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Apr 13, 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() 178,334 177,845 +0.3%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 192,394 192,402 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 889,622 889,621 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 980,138 980,792 -0.1%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,466,120 1,466,047 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,673,560 2,672,732 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,788,312 9,789,529 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 46,483,807 46,481,411 +0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,658,166 12,534,848 +1.0%
binary_benchmark::marshal_group::bench_marshal_allocating 95,570 95,570 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 95,603 95,603 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 113,983 113,983 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 102,682 102,682 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 95,670 95,670 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 15,768 15,768 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 15,812 15,812 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 17,601 17,601 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 533,124 533,124 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 532,690 532,690 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 534,051 534,051 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 13,407,227 13,407,227 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 13,351,504 13,351,504 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 26,652,502 26,652,502 +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,807 90,807 +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,843 90,843 +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,677 104,677 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 475,970 475,970 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 13,477 13,477 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,404,889 17,187,072 +1.3%
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,112 162,112 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 298,289 298,353 -0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 712,883 712,819 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,430,085 12,577,623 -1.2%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,600,869 27,613,161 -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() 125,433,283 125,663,293 -0.2%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,830,452 2,830,452 +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: 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 `@src/prekeys.rs`:
- Around line 323-331: The check currently compares loaded_map.len() (deduped)
to response.prekey_ids.len() (may contain duplicates) causing false misses;
change the comparison to dedupe response.prekey_ids first (e.g., build a HashSet
from response.prekey_ids) and compare loaded_map.len() against that deduped set
size, while continuing to use the original response.prekey_ids list for digest
ordering and iteration; update the branch that logs and returns to use the
deduped count so repeated IDs don't trigger a false missing-local-prekeys
warning in the code paths around loaded_map and response.prekey_ids.

In `@src/send.rs`:
- Around line 1068-1104: The code discards the successful results from
get_user_devices() and always re-reads the registry, which can cause fallback to
bare-JID fanout or omit own devices; change the logic to use the Vec<Jid>
returned by get_user_devices() when it succeeds instead of immediately calling
get_devices_from_registry() again: when calling get_user_devices(&[to]) or
get_user_devices(own_jid) capture and use its returned Vec<Jid> to populate
recipient_cached and own_cached (or directly append into all_dm_jids), preserve
the existing hosted-device filtering (j.is_hosted()), exclude the sender using
the existing own_lid check, and deduplicate the final all_dm_jids before calling
ensure_e2e_sessions() so you don’t rely solely on registry visibility.

In `@wacore/src/iq/props.rs`:
- Around line 198-207: The parsing currently prefers Self::Experiment when both
discriminators are present; update the conditional in the prop discriminator
logic to explicitly detect the ambiguous case where optional_attr(node,
"config_code").is_some() && optional_attr(node, "event_code").is_some() and
return an Err (include node.attrs for context) instead of falling through to
AbProp::try_from_node_ref; otherwise proceed to construct Self::Experiment via
AbProp::try_from_node_ref or Self::Sampling via SamplingProp::try_from_node_ref
as before.
🪄 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: bfbc90c0-fb68-493f-b5cc-2f58ff32e6a5

📥 Commits

Reviewing files that changed from the base of the PR and between 2194547 and bd0e610.

📒 Files selected for processing (12)
  • http_clients/ureq-client/src/lib.rs
  • src/prekeys.rs
  • src/request.rs
  • src/send.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/props.rs
  • wacore/src/prekeys.rs
  • wacore/src/request.rs
  • wacore/src/send.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread src/prekeys.rs Outdated
Comment thread src/send.rs Outdated
Comment on lines +1068 to +1104
// Local registry first; network warm only on miss to avoid
// unnecessary LID-migration side effects from get_user_devices
let mut recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
if recipient_cached.is_none() {
let _ = self.get_user_devices(std::slice::from_ref(&to)).await;
recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
}

let mut own_cached = self.get_devices_from_registry(own_jid).await;
if own_cached.is_none() {
let _ = self.get_user_devices(std::slice::from_ref(own_jid)).await;
own_cached = self.get_devices_from_registry(own_jid).await;
}

// Build device list, filter hosted in-place, reuse Vecs
let mut all_dm_jids = match recipient_cached {
Some(mut devices) => {
devices.retain(|j| !j.is_hosted());
devices
}
// No record at all — bare JID, server handles fanout
None => vec![recipient_bare],
};

if let Some(mut own_devices) = own_cached {
own_devices.retain(|j| !j.is_hosted());
all_dm_jids.append(&mut own_devices);
}

let mut all_dm_jids = Vec::with_capacity(1 + own_devices.len());
all_dm_jids.push(recipient_bare);
all_dm_jids.extend(own_devices);
// Exclude exact sender device (WA Web: isMeDevice in getFanOutList)
// so ensure_e2e_sessions never creates a self-session
let own_lid = device_snapshot.lid.as_ref();
all_dm_jids.retain(|j| {
let is_sender = (j.is_same_user_as(own_jid) && j.device == own_jid.device)
|| own_lid.is_some_and(|lid| j.is_same_user_as(lid) && j.device == lid.device);
!is_sender
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't discard successful get_user_devices() results here.

get_user_devices() already returns the resolved device list, but this path ignores that Vec<Jid> and immediately re-reads the registry. If the refresh succeeds but the cache/DB write is not visible yet, recipients fall back to bare-JID fanout and own companions are silently omitted from DSM fanout. Build all_dm_jids from the returned lists directly, then dedup before ensure_e2e_sessions().

🛠️ Suggested fix
-            let mut recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
-            if recipient_cached.is_none() {
-                let _ = self.get_user_devices(std::slice::from_ref(&to)).await;
-                recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
-            }
-
-            let mut own_cached = self.get_devices_from_registry(own_jid).await;
-            if own_cached.is_none() {
-                let _ = self.get_user_devices(std::slice::from_ref(own_jid)).await;
-                own_cached = self.get_devices_from_registry(own_jid).await;
-            }
+            let mut recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
+            if recipient_cached.is_none() {
+                recipient_cached = self
+                    .get_user_devices(std::slice::from_ref(&to))
+                    .await
+                    .ok();
+            }
+
+            let mut own_cached = self.get_devices_from_registry(own_jid).await;
+            if own_cached.is_none() {
+                own_cached = self
+                    .get_user_devices(std::slice::from_ref(own_jid))
+                    .await
+                    .ok();
+            }
@@
-            if let Some(mut own_devices) = own_cached {
+            if let Some(mut own_devices) = own_cached {
                 own_devices.retain(|j| !j.is_hosted());
                 all_dm_jids.append(&mut own_devices);
             }
+
+            wacore::types::jid::sort_dedup_by_device(&mut all_dm_jids);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Local registry first; network warm only on miss to avoid
// unnecessary LID-migration side effects from get_user_devices
let mut recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
if recipient_cached.is_none() {
let _ = self.get_user_devices(std::slice::from_ref(&to)).await;
recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
}
let mut own_cached = self.get_devices_from_registry(own_jid).await;
if own_cached.is_none() {
let _ = self.get_user_devices(std::slice::from_ref(own_jid)).await;
own_cached = self.get_devices_from_registry(own_jid).await;
}
// Build device list, filter hosted in-place, reuse Vecs
let mut all_dm_jids = match recipient_cached {
Some(mut devices) => {
devices.retain(|j| !j.is_hosted());
devices
}
// No record at all — bare JID, server handles fanout
None => vec![recipient_bare],
};
if let Some(mut own_devices) = own_cached {
own_devices.retain(|j| !j.is_hosted());
all_dm_jids.append(&mut own_devices);
}
let mut all_dm_jids = Vec::with_capacity(1 + own_devices.len());
all_dm_jids.push(recipient_bare);
all_dm_jids.extend(own_devices);
// Exclude exact sender device (WA Web: isMeDevice in getFanOutList)
// so ensure_e2e_sessions never creates a self-session
let own_lid = device_snapshot.lid.as_ref();
all_dm_jids.retain(|j| {
let is_sender = (j.is_same_user_as(own_jid) && j.device == own_jid.device)
|| own_lid.is_some_and(|lid| j.is_same_user_as(lid) && j.device == lid.device);
!is_sender
});
// Local registry first; network warm only on miss to avoid
// unnecessary LID-migration side effects from get_user_devices
let mut recipient_cached = self.get_devices_from_registry(&recipient_bare).await;
if recipient_cached.is_none() {
recipient_cached = self
.get_user_devices(std::slice::from_ref(&to))
.await
.ok();
}
let mut own_cached = self.get_devices_from_registry(own_jid).await;
if own_cached.is_none() {
own_cached = self
.get_user_devices(std::slice::from_ref(own_jid))
.await
.ok();
}
// Build device list, filter hosted in-place, reuse Vecs
let mut all_dm_jids = match recipient_cached {
Some(mut devices) => {
devices.retain(|j| !j.is_hosted());
devices
}
// No record at all — bare JID, server handles fanout
None => vec![recipient_bare],
};
if let Some(mut own_devices) = own_cached {
own_devices.retain(|j| !j.is_hosted());
all_dm_jids.append(&mut own_devices);
}
wacore::types::jid::sort_dedup_by_device(&mut all_dm_jids);
// Exclude exact sender device (WA Web: isMeDevice in getFanOutList)
// so ensure_e2e_sessions never creates a self-session
let own_lid = device_snapshot.lid.as_ref();
all_dm_jids.retain(|j| {
let is_sender = (j.is_same_user_as(own_jid) && j.device == own_jid.device)
|| own_lid.is_some_and(|lid| j.is_same_user_as(lid) && j.device == lid.device);
!is_sender
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 1068 - 1104, The code discards the successful
results from get_user_devices() and always re-reads the registry, which can
cause fallback to bare-JID fanout or omit own devices; change the logic to use
the Vec<Jid> returned by get_user_devices() when it succeeds instead of
immediately calling get_devices_from_registry() again: when calling
get_user_devices(&[to]) or get_user_devices(own_jid) capture and use its
returned Vec<Jid> to populate recipient_cached and own_cached (or directly
append into all_dm_jids), preserve the existing hosted-device filtering
(j.is_hosted()), exclude the sender using the existing own_lid check, and
deduplicate the final all_dm_jids before calling ensure_e2e_sessions() so you
don’t rely solely on registry visibility.

Comment thread wacore/src/iq/props.rs
Heaptrack-guided optimizations targeting connection startup hot paths:

- build_iq_node: take InfoQuery by value, eliminating deep Node tree
  clones (was 17K alloc calls for prekey upload alone)
- build_upload_prekeys_request: accept iterator instead of slice,
  removing intermediate Vec collect and per-key public_bytes clone
- AbPropConfig: check discriminating attribute before parsing to avoid
  double-parse allocations on every prop node
- AbProp config_value: use into_owned() instead of to_string() on Cow
- SignalStore: add has_session() with default impl; InMemoryBackend
  and SqliteStore override with contains_key/SELECT EXISTS to avoid
  loading full session blobs
- SignalStore: add load_prekeys_batch() and store_prekeys_batch()
  overrides for InMemoryBackend (single lock) and SqliteStore
  (WHERE IN query), eliminating 2,400+ per-key async_trait box allocs
- validate_digest_key: batch-load prekeys instead of 812 individual
  load_prekey calls
- ureq: configure 16KB buffers (down from 128KB default) and reduce
  idle connection pool; media uses streaming I/O so throughput is
  unaffected

Benchmark results (1-message mock server, heaptrack):
  Before: 29,869 allocs, 3.17 MB peak heap, 22.02 MB RSS
  After:  23,622 allocs, 2.19 MB peak heap, 20.06 MB RSS

BREAKING: build_iq_node takes InfoQuery by value;
build_upload_prekeys_request takes impl IntoIterator.
@jlucaso1
jlucaso1 force-pushed the perf/reduce-startup-allocations branch from 5b7d82a to a298676 Compare April 13, 2026 05:41

@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

♻️ Duplicate comments (2)
wacore/src/iq/props.rs (1)

198-208: ⚠️ Potential issue | 🟡 Minor

Handle ambiguous discriminators explicitly (config_code + event_code).

A malformed <prop> containing both config_code and event_code attributes is silently treated as Experiment (line 199-200). This should be rejected explicitly to avoid misclassification and ensure defensive parsing.

🛡️ Proposed fix to detect and reject ambiguous cases
-        // Check discriminating attribute to avoid double-parse allocations
-        if optional_attr(node, "config_code").is_some() {
-            Ok(Self::Experiment(AbProp::try_from_node_ref(node)?))
-        } else if optional_attr(node, "event_code").is_some() {
-            Ok(Self::Sampling(SamplingProp::try_from_node_ref(node)?))
-        } else {
-            Err(anyhow::anyhow!(
-                "prop has neither config_code nor event_code (attrs: {:?})",
-                node.attrs
-            ))
-        }
+        // Check discriminating attribute to avoid double-parse allocations
+        let has_config_code = optional_attr(node, "config_code").is_some();
+        let has_event_code = optional_attr(node, "event_code").is_some();
+
+        match (has_config_code, has_event_code) {
+            (true, false) => Ok(Self::Experiment(AbProp::try_from_node_ref(node)?)),
+            (false, true) => Ok(Self::Sampling(SamplingProp::try_from_node_ref(node)?)),
+            (true, true) => Err(anyhow::anyhow!(
+                "prop has both config_code and event_code (attrs: {:?})",
+                node.attrs
+            )),
+            (false, false) => Err(anyhow::anyhow!(
+                "prop has neither config_code nor event_code (attrs: {:?})",
+                node.attrs
+            )),
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/props.rs` around lines 198 - 208, The current discriminator
logic silently prefers Experiment when both attributes exist; update the branch
in the constructor where optional_attr(node, "config_code") and
optional_attr(node, "event_code") are checked to explicitly detect the ambiguous
case (both present) and return an Err with a clear message including node.attrs,
rather than falling through to Experiment. Modify the flow around
Self::Experiment(AbProp::try_from_node_ref(node)?) and
Self::Sampling(SamplingProp::try_from_node_ref(node)?) so you first check for
both attributes and error, then check singletons to call try_from_node_ref for
AbProp or SamplingProp accordingly.
src/prekeys.rs (1)

323-331: ⚠️ Potential issue | 🟡 Minor

Deduplicate the server IDs before the missing-prekey check.

Line 326 compares loaded_map.len() against response.prekey_ids.len(), but loaded_map is already deduped by key. If the digest list repeats an ID, this branch will still log a false miss and skip validation even though the local prekey exists.

[suggested fix]

Proposed patch
         // Build a lookup so we preserve the server-requested order
         let loaded_map: std::collections::HashMap<u32, Vec<u8>> = loaded.into_iter().collect();
+        let unique_prekey_ids: std::collections::HashSet<u32> =
+            response.prekey_ids.iter().copied().collect();
 
-        if loaded_map.len() < response.prekey_ids.len() {
+        if loaded_map.len() < unique_prekey_ids.len() {
             log::warn!(
                 "digestKey: missing {} local prekeys, skipping",
-                response.prekey_ids.len() - loaded_map.len()
+                unique_prekey_ids.len() - loaded_map.len()
             );
             return Ok(());
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/prekeys.rs` around lines 323 - 331, The missing-prekey check compares
loaded_map.len() to response.prekey_ids.len(), but response.prekey_ids may
contain duplicates; deduplicate the server IDs before that comparison by
creating a unique set (e.g., a HashSet or a deduped Vec) from
response.prekey_ids and use unique_ids.len() in the comparison and log message
instead of response.prekey_ids.len(); update references around loaded_map and
the warning log so you count unique server-requested IDs when deciding to
early-return from digestKey.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 1369-1389: The batch loader currently converts the input ids and
returns DB rows in database order, losing caller order and duplicates; modify
load_prekeys_batch so after loading rows (variable rows) you build a lookup
(e.g., HashMap<i32, Vec<u8>> or HashMap<i32, Vec<u8>>) from the returned rows
and then iterate the original input order (preserve the original ids slice
before converting to i32), pushing an entry for each requested id in request
order and repeating entries for duplicate requested ids when present in the DB;
ensure you move the preserved original ids into the semaphore closure and use
that to assemble the Vec<(u32, Vec<u8>)> so behavior matches the default
SignalStore::load_prekeys_batch ordering and duplicate semantics.

---

Duplicate comments:
In `@src/prekeys.rs`:
- Around line 323-331: The missing-prekey check compares loaded_map.len() to
response.prekey_ids.len(), but response.prekey_ids may contain duplicates;
deduplicate the server IDs before that comparison by creating a unique set
(e.g., a HashSet or a deduped Vec) from response.prekey_ids and use
unique_ids.len() in the comparison and log message instead of
response.prekey_ids.len(); update references around loaded_map and the warning
log so you count unique server-requested IDs when deciding to early-return from
digestKey.

In `@wacore/src/iq/props.rs`:
- Around line 198-208: The current discriminator logic silently prefers
Experiment when both attributes exist; update the branch in the constructor
where optional_attr(node, "config_code") and optional_attr(node, "event_code")
are checked to explicitly detect the ambiguous case (both present) and return an
Err with a clear message including node.attrs, rather than falling through to
Experiment. Modify the flow around
Self::Experiment(AbProp::try_from_node_ref(node)?) and
Self::Sampling(SamplingProp::try_from_node_ref(node)?) so you first check for
both attributes and error, then check singletons to call try_from_node_ref for
AbProp or SamplingProp accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5de42919-bc0c-43b4-8138-99a646644939

📥 Commits

Reviewing files that changed from the base of the PR and between bd0e610 and 5b7d82a.

📒 Files selected for processing (10)
  • http_clients/ureq-client/src/lib.rs
  • src/prekeys.rs
  • src/request.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/props.rs
  • wacore/src/prekeys.rs
  • wacore/src/request.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment on lines +1369 to +1389
async fn load_prekeys_batch(&self, ids: &[u32]) -> Result<Vec<(u32, Vec<u8>)>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let pool = self.pool.clone();
let device_id = self.device_id;
let ids: Vec<i32> = ids.iter().map(|&id| id as i32).collect();
self.with_semaphore(move || -> Result<Vec<(u32, Vec<u8>)>> {
let mut conn = pool
.get()
.map_err(|e| StoreError::Connection(e.to_string()))?;
let rows: Vec<(i32, Vec<u8>)> = prekeys::table
.select((prekeys::id, prekeys::key))
.filter(prekeys::id.eq_any(&ids))
.filter(prekeys::device_id.eq(device_id))
.load(&mut conn)
.map_err(|e| StoreError::Database(e.to_string()))?;
Ok(rows.into_iter().map(|(id, key)| (id as u32, key)).collect())
})
.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.

🛠️ Refactor suggestion | 🟠 Major

Preserve caller order in the SQLite batch path.

eq_any(&ids) returns matching rows in database order and only once per stored ID, while the default SignalStore::load_prekeys_batch implementation iterates the input slice and can return repeated IDs in request order. That makes this new API behave differently by backend.

Proposed patch
     async fn load_prekeys_batch(&self, ids: &[u32]) -> Result<Vec<(u32, Vec<u8>)>> {
         if ids.is_empty() {
             return Ok(Vec::new());
         }
         let pool = self.pool.clone();
         let device_id = self.device_id;
-        let ids: Vec<i32> = ids.iter().map(|&id| id as i32).collect();
+        let requested_ids: Vec<u32> = ids.to_vec();
+        let ids: Vec<i32> = requested_ids.iter().map(|&id| id as i32).collect();
         self.with_semaphore(move || -> Result<Vec<(u32, Vec<u8>)>> {
             let mut conn = pool
                 .get()
                 .map_err(|e| StoreError::Connection(e.to_string()))?;
             let rows: Vec<(i32, Vec<u8>)> = prekeys::table
@@
                 .filter(prekeys::device_id.eq(device_id))
                 .load(&mut conn)
                 .map_err(|e| StoreError::Database(e.to_string()))?;
-            Ok(rows.into_iter().map(|(id, key)| (id as u32, key)).collect())
+            let row_map: std::collections::HashMap<u32, Vec<u8>> =
+                rows.into_iter().map(|(id, key)| (id as u32, key)).collect();
+            Ok(requested_ids
+                .into_iter()
+                .filter_map(|id| row_map.get(&id).cloned().map(|key| (id, key)))
+                .collect())
         })
         .await
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async fn load_prekeys_batch(&self, ids: &[u32]) -> Result<Vec<(u32, Vec<u8>)>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let pool = self.pool.clone();
let device_id = self.device_id;
let ids: Vec<i32> = ids.iter().map(|&id| id as i32).collect();
self.with_semaphore(move || -> Result<Vec<(u32, Vec<u8>)>> {
let mut conn = pool
.get()
.map_err(|e| StoreError::Connection(e.to_string()))?;
let rows: Vec<(i32, Vec<u8>)> = prekeys::table
.select((prekeys::id, prekeys::key))
.filter(prekeys::id.eq_any(&ids))
.filter(prekeys::device_id.eq(device_id))
.load(&mut conn)
.map_err(|e| StoreError::Database(e.to_string()))?;
Ok(rows.into_iter().map(|(id, key)| (id as u32, key)).collect())
})
.await
}
async fn load_prekeys_batch(&self, ids: &[u32]) -> Result<Vec<(u32, Vec<u8>)>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let pool = self.pool.clone();
let device_id = self.device_id;
let requested_ids: Vec<u32> = ids.to_vec();
let ids: Vec<i32> = requested_ids.iter().map(|&id| id as i32).collect();
self.with_semaphore(move || -> Result<Vec<(u32, Vec<u8>)>> {
let mut conn = pool
.get()
.map_err(|e| StoreError::Connection(e.to_string()))?;
let rows: Vec<(i32, Vec<u8>)> = prekeys::table
.select((prekeys::id, prekeys::key))
.filter(prekeys::id.eq_any(&ids))
.filter(prekeys::device_id.eq(device_id))
.load(&mut conn)
.map_err(|e| StoreError::Database(e.to_string()))?;
let row_map: std::collections::HashMap<u32, Vec<u8>> =
rows.into_iter().map(|(id, key)| (id as u32, key)).collect();
Ok(requested_ids
.into_iter()
.filter_map(|id| row_map.get(&id).cloned().map(|key| (id, key)))
.collect())
})
.await
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 1369 - 1389, The
batch loader currently converts the input ids and returns DB rows in database
order, losing caller order and duplicates; modify load_prekeys_batch so after
loading rows (variable rows) you build a lookup (e.g., HashMap<i32, Vec<u8>> or
HashMap<i32, Vec<u8>>) from the returned rows and then iterate the original
input order (preserve the original ids slice before converting to i32), pushing
an entry for each requested id in request order and repeating entries for
duplicate requested ids when present in the DB; ensure you move the preserved
original ids into the semaphore closure and use that to assemble the Vec<(u32,
Vec<u8>)> so behavior matches the default SignalStore::load_prekeys_batch
ordering and duplicate semantics.

@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

Caution

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

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

138-152: 🧹 Nitpick | 🔵 Trivial

Avoid cloning query.id now that send_iq owns the query.

This still allocates a fresh String for caller-supplied IDs before cloning it again for waiter registration / node building. Since the query is already owned here, take the ID out of query instead.

♻️ Suggested change
-    pub async fn send_iq(
-        &self,
-        query: InfoQuery<'_>,
-    ) -> Result<Arc<wacore_binary::OwnedNodeRef>, IqError> {
+    pub async fn send_iq(
+        &self,
+        mut query: InfoQuery<'_>,
+    ) -> Result<Arc<wacore_binary::OwnedNodeRef>, IqError> {
@@
-        let req_id = query
-            .id
-            .clone()
-            .unwrap_or_else(|| self.generate_request_id());
+        let req_id = query.id.take().unwrap_or_else(|| self.generate_request_id());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/request.rs` around lines 138 - 152, The code currently clones query.id
unnecessarily even though send_iq owns query; replace
query.id.clone().unwrap_or_else(...) with taking the ID out of the owned query
(e.g. make query mutable if needed and use query.id.take().unwrap_or_else(||
self.generate_request_id())) so you consume the Option<String> instead of
allocating a new String, then use that req_id for registering the waiter and for
node building (keep any remaining .clone() only where a second owned copy is
required).
♻️ Duplicate comments (1)
storages/sqlite-storage/src/sqlite_store.rs (1)

1369-1389: ⚠️ Potential issue | 🟠 Major

Preserve caller order in the SQLite batch loader.

Line 1386 returns rows in database order and collapses repeated requested IDs, while the default SignalStore::load_prekeys_batch() implementation walks the input slice and repeats entries for duplicate IDs. That makes this API backend-dependent. Rebuild the result from the original request list after querying.

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

In `@storages/sqlite-storage/src/sqlite_store.rs` around lines 1369 - 1389, The
batch loader currently returns rows in DB order and collapses duplicates; to
preserve caller order and duplicates, capture the original ids slice into a
Vec<u32> (e.g., original_ids) before converting to Vec<i32>, then after loading
rows inside load_prekeys_batch build a HashMap from fetched rows (id -> key) and
iterate original_ids, pushing (id, key.clone()) into the result for each
original id that exists in the map so duplicates are preserved; ensure you move
original_ids into the closure used by with_semaphore and use key.clone() when
repeating keys.
🤖 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/iq/props.rs`:
- Around line 192-215: Add regression tests that exercise the two error branches
in the prop parser: one where both "config_code" and "event_code" attributes are
present and one where neither is present. Construct a node with tag "prop" and
attrs containing both keys and assert that Prop parsing returns an error
mentioning both attributes (exercise the has_config && has_event branch which
currently returns Err with node.attrs); likewise construct a node with tag
"prop" and no discriminator attrs and assert parsing returns the "neither" error
(exercise the else branch). Use the same entry point exercised by the code (the
Prop parsing / TryFrom node entry such as try_from_node_ref or the public
Prop->try_from_node API) so the tests fail if those branches regress.

In `@wacore/src/store/traits.rs`:
- Around line 129-139: Update the doc comment for the default implementation of
load_prekeys_batch to explicitly state its contract: it preserves the input
order and retains duplicates from the ids slice, includes one entry for each id
only if that id exists in storage (missing ids are omitted), and returns a
Vec<(u32, Vec<u8>)> in the same order as the input with duplicates repeated when
present; mention that custom backend implementations should match this
ordering/duplicate behavior to remain compatible with callers relying on it.

---

Outside diff comments:
In `@src/request.rs`:
- Around line 138-152: The code currently clones query.id unnecessarily even
though send_iq owns query; replace query.id.clone().unwrap_or_else(...) with
taking the ID out of the owned query (e.g. make query mutable if needed and use
query.id.take().unwrap_or_else(|| self.generate_request_id())) so you consume
the Option<String> instead of allocating a new String, then use that req_id for
registering the waiter and for node building (keep any remaining .clone() only
where a second owned copy is required).

---

Duplicate comments:
In `@storages/sqlite-storage/src/sqlite_store.rs`:
- Around line 1369-1389: The batch loader currently returns rows in DB order and
collapses duplicates; to preserve caller order and duplicates, capture the
original ids slice into a Vec<u32> (e.g., original_ids) before converting to
Vec<i32>, then after loading rows inside load_prekeys_batch build a HashMap from
fetched rows (id -> key) and iterate original_ids, pushing (id, key.clone())
into the result for each original id that exists in the map so duplicates are
preserved; ensure you move original_ids into the closure used by with_semaphore
and use key.clone() when repeating keys.
🪄 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: 00158dcb-1793-49e2-b27e-2e6703887d53

📥 Commits

Reviewing files that changed from the base of the PR and between 5b7d82a and a298676.

📒 Files selected for processing (10)
  • http_clients/ureq-client/src/lib.rs
  • src/prekeys.rs
  • src/request.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/iq/prekeys.rs
  • wacore/src/iq/props.rs
  • wacore/src/prekeys.rs
  • wacore/src/request.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/store/traits.rs

Comment thread wacore/src/iq/props.rs
Comment on lines +192 to +215
use crate::iq::node::optional_attr;

if node.tag != "prop" {
return Err(anyhow::anyhow!("expected <prop>, got <{}>", node.tag));
}

let experiment = AbProp::try_from_node_ref(node);
if let Ok(prop) = experiment {
return Ok(Self::Experiment(prop));
}

let sampling = SamplingProp::try_from_node_ref(node);
if let Ok(prop) = sampling {
return Ok(Self::Sampling(prop));
// Check discriminating attribute to avoid double-parse allocations
let has_config = optional_attr(node, "config_code").is_some();
let has_event = optional_attr(node, "event_code").is_some();

if has_config && has_event {
Err(anyhow::anyhow!(
"prop has both config_code and event_code (attrs: {:?})",
node.attrs
))
} else if has_config {
Ok(Self::Experiment(AbProp::try_from_node_ref(node)?))
} else if has_event {
Ok(Self::Sampling(SamplingProp::try_from_node_ref(node)?))
} else {
Err(anyhow::anyhow!(
"prop has neither config_code nor event_code (attrs: {:?})",
node.attrs
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Add regression tests for the new discriminator error branches.

The parser logic is improved, but there’s no direct test coverage for the (config_code + event_code) and (neither) error paths.

Proposed test additions
@@
     fn test_sampling_prop_protocol_node_round_trip() {
         let prop = SamplingProp {
             event_code: 5138,
             sampling_weight: -1,
         };

         let node = prop.clone().into_node();
         let parsed = SamplingProp::try_from_node(&node).unwrap();

         assert_eq!(parsed.event_code, prop.event_code);
         assert_eq!(parsed.sampling_weight, prop.sampling_weight);
     }
+
+    #[test]
+    fn test_ab_prop_config_rejects_ambiguous_discriminator() {
+        let node = NodeBuilder::new("prop")
+            .attr("config_code", "100")
+            .attr("config_value", "enabled")
+            .attr("event_code", "5138")
+            .attr("sampling_weight", "-1")
+            .build();
+
+        let err = AbPropConfig::try_from_node(&node).unwrap_err();
+        assert!(err.to_string().contains("both config_code and event_code"));
+    }
+
+    #[test]
+    fn test_ab_prop_config_rejects_missing_discriminator() {
+        let node = NodeBuilder::new("prop")
+            .attr("config_value", "enabled")
+            .build();
+
+        let err = AbPropConfig::try_from_node(&node).unwrap_err();
+        assert!(err.to_string().contains("neither config_code nor event_code"));
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/iq/props.rs` around lines 192 - 215, Add regression tests that
exercise the two error branches in the prop parser: one where both "config_code"
and "event_code" attributes are present and one where neither is present.
Construct a node with tag "prop" and attrs containing both keys and assert that
Prop parsing returns an error mentioning both attributes (exercise the
has_config && has_event branch which currently returns Err with node.attrs);
likewise construct a node with tag "prop" and no discriminator attrs and assert
parsing returns the "neither" error (exercise the else branch). Use the same
entry point exercised by the code (the Prop parsing / TryFrom node entry such as
try_from_node_ref or the public Prop->try_from_node API) so the tests fail if
those branches regress.

Comment on lines +129 to +139
/// Load multiple pre-keys by ID in a single batch operation.
/// Returns only the keys that exist.
async fn load_prekeys_batch(&self, ids: &[u32]) -> Result<Vec<(u32, Vec<u8>)>> {
let mut result = Vec::with_capacity(ids.len());
for &id in ids {
if let Some(record) = self.load_prekey(id).await? {
result.push((id, record));
}
}
Ok(result)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Clarify the load_prekeys_batch contract.

The default implementation preserves request order and duplicate IDs while omitting missing rows, but the doc comment only promises that existing keys are returned. Please spell out the ordering/duplicate semantics here so custom backends stay behaviorally compatible.

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

In `@wacore/src/store/traits.rs` around lines 129 - 139, Update the doc comment
for the default implementation of load_prekeys_batch to explicitly state its
contract: it preserves the input order and retains duplicates from the ids
slice, includes one entry for each id only if that id exists in storage (missing
ids are omitted), and returns a Vec<(u32, Vec<u8>)> in the same order as the
input with duplicates repeated when present; mention that custom backend
implementations should match this ordering/duplicate behavior to remain
compatible with callers relying on it.

@jlucaso1
jlucaso1 merged commit ab5c4c1 into main Apr 13, 2026
13 of 14 checks passed
@jlucaso1
jlucaso1 deleted the perf/reduce-startup-allocations branch April 13, 2026 20:57
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