Skip to main content

max / makenotwork

5.4 KB · 161 lines History Blame Raw
1 //! Hourly SyncKit usage warning emails.
2 //!
3 //! Walks every `active`, non-internal sync app and computes the highest
4 //! warning band (75/90/100%) currently breached above the previously-stamped
5 //! `last_warning_pct`. For each breach, emails the app owner and stamps the
6 //! threshold so we don't re-fire on subsequent ticks.
7 //!
8 //! Thresholds reset at period rollover via `reset_period_usage` (called from
9 //! the `invoice.paid` webhook handler), so each billing cycle can fire warnings
10 //! again from scratch.
11 //!
12 //! Storage breaches do NOT reset at period rollover (the cap is absolute, not
13 //! per-period), but `last_warning_pct` is shared across both dimensions for
14 //! simplicity. A creator who silenced storage warnings at 75% and then crosses
15 //! egress 75% in the same cycle won't get re-notified for egress 75% — they'll
16 //! get the next band (90%/100%) instead. This is an acceptable v1 quirk; if
17 //! it bites we'll split `last_warning_pct` into two columns.
18
19 use crate::db::synckit_billing;
20 use crate::AppState;
21
22 /// Run one pass: fetch breach candidates, send warning emails, update
23 /// `last_warning_pct`. Errors on individual apps are logged and skipped.
24 #[tracing::instrument(skip_all)]
25 pub(super) async fn check_and_send_warnings(state: &AppState) {
26 let candidates = match synckit_billing::get_apps_needing_warning(&state.db).await {
27 Ok(c) => c,
28 Err(e) => {
29 tracing::error!(error = ?e, "synckit warnings: query failed");
30 return;
31 }
32 };
33
34 if candidates.is_empty() { return; }
35 tracing::info!(count = candidates.len(), "synckit warnings: candidates");
36
37 for c in candidates {
38 let url = format!(
39 "{}/sync/apps/{}/billing",
40 state.config.host_url, c.app_id,
41 );
42 if let Err(e) = state.email.send_synckit_usage_warning(
43 &c.creator_email,
44 &c.app_name,
45 c.dimension,
46 c.key.as_deref(),
47 c.threshold_pct,
48 c.used,
49 c.limit,
50 &url,
51 ).await {
52 tracing::error!(
53 error = ?e,
54 app_id = %c.app_id,
55 dimension = c.dimension,
56 key = c.key.as_deref().unwrap_or(""),
57 pct = c.threshold_pct,
58 "synckit warnings: send failed",
59 );
60 continue;
61 }
62 // Stamp the band on the right table: per-key warnings stamp the
63 // per-key row, app-wide stamps the app row. Failure to stamp logs
64 // and continues — next tick will re-fire, which is preferable to
65 // silently losing the notification.
66 let stamp = match c.key.as_deref() {
67 Some(k) => {
68 synckit_billing::update_key_warning_pct(
69 &state.db, c.app_id, k, c.threshold_pct,
70 ).await
71 }
72 None => {
73 synckit_billing::update_warning_pct(
74 &state.db, c.app_id, c.threshold_pct,
75 ).await
76 }
77 };
78 if let Err(e) = stamp {
79 tracing::error!(
80 error = ?e,
81 app_id = %c.app_id,
82 key = c.key.as_deref().unwrap_or(""),
83 "synckit warnings: stamp failed (will re-fire next tick)",
84 );
85 }
86 }
87 }
88
89 #[cfg(test)]
90 mod tests {
91 use crate::db::synckit_billing::highest_breached_threshold;
92
93 #[test]
94 fn no_breach_below_first_threshold() {
95 // 50% used, no warning yet
96 assert!(highest_breached_threshold(50, 100, 0).is_none());
97 }
98
99 #[test]
100 fn first_breach_at_75() {
101 assert_eq!(highest_breached_threshold(75, 100, 0), Some(75));
102 assert_eq!(highest_breached_threshold(80, 100, 0), Some(75));
103 }
104
105 #[test]
106 fn no_re_fire_at_same_band() {
107 // 80% used, already warned at 75% — don't fire again at 75%.
108 assert!(highest_breached_threshold(80, 100, 75).is_none());
109 }
110
111 #[test]
112 fn next_band_fires_after_previous() {
113 // 90% used, last fired at 75% — fire 90%.
114 assert_eq!(highest_breached_threshold(90, 100, 75), Some(90));
115 }
116
117 #[test]
118 fn jumps_to_highest_band_only() {
119 // 100% used after only firing 75% — fire 100%, skip 90%.
120 assert_eq!(highest_breached_threshold(100, 100, 75), Some(100));
121 }
122
123 #[test]
124 fn overshoot_caps_at_100() {
125 // 150% used, none fired — still goes straight to 100% (highest band).
126 assert_eq!(highest_breached_threshold(150, 100, 0), Some(100));
127 }
128
129 #[test]
130 fn nothing_above_100_fires() {
131 // Already at 100%; further growth doesn't trigger anything new.
132 assert!(highest_breached_threshold(200, 100, 100).is_none());
133 }
134
135 #[test]
136 fn zero_limit_is_safe() {
137 // Defensive: don't divide by zero.
138 assert!(highest_breached_threshold(50, 0, 0).is_none());
139 }
140
141 #[test]
142 fn exactly_75_percent() {
143 // Floor of 75.0 is 75, should breach the 75 band.
144 assert_eq!(highest_breached_threshold(75, 100, 0), Some(75));
145 }
146
147 #[test]
148 fn just_under_75_does_not_fire() {
149 // 74.99% — floor is 74, below the 75 threshold.
150 assert!(highest_breached_threshold(74, 100, 0).is_none());
151 }
152
153 #[test]
154 fn large_bytes_no_overflow() {
155 // 800 GiB of 1 TiB, no warning yet.
156 let used: i64 = 800 * 1024 * 1024 * 1024;
157 let limit: i64 = 1024 * 1024 * 1024 * 1024;
158 assert_eq!(highest_breached_threshold(used, limit, 0), Some(75));
159 }
160 }
161