-
-
Notifications
You must be signed in to change notification settings - Fork 126
feat(retry): quarantine per-(chat, requester) inbound retry receipts #982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c9b0430
ed47284
3e6e8f7
32056d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,11 +137,158 @@ impl ResendRateLimiter { | |
| } | ||
| } | ||
|
|
||
| /// Default burst for the per-(chat, requester) retry-receipt quarantine: how | ||
| /// many "fresh SKDM" repair attempts one member gets at full speed. One mark | ||
| /// is enough to repair a healthy member (the next send carries the SKDM), so | ||
| /// anything past a small burst is a member whose session never establishes. | ||
| pub(crate) const DEFAULT_RETRY_MARK_BURST: u32 = 2; | ||
|
|
||
| /// Sustained repair attempts per day per (chat, requester) once the burst is | ||
| /// spent. Keeps a genuinely-recovering member repairable (a fresh attempt | ||
| /// every ~12h) while bounding a permanently-broken member to O(1)/day instead | ||
| /// of O(group messages)/day. | ||
| pub(crate) const DEFAULT_RETRY_MARK_REFILL_PER_DAY: u32 = 2; | ||
|
|
||
| /// Per-(chat, requester) quarantine for inbound group retry receipts. | ||
| /// | ||
| /// 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 sender-key cache invalidation for | ||
| /// the whole group), bundle processing and possibly a resend — all upstream | ||
| /// of the per-chat resend cap, which only bounds the resend itself. Observed | ||
| /// in production at ~58k marks per 3.5 days from 468 members of a single | ||
| /// 1012-participant group. This bounds the whole repair path per member: | ||
| /// past the burst, further receipts from the same (chat, requester) are | ||
| /// dropped before any work happens; the bucket refills so real recovery is | ||
| /// still possible. | ||
| pub(crate) struct RetryMarkQuarantine { | ||
| /// One bucket per (chat user, requester user). Capacity-only: evicting an | ||
| /// idle pair only forgives rate (it recreates full), never over-restricts. | ||
| buckets: Cache<(String, String), Arc<Mutex<TokenBucket>>>, | ||
| burst: AtomicU32, | ||
| refill_per_day: AtomicU32, | ||
| throttled_total: AtomicU64, | ||
| } | ||
|
|
||
| impl RetryMarkQuarantine { | ||
| pub(crate) fn new(capacity: u64, burst: u32, refill_per_day: u32) -> Self { | ||
| Self { | ||
| buckets: Cache::builder().max_capacity(capacity.max(1)).build(), | ||
| burst: AtomicU32::new(burst), | ||
| refill_per_day: AtomicU32::new(refill_per_day), | ||
| throttled_total: AtomicU64::new(0), | ||
| } | ||
| } | ||
|
|
||
| /// Retune the rate live. A `burst` of 0 disables the quarantine. | ||
| pub(crate) fn set_rate(&self, burst: u32, refill_per_day: u32) { | ||
| self.burst.store(burst, Ordering::Relaxed); | ||
| self.refill_per_day.store(refill_per_day, Ordering::Relaxed); | ||
| } | ||
|
|
||
| /// Try to consume one repair token for (chat, requester). `true` lets the | ||
| /// retry receipt through; `false` quarantines it. Device is intentionally | ||
| /// excluded from the key so all devices of a broken account share one | ||
| /// budget (WA Web re-targets the whole user when the primary goes cold). | ||
| pub(crate) async fn try_acquire(&self, chat: &Jid, requester: &Jid) -> bool { | ||
| let burst = self.burst.load(Ordering::Relaxed); | ||
| if burst == 0 { | ||
| return true; | ||
| } | ||
| let burst = burst as f64; | ||
| let refill_per_sec = self.refill_per_day.load(Ordering::Relaxed) as f64 / 86_400.0; | ||
|
|
||
| // The owned key allocates two small strings per receipt (no | ||
| // Borrow<(&str, &str)> for (String, String)); acceptable here — this | ||
| // runs once per retry receipt, not per message, and replaces a DB | ||
| // write + whole-group cache invalidation when it quarantines. | ||
| let key = (chat.user.to_string(), requester.user.to_string()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| let bucket = self | ||
| .buckets | ||
| .get_with(key, async move { | ||
| Arc::new(Mutex::new(TokenBucket::new(burst, Instant::now()))) | ||
| }) | ||
| .await; | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| let allowed = bucket | ||
| .lock() | ||
| .await | ||
| .try_take(Instant::now(), burst, refill_per_sec); | ||
| if !allowed { | ||
| self.throttled_total.fetch_add(1, Ordering::Relaxed); | ||
| } | ||
| allowed | ||
| } | ||
|
|
||
| /// Total receipts quarantined since start (observability). | ||
| pub(crate) fn throttled_total(&self) -> u64 { | ||
| self.throttled_total.load(Ordering::Relaxed) | ||
| } | ||
|
|
||
| /// Number of (chat, requester) pairs holding a live bucket (diagnostics). | ||
| pub(crate) fn entry_count(&self) -> u64 { | ||
| self.buckets.entry_count() | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use std::time::Duration; | ||
|
|
||
| #[tokio::test] | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
| async fn quarantine_bounds_per_pair_and_isolates_pairs() { | ||
| // burst 2, refill 0: two repair attempts then quarantined. | ||
| let q = RetryMarkQuarantine::new(100, 2, 0); | ||
| let g: Jid = "123-456@g.us".parse().unwrap(); | ||
| let a: Jid = "111@lid".parse().unwrap(); | ||
| let b: Jid = "222@lid".parse().unwrap(); | ||
| assert!(q.try_acquire(&g, &a).await); | ||
| assert!(q.try_acquire(&g, &a).await); | ||
| assert!(!q.try_acquire(&g, &a).await, "third receipt quarantined"); | ||
| // Another requester in the same chat has its own budget. | ||
| assert!(q.try_acquire(&g, &b).await); | ||
| assert_eq!(q.throttled_total(), 1); | ||
| // burst 0 disables. | ||
| let off = RetryMarkQuarantine::new(100, 0, 0); | ||
| assert!(off.try_acquire(&g, &a).await); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| #[tokio::test(flavor = "multi_thread", worker_threads = 4)] | ||
| async fn quarantine_concurrent_acquires_for_one_pair_do_not_exceed_burst() { | ||
| // Mirrors the ResendRateLimiter contention test: hundreds of members | ||
| // hammering one group is the exact storm this guards, so burst | ||
| // enforcement must hold under concurrent receipts for the SAME pair. | ||
| let q = Arc::new(RetryMarkQuarantine::new(100, 5, 0)); | ||
| let g: Jid = "123-456@g.us".parse().unwrap(); | ||
| let r: Jid = "999@lid".parse().unwrap(); | ||
| let allowed = Arc::new(AtomicU64::new(0)); | ||
|
|
||
| let mut handles = Vec::new(); | ||
| for _ in 0..40 { | ||
| let q = q.clone(); | ||
| let g = g.clone(); | ||
| let r = r.clone(); | ||
| let allowed = allowed.clone(); | ||
| handles.push(tokio::spawn(async move { | ||
| if q.try_acquire(&g, &r).await { | ||
| allowed.fetch_add(1, Ordering::Relaxed); | ||
| } | ||
| })); | ||
| } | ||
| for h in handles { | ||
| h.await.unwrap(); | ||
| } | ||
|
|
||
| assert_eq!( | ||
| allowed.load(Ordering::Relaxed), | ||
| 5, | ||
| "exactly burst repairs pass under contention, no bypass" | ||
| ); | ||
| assert_eq!(q.throttled_total(), 35); | ||
| } | ||
|
|
||
| fn chat(s: &str) -> Jid { | ||
| s.parse().unwrap() | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.