Review of upstream PR #985 (oxidezap/whatsapp-rust) - #5
Conversation
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
Hi there 👋
The merge request introduces a These might need a close look
Worth checking
Small things (take or leave)
Review time: 4m 4s |
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:
RetryAdmissionhook so a quarantine can live in operator code, keeping the SDK's default path byte-for-byte WA Web.markForgetSenderKeynow 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:
WAWebHandleRetryRequestserializes per chat (onMessageQueue+sendMsgQueueMap), refuses pastMAX_RETRY, and otherwise processes every receipt. Its only gates are semantic (getMsgIfAuthorized→isRetryEligible:ALREADY_DELIVERED,CHANGED_IDENTITY,RECORD_MISSING,DEVICE_NOT_RECIPIENT,HIGH_RETRY_COUNT,MESSAGE_EXPIRED), never "this member is asking too often."GroupSkmsgJobmarksmarkHasSenderKey(x, M)over the wholeskDistribList(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.
RetryAdmissionhook (opt-in, zero overhead when unused)src/types/retry_admission.rs) mirroring the existingInboundDurabilityHookidiom (OnceLock, builder-lessClient::set_retry_admission). Object-safe, WASM-safe.admit(chat, requester, retry_count) -> bool: a gate never needs to wait, and a sync verdict cannot stall retry processing while thepending_retriesscopeguard is held.handle_retry_receipt, placed exactly where feat(retry): quarantine per-(chat, requester) inbound retry receipts oxidezap/whatsapp-rust#982 put its gate (afteris_peer, before the group-info fetch, the unknown-devicerotateKeyblock,markForgetSenderKey, key-bundle processing, and the resend). Own companion devices (is_peer) and all DMs never reach the policy.OnceLock::get().Marked … for fresh SKDMline drops frominfotodebug(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
markForgetSenderKeyflips one device's flag in the participant record in place. Ours invalidated the whole-groupsender_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).SenderKeyDeviceMapstoreshas_keyas anAtomicBooland carries anAtomicU64generation.SenderKeyDeviceCache::mark_forgottenflips 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.skdm_warm_memolets a warm repeat send skip the O(devices)filter_skdm_targetsscan; it now stores the map'sgenerationalongside the twoWeakArcs(Weak<ResolvedGroupDevices>, Weak<SenderKeyDeviceMap>, u64). An in-place cold flip keeps the sameArc, 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.What stays out of the core (vs oxidezap#982)
retry_mark_quarantine_capacity, theStatsSnapshotandMemoryReportfields, the lifecycle wiring, and any on-by-default behavior.Remaining follow-up (separate PR)
Close the
isRetryEligibleparity gap — WA Web tracks per-(message, recipient-device) delivery state and rejectsALREADY_DELIVERED/CHANGED_IDENTITY. We don't, so we can't.handle_retry_receiptnow 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:
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.from_db_rowsskips corrupt / partially-migrated JID rows without dropping the valid devices around them.!isMeDevice), and is a full no-op (no DB write, no generation bump) when every named device is our own.burst = 0disable, 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 --checkclean;cargo clippy -p whatsapp-rust --tests --examplesclean; 942 lib tests pass;examples/retry_quarantine.rsbuilds and its 5 unit tests pass.@Salientekill — would you try this in your 1000-member group? Register
RetryQuarantinefrom the example viaclient.set_retry_admission(...)with burst 2 / refill 2/day and confirm it bounds the storm the way your version did.