Skip to content

Review of upstream PR #985 (oxidezap/whatsapp-rust) - #5

Open
marchugon wants to merge 7 commits into
fr-review-base-pr985from
fr-review-head-pr985
Open

Review of upstream PR #985 (oxidezap/whatsapp-rust)#5
marchugon wants to merge 7 commits into
fr-review-base-pr985from
fr-review-head-pr985

Conversation

@marchugon

Copy link
Copy Markdown
Owner

FriendlyReviewer review of oxidezap/whatsapp-rust#985.


Original PR description:

Summary

A WA-Web-compliant, decoupled alternative to the inbound retry-receipt quarantine in oxidezap#982 (thanks @Salientekill for the diagnosis and production data that made this possible), plus the compliant efficiency win that helps every send by default.

Two things:

  1. An opt-in RetryAdmission hook so a quarantine can live in operator code, keeping the SDK's default path byte-for-byte WA Web.
  2. markForgetSenderKey now updates the sender-key-device cache in place, per device, matching WA Web, instead of invalidating the whole group. A generation stamp keeps the warm-send memo correct without any cross-cache invalidation.

Why not land oxidezap#982 as-is

Cross-checked against the captured WA Web bundle:

  • WA Web has no volume-based throttle on inbound retry receipts. WAWebHandleRetryRequest serializes per chat (onMessageQueue + sendMsgQueueMap), refuses past MAX_RETRY, and otherwise processes every receipt. Its only gates are semantic (getMsgIfAuthorizedisRetryEligible: ALREADY_DELIVERED, CHANGED_IDENTITY, RECORD_MISSING, DEVICE_NOT_RECIPIENT, HIGH_RETRY_COUNT, MESSAGE_EXPIRED), never "this member is asking too often."
  • The send side is faithful too: GroupSkmsgJob marks markHasSenderKey(x, M) over the whole skDistribList (failed devices included). So the "broken member retries every message" loop is WA Web's own design, and the per-receipt session rebuild is work WA Web does too. WA Web survives it only because a single-user client never sends at bot volume.

A per-(chat, requester) quarantine that drops eligible repair requests is a deliberate divergence, fine for a bot operator but not a default the library should impose.

1. RetryAdmission hook (opt-in, zero overhead when unused)

  • Trait (src/types/retry_admission.rs) mirroring the existing InboundDurabilityHook idiom (OnceLock, builder-less Client::set_retry_admission). Object-safe, WASM-safe.
  • Synchronous admit(chat, requester, retry_count) -> bool: a gate never needs to wait, and a sync verdict cannot stall retry processing while the pending_retries scopeguard is held.
  • Gate in handle_retry_receipt, placed exactly where feat(retry): quarantine per-(chat, requester) inbound retry receipts oxidezap/whatsapp-rust#982 put its gate (after is_peer, before the group-info fetch, the unknown-device rotateKey block, markForgetSenderKey, key-bundle processing, and the resend). Own companion devices (is_peer) and all DMs never reach the policy.
  • Zero overhead when unused: with no policy registered the check is a single OnceLock::get().
  • Log demotion: the per-receipt Marked … for fresh SKDM line drops from info to debug (a broken cohort emits tens of thousands/day; WA Web logs it at its verbose level).
  • examples/retry_quarantine.rs — a token-bucket quarantine keyed by (chat, requester), burst 2 / refill 2/day, built on the hook. This is @Salientekill's mechanism from feat(retry): quarantine per-(chat, requester) inbound retry receipts oxidezap/whatsapp-rust#982, now operator-owned and tunable.

2. In-place sender-key-device cache forget (compliant perf win, default)

WA Web's markForgetSenderKey flips one device's flag in the participant record in place. Ours invalidated the whole-group sender_key_device_cache, forcing the next send to re-read and re-parse every device row from the DB (a 1000+ device map re-materialized per send during a storm).

  • SenderKeyDeviceMap stores has_key as an AtomicBool and carries an AtomicU64 generation. SenderKeyDeviceCache::mark_forgotten flips only the touched devices cold in a live cache entry (no invalidation, no re-read), and bumps the generation once — only when a flag actually transitions warm→cold (swap(false)), so a retry storm of duplicate cold marks never churns it.
  • The warm-marking path still invalidates the group, since marking a device warm can introduce (user, device) pairs the cached map doesn't have yet.
  • The warm-send memo stays correct without a second invalidation. skdm_warm_memo lets a warm repeat send skip the O(devices) filter_skdm_targets scan; it now stores the map's generation alongside the two Weak Arcs (Weak<ResolvedGroupDevices>, Weak<SenderKeyDeviceMap>, u64). An in-place cold flip keeps the same Arc, so pointer identity alone can't see it — the generation can. The send path loads the generation before filtering (Acquire), so a flip racing in after that stamps the memo as already stale and the next send re-runs the filter. A single Acquire/Release generation pair carries the freshness (the same contract the device-registry generation gives membership), so there is no separate memo invalidation and therefore no cross-cache ordering window.
  • The DB write stays the source of truth; a cache miss rebuilds from it.

What stays out of the core (vs oxidezap#982)

retry_mark_quarantine_capacity, the StatsSnapshot and MemoryReport fields, the lifecycle wiring, and any on-by-default behavior.

Remaining follow-up (separate PR)

Close the isRetryEligible parity gap — WA Web tracks per-(message, recipient-device) delivery state and rejects ALREADY_DELIVERED / CHANGED_IDENTITY. We don't, so we can't. handle_retry_receipt now documents which reject reasons we already enforce (HIGH_RETRY_COUNT, MESSAGE_EXPIRED / RECORD_MISSING, DEVICE_NOT_IN_DATABASE) and which stay a known gap. Closing the rest needs a per-recipient receipt store (schema + hot-path writes per recipient), which is a substantial standalone feature and out of scope here.

Tests

Both the happy and the failure/edge paths are covered:

  • Cache generation — in-place flip advances the generation while keeping the same Arc; a duplicate-cold or absent-device mark must not advance it; a mixed batch flips every present device and bumps the generation exactly once; a cache miss is a no-op.
  • WA Web warm gate — a device is warm only when it and its primary (device 0) both hold the key; a cold or absent primary makes the whole user cold.
  • Malformed inputfrom_db_rows skips corrupt / partially-migrated JID rows without dropping the valid devices around them.
  • Own-device exclusion — a cold mark that names our own device plus a broken member flips only the member (WA Web !isMeDevice), and is a full no-op (no DB write, no generation bump) when every named device is our own.
  • Quarantine example — per-pair bounding and isolation, burst = 0 disable, fail-open past capacity, gradual token refill (recovery, not a reset), and the idle clamp that stops an idle pair banking an unbounded reserve.

cargo fmt --all --check clean; cargo clippy -p whatsapp-rust --tests --examples clean; 942 lib tests pass; examples/retry_quarantine.rs builds and its 5 unit tests pass.

@Salientekill — would you try this in your 1000-member group? Register RetryQuarantine from the example via client.set_retry_admission(...) with burst 2 / refill 2/day and confirm it bounds the storm the way your version did.

claude added 7 commits July 5, 2026 04:15
WhatsApp Web has no volume-based throttle on inbound retry receipts: it
serializes them per chat, refuses past MAX_RETRY, and otherwise processes
every one (markForgetSenderKey, key-bundle processing, resend). This SDK
mirrors that by default. Bot-scale senders in large groups can still hit a
repair-path storm from a cohort of members whose sessions never establish.

Rather than bake a quarantine into the core (on by default, diverging from
WA Web for everyone), expose a RetryAdmission trait mirroring the existing
InboundDurabilityHook idiom, so operators plug in their own policy. Unset
(the default) admits every receipt and keeps exact WA Web behavior; the
receive path then pays only one lock-free OnceLock::get, so there is zero
overhead when no policy is registered.

- src/types/retry_admission.rs: the trait (WASM-aware, object-safe).
- Client::set_retry_admission + OnceLock field; gate in handle_retry_receipt
  placed after is_peer and before all repair work (group-info fetch, the
  unknown-device rotateKey block, markForgetSenderKey, key-bundle processing,
  resend). Own companion devices (is_peer) and DMs never reach the policy.
- Demote the per-receipt "Marked for fresh SKDM" log to debug (a broken
  cohort emits tens of thousands/day; WA Web logs it at its verbose level).
- examples/retry_quarantine.rs: a token-bucket quarantine built on the hook,
  porting the (chat, requester) mechanism from oxidezap#982.

Supersedes the on-by-default quarantine in oxidezap#982 with a compliant, decoupled,
zero-overhead-when-unused alternative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WU3ojqdUcByaSGKDF4pdax
Addresses review feedback on the RetryAdmission hook and lands the first
compliant follow-up.

RetryAdmission:
- Make `admit` synchronous. A gate never needs to wait, and a sync verdict
  can't stall retry processing while the pending_retries scopeguard is held
  (raised by CodeRabbit + cubic). Drops async_trait from the trait, call
  site, example, and reexport test.
- Tighten the gate comment and the client field doc to explain why, not what
  (raised by Greptile + cubic, per AGENTS.md).

In-place sender-key-device cache forget (follow-up):
- `markForgetSenderKey` on WA Web flips one device's sender-key flag in the
  participant record in place. Ours invalidated the whole-group
  `sender_key_device_cache`, forcing the next send to re-read and re-parse
  every device row from the DB. Under a retry storm that re-materializes a
  1000+ device map on every send.
- Store `has_key` as an `AtomicBool` in `SenderKeyDeviceMap` and add
  `SenderKeyDeviceCache::mark_forgotten`, which flips only the touched
  devices cold in a live cache entry (no invalidation, no re-read). The
  warm-marking path still invalidates, since it can introduce new device
  entries. The DB write stays the source of truth; a cache miss rebuilds
  from it. Adds unit tests for the in-place flip and the miss no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WU3ojqdUcByaSGKDF4pdax
Make the retry-eligibility parity explicit: WA Web authorizes inbound
retries via isRetryEligible (WAWebApiMessageInfoStore). We already enforce
the reject reasons that need no per-recipient state — HIGH_RETRY_COUNT
(MAX_RETRY_COUNT), MESSAGE_EXPIRED / RECORD_MISSING (recent-message cache
miss), DEVICE_NOT_IN_DATABASE (should_drop_unknown_device_retry) — and
handle identity changes during repair. ALREADY_DELIVERED and
DEVICE_NOT_RECIPIENT need a per-(message, device) receipt store we do not
keep, so they are documented as a known parity gap rather than left implicit.
No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WU3ojqdUcByaSGKDF4pdax
Adversarial-review catch. The in-place mark_forgotten flip (has_key=false)
mutates the AtomicBool inside the cached SenderKeyDeviceMap without swapping
the Arc. But skdm_warm_memo skips filter_skdm_targets when the cached_map
Arc is pointer-identical (its documented invariant: "both Arcs swap on any
warm-state change, so a stale skip is impossible"). Since the in-place flip
does not swap the Arc, a warm-memoized group would short-circuit past the
now-cold device and never redistribute its SKDM — so a member that sent a
retry receipt (the exact case repair should fix) stays starved until the
cached_map is evicted by TTI. group_devices_memo_enabled is true by default.

Fix: drop the group's skdm_warm_memo entry on the cold branch so the next
send re-runs filter_skdm_targets and re-targets the cold device. The
warm-marking path still invalidates the whole cache (Arc swaps), so it needs
no memo drop. Regression test asserts the cold mark clears the memo.

937 lib tests pass; fmt/clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WU3ojqdUcByaSGKDF4pdax
…ate patch)

Replaces the racy "in-place flip + explicit skdm_warm_memo.invalidate" with a
version counter, resolving every open review finding at once and matching the
freshness pattern the device-registry memo already uses.

Root cause: skdm_warm_memo skips filter_skdm_targets when the (devices,
cached_map) Arc pair is pointer-identical. An in-place cold flip keeps the same
Arc, so pointer identity cannot see it. The previous fix dropped the memo
entry, but that was two non-atomic cache ops (a lock-free warm send could
observe the stale memo between them; flagged by CodeRabbit + cubic) plus a
silent group_jid re-parse (flagged by Greptile).

Fix: SenderKeyDeviceMap carries a monotonic `generation` (AtomicU64) bumped by
mark_forgotten on each in-place flip. resolve_skdm_targets_memoized loads the
generation BEFORE filtering and stores it in the memo; the warm-skip now also
requires the generation to match, so an in-place flip is detected without
swapping the Arc. The cold path no longer touches the memo at all — no parse,
no cross-cache ordering window. Only the benign one-send window inherent to any
lock-free warm memo remains (self-heals on the next send), identical to the
original whole-group invalidate.

Keeps the in-place O(1) flip (WA Web parity, no DB re-read), no lost-update
(independent atomics), no added per-send scan. No public API / breaking change.
Tests: cold mark advances the generation and flips the device; a no-op mark
does not bump it. 937 lib tests pass; fmt/clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WU3ojqdUcByaSGKDF4pdax
Use swap(false) so a duplicate retry for a device already marked cold no
longer advances the generation. Otherwise a retry storm would churn the
warm memo with no-op misses on every subsequent send.
Add scenario tests that exercise behavior the earlier suite skipped:

- token bucket refills gradually (recovery, not reset) and clamps at burst
  after a long idle, so the example's core rate claims are pinned
- WA Web warm gate matrix: warm requires the device AND its primary; a cold
  or absent primary makes the whole user cold
- from_db_rows skips malformed/corrupt JID rows without dropping valid ones
- mark_forgotten flips a mixed batch and bumps the generation exactly once
- cold mark excludes our own devices (WA Web !isMeDevice) and is a full no-op
  when every named device is our own
@friendlyreviewer-staging

friendlyreviewer-staging Bot commented Jul 5, 2026

Copy link
Copy Markdown

Hi there 👋

🌥️ Tech
1 high, 2 medium, 2 low

The merge request introduces a RetryAdmission trait and its integration into the retry handling pipeline, along with sender-key device cache improvements. Overall, the design is solid: the trait is object-safe, the OnceLock-based dispatch is zero-overhead when unset, the gate placement in handle_retry_receipt is correct, and the public API re-exports are complete. The example implementation of a token-bucket quarantine is well-tested. However, a substantive race window was identified in the warm-memo generation stamp for SKDM filtering: the cached generation is loaded before an async yield, allowing a concurrent mark_forgotten to be missed until the next send. Additionally, minor issues include a redundant cache invalidation, missing edge-case test, a suboptimal clone, and a documentation gap for relaxed loads in the non-memoized path. The log demotion from info to debug is correctly applied. Addressing the race window is recommended to align documentation with implementation.


These might need a close look

  • 🔴 src/send/mod.rs (L1097)
    Race window in warm-memo generation stamp: cached_map_gen is loaded before an async call (resolve_group_devices_memoized), so a concurrent mark_forgotten during that gap is not detected. The memo check may incorrectly skip filter_skdm_targets, causing one missed SKDM per racing mark. Update the documentation to accurately describe the best-effort stamp or reload the generation after the async gap.

Worth checking

  • 🟡 src/send/mod.rs (L1138)
    Double invalidation of sender_key_device_cache: update_sender_key_devices calls set_sender_key_status_for_devices which already invalidates the cache, then issues the same invalidation again. Remove the redundant invalidation or restructure to avoid the extra async operation.
  • 🟡 src/sender_key_device_cache.rs (L94)
    Documentation gap for relaxed loads in device_and_primary_warm: the function uses Ordering::Relaxed which is correct when preceded by an Acquire load of generation(), but the non-memoized path in filter_skdm_targets does not perform such an acquire. Document the caller responsibility or acknowledge the relaxed consistency.

Small things (take or leave)

  • 🔵 src/sender_key_device_cache.rs (L192)
    No test coverage for empty devices slice in mark_forgotten. Adding a test for mark_forgotten(group, &[]) would document that the function correctly skips the loop and does not bump the generation.
  • 🔵 src/client/sender_keys.rs (L75)
    Redundant Vec<Jid> clone in the cold-mark branch: kept could be collected as Vec<Jid> directly from the filter to avoid an extra allocation and clone. Minor optimization opportunity.

Review time: 4m 4s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants