use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WriteProvenance {
pub source: String,
pub correlation_id: Option<String>,
pub note: Option<String>,
}
impl WriteProvenance {
pub fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
correlation_id: None,
note: None,
}
}
pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
self.correlation_id = Some(correlation_id.into());
self
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "provenance")]
#[non_exhaustive]
pub enum EventAttribution {
Attributed(WriteProvenance),
ExternalOrUnknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
#[non_exhaustive]
pub enum HookEvent {
FileCreated {
path: String,
},
FileModified {
path: String,
},
FileDeleted {
path: String,
},
FileRenamed {
from: String,
to: String,
},
ResyncRequired {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct VaultEventEnvelope {
pub epoch: String,
pub sequence: u64,
pub observed_at_ms: u64,
pub vault: String,
pub event: HookEvent,
pub content_hash: Option<String>,
pub plugin_id: Option<String>,
pub attribution: EventAttribution,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookLifecycle {
Running,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PublishError {
#[error("hook bus is closed")]
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum HookRecvError {
#[error("no hook event is currently available")]
Empty,
#[error("hook subscriber lagged by {skipped} event(s); resync required")]
Lagged {
skipped: u64,
},
#[error("hook bus is closed")]
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventCursor {
epoch: String,
sequence: u64,
}
impl EventCursor {
pub fn after(envelope: &VaultEventEnvelope) -> Self {
Self {
epoch: envelope.epoch.clone(),
sequence: envelope.sequence,
}
}
pub fn epoch(&self) -> &str {
&self.epoch
}
pub fn sequence(&self) -> u64 {
self.sequence
}
pub fn resumes_on(&self, bus: &HookBus) -> bool {
self.epoch == bus.epoch()
}
}
struct HookBusInner {
sender: Mutex<Option<broadcast::Sender<VaultEventEnvelope>>>,
epoch: String,
sequence: AtomicU64,
closed: AtomicBool,
capacity: usize,
}
#[derive(Clone)]
pub struct HookBus {
inner: Arc<HookBusInner>,
}
impl std::fmt::Debug for HookBus {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("HookBus")
.field("epoch", &self.epoch())
.field("capacity", &self.capacity())
.field("lifecycle", &self.lifecycle())
.finish()
}
}
impl HookBus {
fn sender(&self) -> std::sync::MutexGuard<'_, Option<broadcast::Sender<VaultEventEnvelope>>> {
self.inner
.sender
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn new(capacity: usize) -> Self {
let capacity = capacity.max(1);
let (sender, _) = broadcast::channel(capacity);
Self {
inner: Arc::new(HookBusInner {
sender: Mutex::new(Some(sender)),
epoch: uuid::Uuid::new_v4().to_string(),
sequence: AtomicU64::new(0),
closed: AtomicBool::new(false),
capacity,
}),
}
}
pub fn epoch(&self) -> &str {
&self.inner.epoch
}
pub fn capacity(&self) -> usize {
self.inner.capacity
}
pub fn lifecycle(&self) -> HookLifecycle {
if self.inner.closed.load(Ordering::Acquire) {
HookLifecycle::Closed
} else {
HookLifecycle::Running
}
}
pub fn subscribe(&self) -> Result<HookSubscription, PublishError> {
let guard = self.sender();
let sender = guard.as_ref().ok_or(PublishError::Closed)?;
Ok(HookSubscription {
receiver: sender.subscribe(),
})
}
pub fn publish(
&self,
vault: impl Into<String>,
event: HookEvent,
content_hash: Option<String>,
plugin_id: Option<String>,
attribution: EventAttribution,
) -> Result<VaultEventEnvelope, PublishError> {
let guard = self.sender();
let sender = guard.as_ref().ok_or(PublishError::Closed)?;
let envelope = VaultEventEnvelope {
epoch: self.inner.epoch.clone(),
sequence: self.inner.sequence.fetch_add(1, Ordering::Relaxed) + 1,
observed_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX),
vault: vault.into(),
event,
content_hash,
plugin_id,
attribution,
};
let _subscriber_count = sender.send(envelope.clone());
Ok(envelope)
}
pub fn close(&self) -> bool {
let mut guard = self.sender();
let changed = guard.take().is_some();
self.inner.closed.store(true, Ordering::Release);
changed
}
}
pub struct HookSubscription {
receiver: broadcast::Receiver<VaultEventEnvelope>,
}
impl std::fmt::Debug for HookSubscription {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("HookSubscription")
.finish_non_exhaustive()
}
}
impl HookSubscription {
pub async fn recv(&mut self) -> Result<VaultEventEnvelope, HookRecvError> {
self.receiver.recv().await.map_err(|error| match error {
broadcast::error::RecvError::Closed => HookRecvError::Closed,
broadcast::error::RecvError::Lagged(skipped) => HookRecvError::Lagged { skipped },
})
}
pub fn try_recv(&mut self) -> Result<VaultEventEnvelope, HookRecvError> {
self.receiver.try_recv().map_err(|error| match error {
broadcast::error::TryRecvError::Closed => HookRecvError::Closed,
broadcast::error::TryRecvError::Lagged(skipped) => HookRecvError::Lagged { skipped },
broadcast::error::TryRecvError::Empty => HookRecvError::Empty,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn publish(bus: &HookBus, path: &str) -> VaultEventEnvelope {
bus.publish(
"test",
HookEvent::FileModified {
path: path.to_string(),
},
None,
None,
EventAttribution::ExternalOrUnknown,
)
.expect("publish")
}
#[tokio::test]
async fn sequences_events_and_closes_after_buffered_delivery() {
const CAPACITY: usize = 2;
let bus = HookBus::new(CAPACITY);
let mut subscription = bus.subscribe().expect("subscribe");
assert_eq!(publish(&bus, "a.md").sequence, 1);
assert_eq!(publish(&bus, "b.md").sequence, 2);
assert!(bus.close());
assert!(!bus.close());
assert_eq!(subscription.recv().await.expect("first").sequence, 1);
assert_eq!(subscription.recv().await.expect("second").sequence, 2);
assert_eq!(subscription.recv().await, Err(HookRecvError::Closed));
assert_eq!(bus.lifecycle(), HookLifecycle::Closed);
}
#[tokio::test]
async fn reports_lag_and_requires_resync() {
const CAPACITY: usize = 1;
let bus = HookBus::new(CAPACITY);
let mut subscription = bus.subscribe().expect("subscribe");
publish(&bus, "a.md");
publish(&bus, "b.md");
assert_eq!(
subscription.recv().await,
Err(HookRecvError::Lagged { skipped: 1 })
);
assert_eq!(subscription.recv().await.expect("retained").sequence, 2);
}
#[test]
fn cursors_do_not_resume_across_a_restart() {
let first_run = HookBus::new(4);
let second_run = HookBus::new(4);
assert_ne!(first_run.epoch(), second_run.epoch());
let applied = EventCursor::after(&publish(&first_run, "a.md"));
assert_eq!(applied.sequence(), 1);
assert!(applied.resumes_on(&first_run));
let after_restart = publish(&second_run, "b.md");
assert_eq!(after_restart.sequence, applied.sequence());
assert!(!applied.resumes_on(&second_run));
let encoded = serde_json::to_vec(&applied).expect("serialize cursor");
let decoded: EventCursor = serde_json::from_slice(&encoded).expect("deserialize cursor");
assert_eq!(decoded, applied);
assert!(decoded.resumes_on(&first_run));
}
#[test]
fn rejects_new_work_after_close() {
let bus = HookBus::new(0);
assert_eq!(bus.capacity(), 1);
assert!(bus.close());
assert!(matches!(bus.subscribe(), Err(PublishError::Closed)));
assert_eq!(
bus.publish(
"test",
HookEvent::ResyncRequired {
reason: "closed".to_string()
},
None,
None,
EventAttribution::ExternalOrUnknown
),
Err(PublishError::Closed)
);
}
}