@@ -10,12 +10,16 @@ use std::{
10
10
} ;
11
11
12
12
use axum:: http:: { Uri , uri:: Authority } ;
13
- use chrono:: { DateTime , TimeDelta , Utc } ;
13
+ use chrono:: TimeDelta ;
14
14
use futures:: { StreamExt , future:: join, stream:: FuturesUnordered } ;
15
15
use indexmap:: IndexMap ;
16
16
use notify:: { EventKind , RecommendedWatcher , RecursiveMode , Watcher as _} ;
17
17
use rand:: seq:: SliceRandom ;
18
- use tokio:: sync:: { Mutex as AsyncMutex , RwLock as AsyncRwLock } ;
18
+ use tokio:: {
19
+ sync:: { Mutex as AsyncMutex , RwLock as AsyncRwLock } ,
20
+ task:: JoinHandle ,
21
+ time:: Instant ,
22
+ } ;
19
23
20
24
use sarlacc:: Intern ;
21
25
use serde:: Deserialize ;
@@ -25,7 +29,7 @@ use tracing::{Instrument, debug, error, field::display, info, info_span, instrum
25
29
use crate :: {
26
30
checking:: check,
27
31
config:: { Config , MemberSpec } ,
28
- discord:: { DiscordNotifier , Snowflake } ,
32
+ discord:: { DiscordNotifier , NOTIFICATION_DEBOUNCE_PERIOD , Snowflake } ,
29
33
homepage:: { Homepage , MemberForHomepage } ,
30
34
stats:: { Stats , UNKNOWN_ORIGIN } ,
31
35
} ;
@@ -68,7 +72,7 @@ struct Member {
68
72
/// Whether the last check was successful
69
73
check_successful : Arc < AtomicBool > ,
70
74
/// The last time the member was notified of a failure.
71
- last_notified : Arc < AsyncMutex < Option < DateTime < Utc > > > > ,
75
+ last_notified : Arc < AsyncMutex < Option < Instant > > > ,
72
76
}
73
77
74
78
impl From < ( & str , & MemberSpec ) > for Member {
@@ -88,12 +92,15 @@ impl From<(&str, &MemberSpec)> for Member {
88
92
impl Member {
89
93
/// Checks the member's site, and stores the result. If the check fails and the member has
90
94
/// opted in to notifications, also notifies them of the failure.
95
+ ///
96
+ /// Returns a future that resolves to the handle of the spawned task that sends the Discord
97
+ /// notification, if any. (Mainly intended for testing purposes.)
91
98
#[ instrument( name = "webring.check_member" , skip_all, fields( site) ) ]
92
99
fn check_and_store_and_optionally_notify (
93
100
& self ,
94
101
base_address : Intern < Uri > ,
95
102
notifier : Option < Arc < DiscordNotifier > > ,
96
- ) -> impl Future < Output = ( ) > + Send + ' static {
103
+ ) -> impl Future < Output = Option < JoinHandle < ( ) > > > + Send + ' static {
97
104
tracing:: Span :: current ( ) . record ( "site" , display ( & self . website ) ) ;
98
105
99
106
let website = self . website ;
@@ -106,29 +113,35 @@ impl Member {
106
113
async move {
107
114
let Ok ( check_result) = check ( & website, check_level, base_address_for_block) . await
108
115
else {
109
- return ;
116
+ return None ;
110
117
} ;
111
118
119
+ debug ! ( site = %website, ?check_result, "got check result for member site" ) ;
112
120
if let Some ( failure) = check_result {
113
121
let prev_was_successful = successful. swap ( false , Ordering :: Relaxed ) ;
114
122
if let ( Some ( notifier) , Some ( user_id) ) = ( notifier, discord_id_for_block) {
115
123
// Notifications are enabled. Send notification asynchronously.
116
- tokio:: spawn ( async move {
124
+ Some ( tokio:: spawn ( async move {
117
125
// If the last check was successful or the last notification was sent more than
118
126
// a day ago, notify the user.
119
127
let mut last_notified = last_notified_for_block. lock ( ) . await ;
120
- let now = Utc :: now ( ) ;
121
128
if prev_was_successful
122
- || last_notified. is_none_or ( |last| ( now - last) > TimeDelta :: days ( 1 ) )
129
+ || last_notified. is_none_or ( |last| {
130
+ Instant :: now ( ) . duration_since ( last) > NOTIFICATION_DEBOUNCE_PERIOD
131
+ } )
123
132
{
124
133
let message = format ! ( "<@{}> {}" , user_id, failure. to_message( ) ) ;
125
- let _ = notifier. send_message ( Some ( user_id) , & message) . await ;
126
- * last_notified = Some ( now) ;
134
+ if notifier. send_message ( Some ( user_id) , & message) . await . is_ok ( ) {
135
+ * last_notified = Some ( Instant :: now ( ) ) ;
136
+ }
127
137
}
128
- } ) ;
138
+ } ) )
139
+ } else {
140
+ None
129
141
}
130
142
} else {
131
143
successful. store ( true , Ordering :: Relaxed ) ;
144
+ None
132
145
}
133
146
}
134
147
}
@@ -646,6 +659,7 @@ mod tests {
646
659
routing:: get,
647
660
} ;
648
661
use chrono:: Utc ;
662
+ use httpmock:: prelude:: * ;
649
663
use indexmap:: IndexMap ;
650
664
use indoc:: indoc;
651
665
use pretty_assertions:: assert_eq;
@@ -655,6 +669,7 @@ mod tests {
655
669
656
670
use crate :: {
657
671
config:: { Config , MemberSpec } ,
672
+ discord:: { DiscordNotifier , NOTIFICATION_DEBOUNCE_PERIOD , Snowflake } ,
658
673
stats:: { TIMEZONE , UNKNOWN_ORIGIN } ,
659
674
webring:: { CheckLevel , Webring } ,
660
675
} ;
@@ -1123,4 +1138,141 @@ mod tests {
1123
1138
assert_eq ! ( 0 , weak_ptr. strong_count( ) ) ;
1124
1139
assert_eq ! ( 0 , weak_ptr. weak_count( ) ) ;
1125
1140
}
1141
+
1142
+ /// Test notification sending logic.
1143
+ #[ allow( clippy:: too_many_lines) ]
1144
+ #[ tokio:: test]
1145
+ async fn test_notification_debounce ( ) {
1146
+ #[ derive( Copy , Clone , PartialEq , Eq , Debug ) ]
1147
+ enum SiteStatus {
1148
+ Up ,
1149
+ Down ,
1150
+ }
1151
+ #[ derive( Copy , Clone , PartialEq , Eq , Debug ) ]
1152
+ enum ShouldNotify {
1153
+ Yes ,
1154
+ No ,
1155
+ }
1156
+ #[ derive( Copy , Clone , PartialEq , Eq , Debug ) ]
1157
+ enum NotifyStatus {
1158
+ Success ,
1159
+ Fail ,
1160
+ }
1161
+
1162
+ let sequence = & [
1163
+ // site is up; no notification
1164
+ ( SiteStatus :: Up , ShouldNotify :: No , None , None ) ,
1165
+ // site is newly down; should notify
1166
+ (
1167
+ SiteStatus :: Down ,
1168
+ ShouldNotify :: Yes ,
1169
+ Some ( NotifyStatus :: Success ) ,
1170
+ None ,
1171
+ ) ,
1172
+ // site is still down; should not notify
1173
+ (
1174
+ SiteStatus :: Down ,
1175
+ ShouldNotify :: No ,
1176
+ None ,
1177
+ Some ( NOTIFICATION_DEBOUNCE_PERIOD + Duration :: from_secs ( 10 ) ) ,
1178
+ ) ,
1179
+ // waited 25 hours; should notify again
1180
+ (
1181
+ SiteStatus :: Down ,
1182
+ ShouldNotify :: Yes ,
1183
+ Some ( NotifyStatus :: Fail ) ,
1184
+ None ,
1185
+ ) ,
1186
+ // last notification failed, so didn't count; should notify again
1187
+ (
1188
+ SiteStatus :: Down ,
1189
+ ShouldNotify :: Yes ,
1190
+ Some ( NotifyStatus :: Success ) ,
1191
+ None ,
1192
+ ) ,
1193
+ // site is still down; should not notify
1194
+ ( SiteStatus :: Down , ShouldNotify :: No , None , None ) ,
1195
+ ] ;
1196
+
1197
+ let server = MockServer :: start ( ) ;
1198
+
1199
+ let site_url: Uri = server. url ( "/site" ) . parse ( ) . unwrap ( ) ;
1200
+ let member = Member {
1201
+ name : "bad" . to_owned ( ) ,
1202
+ website : Intern :: new ( site_url. clone ( ) ) ,
1203
+ authority : Intern :: new ( site_url. into_parts ( ) . authority . unwrap ( ) ) ,
1204
+ discord_id : Some ( Snowflake :: new ( 1234 ) ) ,
1205
+ check_level : CheckLevel :: JustOnline ,
1206
+ check_successful : Arc :: new ( AtomicBool :: new ( true ) ) ,
1207
+ last_notified : Arc :: new ( AsyncMutex :: new ( None ) ) ,
1208
+ } ;
1209
+
1210
+ let notifier = Arc :: new ( DiscordNotifier :: new (
1211
+ & server. url ( "/discord" ) . parse ( ) . unwrap ( ) ,
1212
+ ) ) ;
1213
+ let base_address = Intern :: new ( Uri :: from_static ( "https://ring.purduehackers.com" ) ) ;
1214
+
1215
+ for ( i, & ( site_status, should_notify, notify_status, delay) ) in sequence. iter ( ) . enumerate ( )
1216
+ {
1217
+ // Create mock endpoints
1218
+ let mut mock_bad_site = server. mock ( |when, then| {
1219
+ when. path ( "/site" ) ;
1220
+ // Respond with the next status in the sequence
1221
+ then. status ( match site_status {
1222
+ SiteStatus :: Up => 200 ,
1223
+ SiteStatus :: Down => 404 ,
1224
+ } ) ;
1225
+ } ) ;
1226
+
1227
+ // Mocks the Discord notification endpoint; succeeds/fails based on the sequence.
1228
+ let mut mock_discord_server = server. mock ( |when, then| {
1229
+ when. method ( POST ) . path ( "/discord" ) ;
1230
+ then. status ( match notify_status {
1231
+ Some ( NotifyStatus :: Success ) => 204 ,
1232
+ Some ( NotifyStatus :: Fail ) => 500 ,
1233
+ _ => 404 ,
1234
+ } ) ;
1235
+ } ) ;
1236
+
1237
+ // Perform check
1238
+ let maybe_notification_task = member
1239
+ . check_and_store_and_optionally_notify ( base_address, Some ( Arc :: clone ( & notifier) ) )
1240
+ . await ;
1241
+ if let Some ( notification_task) = maybe_notification_task {
1242
+ // Wait for the notification task to complete
1243
+ notification_task. await . unwrap ( ) ;
1244
+ }
1245
+ match should_notify {
1246
+ ShouldNotify :: No => {
1247
+ assert_eq ! (
1248
+ mock_discord_server. hits( ) ,
1249
+ 0 ,
1250
+ "expected no notification for step {i}"
1251
+ ) ;
1252
+ }
1253
+ ShouldNotify :: Yes => {
1254
+ assert_eq ! (
1255
+ mock_discord_server. hits( ) ,
1256
+ 1 ,
1257
+ "expected notification for step {i}"
1258
+ ) ;
1259
+ }
1260
+ }
1261
+ // Check stored status
1262
+ assert_eq ! (
1263
+ member. check_successful. load( Ordering :: Relaxed ) ,
1264
+ site_status == SiteStatus :: Up ,
1265
+ "check_successful mismatch at step {i}"
1266
+ ) ;
1267
+ if let Some ( delay) = delay {
1268
+ tokio:: time:: pause ( ) ;
1269
+ tokio:: time:: advance ( delay) . await ;
1270
+ tokio:: time:: resume ( ) ;
1271
+ }
1272
+
1273
+ // Delete mock endpoints
1274
+ mock_bad_site. delete ( ) ;
1275
+ mock_discord_server. delete ( ) ;
1276
+ }
1277
+ }
1126
1278
}
0 commit comments