Skip to content

fix: cache-aside fallback in get_lid_pn_entry - #565

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/lid-pn-entry-cache-aside
Apr 18, 2026
Merged

fix: cache-aside fallback in get_lid_pn_entry#565
jlucaso1 merged 2 commits into
mainfrom
fix/lid-pn-entry-cache-aside

Conversation

@jlucaso1

Copy link
Copy Markdown
Collaborator

Summary

Client::get_lid_pn_entry only queried the in-memory cache. Any mapping that lived exclusively on the backend (because the cache had been evicted, or warm-up wasn't run for that client instance) would return None even though the persistent data had the answer.

Now it's cache-aside: on cache miss, falls back to Backend::get_lid_mapping / get_pn_mapping, converts to LidPnEntry, populates the in-memory cache, and returns the entry. Any backend implementation gains this fallback without additional wiring.

Change

src/client/lid_pn.rs:

  • get_lid_pn_entry tries the in-memory cache first, then the backend on miss
  • Conversion from LidPnMappingEntryLidPnEntry mirrors the existing warm_up_lid_pn_cache path (LidPnEntry::with_timestamp + LearningSource::parse)
  • Backend errors are logged at DEBUG and treated as "no mapping" — the lookup never propagates a backend failure to the caller

Test plan

  • cargo test -p whatsapp-rust --lib client::lid_pn::tests — 6 cases pass, including new test_get_lid_pn_entry_falls_back_to_backend regression
  • cargo clippy -p whatsapp-rust --tests

Previously only queried the in-memory cache; mappings present only in
the backend (after cache eviction, or when warm_up wasn't called) would
return None even though the persistent data had the answer.

Now falls back to `Backend::get_lid_mapping` / `get_pn_mapping` on
cache miss and re-populates the cache. Any backend impl gains the
fallback without additional wiring.
@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 03806453-5f79-412f-a65d-9d397083b270

📥 Commits

Reviewing files that changed from the base of the PR and between f8422c8 and 395b825.

📒 Files selected for processing (6)
  • src/client/device_registry.rs
  • src/client/lid_pn.rs
  • src/features/groups.rs
  • src/lid_pn_cache.rs
  • src/message.rs
  • src/retry.rs

📝 Walkthrough

Summary by CodeRabbit

  • Improvements
    • More reliable LID↔PN lookups with automatic cache fallback to the backend when needed.
    • Cache warm-up and insertions optimized to reduce missed lookups and improve subsequent query performance.
    • Error handling tightened so backend failures are surfaced (no longer treated as silent “not found”), improving diagnostic clarity.

Walkthrough

Client::get_lid_pn_entry was changed from cache-only to cache-aside: it checks the in-memory Lid↔PN cache, queries the persistence backend on miss (direction based on Jid type), converts backend mappings into LidPnEntry, repopulates the cache, and now returns Result<Option<LidPnEntry>>, propagating backend errors.

Changes

Cohort / File(s) Summary
Client lid↔pn logic
src/client/lid_pn.rs
Added mapping_to_entry conversion, refactored warm_up_lid_pn_cache to use iterator mapping, changed add_lid_pn_mapping to insert by reference, and rewrote Client::get_lid_pn_entry to a cache-aside flow returning Result<Option<LidPnEntry>> (propagates backend errors). Added test test_get_lid_pn_entry_falls_back_to_backend.
LidPn cache API
src/lid_pn_cache.rs
Changed LidPnCache::add to take &LidPnEntry and clone internally; updated warm_up and internal insert logic to use borrowed entries.
Call-site updates (tests & features)
src/device_registry.rs, src/message.rs, src/retry.rs, src/features/groups.rs
Updated call sites to pass &entry to lid_pn_cache.add. Groups::create_group now uses await? when calling get_lid_pn_entry to propagate errors from the new Result return type. Test adjustments to unwrap Result<Option<...>> and verify inner Option.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Cache as In-Memory Cache
    participant Backend as Persistence Backend

    Client->>Cache: lookup LidPnEntry (Jid)
    alt Cache hit
        Cache-->>Client: return LidPnEntry
    else Cache miss
        Client->>Backend: query mapping (get_lid_mapping / get_pn_mapping)
        Backend-->>Client: mapping or NotFound or Error
        alt Mapping found
            Client->>Client: mapping_to_entry (parse learning_source, with_timestamp)
            Client->>Cache: add(&LidPnEntry)
            Cache-->>Client: ack
            Client-->>Client: return Ok(Some(LidPnEntry))
        else NotFound
            Client-->>Client: return Ok(None)
        else Error
            Backend-->>Client: error
            Client-->>Client: return Err(error)
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

breaking-change

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: implementing cache-aside fallback behavior in get_lid_pn_entry to query the backend when the in-memory cache misses.
Description check ✅ Passed The description clearly explains the problem being solved (mappings only in backend storage returning None), the solution (cache-aside fallback with backend lookup and cache population), and includes test instructions.
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 fix/lid-pn-entry-cache-aside

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

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review nitpicky also check opportunities of allocation-less reduce clone etc

@claude

claude Bot commented Apr 18, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 2m 40s —— View job


Nitpicky Review: get_lid_pn_entry cache-aside fallback

  • Read the full changed file and diff
  • Review correctness and logic
  • Check for unnecessary allocations, clones, and optimization opportunities
  • Post detailed review

Overall the change is clean and correct. The move semantics from LidPnMappingEntryLidPnEntry are right (mapping.lid and mapping.phone_number are moved, not cloned), and LearningSource::parse borrows without allocating. Here are the nits:


1. Avoidable clone on cache insertion — lid_pn.rs:311

self.lid_pn_cache.add(entry.clone()).await;
Some(entry)

add() takes LidPnEntry by value and then clones it twice more internally (once per map insertion). So the full cache-miss path does 3 clones of the two String fields (= 6 heap allocations just for the strings).

If LidPnCache::add took &LidPnEntry instead, this call-site clone disappears — add already has to clone for each map insertion anyway, so it loses nothing. That saves 1 clone (2 string allocs) per cache-miss lookup. Not urgent, but worth a follow-up if this path is hot (every cold JID resolution hits it).

Fix this →

2. Broader win: Arc<str> for LidPnEntry fields

LidPnEntry is cloned frequently — every cache read returns a clone, and every add() clones twice. With lid: String and phone_number: String, each clone copies the heap buffer. Switching these to Arc<str> would make clones reference-count bumps (~1 ns) instead of memcpy + malloc. The struct is never mutated after construction, so Arc<str> is a natural fit. This is a bigger change that touches LidPnMappingEntryLidPnEntry conversion boundaries, but would pay off across the board — not just in this new path.

3. Log level for backend failure — lid_pn.rs:300

debug!("LID-PN backend lookup failed for {jid}: {e:?}");

A backend lookup failure means persistence is broken — that's usually warn! territory, not debug!. debug! is appropriate for "no mapping found" (which is Ok(None) and correctly silent), but an Err from the database deserves visibility without requiring RUST_LOG=debug.

4. LidPnMappingEntry.learning_source: String → immediately dropped

LearningSource::parse(&mapping.learning_source),

The backend allocates a String for learning_source, it gets borrowed for a zero-alloc enum parse, and then the String is dropped. No waste in this code, but if the trait returned LearningSource directly (or &str via lifetime), backends that store it as an enum internally wouldn't need to round-trip through String. Low priority — mentioning for completeness.

5. Minor: {e:?} vs {e} in the debug log

debug!("LID-PN backend lookup failed for {jid}: {e:?}");

{e:?} uses Debug formatting, which for anyhow::Error shows the chain but with quotes and struct wrappers. {e:#} (Display alternate) gives the full causal chain in a more readable format. Nit-level.

6. Test: string literals via into()lid_pn.rs:413-417

LidPnMappingEntry {
    lid: lid.into(),
    phone_number: pn.into(),
    ...
    learning_source: "usync".into(),
}

No issue — &str → String via .into() is the right call for test setup. Just confirming this was checked since the review asked about allocation-less patterns.


What's already good

  • mapping.lid and mapping.phone_number are moved into with_timestamp — no redundant clone on the conversion path.
  • LearningSource::parse borrows &str and returns a Copy enum — zero-alloc.
  • The is_lid flag avoids duplicating the backend dispatch logic.
  • Early return for non-user JIDs (groups, newsletters) short-circuits before any work.
  • Test coverage is solid: cache hit, cache miss + LID lookup, cache miss + PN lookup, and cache re-population.

TLDR: Correctness is fine. The main actionable item is the clone on line 311 (fixable by changing add() to take &LidPnEntry). The Arc<str> idea is higher-effort but would eliminate clone overhead across the cache entirely. Consider bumping the log level from debug! to warn! for backend errors.


@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@src/client/lid_pn.rs`:
- Around line 305-310: Extract the duplicated mapping→entry construction into a
single helper (e.g., a function like mapping_to_lid_pn_entry) and use it
wherever entries are created (the current site using LidPnEntry::with_timestamp
and the warm_up_lid_pn_cache function). The helper should accept the DB mapping
(with fields mapping.lid, mapping.phone_number, mapping.created_at,
mapping.learning_source) and return LidPnEntry by calling
LidPnEntry::with_timestamp(...,
LearningSource::parse(&mapping.learning_source)). Replace both manual
constructions with calls to this helper to centralize timestamp and source
parsing logic.
- Around line 296-303: The match currently swallows backend errors and returns
None; change the function that produces mapping to return a
Result<Option<LidPnEntry>, E> (or the crate's common error type) instead of
Option<LidPnEntry>, so backend Err(e) is propagated as Err(e) rather than being
converted to Ok(None). Locate the match over result (variables named result,
mapping, and the LidPnEntry type) and replace the Err(e) branch with return
Err(e.into()) (or appropriate error conversion), keep Ok(Some(m)) => Ok(Some(m))
and Ok(None) => Ok(None); then update callers (e.g., the code in features
handling groups that treats None as “missing mapping”) to handle
Result<Option<...>> and distinguish real misses from backend errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9c6d1e8e-775d-4bf4-978c-8bd2e60d1038

📥 Commits

Reviewing files that changed from the base of the PR and between 6fd9f13 and f8422c8.

📒 Files selected for processing (1)
  • src/client/lid_pn.rs

Comment thread src/client/lid_pn.rs Outdated
Comment thread src/client/lid_pn.rs Outdated

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

ℹ️ 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/client/lid_pn.rs Outdated
mapping.created_at,
LearningSource::parse(&mapping.learning_source),
);
self.lid_pn_cache.add(entry.clone()).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.

P1 Badge Avoid poisoning PN cache from LID fallback lookups

When this path misses cache for a LID JID, get_lid_mapping can return a historical row for a phone number that now maps to a newer LID (the SQLite backend keeps multiple LIDs per phone and uses get_pn_mapping ordering to pick the current one). The unconditional add() call writes both directions, so a cold PN side gets repopulated with that stale LID and subsequent PN-based resolution (e.g. encryption/session routing) can use the wrong identity. This regression is specific to cache-miss LID lookups introduced in this commit; only the LID side should be hydrated here, or the PN side should be validated via get_pn_mapping first.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Apr 18, 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,933 3,933 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 12,038 12,038 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 43,514 43,514 +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,579 68,579 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 76,679 76,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 2,230 2,230 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 5,988 5,988 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 169,073 169,495 -0.2%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 190,994 190,846 +0.1%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 875,188 875,200 -0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 966,202 966,224 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 1,453,177 1,453,138 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 2,575,194 2,569,270 +0.2%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 9,376,427 9,375,189 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 44,461,525 44,458,765 +0.0%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 12,412,843 12,498,815 -0.7%
binary_benchmark::marshal_group::bench_marshal_allocating 71,247 71,247 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 71,300 71,300 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 98,367 98,367 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 78,801 78,801 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 71,347 71,347 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 7,518 7,518 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 7,561 7,561 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 9,273 9,273 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 530,504 530,504 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 530,072 530,072 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 531,427 531,427 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 8,506,160 8,506,160 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 8,450,412 8,450,412 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 19,677,947 19,677,947 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 2,468 2,468 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 33,558 33,558 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 787 787 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 526,732 526,732 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 4,986 4,986 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 5,315 5,315 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 61,874 61,874 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 5,347 5,347 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 61,942 61,942 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 6,734 6,734 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 85,564 85,564 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 477,570 477,570 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 11,563 11,563 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 17,207,274 17,330,542 -0.7%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 157,113 157,113 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 5,510,200 5,510,200 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 157,827 157,827 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 296,767 296,767 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 706,282 706,282 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 12,487,522 12,693,250 -1.6%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 27,554,112 27,449,912 +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() 126,219,833 126,599,123 -0.3%
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,072,844 5,072,844 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 316,083 316,083 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 14,255,917 14,255,917 +0.0%
No significant changes detected.

- Extract `mapping_to_entry` helper used by both warm_up and cache-aside
  fallback paths; centralizes `LearningSource::parse` boilerplate.
- `get_lid_pn_entry` now returns `Result<Option<LidPnEntry>>`. Backend
  errors propagate instead of being silently swallowed as `None`,
  letting callers distinguish "missing mapping" from "lookup failed".
- `LidPnCache::add` takes `&LidPnEntry` instead of `LidPnEntry`. Saves
  one clone per call site (`get_lid_pn_entry`, `add_lid_pn_mapping`,
  `warm_up`, and several retry/message paths).
- Update `Groups::create_group` to propagate backend errors and keep
  the existing "Missing phone number mapping" error for `Ok(None)`.
@jlucaso1
jlucaso1 merged commit 0289b14 into main Apr 18, 2026
11 checks passed
@jlucaso1
jlucaso1 deleted the fix/lid-pn-entry-cache-aside branch April 18, 2026 04:25
jlucaso1 added a commit that referenced this pull request Apr 18, 2026
`send_status_message` used to force every recipient to PN addressing
via an in-memory-only lookup, then abort the whole send if any LID had
no PN mapping in the cache. That made status reactions to LID-only
contacts fail indefinitely — the bot can decrypt the status (Signal
state is under the LID), but the status sender has no way to learn the
poster's phone number without a DM or usync round-trip.

This mismatch also contradicts WA Web. `WAWebEncryptAndSendStatusMsg`
(`docs/captured-js/WAWeb/Encrypt/AndSendStatusMsg.js:46`) does:

    compactMap(x.list, WAWebLidMigrationUtils.toUserLid)

i.e. every recipient is mapped to its LID, and `compactMap` silently
drops entries that can't be resolved. Status is LID-addressed, not PN,
since the LID migration.

Changes:

- `send_status_message` now resolves each recipient to its LID form
  (LID passes through; PN goes through the cache-aside
  `Client::get_lid_pn_entry`, which falls back to the backend on a
  cold cache — added in PR #565). Unresolvable entries are skipped
  silently, matching WA Web. `AddressingMode::Lid` and `own_lid` drive
  the downstream encryption path.
- New pure helper `wacore::send::assemble_status_participants` owns
  the dedup + own-LID anchoring + empty-list guard. Linear Vec scan
  (no HashSet allocation — status lists are small) and it consumes
  the resolved JIDs by value, so CompactString lives exactly once per
  kept recipient. 6 unit tests cover dedup, compactMap-style skipping,
  the empty-after-skip error path, and the ad-vs-bare own-LID case.
- New helper `Client::resolve_recipient_to_lid` mirrors
  `WAWebLidMigrationUtils.toUserLid` and is the single call site for
  PN→LID resolution in the status path (DRY).
- The sender-key-name derivation and `resolve_skdm_targets` now key
  off `own_lid` to match the stanza's addressing mode.

Error only when every resolved recipient is unresolvable — a single
LID-only contact no longer breaks the whole send.
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