Skip to main content

max / quasi

16.8 KB · 458 lines History Blame Raw
1 //! Getting a declared kind in front of a person, once.
2 //!
3 //! The split this crate rests on, stated on the other side: what a
4 //! notification says and what it is for is the description; **how it arrives is
5 //! the host's**, exactly as a control's keys are the terminal renderer's and
6 //! not the description's. So a host writes [`Deliver`], which is one method,
7 //! and everything a host would otherwise re-answer lives in [`Outbox`].
8 //!
9 //! # What an outbox owes, and where the list came from
10 //!
11 //! goingson's watcher is 483 lines of working code and four of its behaviours
12 //! are not about goingson at all. Each one below is a bug the next app would
13 //! have written for itself:
14 //!
15 //! 1. **The kind's `enabled` key is consulted before anything is sent.** An off
16 //! kind produces no call, so a host adapter never has to know that settings
17 //! exist.
18 //! 2. **A duplicate is suppressed by identity, not by timing.** The watcher
19 //! ticks every 60 seconds over a query that keeps answering; without the
20 //! notified-set it would fire the same reminder every minute until the row
21 //! changed.
22 //! 3. **A restart does not spam.** See [`CatchUp`], which is declared per kind
23 //! because goingson answers it differently for its two: a snooze that
24 //! expired overnight still fires, and an event reminder for a meeting that
25 //! started an hour ago does not.
26 //! 4. **The memory is bounded.** The watcher clears its sets at ten thousand
27 //! entries, and clearing one has to reset that kind's restart bootstrap or
28 //! the next pass fires everything the set was holding back.
29 //!
30 //! # What is out of scope, and stays out
31 //!
32 //! Server-mediated push (APNs/FCM), declined with `ce3be80a`. Its costs stand:
33 //! credentials, a device-token table, a send path, and the server learning
34 //! enough about a reminder to send it, which cuts against SyncKit's E2E
35 //! posture. Nothing here forecloses it -- a push adapter is a [`Deliver`] like
36 //! any other -- and nothing here reaches for it.
37
38 use crate::{CatchUp, Registry, config::Settings};
39 use std::collections::{HashMap, HashSet};
40
41 /// One notification about to happen, or not.
42 ///
43 /// Not a [`Kind`](crate::Kind). A kind is "snoozed items resurface", declared
44 /// once; an occurrence is the one about the task that came back at 4pm, built
45 /// where the app noticed. The kind carries everything a settings pane needs and
46 /// this carries everything a person reads.
47 #[derive(Debug, Clone, PartialEq, Eq)]
48 pub struct Occurrence {
49 /// The declared [`Kind::id`](crate::Kind::id) this is one of.
50 pub kind: &'static str,
51 /// What makes this occurrence *this* one.
52 ///
53 /// The duplicate-suppression identity, and the only thing an outbox
54 /// remembers. A task id for a snooze; an event id and its offset for a
55 /// reminder, because one event has several and each fires once.
56 ///
57 /// It has to be stable across ticks: an identity built from the current
58 /// time is a new identity every tick, which is the same as having none.
59 pub token: String,
60 /// The line the reader sees first.
61 pub title: String,
62 /// The rest of it.
63 pub body: String,
64 }
65
66 impl Occurrence {
67 /// An occurrence of `kind`, identified by `token`.
68 pub fn new(
69 kind: &'static str,
70 token: impl Into<String>,
71 title: impl Into<String>,
72 body: impl Into<String>,
73 ) -> Self {
74 Self {
75 kind,
76 token: token.into(),
77 title: title.into(),
78 body: body.into(),
79 }
80 }
81 }
82
83 /// A host's way of putting an [`Occurrence`] in front of a person.
84 ///
85 /// One method, and it cannot fail in a way the caller can act on: a host that
86 /// could not show a notification has already lost the occurrence, and an
87 /// outbox that retried would be a queue nobody asked for. Log it and carry on,
88 /// which is what goingson's `send_notification` does today.
89 pub trait Deliver {
90 /// Show it.
91 fn deliver(&mut self, note: &Occurrence);
92 }
93
94 /// What an outbox did with an occurrence, and why.
95 ///
96 /// Returned rather than logged, so a caller can count what it suppressed. That
97 /// matters most for [`Unknown`](Self::Unknown), which is a bug in the app
98 /// rather than a preference of the reader.
99 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
100 pub enum Sent {
101 /// Handed to the host.
102 Delivered,
103 /// The reader has this kind off.
104 Off,
105 /// Already delivered under this token.
106 Duplicate,
107 /// It came due while the app was shut, and the kind declares
108 /// [`CatchUp::Skip`].
109 Late,
110 /// No such kind is declared. Nothing is sent, ever, for one of these.
111 Unknown,
112 }
113
114 impl Sent {
115 /// Whether it reached the host.
116 #[must_use]
117 pub const fn delivered(self) -> bool {
118 matches!(self, Self::Delivered)
119 }
120 }
121
122 /// The shared half of delivery: suppression, restart and bounded memory.
123 ///
124 /// Holds a registry and one host adapter. One per app, alongside the registry
125 /// it reads, and it is `!Sync` by nature rather than by design -- the watcher
126 /// that owns it is a single task, and two outboxes over one adapter would each
127 /// have half the memory of what has already been sent.
128 #[derive(Debug)]
129 pub struct Outbox<D> {
130 registry: Registry,
131 adapter: D,
132 /// Per kind, the tokens already delivered.
133 seen: HashMap<&'static str, HashSet<String>>,
134 /// The kinds that have finished a pass since this outbox was built.
135 swept: HashSet<&'static str>,
136 remember: usize,
137 }
138
139 /// How many tokens a kind remembers before the set is dropped.
140 ///
141 /// goingson's number, and its reasoning holds: a token is small, and clearing
142 /// too eagerly re-fires an occurrence whose follow-up write failed.
143 pub const REMEMBER: usize = 10_000;
144
145 impl<D: Deliver> Outbox<D> {
146 /// An outbox over this registry, delivering through this adapter.
147 pub fn new(registry: Registry, adapter: D) -> Self {
148 Self {
149 registry,
150 adapter,
151 seen: HashMap::new(),
152 swept: HashSet::new(),
153 remember: REMEMBER,
154 }
155 }
156
157 /// Remember this many tokens per kind rather than [`REMEMBER`], chaining.
158 #[must_use]
159 pub const fn remembering(mut self, tokens: usize) -> Self {
160 self.remember = tokens;
161 self
162 }
163
164 /// The adapter, for a host that has more to say to its own.
165 pub const fn adapter(&mut self) -> &mut D {
166 &mut self.adapter
167 }
168
169 /// Deliver one occurrence, if everything says it should be delivered.
170 ///
171 /// A one-shot [`sweep`](Self::sweep): the kind counts as having finished a
172 /// pass afterwards, so a [`CatchUp::Skip`] kind suppresses the first
173 /// occurrence offered this way and delivers the rest. A kind that finds its
174 /// due occurrences several at a time wants [`sweep`](Self::sweep) instead,
175 /// or its first pass will deliver everything after the first.
176 pub fn offer(&mut self, note: &Occurrence, settings: &impl Settings) -> Sent {
177 let sent = self.consider(note, settings);
178 self.swept.insert(note.kind);
179 sent
180 }
181
182 /// Offer a whole pass of one kind's due occurrences.
183 ///
184 /// The pass is what [`CatchUp`] is about: everything offered before the
185 /// [`Sweep`] is dropped belongs to the same look at the world, and for a
186 /// [`CatchUp::Skip`] kind the first such look is the one that is noted and
187 /// not delivered.
188 pub fn sweep<'a>(&'a mut self, kind: &'static str) -> Sweep<'a, D> {
189 Sweep { outbox: self, kind }
190 }
191
192 /// Whether this kind has finished a pass since the app started.
193 #[must_use]
194 pub fn swept(&self, kind: &str) -> bool {
195 self.swept.contains(kind)
196 }
197
198 /// Whether this token has already been delivered under this kind.
199 #[must_use]
200 pub fn seen(&self, kind: &str, token: &str) -> bool {
201 self.seen.get(kind).is_some_and(|set| set.contains(token))
202 }
203
204 /// Everything except the bookkeeping about who has finished a pass.
205 fn consider(&mut self, note: &Occurrence, settings: &impl Settings) -> Sent {
206 let Some(kind) = self.registry.kind(note.kind) else {
207 return Sent::Unknown;
208 };
209 let restart = kind.restart;
210
211 if !self.registry.is_on(note.kind, settings) {
212 return Sent::Off;
213 }
214 if self.seen(note.kind, &note.token) {
215 return Sent::Duplicate;
216 }
217
218 // Noted either way. A skipped occurrence must not be reconsidered on
219 // the next pass, which is the whole of what "already missed" means.
220 self.note(note.kind, note.token.clone());
221
222 if restart == CatchUp::Skip && !self.swept(note.kind) {
223 return Sent::Late;
224 }
225
226 self.adapter.deliver(note);
227 Sent::Delivered
228 }
229
230 /// Remember a token, forgetting the kind's whole set if it has grown past
231 /// the bound.
232 fn note(&mut self, kind: &'static str, token: String) {
233 let set = self.seen.entry(kind).or_default();
234 if set.len() >= self.remember {
235 // Dropping the set makes every token in it deliverable again, so a
236 // kind that suppresses late occurrences has to go back through its
237 // bootstrap or the next pass fires everything the set was holding
238 // back. goingson's watcher does exactly this, and it is the one
239 // interaction between the two mechanisms.
240 set.clear();
241 self.swept.remove(kind);
242 }
243 self.seen.entry(kind).or_default().insert(token);
244 }
245 }
246
247 /// One pass of a kind's due occurrences. See [`Outbox::sweep`].
248 ///
249 /// The pass ends when this is dropped, which is what makes a
250 /// [`CatchUp::Skip`] kind's *first* pass the quiet one rather than its first
251 /// occurrence.
252 #[derive(Debug)]
253 pub struct Sweep<'a, D> {
254 outbox: &'a mut Outbox<D>,
255 kind: &'static str,
256 }
257
258 impl<D: Deliver> Sweep<'_, D> {
259 /// Offer one occurrence in this pass.
260 ///
261 /// An occurrence of another kind is still handled correctly -- it is the
262 /// registry that decides, not this guard -- but only [`kind`](Self::kind)
263 /// finishes its pass when the sweep ends.
264 pub fn offer(&mut self, note: &Occurrence, settings: &impl Settings) -> Sent {
265 self.outbox.consider(note, settings)
266 }
267
268 /// The kind whose pass this is.
269 #[must_use]
270 pub const fn kind(&self) -> &'static str {
271 self.kind
272 }
273 }
274
275 impl<D> Drop for Sweep<'_, D> {
276 fn drop(&mut self) {
277 self.outbox.swept.insert(self.kind);
278 }
279 }
280
281 #[cfg(test)]
282 mod tests {
283 use super::*;
284 use crate::{Kind, Registry, config::Unset};
285 use std::collections::HashMap;
286
287 static KINDS: &[Kind] = &[
288 Kind::new(
289 "snooze-expiry",
290 "Snoozed items resurface",
291 "When something you snoozed comes back.",
292 "Reminders",
293 )
294 .shipping_on(),
295 Kind::new(
296 "event-reminder",
297 "Event reminders",
298 "Before an event starts.",
299 "Reminders",
300 )
301 .shipping_on()
302 .quiet_after_restart(),
303 Kind::new("digest", "Daily digest", "One summary.", "Summaries"),
304 ];
305
306 static NOTIFS: Registry = Registry::new(KINDS);
307
308 /// Every occurrence it was handed, in order.
309 #[derive(Debug, Default)]
310 struct Spy(Vec<String>);
311
312 impl Deliver for Spy {
313 fn deliver(&mut self, note: &Occurrence) {
314 self.0.push(format!("{}:{}", note.kind, note.token));
315 }
316 }
317
318 fn outbox() -> Outbox<Spy> {
319 Outbox::new(NOTIFS, Spy::default())
320 }
321
322 fn stored(pairs: &[(&str, &str)]) -> impl Settings {
323 let map: HashMap<String, String> = pairs
324 .iter()
325 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
326 .collect();
327 move |key: &str| map.get(key).cloned()
328 }
329
330 fn snooze(token: &str) -> Occurrence {
331 Occurrence::new("snooze-expiry", token, "Task resurfaced", "Write the thing")
332 }
333
334 #[test]
335 fn an_off_kind_produces_no_call_at_all() {
336 // The reason suppression is shared: a host adapter never has to know
337 // that settings exist.
338 let mut outbox = outbox();
339 let off = stored(&[("snooze-expiry.enabled", "false")]);
340 assert_eq!(outbox.offer(&snooze("t1"), &off), Sent::Off);
341 assert!(outbox.adapter().0.is_empty());
342 }
343
344 #[test]
345 fn a_kind_that_ships_on_fires_for_a_reader_who_has_touched_nothing() {
346 let mut outbox = outbox();
347 assert_eq!(outbox.offer(&snooze("t1"), &Unset), Sent::Delivered);
348 assert_eq!(outbox.adapter().0, vec!["snooze-expiry:t1"]);
349 }
350
351 #[test]
352 fn a_kind_that_ships_off_stays_quiet_until_it_is_turned_on() {
353 let mut outbox = outbox();
354 let note = Occurrence::new("digest", "2026-08-17", "Today", "Six things");
355 assert_eq!(outbox.offer(&note, &Unset), Sent::Off);
356 let on = stored(&[("digest.enabled", "true")]);
357 assert_eq!(outbox.offer(&note, &on), Sent::Delivered);
358 }
359
360 #[test]
361 fn the_same_token_is_delivered_once_however_many_ticks_ask() {
362 // The watcher ticks every 60 seconds over a query that keeps
363 // answering. Without this it fires every minute.
364 let mut outbox = outbox();
365 assert_eq!(outbox.offer(&snooze("t1"), &Unset), Sent::Delivered);
366 for _ in 0..5 {
367 assert_eq!(outbox.offer(&snooze("t1"), &Unset), Sent::Duplicate);
368 }
369 assert_eq!(outbox.offer(&snooze("t2"), &Unset), Sent::Delivered);
370 assert_eq!(
371 outbox.adapter().0,
372 vec!["snooze-expiry:t1", "snooze-expiry:t2"]
373 );
374 }
375
376 #[test]
377 fn a_restart_does_not_spam_the_kind_that_says_the_moment_has_passed() {
378 let mut outbox = outbox();
379 let late = |n: &'static str| Occurrence::new("event-reminder", n, "Standup", "In 15 min");
380
381 // First pass after launch: everything already due is noted, silently.
382 {
383 let mut pass = outbox.sweep("event-reminder");
384 assert_eq!(pass.offer(&late("e1"), &Unset), Sent::Late);
385 assert_eq!(pass.offer(&late("e2"), &Unset), Sent::Late);
386 }
387 assert!(outbox.adapter().0.is_empty());
388
389 // Second pass: the kind is live, and what was skipped stays skipped.
390 {
391 let mut pass = outbox.sweep("event-reminder");
392 assert_eq!(pass.offer(&late("e1"), &Unset), Sent::Duplicate);
393 assert_eq!(pass.offer(&late("e3"), &Unset), Sent::Delivered);
394 }
395 assert_eq!(outbox.adapter().0, vec!["event-reminder:e3"]);
396 }
397
398 #[test]
399 fn a_kind_that_keeps_its_worth_when_late_fires_on_the_first_pass() {
400 // The other half of the same measurement: a snooze that expired
401 // overnight is still expired, and the reader still wants it back.
402 let mut outbox = outbox();
403 let mut pass = outbox.sweep("snooze-expiry");
404 assert_eq!(pass.offer(&snooze("t1"), &Unset), Sent::Delivered);
405 }
406
407 #[test]
408 fn one_kinds_pass_does_not_end_anothers() {
409 let mut outbox = outbox();
410 drop(outbox.sweep("snooze-expiry"));
411 assert!(outbox.swept("snooze-expiry"));
412 assert!(!outbox.swept("event-reminder"));
413 }
414
415 #[test]
416 fn forgetting_a_kinds_tokens_puts_it_back_through_its_bootstrap() {
417 // The one interaction between the two mechanisms: clearing the set
418 // makes every token deliverable again, so a kind that suppresses late
419 // occurrences must not treat the next pass as a live one.
420 let mut outbox = outbox().remembering(2);
421 let note = |n: &'static str| Occurrence::new("event-reminder", n, "Standup", "Soon");
422
423 drop(outbox.sweep("event-reminder")); // bootstrap over
424 {
425 let mut pass = outbox.sweep("event-reminder");
426 assert_eq!(pass.offer(&note("e1"), &Unset), Sent::Delivered);
427 assert_eq!(pass.offer(&note("e2"), &Unset), Sent::Delivered);
428 // The third trips the bound: the set is dropped and so is the
429 // knowledge that this kind has finished a pass.
430 assert_eq!(pass.offer(&note("e3"), &Unset), Sent::Late);
431 }
432 assert_eq!(
433 outbox.adapter().0,
434 vec!["event-reminder:e1", "event-reminder:e2"]
435 );
436 assert!(!outbox.seen("event-reminder", "e1"));
437 }
438
439 #[test]
440 fn an_undeclared_kind_never_reaches_the_host() {
441 let mut outbox = outbox();
442 let note = Occurrence::new("invented", "x", "Hello", "There");
443 assert_eq!(outbox.offer(&note, &Unset), Sent::Unknown);
444 assert!(outbox.adapter().0.is_empty());
445 // And it is not remembered either: nothing was suppressed by
446 // preference, so there is nothing to hold.
447 assert!(!outbox.seen("invented", "x"));
448 }
449
450 #[test]
451 fn what_was_suppressed_is_countable_rather_than_only_logged() {
452 let mut outbox = outbox();
453 let off = stored(&[("snooze-expiry.enabled", "false")]);
454 assert!(!outbox.offer(&snooze("t1"), &off).delivered());
455 assert!(outbox.offer(&snooze("t1"), &Unset).delivered());
456 }
457 }
458