Skip to content

Commit 738a5c8

Browse files
committed
feat: debounce notifications
1 parent f4ddb2b commit 738a5c8

File tree

4 files changed

+94
-0
lines changed

4 files changed

+94
-0
lines changed

src/debouncer.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//! # Debouncer for notifications.
2+
//!
3+
//! Sometimes the client application may be reinstalled
4+
//! while keeping the notification token.
5+
//! In this case the same token is stored twice
6+
//! for the same mailbox on a chatmail relay
7+
//! and is notified twice for the same message.
8+
//! Since it is not possible for the chatmail relay
9+
//! to deduplicate the tokens in this case
10+
//! as only the notification gateway
11+
//! can decrypt them, notification gateway needs
12+
//! to debounce notifications to the same token.
13+
14+
use std::time::{Instant, Duration};
15+
use std::collections::{HashSet, BinaryHeap};
16+
use std::cmp::Reverse;
17+
use std::sync::Mutex;
18+
19+
#[derive(Default)]
20+
pub(crate) struct Debouncer {
21+
state: Mutex<DebouncerState>
22+
}
23+
24+
#[derive(Default)]
25+
struct DebouncerState {
26+
/// Set of recently notified tokens.
27+
///
28+
/// The tokens are stored in plaintext,
29+
/// not hashed or encrypted.
30+
/// No token is stored for a long time anyway.
31+
tokens: HashSet<String>,
32+
33+
/// Binary heap storing tokens
34+
/// sorted by the timestamp of the recent notifications.
35+
///
36+
/// `Reverse` is used to turn max-heap into min-heap.
37+
heap: BinaryHeap<Reverse<(Instant, String)>>,
38+
}
39+
40+
impl DebouncerState {
41+
/// Removes old entries for tokens that can be notified again.
42+
fn cleanup(&mut self) {
43+
let now = Instant::now();
44+
loop {
45+
let Some(Reverse((timestamp, token))) = self.heap.pop() else {
46+
break;
47+
};
48+
49+
if timestamp.duration_since(now) < Duration::from_secs(10) {
50+
self.heap.push(Reverse((timestamp, token)));
51+
break;
52+
}
53+
54+
self.tokens.remove(&token);
55+
}
56+
}
57+
58+
/// Returns true if the token was notified recently
59+
/// and should not be notified again.
60+
fn is_debounced(&mut self, token: &String) -> bool {
61+
self.cleanup();
62+
self.tokens.contains(token)
63+
}
64+
65+
fn notify(&mut self, token: String) {
66+
if self.tokens.insert(token.clone()) {
67+
self.heap.push(Reverse((Instant::now(), token)));
68+
}
69+
}
70+
}
71+
72+
impl Debouncer {
73+
pub(crate) fn is_debounced(&self, token: &String) -> bool {
74+
let mut state = self.state.lock().unwrap();
75+
state.is_debounced(token)
76+
}
77+
78+
pub(crate) fn notify(&self, token: String) {
79+
self.state.lock().unwrap().notify(token);
80+
}
81+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod debouncer;
12
pub mod metrics;
23
pub mod notifier;
34
mod openpgp;

src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@ async fn notify_device(
293293
}
294294

295295
info!("Got direct notification for {device_token}.");
296+
if state.debouncer().is_debounced(&device_token) {
297+
return Ok(StatusCode::OK);
298+
}
299+
state.debouncer().notify(device_token.clone());
296300
let device_token: NotificationToken = device_token.as_str().parse()?;
297301

298302
let status_code = match device_token {

src/state.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use anyhow::{Context as _, Result};
99
use crate::metrics::Metrics;
1010
use crate::openpgp::PgpDecryptor;
1111
use crate::schedule::Schedule;
12+
use crate::debouncer::Debouncer;
1213

1314
#[derive(Clone)]
1415
pub struct State {
@@ -36,6 +37,8 @@ pub struct InnerState {
3637
/// Decryptor for incoming tokens
3738
/// storing the secret keyring inside.
3839
openpgp_decryptor: PgpDecryptor,
40+
41+
debouncer: Debouncer,
3942
}
4043

4144
impl State {
@@ -88,6 +91,7 @@ impl State {
8891
interval,
8992
fcm_authenticator,
9093
openpgp_decryptor,
94+
debouncer: Default::default(),
9195
}),
9296
})
9397
}
@@ -134,4 +138,8 @@ impl State {
134138
pub fn openpgp_decryptor(&self) -> &PgpDecryptor {
135139
&self.inner.openpgp_decryptor
136140
}
141+
142+
pub(crate) fn debouncer(&self) -> &Debouncer {
143+
&self.inner.debouncer
144+
}
137145
}

0 commit comments

Comments
 (0)