use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use pagesift_store::Store;
use serde_json::{Value, json};
use tokio::sync::{Mutex, Notify, mpsc};
use super::{Event, WebhookConfig, Webhooks};
use crate::metrics::Metrics;
use crate::types::Document;
pub(crate) fn outbox_key(job: &str) -> String {
format!("job/{job}/hook")
}
pub(crate) fn seq_key(job: &str) -> String {
format!("job/{job}/hook_seq")
}
pub(crate) fn ack_key(job: &str) -> String {
format!("job/{job}/hook_ack")
}
fn read_u64(store: &Store, key: &str) -> pagesift_store::Result<u64> {
Ok(store
.tasks()
.kv_get(key)?
.and_then(|v| std::str::from_utf8(&v).ok()?.parse().ok())
.unwrap_or(0))
}
#[derive(Default)]
struct Bell {
notify: Notify,
closed: AtomicBool,
degraded: AtomicBool,
}
impl Bell {
fn degrade(&self) {
self.degraded.store(true, Ordering::SeqCst);
self.notify.notify_one();
}
fn degraded(&self) -> bool {
self.degraded.load(Ordering::SeqCst)
}
}
enum Outbox {
Memory(mpsc::Sender<Value>),
Stored {
store: Store,
bell: Arc<Bell>,
spill: mpsc::Sender<Value>,
mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
},
}
struct Core {
cfg: Arc<WebhookConfig>,
kind: &'static str,
job_id: Arc<str>,
metrics: Arc<Metrics>,
next: Mutex<Option<u64>>,
out: Outbox,
}
impl Drop for Core {
fn drop(&mut self) {
if let Outbox::Stored { bell, .. } = &self.out {
bell.closed.store(true, Ordering::SeqCst);
bell.notify.notify_one();
}
}
}
#[derive(Clone)]
pub(crate) struct Hook {
core: Arc<Core>,
}
impl Webhooks {
pub(crate) fn start(
&self,
job_id: &str,
kind: &'static str,
cfg: WebhookConfig,
store: Option<Store>,
) -> Hook {
let cfg = Arc::new(cfg);
let job_id: Arc<str> = job_id.into();
let (tx, rx) = mpsc::channel::<Value>(self.settings.queue.max(1));
let (out, next) = match store {
Some(store) => {
let bell = Arc::<Bell>::default();
let (this, store2, job, cfg, bell2) = (
self.clone(),
store.clone(),
job_id.clone(),
cfg.clone(),
bell.clone(),
);
tokio::spawn(async move {
this.drain_stored(&store2, &job, &cfg, &bell2).await;
this.drain_memory(rx, &cfg).await;
});
let out = Outbox::Stored {
store,
bell,
spill: tx,
mirror: self.mirror.clone(),
};
(out, None)
}
None => {
let (this, cfg) = (self.clone(), cfg.clone());
tokio::spawn(async move { this.drain_memory(rx, &cfg).await });
(Outbox::Memory(tx), Some(1))
}
};
Hook {
core: Arc::new(Core {
cfg,
kind,
job_id,
metrics: self.metrics.clone(),
next: Mutex::new(next),
out,
}),
}
}
async fn drain_memory(&self, mut rx: mpsc::Receiver<Value>, cfg: &WebhookConfig) {
while let Some(body) = rx.recv().await {
self.deliver(cfg, &body).await;
}
}
async fn drain_stored(&self, store: &Store, job: &str, cfg: &WebhookConfig, bell: &Bell) {
let (list, ack) = (outbox_key(job), ack_key(job));
let key = ack.clone();
let mut acked = match store.blocking(move |s| read_u64(s, &key)).await {
Ok(n) => n,
Err(e) => {
tracing::error!(job, error = %e, "reading the webhook cursor failed; switching to the memory queue");
bell.degrade();
return;
}
};
let mut last_look = false;
loop {
let l = list.clone();
let front = match store.blocking(move |s| s.tasks().list_front(&l)).await {
Ok(f) => f,
Err(e) => {
tracing::error!(job, error = %e, "reading the webhook queue failed; switching to the memory queue");
bell.degrade();
return;
}
};
let Some(raw) = front else {
if bell.degraded() || (bell.closed.load(Ordering::SeqCst) && last_look) {
return;
}
if bell.closed.load(Ordering::SeqCst) {
last_look = true;
} else {
bell.notify.notified().await;
}
continue;
};
let body = serde_json::from_slice::<Value>(&raw).ok();
let seq = body.as_ref().and_then(|b| b["seq"].as_u64());
let fresh = match (&body, seq) {
(Some(b), Some(seq)) if seq > acked => {
self.deliver(cfg, b).await;
acked = seq;
Some(seq)
}
_ => None,
};
let (l, a, m) = (list.clone(), ack.clone(), self.mirror.clone());
let r = store
.blocking(move |s| -> pagesift_store::Result<()> {
crate::jobs::mirror::write(s, m.as_deref(), |t| {
if let Some(seq) = fresh {
t.kv_put(&a, seq.to_string().as_bytes())?;
}
t.list_pop_front(&l)
})
})
.await;
if let Err(e) = r {
tracing::error!(job, error = %e, "advancing the webhook cursor failed; switching to the memory queue");
bell.degrade();
return;
}
}
}
}
impl Hook {
pub(crate) async fn send(&self, event: Event, data: Vec<Document>, error: Option<String>) {
let c = &*self.core;
if !c.cfg.wants(event) {
return;
}
let mut next = c.next.lock().await;
let seq = match *next {
Some(n) => n,
None => c.stored_next().await,
};
let mut body = json!({
"success": error.is_none(),
"type": format!("{}.{}", c.kind, event.name()),
"id": &*c.job_id,
"eventId": format!("{}:{seq}", c.job_id),
"seq": seq,
"data": data,
"metadata": c.cfg.metadata,
});
if let Some(e) = error {
body["error"] = e.into();
}
let memory = match &c.out {
Outbox::Memory(tx) => Some((tx, body)),
Outbox::Stored {
store,
bell,
spill,
mirror,
} => c
.persist(store, mirror.clone(), bell, seq, &body)
.await
.then_some((spill, body)),
};
if let Some((tx, body)) = memory
&& tx.send(body).await.is_err()
{
c.metrics.webhook("dropped");
}
*next = Some(seq + 1);
}
}
impl Core {
async fn persist(
&self,
store: &Store,
mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
bell: &Bell,
seq: u64,
body: &Value,
) -> bool {
if bell.degraded() {
return true;
}
let (seq_k, list) = (seq_key(&self.job_id), outbox_key(&self.job_id));
let raw = body.to_string().into_bytes();
let m = mirror;
let r = store
.blocking(move |s| -> pagesift_store::Result<()> {
crate::jobs::mirror::write(s, m.as_deref(), |t| {
t.kv_put(&seq_k, seq.to_string().as_bytes())?;
t.list_push(&list, &raw)
})
})
.await;
match r {
Ok(()) => {
bell.notify.notify_one();
bell.degraded()
}
Err(e) => {
tracing::error!(job = %self.job_id, seq, error = %e, "queueing a webhook event on disk failed; switching to the memory queue");
bell.degrade();
true
}
}
}
async fn stored_next(&self) -> u64 {
let Outbox::Stored { store, .. } = &self.out else {
return 1;
};
let key = seq_key(&self.job_id);
match store.blocking(move |s| read_u64(s, &key)).await {
Ok(n) => n + 1,
Err(e) => {
let key = ack_key(&self.job_id);
let acked = store.blocking(move |s| read_u64(s, &key)).await;
tracing::warn!(job = %self.job_id, error = %e, "reading the webhook sequence failed");
acked.unwrap_or(0) + 1
}
}
}
}