use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use sqlx::PgPool;
use crate::sysdb::notify::{Registry, key_for};
pub(crate) const COALESCE_INTERVAL: Duration = Duration::from_millis(10);
const PUSH_BATCH: &str = "SELECT pg_notify($1, p) FROM unnest($2::text[]) AS p";
type Pending = HashMap<&'static str, HashSet<String>>;
pub(crate) struct Notifier {
pool: PgPool,
registry: std::sync::Arc<Registry>,
interval: Duration,
pushing: AtomicBool,
pending: Mutex<Pending>,
woken: tokio::sync::Notify,
stopping: AtomicBool,
}
impl Notifier {
pub(crate) fn new(
pool: PgPool,
registry: std::sync::Arc<Registry>,
interval: Option<Duration>,
) -> Self {
Self {
pool,
registry,
interval: interval.unwrap_or(COALESCE_INTERVAL),
pushing: AtomicBool::new(false),
pending: Mutex::default(),
woken: tokio::sync::Notify::new(),
stopping: AtomicBool::new(false),
}
}
pub(crate) fn enable(&self) {
self.pushing.store(true, Ordering::Relaxed);
}
pub(crate) fn is_pushing(&self) -> bool {
self.pushing.load(Ordering::Relaxed)
}
pub(crate) fn signal(&self, channel: &'static str, workflow_id: &str, key: &str) {
let payload = format!("{workflow_id}::{key}");
let Some(registry_key) = key_for(channel, &payload) else {
tracing::warn!(channel, "signalled on an unexpected channel");
return;
};
self.registry.wake(®istry_key);
if !self.is_pushing() {
return;
}
let first = {
let mut pending = self.pending.lock().expect("notifier lock");
let batch = pending.entry(channel).or_default();
batch.insert(payload);
pending.values().map(HashSet::len).sum::<usize>() == 1
};
if first {
self.woken.notify_one();
}
}
pub(crate) async fn run(self: std::sync::Arc<Self>) {
while !self.stopping.load(Ordering::Relaxed) {
self.woken.notified().await;
if self.stopping.load(Ordering::Relaxed) {
break;
}
let _ = crate::rt::timeout(self.interval, self.woken.notified()).await;
self.flush().await;
}
self.flush().await;
tracing::debug!("the notifier stopped");
}
pub(crate) fn stop(&self) {
self.stopping.store(true, Ordering::Relaxed);
self.woken.notify_one();
}
async fn flush(&self) {
let batches: Vec<(&'static str, Vec<String>)> = {
let mut pending = self.pending.lock().expect("notifier lock");
pending
.drain()
.filter(|(_, batch)| !batch.is_empty())
.map(|(channel, batch)| (channel, batch.into_iter().collect()))
.collect()
};
for (channel, payloads) in batches {
let sent = sqlx::query(PUSH_BATCH)
.bind(channel)
.bind(&payloads)
.execute(&self.pool)
.await;
if let Err(e) = sent {
tracing::warn!(
channel,
count = payloads.len(),
error = %e,
"could not push notifications; readers fall back to re-querying",
);
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::sysdb::notify::{EVENTS_CHANNEL, STREAMS_CHANNEL, event_key};
fn notifier() -> Arc<Notifier> {
Arc::new(Notifier::new(
PgPool::connect_lazy("postgres://unused").expect("lazy pool"),
Arc::default(),
None,
))
}
fn drained(notifier: &Notifier) -> Vec<(&'static str, Vec<String>)> {
let mut pending = notifier.pending.lock().expect("notifier lock");
let mut batches: Vec<_> = pending
.drain()
.map(|(channel, batch)| {
let mut payloads: Vec<String> = batch.into_iter().collect();
payloads.sort();
(channel, payloads)
})
.collect();
batches.sort();
batches
}
#[tokio::test]
async fn a_key_written_repeatedly_is_pushed_once() {
let notifier = notifier();
notifier.enable();
for _ in 0..5 {
notifier.signal(STREAMS_CHANNEL, "wf", "progress");
}
assert_eq!(
drained(¬ifier),
vec![(STREAMS_CHANNEL, vec!["wf::progress".to_owned()])],
);
}
#[tokio::test]
async fn the_two_channels_batch_separately() {
let notifier = notifier();
notifier.enable();
notifier.signal(EVENTS_CHANNEL, "wf", "ready");
notifier.signal(STREAMS_CHANNEL, "wf", "progress");
notifier.signal(EVENTS_CHANNEL, "wf", "result");
assert_eq!(
drained(¬ifier),
vec![
(STREAMS_CHANNEL, vec!["wf::progress".to_owned()]),
(
EVENTS_CHANNEL,
vec!["wf::ready".to_owned(), "wf::result".to_owned()],
),
],
);
}
#[tokio::test]
async fn without_the_push_a_signal_still_wakes_a_local_waiter() {
let notifier = notifier();
let mut subscription = notifier.registry.subscribe(event_key("wf", "ready"));
notifier.signal(EVENTS_CHANNEL, "wf", "ready");
assert!(drained(¬ifier).is_empty(), "nothing drains this queue");
assert!(
crate::rt::timeout(Duration::from_secs(5), subscription.notified())
.await
.is_ok(),
"the waiter was not woken",
);
}
#[tokio::test]
async fn the_pushed_payload_is_the_key_a_waiter_holds() {
let notifier = notifier();
notifier.enable();
notifier.signal(EVENTS_CHANNEL, "wf", "ready");
let [(_, ref payloads)] = drained(¬ifier)[..] else {
panic!("expected one channel's batch");
};
assert_eq!(
key_for(EVENTS_CHANNEL, &payloads[0]).as_deref(),
Some(event_key("wf", "ready").as_str()),
);
}
}