Skip to content

feat(retry): quarantine per-(chat, requester) inbound retry receipts - #982

Closed
Salientekill wants to merge 4 commits into
oxidezap:mainfrom
Salientekill:feat/retry-receipt-quarantine
Closed

feat(retry): quarantine per-(chat, requester) inbound retry receipts#982
Salientekill wants to merge 4 commits into
oxidezap:mainfrom
Salientekill:feat/retry-receipt-quarantine

Conversation

@Salientekill

Copy link
Copy Markdown
Contributor

Problem

In large groups, a cohort of members whose pairwise sessions never establish (dead registrations, exhausted prekeys, re-registered LIDs) sends a retry receipt for every single group message. Each receipt pays markForgetSenderKey (a DB write + a whole-group sender-key cache invalidation), bundle processing and possibly a resend — all upstream of the per-chat resend cap (#871), which only bounds the resend itself.

Production evidence (bot in a 1012-participant group, 3.5 days of debug logs):

  • 58,554 Marked <jid> for fresh SKDM ... due to retry receipt lines — 99.7% from this one group, coming from 468 distinct members (~68 marks/member/day ≈ one per bot message: they fail on every message, i.e. their sessions never repair).
  • The per-chat cap blocked ~75% of the resends (23k+19k+1k throttled) — but none of the mark/bundle work, which runs before the cap check in handle_retry_receipt.
  • Related root cause on the send side: after the server ACK the whole SKDM target set is marked has_key=true, including devices whose distribution failed (406 / no bundle) — wacore/src/send/group.rs — relying on the retry-receipt path to repair, which produces a permanent warm/cold oscillation for this cohort. (A follow-up PR could add a per-device re-target cooldown; this PR bounds the receipt side.)

Mechanism

RetryMarkQuarantine: a token bucket keyed by (chat user, requester user) — burst 2, refill 2/day. One mark is enough to repair a healthy member (the next send carries the SKDM), so past the burst, further receipts from the same pair are dropped before any repair work; the refill keeps genuine recovery possible. Device is excluded from the key on purpose: WA Web re-targets the whole user when the primary goes cold, so all devices of a broken account share one budget.

With the observed numbers this cuts the repair-path work from ~16.7k/day to ~470×3/day worst case (~97%), while leaving first-time repairs untouched.

Implementation

  • Reuses the existing TokenBucket + capacity-only Cache machinery from the resend limiter (lazy monotonic refill, no timers, allocation only on miss).
  • Gate placed in handle_retry_receipt right before update_local_signal_session, after the cheap early-outs and the concurrency guard; early return releases the pending-retry scopeguard normally.
  • burst = 0 disables; live-tunable via Client::set_retry_mark_quarantine(burst, refill_per_day).
  • Observability: StatsSnapshot.retry_receipts_quarantined + retry_mark_quarantine_pairs in MemoryDiagnostics.
  • Unit test covers per-pair bounding, pair isolation and the disable knob.

Relation to #924: that PR bounds outbound retry receipts (our own decrypt failures); this one bounds the inbound direction, which is where the group-storm cost actually lands.

In large groups a cohort of members whose pairwise sessions never
establish (dead registrations, exhausted prekeys, re-registered LIDs)
sends a retry receipt for every single group message. Each receipt pays
markForgetSenderKey (a DB write plus a whole-group sender-key cache
invalidation), bundle processing and possibly a resend - all upstream of
the per-chat resend cap (oxidezap#871), which only bounds the resend itself.

Observed in production: ~58k "Marked ... for fresh SKDM" in 3.5 days
from 468 distinct members of a single 1012-participant group (~99.7% of
all marks), with the per-chat cap blocking ~75% of the resends but none
of the repair work.

This bounds the whole repair path per member with a token bucket keyed
by (chat user, requester user): burst 2, refill 2/day. One mark is
enough to repair a healthy member (the next send carries the SKDM), so
past the burst further receipts from the same pair are dropped before
any work happens; the refill keeps genuine recovery possible. Device is
excluded from the key on purpose - WA Web re-targets the whole user when
the primary goes cold, so all devices of a broken account share one
budget.

- RetryMarkQuarantine reuses the existing TokenBucket/Cache machinery
  from the resend limiter (lazy monotonic refill, capacity-only cache)
- burst 0 disables; live-tunable via Client::set_retry_mark_quarantine
- observability: StatsSnapshot.retry_receipts_quarantined counter and
  retry_mark_quarantine_pairs in MemoryDiagnostics
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: adad1a26-1894-42f5-a97a-105d6238c20b

📥 Commits

Reviewing files that changed from the base of the PR and between 3e6e8f7 and 32056d6.

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

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added per-chat/per-requester quarantine limiting for group and status-broadcast retry receipts, with optional burst/refill tuning.
    • Introduced a new cache setting to cap quarantine tracking (including updated defaults and debug output).
    • Expanded session stats and memory reporting with quarantined retry receipt counts and quarantine pair tracking.
  • Bug Fixes
    • Retry receipts that exceed the quarantine budget are dropped earlier, avoiding unnecessary downstream handling.
  • Tests
    • Added coverage for burst limiting, requester isolation, and concurrent acquire behavior.

Walkthrough

This PR adds a per-(chat, requester) retry-receipt quarantine, wires it into client setup and retry handling, and exports quarantine counters through stats and memory reporting.

Changes

Retry receipt quarantine

Layer / File(s) Summary
RetryMarkQuarantine limiter implementation
src/resend_rate_limiter.rs
Adds the quarantine limiter, default burst/refill constants, and tests for per-pair isolation, disable behavior, and concurrent enforcement.
Client construction and field wiring
src/client.rs, src/client/lifecycle.rs, src/client/accessors.rs, src/retry.rs
Adds the client quarantine field, initializes it during client construction, exposes live retuning, and gates retry receipt handling on the quarantine check.
Stats, memory, and config exposure
wacore/src/stats.rs, src/client.rs, src/client/accessors.rs, src/cache_config.rs
Adds quarantine counters to stats and memory reporting, updates the memory display output, and adds the backing cache capacity setting and default.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Source as Retry receipt source
    participant Handler as handle_retry_receipt
    participant Quarantine as RetryMarkQuarantine
    participant Repair as session repair/resend path

    Source->>Handler: group/status retry receipt
    Handler->>Quarantine: try_acquire(chat, requester)
    alt permit granted
        Quarantine-->>Handler: true
        Handler->>Repair: continue repair/resend logic
        Repair-->>Handler: result
    else throttled
        Quarantine-->>Handler: false
        Handler-->>Source: Ok(()) with debug log
    end
Loading

Possibly related PRs

Suggested labels: api-design

Suggested reviewers: cubic-dev-ai, greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: quarantining inbound retry receipts per chat/requester pair.
Description check ✅ Passed The description is directly about the same quarantine mechanism and matches the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown

Greptile Summary

Adds a per-(chat, requester) token-bucket quarantine (RetryMarkQuarantine) that drops inbound group retry receipts past a small burst before any repair work executes — protecting the group-info fetch, rotateKey DB write, and markForgetSenderKey cache invalidation from being driven at full storm rate by broken members. The implementation cleanly reuses the existing TokenBucket/Cache machinery from ResendRateLimiter.

  • New RetryMarkQuarantine struct in resend_rate_limiter.rs with burst-2 / 2-per-day defaults, a dedicated 32,768-entry cache, and live-retunable rate via Client::set_retry_mark_quarantine; companion devices (is_peer) are exempt from the gate.
  • Observability wired end-to-end: StatsSnapshot.retry_receipts_quarantined counter and MemoryReport.retry_mark_quarantine_pairs entry count; unit and multi-threaded concurrency tests added.

Confidence Score: 5/5

Safe to merge — the quarantine gate is well-placed, companion-device exemption is explicit, and the new cache uses a dedicated capacity field sized for the observed storm.

All changed paths are additive rate-limiting with a disable knob (burst=0); the gate correctly sits after the message-cache early-out (so expired messages don't consume budget) and before all expensive repair work. The concurrency model mirrors the proven ResendRateLimiter pattern and is exercised by a multi-threaded contention test. The only findings are style nits.

src/retry.rs — minor inefficiency where resolve_encryption_jid runs before the gate for quarantined pairs, and inline comment verbosity.

Important Files Changed

Filename Overview
src/resend_rate_limiter.rs Adds RetryMarkQuarantine struct reusing the existing TokenBucket/Cache machinery, with two new constants, correct set_rate/try_acquire/observability methods, and thorough unit + concurrency tests.
src/retry.rs Inserts the quarantine gate after the message-cache lookup and the is_peer check; correctly protects the group-info fetch, rotateKey path, and markForgetSenderKey. Minor inefficiency: resolve_encryption_jid still runs before the gate for all quarantined pairs, though resolved_jid is not consumed until after it. Inline comment violates the project's explain-why-not-what style rule.
src/cache_config.rs Adds a dedicated retry_mark_quarantine_capacity field (default 32,768) with correct wiring into Debug and Default; capacity comment accurately explains the O(groups x storm-members) key space.
src/client.rs Adds retry_mark_quarantine: RetryMarkQuarantine field to Client and retry_mark_quarantine_pairs: u64 to MemoryReport with correct Display formatting.
src/client/accessors.rs Adds set_retry_mark_quarantine public tuning method and wires retry_receipts_quarantined into the stats snapshot and retry_mark_quarantine_pairs into the memory report.
src/client/lifecycle.rs Initializes retry_mark_quarantine with the dedicated retry_mark_quarantine_capacity config field and the two new default constants; previous concern about shared capacity no longer applies.
wacore/src/stats.rs Adds retry_receipts_quarantined: u64 to StatsSnapshot with a correct zero initializer in SessionStats::snapshot().

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[handle_retry_receipt] --> B{retry_count >= MAX?}
    B -- yes --> Z1[drop]
    B -- no --> C[pending_retries dedup check]
    C -- duplicate in-flight --> Z2[drop]
    C -- new --> D[has_device? schedule_unknown_device_sync if not]
    D --> E[message cache lookup]
    E -- not found --> Z3[drop]
    E -- found --> F[resolve_encryption_jid]
    F --> G[should_drop_unknown_device_retry?]
    G -- yes --> Z4[drop]
    G -- no --> H[compute is_peer]
    H --> I{is_group_or_status and not is_peer?}
    I -- no --> J[proceed]
    I -- yes --> K[RetryMarkQuarantine.try_acquire]
    K -- token available --> J
    K -- budget exhausted --> Z5[quarantine drop retry_receipts_quarantined++]
    J --> L[fetch group info]
    L --> M{rotateKey path unknown participant?}
    M -- yes --> N[clear_sender_key_devices DB write + cache invalidate]
    N --> O[update_local_signal_session markForgetSenderKey + processKeyBundle]
    M -- no --> O
    O --> P[resend_rate_limiter.try_acquire]
    P -- throttled --> Z6[drop resend]
    P -- allowed --> Q[resend message]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[handle_retry_receipt] --> B{retry_count >= MAX?}
    B -- yes --> Z1[drop]
    B -- no --> C[pending_retries dedup check]
    C -- duplicate in-flight --> Z2[drop]
    C -- new --> D[has_device? schedule_unknown_device_sync if not]
    D --> E[message cache lookup]
    E -- not found --> Z3[drop]
    E -- found --> F[resolve_encryption_jid]
    F --> G[should_drop_unknown_device_retry?]
    G -- yes --> Z4[drop]
    G -- no --> H[compute is_peer]
    H --> I{is_group_or_status and not is_peer?}
    I -- no --> J[proceed]
    I -- yes --> K[RetryMarkQuarantine.try_acquire]
    K -- token available --> J
    K -- budget exhausted --> Z5[quarantine drop retry_receipts_quarantined++]
    J --> L[fetch group info]
    L --> M{rotateKey path unknown participant?}
    M -- yes --> N[clear_sender_key_devices DB write + cache invalidate]
    N --> O[update_local_signal_session markForgetSenderKey + processKeyBundle]
    M -- no --> O
    O --> P[resend_rate_limiter.try_acquire]
    P -- throttled --> Z6[drop resend]
    P -- allowed --> Q[resend message]
Loading

Reviews (4): Last reviewed commit: "fix(retry): exempt own companion devices..." | Re-trigger Greptile

Comment thread src/client/lifecycle.rs
Comment thread src/retry.rs Outdated
Comment thread src/resend_rate_limiter.rs

@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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/lifecycle.rs`:
- Around line 197-202: `RetryMarkQuarantine` is currently reusing
`resend_rate_limiter_capacity`, which couples it to the per-chat bucket budget.
Add a dedicated capacity setting for the quarantine cache in the
lifecycle/config path, wire it through `Lifecycle` initialization, and use that
new knob when constructing `RetryMarkQuarantine::new` instead of
`cache_config.resend_rate_limiter_capacity`. Keep the existing burst/refill
settings unchanged and update the relevant config structure/constructor so the
quarantine capacity can be tuned independently.

In `@src/resend_rate_limiter.rs`:
- Around line 231-252: Add a multi-threaded concurrency test for
RetryMarkQuarantine similar to the existing ResendRateLimiter contention test.
In the tests module for RetryMarkQuarantine, create a tokio multi_thread test
that spawns many concurrent try_acquire calls against the same Jid pair using
Arc and an atomic counter, then assert the allowed count never exceeds the
configured burst. Use the existing RetryMarkQuarantine::new and try_acquire
symbols so the race behavior is verified under real contention.

In `@src/retry.rs`:
- Around line 412-431: Move the quarantine gate earlier in the retry handling
flow so quarantined (chat, requester) pairs skip the expensive work before it
runs. The current check on retry_mark_quarantine only bypasses
update_local_signal_session, while query_info() and the unknown-device rotation
path still execute; refactor the surrounding logic so the try_acquire() guard in
this retry handler returns before those DB/cache operations, or adjust the
comment if the intended scope is only the later session update.
🪄 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 (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a597b675-2074-4431-981f-31353d651cbc

📥 Commits

Reviewing files that changed from the base of the PR and between 882d1f8 and c9b0430.

📒 Files selected for processing (6)
  • src/client.rs
  • src/client/accessors.rs
  • src/client/lifecycle.rs
  • src/resend_rate_limiter.rs
  • src/retry.rs
  • wacore/src/stats.rs

Comment thread src/client/lifecycle.rs
Comment thread src/resend_rate_limiter.rs
Comment thread src/retry.rs Outdated

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 6 files

Confidence score: 3/5

  • In src/resend_rate_limiter.rs (try_acquire), allocating two Strings per receipt before cache lookup adds allocator churn on every retry event, including cache hits; during receipt storms this can raise CPU/latency and blunt the limiter’s intended protection — avoid per-call allocations (e.g., reuse/borrow keys) before merging.
  • In src/client/lifecycle.rs, RetryMarkQuarantine appears keyed by (chat, requester) but initialized from resend_rate_limiter_capacity (typically per-chat), which can evict active pair buckets in large-group storms and let retries slip through sooner than intended — size this cache for pair cardinality or split capacity settings before merging.
  • src/resend_rate_limiter.rs currently has only single-threaded coverage for the new quarantine limiter, so concurrent acquires for the same (chat, requester) remain unverified and could hide burst-enforcement regressions — add a multi-threaded contention test to de-risk behavior under load.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/resend_rate_limiter.rs">

<violation number="1" location="src/resend_rate_limiter.rs:202">
P2: `try_acquire` allocates two `String`s on every inbound retry receipt before the cache lookup. In the high-volume receipt storms this feature targets, that introduces per-receipt allocator churn even on cache hits, contradicting the 'allocation only on miss' design principle applied to the nearby `ResendRateLimiter`. Consider structuring the cache lookup so the key can be passed by reference (e.g., using a borrowed key type or equivalent lookup) to avoid repeated allocations for the same (chat, requester) pair.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

let burst = burst as f64;
let refill_per_sec = self.refill_per_day.load(Ordering::Relaxed) as f64 / 86_400.0;

let key = (chat.user.to_string(), requester.user.to_string());

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: try_acquire allocates two Strings on every inbound retry receipt before the cache lookup. In the high-volume receipt storms this feature targets, that introduces per-receipt allocator churn even on cache hits, contradicting the 'allocation only on miss' design principle applied to the nearby ResendRateLimiter. Consider structuring the cache lookup so the key can be passed by reference (e.g., using a borrowed key type or equivalent lookup) to avoid repeated allocations for the same (chat, requester) pair.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/resend_rate_limiter.rs, line 202:

<comment>`try_acquire` allocates two `String`s on every inbound retry receipt before the cache lookup. In the high-volume receipt storms this feature targets, that introduces per-receipt allocator churn even on cache hits, contradicting the 'allocation only on miss' design principle applied to the nearby `ResendRateLimiter`. Consider structuring the cache lookup so the key can be passed by reference (e.g., using a borrowed key type or equivalent lookup) to avoid repeated allocations for the same (chat, requester) pair.</comment>

<file context>
@@ -137,11 +137,120 @@ impl ResendRateLimiter {
+        let burst = burst as f64;
+        let refill_per_sec = self.refill_per_day.load(Ordering::Relaxed) as f64 / 86_400.0;
+
+        let key = (chat.user.to_string(), requester.user.to_string());
+        let bucket = self
+            .buckets
</file context>

Comment thread src/client/lifecycle.rs Outdated
Comment thread src/resend_rate_limiter.rs
- cache_config.retry_mark_quarantine_capacity (default 32768): the
  quarantine keyspace is O(groups x broken members), not O(chats), and
  evicting an ACTIVE pair refunds its burst - sharing the resend
  limiter's per-chat capacity (4096) undersized it (greptile P1)
- gate moved before the group-info fetch and the rotateKey path, so a
  quarantined pair no longer pays query_info nor the unknown-sender
  rotateKey (own sender-key deletion + whole-group invalidation) at
  full rate; still after the message-cache lookup so expired-message
  no-ops don't burn the repair budget (greptile P2)
- documented the owned-key allocation tradeoff in try_acquire
@Salientekill

Copy link
Copy Markdown
Contributor Author

Addressed the review in the latest commit:

  • Capacity (P1): added a dedicated cache_config.retry_mark_quarantine_capacity (default 32768). Agreed the keyspace is O(groups × broken members) and that evicting an active pair refunds its burst — the doc on the field now states exactly that.
  • rotateKey before the gate (P2): the gate now sits right after the message-cache lookup and before the group-info fetch and the rotateKey block, so a quarantined pair no longer pays query_info nor the unknown-sender rotateKey (own sender-key deletion + whole-group invalidation) at full rate. Kept it after the cache lookup so receipts for expired messages (cheap no-op today) don't burn a pair's repair budget.
  • Key allocation (P2): documented the tradeoff at the call site — two small strings once per retry receipt (not per message), and when it quarantines it replaces a DB write + whole-group cache invalidation. No Borrow<(&str, &str)> for (String, String) makes a by-ref lookup impractical without a custom key type; happy to revisit if you prefer one.

@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)
src/resend_rate_limiter.rs (1)

100-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

We're literally running the same rate-limiter code twice — let's connect these, not duplicate them.

RetryMarkQuarantine::try_acquire (Lines 194-222) is nearly a byte-for-byte copy of ResendRateLimiter::try_acquire (Lines 100-127): same burst-check short-circuit, same get-or-create-bucket dance, same lock+try_take+throttled_total bump. Only the key type (Jid vs (String, String)) and the refill divisor (60.0 vs 86,400.0) differ. Two copies of concurrency-sensitive token-bucket logic means two places that can silently drift out of sync the next time someone tunes this. At our scale, that's not a hypothetical — it's a "why did burst=0 behave differently in prod" bug waiting to happen.

Consider parameterizing a single generic TokenBucketLimiter<K> over the key type and refill-seconds divisor, then have both ResendRateLimiter and RetryMarkQuarantine wrap it.

♻️ Sketch of a shared generic limiter
-pub(crate) async fn try_acquire(&self, chat: &Jid) -> bool {
-    let burst = self.burst.load(Ordering::Relaxed);
-    if burst == 0 { return true; }
-    ...
-}
+struct TokenBucketLimiter<K: Hash + Eq + Clone + Send + Sync + 'static> {
+    buckets: Cache<K, Arc<Mutex<TokenBucket>>>,
+    burst: AtomicU32,
+    refill_per_window: AtomicU32,
+    window_secs: f64,
+    throttled_total: AtomicU64,
+}
+
+impl<K: Hash + Eq + Clone + Send + Sync + 'static> TokenBucketLimiter<K> {
+    async fn try_acquire_with(&self, key: K) -> bool { /* shared logic once */ }
+}

Also applies to: 174-233

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resend_rate_limiter.rs` around lines 100 - 127, The rate-limiter logic is
duplicated between ResendRateLimiter::try_acquire and
RetryMarkQuarantine::try_acquire, so refactor the shared token-bucket flow into
a single reusable limiter. Introduce a generic TokenBucketLimiter<K> (or
equivalent shared helper) that encapsulates the burst check, get-or-create
bucket, mutex-protected try_take, and throttled_total update, and parameterize
only the key type and refill-per-second calculation. Then have both try_acquire
methods delegate to that shared implementation instead of maintaining two
copies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/resend_rate_limiter.rs`:
- Around line 100-127: The rate-limiter logic is duplicated between
ResendRateLimiter::try_acquire and RetryMarkQuarantine::try_acquire, so refactor
the shared token-bucket flow into a single reusable limiter. Introduce a generic
TokenBucketLimiter<K> (or equivalent shared helper) that encapsulates the burst
check, get-or-create bucket, mutex-protected try_take, and throttled_total
update, and parameterize only the key type and refill-per-second calculation.
Then have both try_acquire methods delegate to that shared implementation
instead of maintaining two copies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f0e5f488-4203-49c8-a2e8-8cb3d78b3ed4

📥 Commits

Reviewing files that changed from the base of the PR and between c9b0430 and ed47284.

📒 Files selected for processing (4)
  • src/cache_config.rs
  • src/client/lifecycle.rs
  • src/resend_rate_limiter.rs
  • src/retry.rs

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

Mirrors the ResendRateLimiter contention test: 40 concurrent receipts
for the same (chat, requester) pair, asserting exactly burst pass and
the rest are throttled — proving burst enforcement holds under the
concurrent-receipt storm the quarantine guards (coderabbit/cubic).
@Salientekill

Copy link
Copy Markdown
Contributor Author

Second round — thanks @coderabbitai and @cubic-dev-ai:

  • Concurrency test (coderabbit resend_rate_limiter.rs:256, cubic P3): added quarantine_concurrent_acquires_for_one_pair_do_not_exceed_burst — 40 concurrent receipts for the same pair, asserts exactly burst pass and the rest are throttled, mirroring the existing ResendRateLimiter contention test. Agreed this needs to be proven, not assumed.
  • Capacity (cubic P2): same fix as greptile's P1 — dedicated retry_mark_quarantine_capacity (default 32768) landed in the previous commit.
  • Key allocation (cubic P2): kept as documented rather than resolved, and here's the reasoning I'd like a maintainer to weigh in on. This runs once per retry receipt, not per message. Even at the pathological volume that motivated the PR (58k marks / 3.5 days) that's ~0.19 receipts/s → two small-string allocations at that rate, and only on the path that replaces a DB write + whole-group cache invalidation. A borrowed-key lookup isn't drop-in ((String,String): !Borrow<(&str,&str)>); a custom key newtype would add real complexity for allocator traffic that's orders of magnitude below the hot path. Happy to add the newtype if you'd prefer the consistency with ResendRateLimiter, but my read is it's not worth it here. Open to your call.

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Second round — thanks @coderabbitai and @cubic-dev-ai:

  • Concurrency test (coderabbit resend_rate_limiter.rs:256, cubic P3): added quarantine_concurrent_acquires_for_one_pair_do_not_exceed_burst — 40 concurrent receipts for the same pair, asserts exactly burst pass and the rest are throttled, mirroring the existing ResendRateLimiter contention test. Agreed this needs to be proven, not assumed.
  • Capacity (cubic P2): same fix as greptile's P1 — dedicated retry_mark_quarantine_capacity (default 32768) landed in the previous commit.
  • Key allocation (cubic P2): kept as documented rather than resolved, and here's the reasoning I'd like a maintainer to weigh in on. This runs once per retry receipt, not per message. Even at the pathological volume that motivated the PR (58k marks / 3.5 days) that's ~0.19 receipts/s → two small-string allocations at that rate, and only on the path that replaces a DB write + whole-group cache invalidation. A borrowed-key lookup isn't drop-in ((String,String): !Borrow<(&str,&str)>); a custom key newtype would add real complexity for allocator traffic that's orders of magnitude below the hot path. Happy to add the newtype if you'd prefer the consistency with ResendRateLimiter, but my read is it's not worth it here. Open to your call.

@Salientekill Looks good across the board. A few thoughts:

Concurrency test — solid addition. Mirroring the existing ResendRateLimiter contention test is exactly the right pattern here, and 40 concurrent tasks hitting the same pair proves the Arc<Mutex<TokenBucket>> + get_with single-flight combo holds under the exact storm pattern you're guarding against.

Capacity — the dedicated 32768 default and the doc explaining the O(groups × broken members) keyspace and eviction risk are well done. A clear upgrade from sharing the resend limiter's 4096.

Key allocation — I buy your reasoning. The tradeoff is cleanly stated (0.19 receipts/s vs. a DB write + whole-group invalidation), and (String, String): !Borrow<(&str, &str)> is a real friction point. A custom key newtype would add ceremony for negligible benefit at this throughput. The comment at the call site is sufficient documentation of the tradeoff. No change needed from my side.

One thing to consider: greptile's point about is_peer not being excluded from the quarantine gate is worth a look. If companion device retry receipts are expected to repair independently of the primary's budget in the same group, you'd want:

if is_group_or_status && !is_peer && !self.retry_mark_quarantine.try_acquire(...).await

Worth deciding whether companion devices share or have their own budget, and either adding the guard or documenting why sharing is intentional.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd resend_rate_limiter.rs

Length of output: 203


🏁 Script executed:

#!/bin/bash
rg -n "quarantine_concurrent_acquires_for_one_pair_do_not_exceed_burst" -A 40 src/resend_rate_limiter.rs

Length of output: 1828


@Salientekill — pulled the test and ran it against the diff. quarantine_concurrent_acquires_for_one_pair_do_not_exceed_burst (src/resend_rate_limiter.rs, lines 259-290) does exactly what it needs to: 40 concurrent tasks, burst=5/refill=0, asserts 5 allowed / 35 throttled. That's proof under contention, not a hand-wave. Good, that's what I want to see — we don't ship rate limiters on vibes.

Capacity fix confirmed from the prior round, no further comment there.

On the key allocation call — I'll weigh in since you asked. Your math checks out: ~0.19 receipts/s even at the pathological volume that motivated this PR, two small string allocs, and only on the branch that's already replacing a DB write plus whole-group cache invalidation. That's not a hot path, it's a rare-event path with a much bigger cost already baked in. Building a custom key newtype purely to enable a borrowed lookup here would be optimizing something that isn't the bottleneck — that's the kind of complexity-for-complexity's-sake I don't want in the codebase. Keep it as documented. If someone profiles this in production and it actually shows up, that's the time to revisit, not now.

Nice work closing the loop on all three points.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

Comment thread src/retry.rs
is_peer (our own account's secondary devices) was subject to the
quarantine gate: a companion that was offline or rotated keys can
legitimately need many group-session repair cycles, and dropping those
after the burst would block group decryption on our own device. The
storm this guards is third-party members, never our own. (greptile)
@Salientekill

Copy link
Copy Markdown
Contributor Author

Good catch @greptile-apps — that was a real regression. Fixed in 32056d6: added && !is_peer to the gate so our own companion devices are exempt from the quarantine. A secondary of our own account that was offline or rotated keys can legitimately need many repair cycles to rebuild its group session; the storm this guards is third-party members, never our own devices. is_peer was already computed just above the gate, so it's a one-line exemption.

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown

The fix is correctly applied — the updated gate in the PR diff already reads:

if is_group_or_status
    && !is_peer
    && !self
        .retry_mark_quarantine
        .try_acquire(&info.chat, &info.requester)
        .await

The exemption is the right call. is_peer already captures the exact semantic needed here (same account, different device), and placing the guard before the try_acquire call means companion devices never burn from the pair budget at all — not just that they bypass quarantine when already throttled.

The inline comment explaining the exemption rationale is also clear and sufficient. The regression is fully addressed. No further changes needed on this.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

jlucaso1 commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the very thorough work here @Salientekill, and especially for the production data. The diagnosis is correct and the engineering (token bucket reuse, the contention test, the is_peer exemption, the observability) is clean.

I want to be transparent about where I landed. I cross-checked the whole retry path against the captured WA Web bundle, and the blocker for me is parity rather than code quality:

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

So a per-(chat, requester) quarantine that drops eligible repair requests is a deliberate divergence from WA Web. That's a perfectly reasonable trade-off for a bot at your scale, but not something I want the library doing for everyone by default.

Rather than reject the idea, I opened #985 as a compliant, decoupled version: an opt-in RetryAdmission hook (mirroring the existing InboundDurabilityHook pattern) with zero overhead when unused (one OnceLock::get on the receive path). Your quarantine keeps everything that made it good — the token bucket, burst/refill tuning, the is_peer exemption, your own counters — and moves into operator code. I ported it as examples/retry_quarantine.rs so nothing is lost and the next person with a 1000-member group has a ready blueprint. The core stays byte-for-byte WA Web on the default path, and the gate sits exactly where yours did (before markForgetSenderKey, rotateKey, key-bundle processing and the resend).

I also folded in two things from reviewing your PR: the per-receipt Marked … for fresh SKDM log is demoted to debug (that's the source of your 58k lines), and I noted two compliant follow-ups in #985 — an in-place sender_key_device_cache update (WA Web mutates the participant record per device instead of invalidating the whole group), and closing the isRetryEligible parity gap.

Would you be up for trying #985 in your setup? Register RetryQuarantine from the example via client.set_retry_admission(...) with your burst 2 / refill 2/day, and confirm it bounds the storm the way your version did. If it does, I'll get it merged and credit this PR. Really appreciate you pushing on this — the fix wouldn't have been obvious without your diagnosis.


Generated by Claude Code

@Salientekill

Copy link
Copy Markdown
Contributor Author

Superseded by #985@jlucaso1's opt-in RetryAdmission hook is the better shape: it keeps the SDK default byte-for-byte WA Web while letting the operator own the exact quarantine mechanism, and it adds the in-place sender-key-device cache forget that attacks the per-mark cost my quarantine left on the table. I've wired my mechanism onto his hook on our side and it builds/tests clean; will report production numbers on #985. Closing in favor of it — thanks all for the reviews here.

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.

3 participants