use std::sync::Arc;
use tokio::sync::{broadcast, watch};
use crate::change_log::ChangeLog as _;
use exocortex_kernel::OntologyFingerprint;
use exocortex_storage::{Invalidation, LeaseKey, OwnerLease, Storage};
use exocortex_wire::cluster::v1::InvalidationEnvelope;
use exocortex_wire::WIRE_VERSION;
#[derive(Debug, thiserror::Error)]
pub enum ClusterError {
#[error("wire version mismatch")]
WireMismatch,
#[error("ontology mismatch")]
OntologyMismatch,
#[error("hmac verification failed")]
HmacFailed,
#[error("storage: {0}")]
Storage(String),
}
pub struct ClusterNode<S: Storage> {
pub storage: Arc<S>,
pub node_id: smol_str::SmolStr,
pub fp: OntologyFingerprint,
pub hmac_key: [u8; 32],
tx: broadcast::Sender<InvalidationEnvelope>,
change_log: crate::change_log::RingChangeLog,
feed_health: watch::Sender<FeedHealth>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct FeedHealth {
pub epoch: u64,
pub ready: bool,
pub failures: u64,
}
pub const REPLAY_CAPACITY_DEFAULT: usize = 1024;
pub use crate::change_log::Replay;
impl<S: Storage + 'static> ClusterNode<S> {
pub fn new(
storage: Arc<S>,
node_id: smol_str::SmolStr,
fp: OntologyFingerprint,
hmac_key: [u8; 32],
) -> Self {
let (tx, _) = broadcast::channel(4096);
let (feed_health, _) = watch::channel(FeedHealth::default());
Self {
storage,
node_id,
fp,
hmac_key,
tx,
change_log: crate::change_log::RingChangeLog::new(),
feed_health,
}
}
pub fn with_replay_capacity(mut self, cap: usize) -> Self {
self.change_log = crate::change_log::RingChangeLog::with_capacity(cap);
self
}
pub fn change_log(&self) -> &dyn crate::change_log::ChangeLog {
&self.change_log
}
pub fn replay_since(&self, since_lsn: u64) -> Replay {
self.change_log.replay_since(since_lsn)
}
pub fn replay_floor(&self) -> u64 {
self.change_log.replay_floor()
}
fn publish_envelope(&self, env: InvalidationEnvelope) {
metrics::counter!("exocortex_cluster_invalidations_published_total").increment(1);
self.change_log.append(env.clone());
let _ = self.tx.send(env);
}
pub fn admit_and_publish(&self, env: InvalidationEnvelope) -> Result<(), ClusterError> {
self.admit(&env)?;
self.publish_envelope(env);
Ok(())
}
pub async fn run(self: Arc<Self>) -> anyhow::Result<()> {
let region = exocortex_storage::RegionKey {
org: "*".into(),
project: "*".into(),
memory_type: 0,
};
let mut delay = std::time::Duration::from_millis(100);
loop {
let mut sub = match self.storage.subscribe_invalidations(®ion).await {
Ok(sub) => sub,
Err(error) => {
self.mark_feed_failed();
tracing::warn!(%error, "storage invalidation subscribe failed; retrying");
tokio::time::sleep(delay).await;
delay = (delay * 2).min(std::time::Duration::from_secs(5));
continue;
}
};
self.mark_feed_ready();
delay = std::time::Duration::from_millis(100);
self.consume_feed_epoch(&mut sub).await?;
tokio::time::sleep(delay).await;
delay = (delay * 2).min(std::time::Duration::from_secs(5));
}
}
async fn consume_feed_epoch(
&self,
sub: &mut futures::stream::BoxStream<'_, exocortex_storage::Result<Invalidation>>,
) -> Result<(), ClusterError> {
use futures::StreamExt as _;
while let Some(inv) = sub.next().await {
let inv = match inv {
Ok(inv) => inv,
Err(error) => {
metrics::counter!("exocortex_cluster_invalidation_decode_errors_total")
.increment(1);
self.mark_feed_failed();
tracing::warn!(%error, "storage invalidation decode failed; reconnecting");
return Ok(());
}
};
self.admit_and_publish(self.envelope(inv))?;
}
self.mark_feed_failed();
tracing::warn!("storage invalidation stream ended; reconnecting");
Ok(())
}
fn mark_feed_ready(&self) {
let mut state = *self.feed_health.borrow();
state.epoch = state.epoch.saturating_add(1);
state.ready = true;
self.feed_health.send_replace(state);
}
fn mark_feed_failed(&self) {
let mut state = *self.feed_health.borrow();
state.ready = false;
state.failures = state.failures.saturating_add(1);
self.feed_health.send_replace(state);
}
pub fn feed_health(&self) -> FeedHealth {
*self.feed_health.borrow()
}
pub fn subscribe_feed_health(&self) -> watch::Receiver<FeedHealth> {
self.feed_health.subscribe()
}
pub fn envelope(&self, inv: Invalidation) -> InvalidationEnvelope {
let inv_pb = crate::sse::invalidation_to_pb(&inv);
let mut env = InvalidationEnvelope {
wire_version: WIRE_VERSION,
ontology_fingerprint: self.fp.0.to_vec(),
emitter_node_id: self.node_id.to_string(),
inv: Some(inv_pb),
hmac: vec![],
};
exocortex_wire::signing::sign_invalidation_envelope(&self.hmac_key, &mut env);
env
}
pub fn verify_hmac(&self, env: &InvalidationEnvelope) -> Result<(), ClusterError> {
if !exocortex_wire::signing::verify_invalidation_envelope(&self.hmac_key, env) {
return Err(ClusterError::HmacFailed);
}
Ok(())
}
pub fn admit(&self, env: &InvalidationEnvelope) -> Result<(), ClusterError> {
if env.wire_version != WIRE_VERSION {
return Err(ClusterError::WireMismatch);
}
exocortex_kernel::admit_peer(&env.ontology_fingerprint, &self.fp.0)
.map_err(|_| ClusterError::OntologyMismatch)?;
self.verify_hmac(env)?;
Ok(())
}
pub async fn acquire(
&self,
key: LeaseKey,
ttl: std::time::Duration,
) -> Result<OwnerLease, ClusterError> {
self.storage
.acquire_lease(&key, ttl)
.await
.map_err(|e| ClusterError::Storage(e.to_string()))
}
pub fn subscribe_local(&self) -> broadcast::Receiver<InvalidationEnvelope> {
self.tx.subscribe()
}
}
#[cfg(test)]
mod tests {
use super::*;
use exocortex_kernel::MemoryId;
use exocortex_pack_dev_v1::pack_def;
use exocortex_storage::{InMemoryStorage, StorageError};
use futures::StreamExt as _;
fn node() -> ClusterNode<InMemoryStorage> {
let ontology =
Arc::new(exocortex_kernel::Ontology::from_packs(vec![pack_def()]).expect("ontology"));
ClusterNode::new(
Arc::new(InMemoryStorage::new(ontology.clone())),
"feed-test".into(),
ontology.fingerprint,
[5; 32],
)
}
#[tokio::test]
async fn replay_floor_reports_the_oldest_buffered_lsn() {
let ontology =
Arc::new(exocortex_kernel::Ontology::from_packs(vec![pack_def()]).expect("ontology"));
let node = ClusterNode::new(
Arc::new(InMemoryStorage::new(ontology.clone())),
"floor-test".into(),
ontology.fingerprint,
[7; 32],
)
.with_replay_capacity(3);
assert_eq!(
node.replay_floor(),
1,
"an empty ring reports the documented default"
);
for lsn in 10u64..=13 {
node.admit_and_publish(node.envelope(Invalidation::MemoryUpserted {
id: MemoryId::new_v7(),
lsn,
}))
.unwrap();
}
assert!(matches!(node.replay_since(0), crate::Replay::TooOld));
assert_eq!(
node.replay_floor(),
11,
"a wrapped ring reports its oldest buffered LSN, never the newest observed"
);
}
#[tokio::test]
async fn decode_failure_and_eof_end_the_epoch_and_are_observable() {
let node = node();
let mut receiver = node.subscribe_local();
node.mark_feed_ready();
let mut first = futures::stream::iter(vec![
Ok(Invalidation::MemoryDeleted {
id: MemoryId::new_v7(),
lsn: 11,
}),
Err(StorageError::Backend("corrupt stream row".into())),
])
.boxed();
node.consume_feed_epoch(&mut first).await.expect("epoch");
assert_eq!(
receiver
.recv()
.await
.expect("published")
.inv
.unwrap()
.backend_lsn,
11
);
assert_eq!(
node.feed_health(),
FeedHealth {
epoch: 1,
ready: false,
failures: 1,
}
);
node.mark_feed_ready();
let mut second = futures::stream::empty().boxed();
node.consume_feed_epoch(&mut second).await.expect("epoch");
assert_eq!(
node.feed_health(),
FeedHealth {
epoch: 2,
ready: false,
failures: 2,
}
);
}
#[tokio::test]
async fn run_resubscribes_after_decode_failure_and_clean_eof() {
let ontology =
Arc::new(exocortex_kernel::Ontology::from_packs(vec![pack_def()]).expect("ontology"));
let storage = Arc::new(InMemoryStorage::new(ontology.clone()));
storage.fail_next_invalidation_epoch();
storage.end_next_invalidation_epoch();
let node = Arc::new(ClusterNode::new(
storage.clone(),
"run-retry".into(),
ontology.fingerprint,
[6; 32],
));
let mut feed_health = node.subscribe_feed_health();
let mut delivered = node.subscribe_local();
let runner = tokio::spawn(node.clone().run());
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let state = *feed_health.borrow_and_update();
if state.epoch >= 3 && state.ready && state.failures >= 2 {
break;
}
feed_health
.changed()
.await
.expect("run supervisor remains live");
}
})
.await
.expect("real run loop reaches a healthy third subscription");
let id = MemoryId::new_v7();
let commit = storage.delete_memory(&id).await.unwrap();
let envelope = tokio::time::timeout(std::time::Duration::from_secs(1), delivered.recv())
.await
.expect("recovered feed publishes")
.expect("local subscriber remains open");
let invalidation = envelope.inv.unwrap();
assert_eq!(invalidation.backend_lsn, commit.lsn);
assert!(matches!(
invalidation.kind,
Some(exocortex_wire::sse::v1::invalidation::Kind::MemoryDeleted(
_
))
));
runner.abort();
}
}