Skip to content

Commit 95da23e

Browse files
committed
🧪 test(storage): share one capture subscriber
The three webhook queue-scan cases assert on captured log output, and one of them failed on some runs and not others, a different case each time, with the count of the cleanup warning reading zero on an unchanged tree. A callsite decides its interest the first time any thread executes it, and every thread reads that decision afterwards. While one dispatcher is alive, tracing resolves that question against the registering thread's own default rather than the registry, so a thread holding no subscriber resolves it against nothing, caches never, and silences the callsite for the tests asserting on it. A subscriber that lives only as long as one test is absent whenever a neighbour asks first. One subscriber for the binary cannot be the one missing at that moment, and routing by thread keeps each case reading only its own events. Serialising the cases would have hidden the interleaving rather than removing it, and the assertion stays as it was: one warning across two scans, which is what proves the second scan found nothing left to clean. The new test drives the interleaving rather than waiting for it. A thread with no subscriber reaches a callsite first, and the event a subscribed thread raises there still lands in its capture. With a per-test subscriber that capture comes back empty.
1 parent 0df149b commit 95da23e

1 file changed

Lines changed: 108 additions & 24 deletions

File tree

crates/peryx-storage/tests/unit/tests/meta/webhook_tests.rs

Lines changed: 108 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,82 @@
1+
use std::cell::RefCell;
12
use std::collections::HashSet;
2-
use std::io::{Read as _, Seek as _};
3-
use std::sync::Mutex;
3+
use std::sync::{Arc, Mutex, OnceLock};
44

55
use rstest::rstest;
66

77
use super::store;
88
use crate::meta::{MetaStore, NewWebhookDelivery, WebhookDeliveryAttempt, WebhookDeliveryStatus, WebhookEventIntent};
99

10+
thread_local! {
11+
/// Where this thread's events go while it holds a [`Captured`], and nowhere otherwise.
12+
static CAPTURE: RefCell<Option<Arc<Mutex<Vec<u8>>>>> = const { RefCell::new(None) };
13+
}
14+
15+
/// A writer that hands each event to the capture belonging to the thread that raised it, so one
16+
/// subscriber serves every test at once.
17+
struct ThreadCapture;
18+
19+
impl std::io::Write for ThreadCapture {
20+
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
21+
CAPTURE.with_borrow(|sink| {
22+
if let Some(sink) = sink {
23+
sink.lock().unwrap().extend_from_slice(buf);
24+
}
25+
});
26+
Ok(buf.len())
27+
}
28+
29+
fn flush(&mut self) -> std::io::Result<()> {
30+
Ok(())
31+
}
32+
}
33+
34+
impl tracing_subscriber::fmt::MakeWriter<'_> for ThreadCapture {
35+
type Writer = Self;
36+
37+
fn make_writer(&self) -> Self {
38+
Self
39+
}
40+
}
41+
42+
/// The events this thread raises, until it drops.
43+
///
44+
/// The subscriber is installed once for the whole binary rather than per test, because a callsite
45+
/// decides its interest the first time any thread executes it and every thread reads that decision
46+
/// afterwards. A per-test subscriber leaves that decision to whichever thread arrives first: one
47+
/// holding no subscriber at all resolves the callsite against nothing, caches `never`, and silences
48+
/// the callsite for the tests that are asserting on it. A subscriber that outlives every test cannot
49+
/// be the one missing when that question is asked.
50+
struct Captured(Arc<Mutex<Vec<u8>>>);
51+
52+
impl Captured {
53+
fn install() -> Self {
54+
static SUBSCRIBER: OnceLock<()> = OnceLock::new();
55+
SUBSCRIBER.get_or_init(|| {
56+
let subscriber = tracing_subscriber::fmt()
57+
.with_ansi(false)
58+
.without_time()
59+
.with_writer(ThreadCapture)
60+
.finish();
61+
tracing::subscriber::set_global_default(subscriber)
62+
.expect("the test binary installs one global subscriber");
63+
});
64+
let sink = Arc::new(Mutex::new(Vec::new()));
65+
CAPTURE.with_borrow_mut(|slot| *slot = Some(Arc::clone(&sink)));
66+
Self(sink)
67+
}
68+
69+
fn output(&self) -> String {
70+
String::from_utf8(self.0.lock().unwrap().clone()).expect("the fmt subscriber writes utf-8")
71+
}
72+
}
73+
74+
impl Drop for Captured {
75+
fn drop(&mut self) {
76+
CAPTURE.with_borrow_mut(|slot| *slot = None);
77+
}
78+
}
79+
1080
fn none() -> HashSet<(String, String)> {
1181
HashSet::new()
1282
}
@@ -346,31 +416,22 @@ fn test_webhook_queue_scan_skips_and_cleans_damaged_rows(#[case] damage: QueueDa
346416

347417
let store = MetaStore::open_existing(&path).unwrap();
348418
assert_eq!(store.next_webhook_delivery_at().unwrap(), Some(20));
349-
let mut capture = tempfile::tempfile().unwrap();
350-
let subscriber = tracing_subscriber::fmt()
351-
.with_ansi(false)
352-
.without_time()
353-
.with_writer(Mutex::new(capture.try_clone().unwrap()))
354-
.finish();
355-
tracing::subscriber::with_default(subscriber, || {
356-
for _ in 0..2 {
357-
assert_eq!(
358-
store
359-
.list_due_webhook_deliveries(100, 10, &none())
360-
.unwrap()
361-
.into_iter()
362-
.map(|record| record.id)
363-
.collect::<Vec<_>>(),
364-
std::slice::from_ref(&healthy)
365-
);
366-
}
367-
});
419+
let captured = Captured::install();
420+
for _ in 0..2 {
421+
assert_eq!(
422+
store
423+
.list_due_webhook_deliveries(100, 10, &none())
424+
.unwrap()
425+
.into_iter()
426+
.map(|record| record.id)
427+
.collect::<Vec<_>>(),
428+
std::slice::from_ref(&healthy)
429+
);
430+
}
368431
if matches!(damage, QueueDamage::InvalidJson) {
369432
assert_eq!(store.get_webhook_delivery(damaged.as_deref().unwrap()).unwrap(), None);
370433
}
371-
capture.rewind().unwrap();
372-
let mut output = String::new();
373-
capture.read_to_string(&mut output).unwrap();
434+
let output = captured.output();
374435
assert_eq!(output.matches("discarding damaged webhook queue rows").count(), 1);
375436
assert!(output.contains(&format!("{count}=1")), "{output}");
376437
assert!(!output.contains("unbounded-corrupt-identifier"));
@@ -457,3 +518,26 @@ fn test_list_due_separates_same_target_name_across_indexes() {
457518
let ids: Vec<&str> = due.iter().map(|record| record.id.as_str()).collect();
458519
assert_eq!(ids, [first.as_str(), second.as_str()]);
459520
}
521+
522+
/// The interleaving that silenced this file's captures: a thread holding no subscriber is the first to
523+
/// execute a callsite, which is when its interest is decided for every thread that follows. A capture
524+
/// that only exists for the length of one test is absent at that moment; the binary's subscriber is
525+
/// not, so the event still reaches the thread asserting on it.
526+
#[test]
527+
fn test_a_capture_survives_a_neighbour_reaching_the_callsite_first() {
528+
let captured = Captured::install();
529+
std::thread::spawn(unsubscribed_first_event).join().unwrap();
530+
unsubscribed_first_event();
531+
532+
assert!(
533+
captured.output().contains("callsite registered by a bare thread"),
534+
"a thread with no subscriber decided this callsite for everyone: {:?}",
535+
captured.output()
536+
);
537+
}
538+
539+
/// A callsite of its own, so the test asserts on a first execution rather than on one some earlier test
540+
/// already resolved.
541+
fn unsubscribed_first_event() {
542+
tracing::warn!(target: "peryx::webhook", "callsite registered by a bare thread");
543+
}

0 commit comments

Comments
 (0)