Skip to content

perf!: use CompactString for NodeValue, NodeContent, and Jid.user - #512

Merged
jlucaso1 merged 1 commit into
mainfrom
perf/compact-string-migration
Apr 11, 2026
Merged

perf!: use CompactString for NodeValue, NodeContent, and Jid.user#512
jlucaso1 merged 1 commit into
mainfrom
perf/compact-string-migration

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Apr 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Replace String with CompactString in NodeValue::String, NodeContent::String, and Jid.user to eliminate heap allocations for short strings (≤ 24 bytes stored inline)
  • Add inline TTL-based eviction to InMemoryBackend::store_sent_message to cap unbounded growth
  • Switch benchmark example to InMemoryBackend instead of SqliteStore

Profiling results

Measured with heaptrack against a real WhatsApp session (benchmark example via bartender mock server):

Metric Before After Change
Peak heap 7.90 MB 5.15–5.24 MB -34%
Peak RSS 32.57 MB 28.28–28.57 MB -13%
Total allocations 6,030,336 5,406,579 -10.3%

The two largest allocation sites from the baseline — Attrs::FromIterator at 1.88 MB and 750 KB (cloning (Cow<str>, NodeValue) tuples during NodeRef::to_owned()) — no longer appear in the top 15 peak consumers. to_owned() still allocates (Vec containers, byte content, long strings > 24 chars), but it is no longer a top-tier memory problem.

Why CompactString

CompactString (compact_str crate) has the same size_of as String (24 bytes on x86_64) but stores strings ≤ 24 bytes inline — zero heap allocation. PoC benchmarks showed:

  • 90.9% of NodeValue::String values are ≤ 24 chars (protocol values like "msg", "pkmsg", "text", timestamps)
  • 100% of Jid.user values (phone numbers, 10-15 chars) fit inline
  • Only message IDs (32 chars) spill to heap

Tags and attribute keys keep Cow<'static, str> + intern_cow() — already zero-alloc for tokenized strings via static references.

Breaking changes

  • NodeValue::String wraps CompactString instead of String
  • NodeContent::String wraps CompactString instead of String
  • Jid.user is CompactString instead of String
  • Jid factory methods take impl Into<CompactString> instead of impl Into<String>

Migration guide

Construction — all existing patterns still work:

NodeValue::from("text")           // &str → CompactString (inline)
NodeValue::from(some_string)      // String → CompactString
Jid::pn("12345")                  // same as before
"user@server".parse::<Jid>()?     // same as before

Pattern matchingCompactString derefs to &str:

match value {
    NodeValue::String(s) => {
        // s: CompactString, but all &str methods work via Deref
        s.starts_with("pkmsg")  // works
        s == "text"             // works
    }
}

When you need String:

jid.user.to_string()              // CompactString → String
map.get(jid.user.as_str())        // for HashMap<String, _> lookups

CompactString is re-exported from both wacore_binary::CompactString and whatsapp_rust::CompactString.

Test plan

  • cargo clippy --all --tests — zero warnings
  • cargo test -p wacore-binary -p wacore -p whatsapp-rust — all passing
  • cargo check -p wacore-binary --all-features — serde feature verified
  • heaptrack profiling (3 runs) confirms consistent ~34% peak heap reduction
  • CI / e2e tests

Summary by CodeRabbit

  • New Features

    • In-memory message store gains configurable sent-message TTL with automatic time-based eviction.
  • Chores

    • Example benchmark switched to the in-memory backend.
    • Workspace adopts a compact string type across crates; several public APIs now expose or return this compact string form (may affect downstream integrations).

@coderabbitai

coderabbitai Bot commented Apr 11, 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

Migrates many core string fields to compact_str::CompactString, adds compact_str as a workspace dependency and re-exports it, updates many call sites/tests to use .into()/.to_string() for compatibility, and adds sent-message TTL-based eviction to the in-memory store.

Changes

Cohort / File(s) Summary
Workspace dependency
Cargo.toml
Added workspace dependency compact_str = { version = "0.8", default-features = false }.
Core binary types & exports
wacore/binary/src/jid.rs, wacore/binary/src/node.rs, wacore/binary/src/lib.rs, wacore/binary/src/builder.rs
Switched Jid.user and Node string storage to CompactString; constructors and builder APIs accept impl Into<CompactString>; re-exported CompactString.
Binary tests & enc/dec/marshal
wacore/binary/src/decoder.rs, wacore/binary/src/encoder.rs, wacore/binary/src/marshal.rs, wacore/binary/src/builder.rs
Updated tests and builders to construct string values via .into() / CompactString::from(...) and adjusted fixtures accordingly.
Client context & GroupInfo
wacore/src/client/context.rs, src/features/groups.rs
Changed LID↔PN mapping key types to CompactString; updated signatures, helpers, and population sites.
Call-site adaptations (many modules)
Multiple files under src/, wacore/src/ (examples: src/client.rs, src/handlers/*, src/send.rs, src/usync.rs, src/history_sync.rs, src/message.rs, src/session.rs, src/retry.rs, wacore/src/iq/*, wacore/src/stanza/*, wacore/src/protocol/*, wacore/src/message_processing.rs, wacore/src/prekeys.rs, wacore/src/usync.rs, tests/e2e/src/lib.rs, wacore/benches/*)
Replaced many .clone() usages on Jid/user with .to_string() where owned Strings are required; updated tests/constructors to use .into(); adapted hashmap lookups to .as_str(); some functions now pass borrowed &str keys to backends.
InMemory backend enhancement
wacore/src/store/in_memory.rs, examples/benchmark.rs
Added sent_message_ttl_secs, eviction timing/state, with_sent_message_ttl() builder, and inline amortized eviction in store_sent_message(); example switched to InMemoryBackend::new().

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped through bytes and strings so bright,
Swapped heavy clones for CompactString light,
Jid and Node grew nimble, tests learned to .into(),
InMemory nibbles stale messages out of sight,
A rabbit cheers — tiny hops, tidy refactor delight!

🚥 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 pull request title clearly and specifically describes the main change: using CompactString to replace String in NodeValue, NodeContent, and Jid.user for performance optimization.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/compact-string-migration

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

❤️ Share

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

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review check for further refinements, possibly remove unnecessary clones, allocations, consistency, bugs, regressions, etc

@claude

claude Bot commented Apr 11, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 5m 4s —— View job


Review: CompactString Migration PR

  • Read full diff and gather context
  • Review wacore/binary changes (node, jid, encoder, decoder, builder, marshal)
  • Review wacore/src changes (iq, send, stanza, store, message_processing, etc.)
  • Review src/ (main crate) changes
  • Review InMemoryBackend TTL eviction changes
  • Check for unnecessary clones, allocations, consistency, bugs, regressions
  • Post final review

Overall this is a clean, well-scoped migration. The profiling data is compelling and the changes are mechanical + correct. A few findings below, roughly ordered by impact.


Unnecessary allocations — .to_string() where .clone() suffices

Throughout the diff, many s.clone() on NodeContent::String(s) / Jid.user were mechanically converted to s.to_string(). For CompactString values ≤ 24 bytes (which is ~91% of them per your data), .clone() is a cheap inline memcpy with zero heap allocation, while .to_string() goes through Displayfmt::Write → always heap-allocates a String. Several of these feed into contexts that could accept CompactString directly, or at least benefit from .clone() + .into():

File Line Current Suggestion
wacore/src/stanza/groups.rs 310 s.to_string()String for member_add_mode Fine (target is String), but could change field to CompactString to avoid alloc
wacore/src/stanza/business.rs 62 s.to_string() for verified name Same — target is Option<String>
wacore/src/iq/usync.rs 103 user.jid.user.to_string() for phone number formatting Needed here because of format!("+{}", ...) in the else branch
src/handlers/notification.rs 625, 689, 704, 715 Multiple from_jid.user.to_string() These feed into DeviceListRecord.user: String and String variables — needed unless downstream types are also migrated
src/send.rs 1220, 1413, 1417, 1473, 1477 to.user.to_string() / sender.user.to_string() / jid.user.to_string() These feed String variables for token_jid lookups into LidPnCache (which uses TypedCache<String, _>) — allocation is necessary here

Verdict: Most of the .to_string() calls are actually necessary because the downstream types (DeviceListRecord, UsyncLidMapping, LidPnEntry, LidPnCache, etc.) still use String. This is the correct approach for a scoped PR — you migrated the hot path (binary protocol layer) without refactoring every downstream consumer. The remaining .to_string() calls are on cold paths (notification handling, IQ responses, token lookups) where the allocation cost is negligible.

However, there is one spot worth reconsidering:

wacore/src/send.rs:1243user_set.insert(d.user.to_string()) inside the SKDM stale-user loop. This collects into a HashSet<String>. Since d.user is CompactString, you could use HashSet<CompactString> and .clone() instead to avoid heap allocations for phone-number-length strings. The stale_device_users field is Vec<String> downstream though, so you'd need to .to_string() when collecting the final Vec. Net benefit is marginal — only matters if the distribution list is large.


Redundant .clone() on CompactString that could be avoided

wacore/src/client/context.rs:13build_pn_to_lid_map:

let lid_jid = Jid::lid(lid_user.clone());

lid_user is &CompactString (from iterating HashMap), and Jid::lid takes impl Into<CompactString>. The .clone() creates a new CompactString, which is correct. However, since you're borrowing from a map you're iterating, the clone is necessary here. No issue.

wacore/src/client/context.rs:132phone_device_jid_to_lid:

Jid::lid_device(lid_base.user.clone(), phone_device_jid.device)

lid_base is &Jid from a map lookup. Clone is necessary. Fine.


InMemoryBackend TTL eviction — potential issue

wacore/src/store/in_memory.rs:499-501:

if s.sent_messages.len() >= SENT_MESSAGE_EVICT_THRESHOLD {
    let cutoff = now - self.sent_message_ttl_secs;
    s.sent_messages.retain(|_, e| e.timestamp >= cutoff);
}

This is a good addition that caps unbounded growth. A couple of observations:

  1. Threshold of 64 is reasonable for an in-memory test/benchmark backend. The eviction only runs on store_sent_message, not on take_sent_message, which is fine since take_sent_message already removes entries.

  2. Edge case: If a clock is misbehaving (e.g., now_secs() returns 0 in tests with mocked time), cutoff = 0 - 300 = -300 would retain everything (all timestamps ≥ -300). This is actually the safe behavior — no accidental data loss. Good.

  3. No eviction on read path: take_sent_message doesn't evict expired entries, but since it uses remove() it won't return stale data. The only risk is if messages are stored but never taken — they accumulate until the next store_sent_message call crosses the threshold. For a test/benchmark backend, this is fine.


Consistency nits

  1. wacore/binary/src/marshal.rs:270-272 uses the verbose form:

    Some(NodeContent::String(compact_str::CompactString::from("hello".repeat(40))))

    Since compact_str::CompactString is already imported as CompactString in node.rs (and re-exported from wacore_binary), this could just be:

    Some(NodeContent::String("hello".repeat(40).into()))

    or import CompactString at the top. Minor — it's test code.

  2. wacore/src/iq/profile.rs:41-43 uses:

    NodeContent::String(wacore_binary::CompactString::from(self.text.as_str()))

    Could be simplified to:

    NodeContent::String(self.text.as_str().into())

    since &str → CompactString via From is available.

  3. src/message.rs:2470 — test still uses HashMap<String, Jid> with .to_string() inserts, while the sibling tests at lines 2345-2351 and 2410-2416 use HashMap<CompactString, Jid> with CompactString::from(...). Inconsistent but functionally fine (it's simulating inline logic, not using GroupInfo).


No bugs or regressions found

  • All CompactString&str conversions are correct. CompactString implements Deref<Target=str>, Borrow<str>, PartialEq<str>, PartialEq<&str>, and PartialEq<String>, so all comparison, HashMap lookup, and method-call sites work correctly.
  • The encoder/decoder round-trip is safe — write_string takes &str and CompactString derefs to &str transparently.
  • serde feature gate correctly adds compact_str/serde — serialization/deserialization of Jid, NodeValue, and NodeContent will work correctly.
  • The benchmark example switch from SqliteStore to InMemoryBackend is appropriate for the profiling use case and removes an unnecessary dependency on SQLite for benchmarks.

Summary

The migration is well-executed and correctly scoped. The -34% peak heap reduction is significant for a change of this size. The main refinement opportunity is the verbose CompactString::from(...) patterns in a few places that could use .into(), but these are cosmetic. No correctness issues found.

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

ℹ️ 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 wacore/src/store/in_memory.rs Outdated
Comment on lines +500 to +501
let cutoff = now - self.sent_message_ttl_secs;
s.sent_messages.retain(|_, e| e.timestamp >= cutoff);

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 Honor configured sent-message TTL in inline eviction

Inline eviction uses self.sent_message_ttl_secs here, but this backend field is initialized to a fixed 300s default and (repo-wide rg) is never wired from Client.cache_config.sent_message_ttl_secs. That means any non-default TTL (including 0 to disable expiration) is ignored once sent_messages reaches the threshold, so older entries can be dropped far earlier than the configured policy and retry handling may fail to find payloads that should still be retained.

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 `@src/client.rs`:
- Line 3606: The code currently allocates an intermediate String via
node.tag.to_string() when inserting the class attribute; replace that allocation
by passing a &str directly to NodeValue::from (use node.tag.as_str() or
&node.tag as appropriate) in the attrs.insert call so NodeValue::from receives a
string slice instead of building a new String; update the line using
attrs.insert, NodeValue::from, and node.tag accordingly to avoid the extra
allocation.

In `@wacore/src/store/in_memory.rs`:
- Around line 498-502: The current eviction does a full retain on
s.sent_messages on nearly every store_sent_message call once len >=
SENT_MESSAGE_EVICT_THRESHOLD, causing O(n) work and lock contention; change the
logic to amortize scans by adding a throttling condition (e.g., a
last_sent_eviction timestamp or counter on the same struct) and only run the
retain when either enough wall-clock time has passed since last eviction (e.g.,
>= SENT_MESSAGE_EVICT_INTERVAL_SECS) or the map grows well beyond the threshold
(e.g., >= SENT_MESSAGE_EVICT_THRESHOLD * 2); update the store_sent_message
function to consult and update that last_sent_eviction marker (and constants
like SENT_MESSAGE_EVICT_INTERVAL_SECS) under the mutex so retain runs rarely, or
alternatively remove a bounded number of expired entries per call instead of
full retain.
- Around line 95-98: The with_sent_message_ttl setter currently accepts
non-positive values which can trigger massive eviction; update the function
with_sent_message_ttl to validate ttl_secs > 0 before assigning to
self.sent_message_ttl_secs and if ttl_secs is <= 0 do not change the field
(return self unchanged) or optionally set a safe minimum (e.g., 1) so
non-positive inputs cannot overwrite the existing TTL and cause unintended
retry-cache invalidation.
🪄 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: 7519d98f-8c58-4d1d-9f4d-ca79fabb367b

📥 Commits

Reviewing files that changed from the base of the PR and between 0eda689 and ed96886.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (36)
  • Cargo.toml
  • examples/benchmark.rs
  • src/client.rs
  • src/client/lid_pn.rs
  • src/features/groups.rs
  • src/handlers/notification.rs
  • src/handlers/router.rs
  • src/history_sync.rs
  • src/lib.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/builder.rs
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/encoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/marshal.rs
  • wacore/binary/src/node.rs
  • wacore/src/client/context.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/usync.rs
  • wacore/src/message_processing.rs
  • wacore/src/prekeys.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/send.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/usync.rs

Comment thread src/client.rs Outdated
Comment thread wacore/src/store/in_memory.rs
Comment thread wacore/src/store/in_memory.rs Outdated
@github-actions

github-actions Bot commented Apr 11, 2026

Copy link
Copy Markdown

Benchmark Results

3 improvement(s):

Benchmark Current Baseline Change
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 17,414 20,910 -16.7%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 13,379,449 14,815,128 -9.7%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 13,379,193 14,813,634 -9.7%
56 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,851 11,851 +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,822 69,139 -0.5%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,789 77,229 -0.6%
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,939 5,939 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 179,674 180,707 -0.6%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 193,227 193,266 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 894,220 898,761 -0.5%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 997,958 1,010,396 -1.2%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,552,042 1,617,583 -4.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,790,860 2,813,120 -0.8%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 10,359,664 10,499,586 -1.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 49,428,610 50,031,964 -1.2%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,691,060 12,501,341 +1.5%
binary_benchmark::marshal_group::bench_marshal_allocating 95,389 98,703 -3.4%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 95,422 98,731 -3.4%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 115,722 118,631 -2.5%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 105,315 108,446 -2.9%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 95,489 98,803 -3.4%
binary_benchmark::marshal_group::bench_marshal_long_string 15,662 15,928 -1.7%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 15,706 15,955 -1.6%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 17,798 18,004 -1.1%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 533,097 533,456 -0.1%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 532,663 533,017 -0.1%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 534,118 534,447 -0.1%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 26,860,794 28,200,750 -4.8%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,716 2,716 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 40,257 41,989 -4.1%
binary_benchmark::unpack_group::bench_unpack_uncompressed 773 773 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 556,090 556,090 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 6,008 6,199 -3.1%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 7,431 7,431 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 89,832 91,558 -1.9%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 7,454 7,454 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 89,863 91,589 -1.9%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 8,966 8,966 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 105,408 107,134 -1.6%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 498,603 523,708 -4.8%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,372,974 17,448,610 -0.4%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 162,069 162,069 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,512,660 5,512,660 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 163,085 163,085 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 298,429 298,493 -0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 713,231 713,231 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,618,624 12,388,471 +1.9%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,615,482 27,494,810 +0.4%
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() 124,988,303 123,513,473 +1.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,970 46,970 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 5,119,851 5,119,851 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 299,173 299,173 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,247,117 14,247,117 +0.0%

@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)
wacore/benches/send_receive_benchmark.rs (1)

190-196: 🧹 Nitpick | 🔵 Trivial

Consider using the Jid::new() factory method.

The struct literal construction could be simplified using the existing factory method, which performs the same operations more concisely.

♻️ Proposed refactor using factory method
-        let jid = Jid {
-            user: user.into(),
-            server: wacore_binary::jid::cow_server_from_str(server),
-            device: 0,
-            agent: 0,
-            integrator: 0,
-        };
+        let jid = Jid::new(user, server);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/benches/send_receive_benchmark.rs` around lines 190 - 196, The code
constructs a Jid via a struct literal (Jid { user: ..., server:
wacore_binary::jid::cow_server_from_str(server), device: 0, agent: 0,
integrator: 0 }) — replace this with the existing factory method
Jid::new(user.into(), server) (or the appropriate Jid::new signature) to
simplify and centralize server conversion logic; update the call sites in
send_receive_benchmark.rs to use Jid::new instead of manual field
initialization.
🤖 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/benches/send_receive_benchmark.rs`:
- Around line 190-196: The code constructs a Jid via a struct literal (Jid {
user: ..., server: wacore_binary::jid::cow_server_from_str(server), device: 0,
agent: 0, integrator: 0 }) — replace this with the existing factory method
Jid::new(user.into(), server) (or the appropriate Jid::new signature) to
simplify and centralize server conversion logic; update the call sites in
send_receive_benchmark.rs to use Jid::new instead of manual field
initialization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ad8d652a-e9ca-4d36-89f3-621f894313f0

📥 Commits

Reviewing files that changed from the base of the PR and between ed96886 and b12fc6e.

📒 Files selected for processing (1)
  • wacore/benches/send_receive_benchmark.rs

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review things carefully; also, check possible additional refinements we can do before merging (since this PR is a breaking change, so we can do superb things)

@claude

claude Bot commented Apr 11, 2026

Copy link
Copy Markdown

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


Deep Review: CompactString Migration

  • Gather context and read CLAUDE.md
  • Review all changed files in current state (post-fixup commits)
  • Audit for unnecessary allocations and clones
  • Check for additional refinement opportunities (since this is breaking anyway)
  • Review InMemoryBackend TTL eviction
  • Check consistency across the codebase
  • Post final review with actionable refinements

Overall: clean, well-scoped migration with compelling profiling data. The fixup commits (65a2ff9, 4ec5401) already addressed most of the low-hanging fruit from the initial reviews. Below are findings ordered by impact, split into "should fix before merge" and "could do while you're here" given this is already a breaking change.


Issues to fix before merge

1. content_as_string() return type change is silently breaking for downstream callers

Node::content_as_string() at wacore/binary/src/node.rs:457 now returns Option<CompactString> instead of Option<String>. Every call site that fed the result into a String-typed field now requires .to_string(). You've added that in wacore/src/iq/groups.rs:647 and wacore/src/stanza/groups.rs:280, but the migration guide in the PR description doesn't mention this change. Downstream consumers who pattern-match or assign the result will get type errors silently. Worth documenting.

2. UsyncLidMapping fields changed to CompactString but LidPnEntry fields stayed String

UsyncLidMapping (wacore/src/usync.rs:8-12) now has CompactString fields, but the consumer in src/usync.rs:134-144 builds a DeviceListRecord from it with:

user: user_list.user.user.to_string(),  // Jid.user → String

And later constructs LidPnEntry (which has lid: String, phone_number: String). This creates an asymmetry where:

  • UsyncLidMapping.phone_number: CompactStringLidPnEntry.phone_number: String (allocation)
  • UsyncLidMapping.lid: CompactStringLidPnEntry.lid: String (allocation)

Since this PR is breaking anyway, consider migrating LidPnEntry.lid and LidPnEntry.phone_number (wacore/src/types/lid_pn.rs:68-70) to CompactString too. These are always ≤16 chars (phone numbers and LID user parts). This would eliminate allocations in the usync → cache → storage pipeline. Similarly LidPnMappingEntry in wacore/src/store/traits.rs:28-30.

Fix this →

3. DeviceListRecord.user: String — missed opportunity

At wacore/src/store/traits.rs:65, DeviceListRecord.user is String. This field holds a phone number or LID user part (always ≤16 chars). In src/handlers/notification.rs:625 and src/usync.rs:145, you call .to_string() to create it from CompactString. Migrating this to CompactString would eliminate those allocations. Since this is a public breaking type change already...


Refinements worth considering (since this is breaking)

4. Unnecessary .to_string() where .clone() would be zero-alloc

Several .to_string() calls on CompactString values go through Display → always heap-allocate a String. Where the target type accepts CompactString, use .clone() instead (inline memcpy for ≤24 chars):

Location Code Why it allocates
wacore/src/send.rs:1243 d.user.to_string() into HashSet<String> Could use HashSet<CompactString> + .clone()
wacore/src/stanza/groups.rs:312 s.to_string() for member_add_mode Target is String in GroupNotificationAction — could change variant field to CompactString
wacore/src/stanza/business.rs:62 s.to_string() for verified name Target is Option<String>
wacore/src/stanza/business.rs:265 id.to_string() for product IDs Target is Vec<String>

The GroupNotificationAction enum fields and related String-typed fields in stanza types could be migrated to CompactString for consistency, but that's a wider change and these are cold paths.

5. ensure_status_participants — nice use of CompactString

In wacore/src/send.rs:1316, existing_users was changed to HashSet<CompactString> — good pattern. But user_set at line 1243 is still HashSet<String>, so d.user.to_string() still allocates. Consider the same treatment.

6. build_user_nodes phone formatting (wacore/src/iq/usync.rs:102-106)

let phone = if user.jid.user.starts_with('+') {
    user.jid.user.to_string()  // allocates String
} else {
    format!("+{}", user.jid.user)  // also allocates
};

The format! branch is unavoidable. The first branch could use .clone() and pass CompactString since string_content takes impl Into<CompactString>. Fix this →

7. iq/profile.rs — verbose CompactString construction

At wacore/src/iq/profile.rs:41:

content: Some(NodeContent::String(self.text.as_str().into())),

This is fine, but could be even simpler since self.text is String:

content: Some(NodeContent::String(self.text.clone().into())),

...or best, since From<String> for CompactString exists:

content: Some(NodeContent::String(CompactString::from(self.text.as_str()))),

Current code is already correct and equivalent. No change needed — just noting for style consistency.


InMemoryBackend TTL eviction — looks solid

The eviction logic at wacore/src/store/in_memory.rs:505-517 is well-designed after the fixup:

  • Amortized: Only scans when both len ≥ 64 AND ≥ 30s since last eviction
  • Atomic last_eviction: Uses Relaxed ordering correctly (monotonic clock, single writer under mutex)
  • TTL validation: with_sent_message_ttl rejects ≤ 0 values
  • No read-path eviction: take_sent_message removes via remove(), no stale data risk

One minor observation: last_eviction is AtomicI64 outside the mutex while sent_messages is inside. This means a concurrent reader of last_eviction could see a stale value, but since the atomic is only used as a "has enough time passed?" heuristic under the mutex guard, this is safe and correct.


No bugs or correctness issues found

  • All CompactString&str conversions are correct via Deref<Target=str>, Borrow<str>, PartialEq<str>, PartialEq<&str>, and PartialEq<String>.
  • Encoder/decoder round-trip is safe — write_string takes &str.
  • serde feature gate correctly adds compact_str/serde.
  • The privacy token notification refactoring (src/handlers/notification.rs:683-721) is a clean borrow-based approach that avoids all intermediate allocations.
  • The history_sync.rs and send.rs token_jid patterns correctly use &str borrows.

Summary

The migration is well-executed. The main opportunity before merge is widening the CompactString adoption to LidPnEntry, LidPnMappingEntry, and DeviceListRecord — these are already breaking types in the PR's dependency chain, and their fields (phone numbers, LIDs) are always ≤16 chars. This would close the allocation gap between the binary protocol layer and the storage/cache layer. Everything else is minor and could be follow-up.


@jlucaso1
jlucaso1 force-pushed the perf/compact-string-migration branch from 4ec5401 to 11edf13 Compare April 11, 2026 06:46

@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: 11edf130f0

ℹ️ 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 +1219 to +1220
let resolved_lid_user = if to.is_lid() {
Some(to.user.clone())
None

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 Preserve LID user for cstoken fallback

Setting resolved_lid_user to None for to.is_lid() drops direct-LID recipients out of the NCT fallback path: the later guard && let Some(lid_user) = &resolved_lid_user will always fail, so no cstoken is attached when there is no stored tctoken. This regresses first-contact sends to LID targets (with NCT salt + flag enabled) and can surface as avoidable 463 failures, while the previous behavior still had the recipient LID available.

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

🤖 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/send.rs`:
- Around line 1219-1225: The current logic forces resolved_lid_user to None when
to.is_lid(), which prevents using cached LID tokens for direct-LID 1:1 sends and
breaks the cstoken fallback; change the assignment so you always call
self.lid_pn_cache.get_current_lid(&to.user).await (i.e., remove the if
to.is_lid() branch) so resolved_lid_user is Some(...) when a cache hit exists
and token_jid still falls back to &to.user via as_deref().unwrap_or(&to.user),
restoring the nct_send_enabled / cstoken fallback path (affecting
resolved_lid_user, to.is_lid(), lid_pn_cache.get_current_lid, and token_jid
usage).
🪄 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: 95bfaff6-0bd8-4826-8c04-d262e8e253f8

📥 Commits

Reviewing files that changed from the base of the PR and between 65a2ff9 and 4ec5401.

📒 Files selected for processing (7)
  • src/features/groups.rs
  • src/handlers/notification.rs
  • src/history_sync.rs
  • src/send.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/src/iq/usync.rs
  • wacore/src/usync.rs

Comment thread src/send.rs Outdated
@jlucaso1
jlucaso1 force-pushed the perf/compact-string-migration branch from 11edf13 to c4b6600 Compare April 11, 2026 06:54

@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

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)

362-373: ⚠️ Potential issue | 🟠 Major

Don’t overwrite tc-token state after a read failure.

Err from get_tc_token(token_key) currently falls into the same branch as Ok(None), and the code still writes a new entry. That skips the monotonicity check and can clobber a newer cached sender_timestamp with stale history-sync data after a transient backend error.

Suggested fix
-        let merged_sender_ts = if let Ok(Some(existing)) = backend.get_tc_token(token_key).await {
-            if (existing.token_timestamp as u64) > timestamp {
-                return;
-            }
-            match (existing.sender_timestamp, incoming_sender_ts) {
-                (Some(e), Some(i)) => Some(e.max(i)),
-                (Some(e), None) => Some(e),
-                (None, i) => i,
-            }
-        } else {
-            incoming_sender_ts
-        };
+        let merged_sender_ts = match backend.get_tc_token(token_key).await {
+            Ok(Some(existing)) => {
+                if (existing.token_timestamp as u64) > timestamp {
+                    return;
+                }
+                match (existing.sender_timestamp, incoming_sender_ts) {
+                    (Some(e), Some(i)) => Some(e.max(i)),
+                    (Some(e), None) => Some(e),
+                    (None, i) => i,
+                }
+            }
+            Ok(None) => incoming_sender_ts,
+            Err(e) => {
+                log::warn!(
+                    target: "Client/TcToken",
+                    "Failed to read existing history sync tctoken for {}: {e}",
+                    token_key
+                );
+                return;
+            }
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/history_sync.rs` around lines 362 - 373, The current logic treats Err
from backend.get_tc_token(token_key) the same as Ok(None) and proceeds to write
merged_sender_ts, which can clobber newer state; change the branch so that if
backend.get_tc_token(token_key) returns Err you do not proceed with writing
(e.g., return or propagate the error) instead of falling back to
incoming_sender_ts—ensure the match around get_tc_token distinguishes
Ok(Some(existing)), Ok(None) and Err(_) and only computes merged_sender_ts/write
when you have a successful read (Ok), keeping references to get_tc_token,
merged_sender_ts, token_key, incoming_sender_ts, sender_timestamp, and
token_timestamp to locate the code to modify.
wacore/src/send.rs (1)

1237-1247: 🧹 Nitpick | 🔵 Trivial

Deduplicate stale users before allocating Strings.

d.user.to_string() allocates once per missing device, so a user with several stale companions still pays multiple heap allocations before the HashSet drops duplicates. In this perf-focused PR, collect borrowed users first and stringify only once when building stale_device_users.

Suggested change
-        let mut user_set = HashSet::new();
+        let mut user_set: HashSet<&str> = HashSet::new();
         if let Some(ref dist) = distribution_list {
             for d in dist {
                 if !encrypted_set.contains(d) {
-                    user_set.insert(d.user.to_string());
+                    user_set.insert(d.user.as_str());
                 }
             }
         }
-        user_set.into_iter().collect()
+        user_set.into_iter().map(str::to_owned).collect()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacore/src/send.rs` around lines 1237 - 1247, The current loop converts
d.user.to_string() for every missing device which repeats allocations; instead
collect borrowed user IDs first and stringify once when building
stale_device_users: in the stale_users block (around had_unregistered_devices,
skdm_encrypted_devices, distribution_list) change user_set to a HashSet of
borrowed IDs (e.g., HashSet<&Jid> or HashSet<&str>) and insert &d.user when a
device is missing, then after the loop convert the unique borrows into Strings
(user_set.into_iter().map(|u| u.to_string()).collect()) so each user is
allocated only once.
♻️ Duplicate comments (1)
src/send.rs (1)

1219-1224: ⚠️ Potential issue | 🟠 Major

Direct-LID chats still bypass the cstoken fallback.

On Line 1219, forcing resolved_lid_user to None for to.is_lid() makes the fallback on Lines 1267-1270 unreachable for direct-LID recipients. If there is no cached tctoken, first-contact LID sends regress back to token-less stanzas.

Also applies to: 1267-1270

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

In `@src/send.rs` around lines 1219 - 1224, The code currently forces
resolved_lid_user to None when to.is_lid(), which prevents the later cstoken
fallback from ever being reached for direct-LID recipients; instead, remove the
short-circuit and always call self.lid_pn_cache.get_current_lid(&to.user).await
to attempt resolution, then let token_jid =
resolved_lid_user.as_deref().unwrap_or(&to.user) and let the existing cstoken
fallback logic (the later token lookup code) handle missing entries; update any
null-handling around lid_pn_cache.get_current_lid and ensure behavior is correct
for both LID and JID inputs (refer to resolved_lid_user, to.is_lid(),
lid_pn_cache.get_current_lid, and token_jid).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples/benchmark.rs`:
- Line 34: The benchmark currently uses InMemoryBackend::new() which brings the
default 300s sent-message retry cache and skews long-run measurements; replace
the call to InMemoryBackend::new() with the backend constructor that accepts an
explicit TTL (e.g., InMemoryBackend::with_ttl or similar) and pass a short,
benchmark-specific Duration (for example 5–30s) so the benchmark measures
protocol performance reproducibly and not retention/eviction behavior—update the
variable backend (Arc::new(...)) to use that TTL-taking constructor.

In `@wacore/binary/src/builder.rs`:
- Around line 54-55: The public method string_content currently exposes
compact_str::CompactString in its signature; change the bound to use the crate
re-export instead (e.g., impl Into<crate::CompactString>) so the public API
references the local CompactString alias; update the signature of string_content
(and any other public-facing signatures in builder.rs that mention
compact_str::CompactString) to use crate::CompactString while leaving the body
(self.content = Some(NodeContent::String(s.into()))) unchanged.

In `@wacore/src/store/in_memory.rs`:
- Around line 507-516: The eviction cadence currently uses the fixed
SENT_MESSAGE_EVICT_INTERVAL_SECS which can exceed a caller-set
sent_message_ttl_secs; change the check that compares now - last against
SENT_MESSAGE_EVICT_INTERVAL_SECS to instead use the smaller of
SENT_MESSAGE_EVICT_INTERVAL_SECS and self.sent_message_ttl_secs so short TTLs
are honored. Update the condition around last_eviction (and any related
bookkeeping such as calling sent_messages.retain and last_eviction.store) to
compute interval = min(SENT_MESSAGE_EVICT_INTERVAL_SECS,
self.sent_message_ttl_secs) and use that interval for the throttle decision so
eviction runs at least as often as the configured TTL.

---

Outside diff comments:
In `@src/history_sync.rs`:
- Around line 362-373: The current logic treats Err from
backend.get_tc_token(token_key) the same as Ok(None) and proceeds to write
merged_sender_ts, which can clobber newer state; change the branch so that if
backend.get_tc_token(token_key) returns Err you do not proceed with writing
(e.g., return or propagate the error) instead of falling back to
incoming_sender_ts—ensure the match around get_tc_token distinguishes
Ok(Some(existing)), Ok(None) and Err(_) and only computes merged_sender_ts/write
when you have a successful read (Ok), keeping references to get_tc_token,
merged_sender_ts, token_key, incoming_sender_ts, sender_timestamp, and
token_timestamp to locate the code to modify.

In `@wacore/src/send.rs`:
- Around line 1237-1247: The current loop converts d.user.to_string() for every
missing device which repeats allocations; instead collect borrowed user IDs
first and stringify once when building stale_device_users: in the stale_users
block (around had_unregistered_devices, skdm_encrypted_devices,
distribution_list) change user_set to a HashSet of borrowed IDs (e.g.,
HashSet<&Jid> or HashSet<&str>) and insert &d.user when a device is missing,
then after the loop convert the unique borrows into Strings
(user_set.into_iter().map(|u| u.to_string()).collect()) so each user is
allocated only once.

---

Duplicate comments:
In `@src/send.rs`:
- Around line 1219-1224: The code currently forces resolved_lid_user to None
when to.is_lid(), which prevents the later cstoken fallback from ever being
reached for direct-LID recipients; instead, remove the short-circuit and always
call self.lid_pn_cache.get_current_lid(&to.user).await to attempt resolution,
then let token_jid = resolved_lid_user.as_deref().unwrap_or(&to.user) and let
the existing cstoken fallback logic (the later token lookup code) handle missing
entries; update any null-handling around lid_pn_cache.get_current_lid and ensure
behavior is correct for both LID and JID inputs (refer to resolved_lid_user,
to.is_lid(), lid_pn_cache.get_current_lid, and token_jid).
🪄 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: 57293095-61ab-4e3d-8d23-6fe0da677029

📥 Commits

Reviewing files that changed from the base of the PR and between 4ec5401 and 11edf13.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (38)
  • Cargo.toml
  • examples/benchmark.rs
  • src/client.rs
  • src/client/lid_pn.rs
  • src/features/groups.rs
  • src/handlers/notification.rs
  • src/handlers/router.rs
  • src/history_sync.rs
  • src/lib.rs
  • src/message.rs
  • src/retry.rs
  • src/send.rs
  • src/session.rs
  • src/usync.rs
  • tests/e2e/src/lib.rs
  • wacore/benches/send_receive_benchmark.rs
  • wacore/binary/Cargo.toml
  • wacore/binary/src/builder.rs
  • wacore/binary/src/decoder.rs
  • wacore/binary/src/encoder.rs
  • wacore/binary/src/jid.rs
  • wacore/binary/src/lib.rs
  • wacore/binary/src/marshal.rs
  • wacore/binary/src/node.rs
  • wacore/src/client/context.rs
  • wacore/src/iq/business.rs
  • wacore/src/iq/groups.rs
  • wacore/src/iq/profile.rs
  • wacore/src/iq/spam_report.rs
  • wacore/src/iq/usync.rs
  • wacore/src/message_processing.rs
  • wacore/src/prekeys.rs
  • wacore/src/protocol/retry.rs
  • wacore/src/send.rs
  • wacore/src/stanza/business.rs
  • wacore/src/stanza/groups.rs
  • wacore/src/store/in_memory.rs
  • wacore/src/usync.rs

Comment thread examples/benchmark.rs Outdated
Comment thread wacore/binary/src/builder.rs Outdated
Comment thread wacore/src/store/in_memory.rs
@jlucaso1
jlucaso1 force-pushed the perf/compact-string-migration branch from c4b6600 to a94c632 Compare April 11, 2026 07:01
Replace heap-allocated `String` with `CompactString` in the three
hottest allocation sites identified by heaptrack profiling:

- `NodeValue::String(String)` → `NodeValue::String(CompactString)`
- `NodeContent::String(String)` → `NodeContent::String(CompactString)`
- `Jid { user: String }` → `Jid { user: CompactString }`

CompactString stores strings ≤ 24 bytes inline (no heap allocation).
Since 90%+ of protocol attribute values and 100% of phone numbers
fit within this threshold, this eliminates the majority of per-node
heap allocations during `NodeRef::to_owned()`.

Measured results (heaptrack, real WhatsApp session):
- Peak heap: 7.90 MB → 5.15-5.24 MB (~34% reduction)
- Peak RSS: 32.57 MB → 28.28-28.57 MB (~13% reduction)
- The two largest allocation sites (Attrs FromIterator at 1.88 MB
  and 750 KB) no longer appear in the top 15 consumers.

Additional changes:
- InMemoryBackend: amortized TTL-based eviction in store_sent_message
  to prevent unbounded growth between periodic cleanup cycles.
- Benchmark example: use InMemoryBackend instead of SqliteStore.
- Re-export CompactString from wacore-binary and whatsapp-rust.
- Jid factory methods now take `impl Into<CompactString>` directly,
  avoiding an intermediate String allocation.
- UsyncLidMapping fields changed to CompactString.
- Token lookup paths (send.rs, history_sync.rs, notification.rs)
  borrow &str instead of allocating String where possible.
- content_as_string() returns CompactString to avoid heap alloc.

BREAKING CHANGE: `NodeValue::String` now wraps `CompactString`
instead of `String`. `NodeContent::String` likewise wraps
`CompactString`. `Jid.user` is now `CompactString` instead of
`String`. `content_as_string()` returns `Option<CompactString>`.
`UsyncLidMapping` fields are now `CompactString`. Jid factory
methods take `impl Into<CompactString>`. All existing `From<&str>`
and `From<String>` conversions still work. Code that pattern-matches
on these types and binds the inner value will see `CompactString`
(which derefs to `&str`). `HashMap<String, _>::get(&jid.user)` must
change to `.get(jid.user.as_str())`.
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