use std::sync::Arc;
use std::time::Duration;
use alkstore::{EnqueueOpts, QueueOpts, StopToken, Store, StreamEvent};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::registry::{CachedRegistryProvider, RegistryRow, RegistryWrite};
use alkgit::RegistryError;
pub const WAKE_CHANNEL: &str = "alkgit-registry";
pub const EVENTS_STREAM: &str = "alkgit-registry-events";
pub const MAINTENANCE_QUEUE: &str = "alkgit-maintenance";
pub const MAINTENANCE_LOCK: &str = "alkgit-sweeper";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RegistryEvent {
Put {
seq: i64,
record: alkgit::RepoRecord,
},
Mutation {
seq: i64,
repo_id: String,
},
Removed {
seq: i64,
repo_id: String,
},
}
impl RegistryEvent {
pub fn repo_id(&self) -> &str {
match self {
RegistryEvent::Put { record, .. } => &record.repo_id,
RegistryEvent::Mutation { repo_id, .. } | RegistryEvent::Removed { repo_id, .. } => {
repo_id
}
}
}
pub fn seq(&self) -> i64 {
match self {
RegistryEvent::Put { seq, .. }
| RegistryEvent::Mutation { seq, .. }
| RegistryEvent::Removed { seq, .. } => *seq,
}
}
pub fn decode_event(event: &StreamEvent) -> Option<RegistryEvent> {
let value = event.payload_as::<Value>().ok()?;
serde_json::from_value(value).ok()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MaintenanceJob {
PackHousekeeping {
repo_id: String,
},
Sweep,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AnnounceReport {
pub published: bool,
pub notified: bool,
pub enqueued_job_id: Option<i64>,
}
#[derive(Default)]
pub struct AppliedWatermark {
seq: std::sync::atomic::AtomicI64,
}
impl AppliedWatermark {
pub fn new() -> Arc<Self> {
Arc::new(AppliedWatermark::default())
}
pub fn observe(&self, seq: i64) {
self.seq
.fetch_max(seq, std::sync::atomic::Ordering::Relaxed);
}
pub fn get(&self) -> i64 {
self.seq.load(std::sync::atomic::Ordering::Relaxed)
}
}
pub struct Reactor {
store: Arc<dyn Store>,
registry: Arc<dyn RegistryWrite>,
provider: Arc<CachedRegistryProvider>,
}
const STOP_GRACE_MS: u64 = 50;
const WAKE_WAIT_MS: u64 = 250;
impl Reactor {
pub fn new(
store: Arc<dyn Store>,
registry: Arc<dyn RegistryWrite>,
provider: Arc<CachedRegistryProvider>,
) -> Self {
Reactor {
store,
registry,
provider,
}
}
pub fn store(&self) -> &Arc<dyn Store> {
&self.store
}
pub fn registry(&self) -> &Arc<dyn RegistryWrite> {
&self.registry
}
pub fn provider(&self) -> &Arc<CachedRegistryProvider> {
&self.provider
}
pub async fn announce(&self, event: &RegistryEvent) -> AnnounceReport {
let payload = match serde_json::to_value(event) {
Ok(payload) => payload,
Err(_) => {
return AnnounceReport::default();
}
};
let published = match self.store.stream(EVENTS_STREAM).await {
Ok(stream) => stream
.publish_with_key(Some(event.repo_id().to_owned()), payload.clone())
.await
.is_ok(),
Err(_) => false,
};
let notified = self.store.notify(WAKE_CHANNEL, payload).await.is_ok();
let enqueued_job_id = self
.enqueue(&MaintenanceJob::PackHousekeeping {
repo_id: event.repo_id().to_owned(),
})
.await
.ok();
AnnounceReport {
published,
notified,
enqueued_job_id,
}
}
pub async fn enqueue(&self, job: &MaintenanceJob) -> alkstore::Result<i64> {
let payload =
serde_json::to_value(job).map_err(|e| alkstore::Error::Codec(e.to_string()))?;
let queue = self
.store
.queue(MAINTENANCE_QUEUE, QueueOpts::default())
.await?;
queue.enqueue(payload, EnqueueOpts::default()).await
}
pub async fn reconcile(
&self,
applied: &AppliedWatermark,
) -> Result<Vec<RegistryRow>, alkgit::RegistryError> {
let rows = self.registry.rows_since(applied.get()).await?;
self.provider.refresh_from(&*self.registry).await?;
applied.observe(self.registry.watermark().await?);
Ok(rows)
}
pub async fn run_wake_runner(&self, stop: StopToken) -> alkstore::Result<()> {
let mut rx = self.store.listen(WAKE_CHANNEL).await?;
while !stop.is_cancelled() {
match rx.recv_timeout(Duration::from_millis(WAKE_WAIT_MS)).await {
Ok(Some(_)) => {
self.provider.refresh_from(&*self.registry).await.ok();
}
Ok(None) => {}
Err(_) => return Ok(()),
}
}
Ok(())
}
pub async fn run_stream_consumer(
&self,
consumer: &str,
applied: &AppliedWatermark,
stop: StopToken,
mut on_event: impl FnMut(RegistryEvent, StreamEvent) + Send,
) -> alkstore::Result<()> {
let stream = self.store.stream(EVENTS_STREAM).await?;
let mut rx = stream.subscribe(consumer).await?;
loop {
tokio::select! {
_ = stop_grace(&stop) => return Ok(()),
event = rx.recv() => {
match event {
None => return Ok(()),
Some(Err(_)) => {
tokio::time::sleep(Duration::from_millis(STOP_GRACE_MS)).await;
}
Some(Ok(event)) => {
if let Some(decoded) = RegistryEvent::decode_event(&event) {
let seq = decoded.seq();
if seq > applied.get() {
on_event(decoded, event.clone());
applied.observe(seq);
}
}
rx.save_offset()?;
}
}
}
}
}
}
}
pub struct AnnouncingRegistry {
durable: Arc<dyn RegistryWrite>,
reactor: Arc<Reactor>,
}
impl AnnouncingRegistry {
pub fn new(durable: Arc<dyn RegistryWrite>, reactor: Arc<Reactor>) -> Self {
AnnouncingRegistry { durable, reactor }
}
pub fn durable(&self) -> &Arc<dyn RegistryWrite> {
&self.durable
}
pub fn reactor(&self) -> &Arc<Reactor> {
&self.reactor
}
}
#[async_trait]
impl RegistryWrite for AnnouncingRegistry {
async fn put(&self, record: &alkgit::RepoRecord) -> Result<i64, RegistryError> {
let seq = self.durable.put(record).await?;
self.reactor
.announce(&RegistryEvent::Put {
seq,
record: record.clone(),
})
.await;
Ok(seq)
}
async fn update(&self, record: &alkgit::RepoRecord) -> Result<i64, RegistryError> {
let seq = self.durable.update(record).await?;
self.reactor
.announce(&RegistryEvent::Put {
seq,
record: record.clone(),
})
.await;
Ok(seq)
}
async fn remove(&self, repo_id: &str) -> Result<i64, RegistryError> {
let seq = self.durable.remove(repo_id).await?;
self.reactor
.announce(&RegistryEvent::Removed {
seq,
repo_id: repo_id.to_owned(),
})
.await;
Ok(seq)
}
async fn watermark(&self) -> Result<i64, RegistryError> {
self.durable.watermark().await
}
async fn rows_since(&self, seq: i64) -> Result<Vec<RegistryRow>, RegistryError> {
self.durable.rows_since(seq).await
}
}
async fn stop_grace(stop: &StopToken) {
let mut tick = tokio::time::interval(Duration::from_millis(STOP_GRACE_MS));
loop {
tick.tick().await;
if stop.is_cancelled() {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::Relaxed;
use std::sync::Mutex;
use alkstore::StreamHandle;
use alkstore::WakeReceiver;
use crate::registry::{InMemoryRegistry, RegistryRead};
#[cfg(feature = "sqlite")]
use crate::testutil::unique_db_path;
use crate::testutil::{record, spawn_wake_runner, stop_and_join, wait_until};
struct LegFailureStore {
inner: Box<dyn Store>,
fail_publish: AtomicBool,
fail_notify: AtomicBool,
fail_enqueue: AtomicBool,
}
#[derive(Clone, Copy, PartialEq)]
enum Leg {
Publish,
Notify,
Enqueue,
}
impl LegFailureStore {
fn suppressing(leg: Leg) -> Arc<Self> {
let store = Arc::new(LegFailureStore {
inner: Box::new(alkstore_mem::MemStore::new()),
fail_publish: AtomicBool::new(false),
fail_notify: AtomicBool::new(false),
fail_enqueue: AtomicBool::new(false),
});
match leg {
Leg::Publish => store.fail_publish.store(true, Relaxed),
Leg::Notify => store.fail_notify.store(true, Relaxed),
Leg::Enqueue => store.fail_enqueue.store(true, Relaxed),
}
store
}
}
impl Store for LegFailureStore {
fn begin_tx(
&self,
) -> alkstore::BoxedFuture<'_, alkstore::Result<Box<dyn alkstore::TxHandle + Send>>>
{
self.inner.begin_tx()
}
fn notify<'a>(
&'a self,
channel: &str,
payload: Value,
) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
if self.fail_notify.load(Relaxed) {
return Box::pin(async {
Err(alkstore::Error::database("notify leg suppressed (probe)"))
});
}
self.inner.notify(channel, payload)
}
fn listen<'a>(
&'a self,
channel: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn WakeReceiver>>> {
self.inner.listen(channel)
}
fn stream<'a>(
&'a self,
name: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn StreamHandle>>> {
if self.fail_publish.load(Relaxed) {
return Box::pin(async {
Err(alkstore::Error::database("publish leg suppressed (probe)"))
});
}
self.inner.stream(name)
}
fn queue<'a>(
&'a self,
name: &str,
opts: QueueOpts,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Queue>>> {
if self.fail_enqueue.load(Relaxed) {
return Box::pin(async {
Err(alkstore::Error::database("enqueue leg suppressed (probe)"))
});
}
self.inner.queue(name, opts)
}
fn outbox<'a>(
&'a self,
name: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Outbox>>> {
self.inner.outbox(name)
}
fn try_lock<'a>(
&'a self,
name: &str,
owner: &str,
ttl: i64,
) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<Box<dyn alkstore::Lock>>>> {
self.inner.try_lock(name, owner, ttl)
}
fn schedule<'a>(
&'a self,
name: &str,
spec: &str,
queue: &str,
payload: Value,
opts: alkstore::ScheduleOpts,
) -> alkstore::BoxedFuture<'a, alkstore::Result<alkstore::Schedule>> {
self.inner.schedule(name, spec, queue, payload, opts)
}
fn unschedule<'a>(
&'a self,
name: &str,
) -> alkstore::BoxedFuture<'a, alkstore::Result<bool>> {
self.inner.unschedule(name)
}
fn run_schedules<'a>(
&'a self,
stop: StopToken,
) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
self.inner.run_schedules(stop)
}
}
fn event(seq: i64, id: &str) -> RegistryEvent {
RegistryEvent::Put {
seq,
record: record(id),
}
}
fn assembly() -> (
Arc<Reactor>,
Arc<dyn RegistryWrite>,
Arc<CachedRegistryProvider>,
) {
let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
let provider = Arc::new(CachedRegistryProvider::new());
let reactor = Arc::new(Reactor::new(
mem_store(),
registry.clone(),
provider.clone(),
));
(reactor, registry, provider)
}
fn mem_store() -> Arc<dyn Store> {
Arc::new(alkstore_mem::MemStore::new())
}
async fn stage(reactor: &Reactor, events: &[RegistryEvent]) {
let stream = reactor.store().stream(EVENTS_STREAM).await.unwrap();
for event in events {
stream
.publish_with_key(
Some(event.repo_id().to_owned()),
serde_json::to_value(event).unwrap(),
)
.await
.unwrap();
}
}
type Seen = Arc<Mutex<Vec<(String, i64)>>>;
fn spawn_consumer(
reactor: Arc<Reactor>,
consumer: &'static str,
applied: Arc<AppliedWatermark>,
seen: Seen,
) -> (StopToken, tokio::task::JoinHandle<()>) {
let stop = StopToken::new();
let join = tokio::spawn({
let stop = stop.clone();
async move {
reactor
.run_stream_consumer(consumer, &applied, stop, move |event, raw| {
seen.lock()
.unwrap()
.push((event.repo_id().to_owned(), raw.offset));
})
.await
.expect("the consumer ran clean");
}
});
(stop, join)
}
#[tokio::test]
async fn announce_lands_all_three_legs() {
let (reactor, registry, _) = assembly();
let store = reactor.store().clone();
let mut listener = store.listen(WAKE_CHANNEL).await.unwrap();
let seq = registry.put(&record("demo")).await.unwrap();
let report = reactor
.announce(&RegistryEvent::Put {
seq,
record: record("demo"),
})
.await;
assert_eq!(
report,
AnnounceReport {
published: true,
notified: true,
enqueued_job_id: Some(1),
}
);
let wake = listener
.recv_timeout(Duration::from_secs(2))
.await
.unwrap()
.expect("wake source open");
assert_eq!(wake.channel, WAKE_CHANNEL);
let stream = store.stream(EVENTS_STREAM).await.unwrap();
let events = stream.read_since(0, 10).await.unwrap();
assert_eq!(
events.len(),
1,
"one announced mutation = one durable feed event"
);
assert_eq!(events[0].key.as_deref(), Some("demo"), "keyed by repo id");
assert_eq!(
RegistryEvent::decode_event(&events[0]),
Some(RegistryEvent::Put {
seq,
record: record("demo"),
})
);
let queue = store
.queue(MAINTENANCE_QUEUE, QueueOpts::default())
.await
.unwrap();
let job = queue
.get_job(report.enqueued_job_id.unwrap())
.await
.unwrap()
.expect("the enqueue leg's job row");
assert_eq!(
job.payload_as::<MaintenanceJob>().unwrap(),
MaintenanceJob::PackHousekeeping {
repo_id: "demo".to_string(),
}
);
}
#[tokio::test]
async fn announcing_registry_writes_and_announces_post_commit() {
let store = mem_store();
let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
let provider = Arc::new(CachedRegistryProvider::new());
let reactor = Arc::new(Reactor::new(
store.clone(),
registry.clone(),
provider.clone(),
));
let announced = AnnouncingRegistry::new(registry.clone(), reactor.clone());
let mut listener = store.listen(WAKE_CHANNEL).await.unwrap();
let seq = announced.put(&record("demo")).await.unwrap();
assert_eq!(seq, 1, "the durable mutation's seq is the wrapper's return");
assert_eq!(
registry.watermark().await.unwrap(),
1,
"the durable side is the watermark's truth"
);
let wake = listener
.recv_timeout(Duration::from_secs(2))
.await
.unwrap()
.expect("wake source open");
assert_eq!(wake.channel, WAKE_CHANNEL);
let stream = store.stream(EVENTS_STREAM).await.unwrap();
let events = stream.read_since(0, 10).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].key.as_deref(), Some("demo"));
assert_eq!(
RegistryEvent::decode_event(&events[0]),
Some(event(seq, "demo"))
);
}
#[tokio::test]
async fn announcing_registry_fail_soft_legs_do_not_fail_the_write() {
let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
let provider = Arc::new(CachedRegistryProvider::new());
let reactor = Arc::new(Reactor::new(
LegFailureStore::suppressing(Leg::Publish) as Arc<dyn Store>,
registry.clone(),
provider.clone(),
));
let announced = AnnouncingRegistry::new(registry.clone(), reactor.clone());
let seq = announced.put(&record("x")).await.unwrap();
assert_eq!(
seq, 1,
"the durable seq is exact though the announce's leg failed"
);
assert_eq!(registry.watermark().await.unwrap(), 1);
let applied = AppliedWatermark::new();
let caught = reactor.reconcile(&applied).await.unwrap();
assert_eq!(caught.len(), 1, "the suppressed window closes in one pass");
assert_eq!(provider.resolve("x"), Some(record("x")));
}
#[tokio::test]
async fn announce_legs_are_independent_and_fail_soft() {
let registry = Arc::new(InMemoryRegistry::new());
let provider = Arc::new(CachedRegistryProvider::new());
for (leg, expect) in [
(
Leg::Publish,
AnnounceReport {
published: false,
notified: true,
enqueued_job_id: Some(1),
},
),
(
Leg::Notify,
AnnounceReport {
published: true,
notified: false,
enqueued_job_id: Some(1),
},
),
(
Leg::Enqueue,
AnnounceReport {
published: true,
notified: true,
enqueued_job_id: None,
},
),
] {
let store = LegFailureStore::suppressing(leg);
let reactor = Reactor::new(
store.clone() as Arc<dyn Store>,
registry.clone(),
provider.clone(),
);
let seq = registry.put(&record("leg")).await.unwrap();
let event = RegistryEvent::Put {
seq,
record: record("leg"),
};
let report = reactor.announce(&event).await;
assert_eq!(
report, expect,
"the other legs are untouched by the suppressed leg's loss"
);
registry.remove("leg").await.unwrap();
}
}
#[tokio::test]
async fn suppressed_announce_closes_by_one_reconcile_pass() {
let (reactor, registry, provider) = assembly();
let applied = AppliedWatermark::new();
let seq = registry.put(&record("crash")).await.unwrap();
let _ = registry.put(&record("also-durable")).await.unwrap();
assert_eq!(
provider.resolve("crash"),
None,
"no announce: the cache is stale until reconcile (the crash window)"
);
let caught = reactor.reconcile(&applied).await.unwrap();
assert_eq!(
caught
.iter()
.map(|row| row.record.repo_id.as_str())
.collect::<Vec<_>>(),
vec!["crash", "also-durable"],
"rows_since(watermark) → snapshot install → watermark advance"
);
assert_eq!(provider.resolve("crash"), Some(record("crash")));
assert_eq!(
provider.resolve("also-durable"),
Some(record("also-durable"))
);
assert!(applied.get() >= seq);
let second = reactor.reconcile(&applied).await.unwrap();
assert!(
second.is_empty(),
"the second pass is empty — idempotent by construction"
);
}
#[tokio::test]
async fn reconcile_covers_removals_and_the_marker_watermark() {
let (reactor, registry, provider) = assembly();
let applied = AppliedWatermark::new();
let seq = registry.put(&record("a")).await.unwrap();
let removals = registry.remove("a").await.unwrap();
let caught = reactor.reconcile(&applied).await.unwrap();
assert!(
caught.is_empty(),
"the removal leaves no row — the catch-up read carries none (the snapshot install surfaces it)"
);
assert_eq!(
provider.resolve("a"),
None,
"the removal is in the snapshot"
);
assert!(
applied.get() >= removals,
"the removal consumed a sequence and the watermark advanced past it (past `seq` {seq})"
);
registry.put(&record("a")).await.unwrap();
let caught = reactor.reconcile(&applied).await.unwrap();
assert_eq!(
caught
.iter()
.map(|row| row.record.repo_id.as_str())
.collect::<Vec<_>>(),
vec!["a"],
"the marker leg's re-created row is caught up (per-seq mutation markers only move the watermark)"
);
assert_eq!(provider.resolve("a"), Some(record("a")));
let second = reactor.reconcile(&applied).await.unwrap();
assert!(second.is_empty());
}
#[tokio::test]
async fn wake_runner_refreshes_from_wakes_and_never_polls() {
let (reactor, registry, provider) = assembly();
let (stop, runner) = spawn_wake_runner(reactor.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let seq = registry.put(&record("wake-me")).await.unwrap();
reactor.announce(&event(seq, "wake-me")).await;
wait_until(|| provider.resolve("wake-me").is_some()).await;
assert_eq!(provider.resolve("wake-me"), Some(record("wake-me")));
tokio::time::sleep(Duration::from_millis(400)).await;
let before = provider.refresh_count();
tokio::time::sleep(Duration::from_millis(600)).await;
assert_eq!(
before,
provider.refresh_count(),
"no commits → no refreshes — the runner does not poll"
);
stop_and_join(stop, runner).await;
}
#[tokio::test]
async fn stream_consumer_replays_resumes_and_checkpoints() {
let (reactor, registry, _) = assembly();
let stream = reactor.store().stream(EVENTS_STREAM).await.unwrap();
let s1 = registry.put(&record("a")).await.unwrap();
stage(&reactor, &[event(s1, "a")]).await;
let applied = AppliedWatermark::new();
let seen: Seen = Arc::new(Mutex::new(Vec::new()));
let (stop, join) =
spawn_consumer(reactor.clone(), "consumer-1", applied.clone(), seen.clone());
wait_until(|| seen.lock().unwrap().len() == 1).await;
stop_and_join(stop, join).await;
assert_eq!(
&*seen.lock().unwrap(),
&[("a".to_string(), 1)],
"a fresh consumer replays from the log head"
);
assert_eq!(
stream.get_offset("consumer-1").await.unwrap(),
1,
"the durable cursor landed at the applied event's offset"
);
let s2 = registry.put(&record("b")).await.unwrap();
stage(&reactor, &[event(s2, "b")]).await;
seen.lock().unwrap().clear();
let (stop, join) =
spawn_consumer(reactor.clone(), "consumer-1", applied.clone(), seen.clone());
wait_until(|| seen.lock().unwrap().len() == 1).await;
stop_and_join(stop, join).await;
assert_eq!(
&*seen.lock().unwrap(),
&[("b".to_string(), 2)],
"restart resumes AFTER the saved checkpoint"
);
assert_eq!(stream.get_offset("consumer-1").await.unwrap(), 2);
let s3 = registry.put(&record("c")).await.unwrap();
stage(&reactor, &[event(s3, "c")]).await;
let fresh_seen: Seen = Arc::new(Mutex::new(Vec::new()));
let fresh_watermark = AppliedWatermark::new();
let (stop, join) = spawn_consumer(
reactor.clone(),
"consumer-2",
fresh_watermark,
fresh_seen.clone(),
);
wait_until(|| fresh_seen.lock().unwrap().len() == 3).await;
stop_and_join(stop, join).await;
assert_eq!(
&*fresh_seen.lock().unwrap(),
&[
("a".to_string(), 1),
("b".to_string(), 2),
("c".to_string(), 3)
],
"a fresh consumer name has no cursor — the whole feed replays"
);
}
#[tokio::test]
async fn stream_consumer_is_per_seq_idempotent_and_skips_poison_events() {
let (reactor, registry, _) = assembly();
assert_eq!(registry.put(&record("a")).await.unwrap(), 1);
registry.remove("a").await.unwrap();
assert_eq!(registry.put(&record("b")).await.unwrap(), 3);
let stream = reactor.store().stream(EVENTS_STREAM).await.unwrap();
stream
.publish(serde_json::json!("not-a-registry-event"))
.await
.unwrap();
stage(
&reactor,
&[
RegistryEvent::Put {
seq: 1,
record: record("a"),
},
RegistryEvent::Removed {
seq: 2,
repo_id: "a".to_string(),
},
RegistryEvent::Mutation {
seq: 3,
repo_id: "b".to_string(),
},
],
)
.await;
let applied = AppliedWatermark::new();
applied.observe(1);
let seen: Seen = Arc::new(Mutex::new(Vec::new()));
let (stop, join) = spawn_consumer(reactor.clone(), "idem", applied.clone(), seen.clone());
wait_until(|| seen.lock().unwrap().len() == 2).await;
stop_and_join(stop, join).await;
assert_eq!(
&*seen.lock().unwrap(),
&[("a".to_string(), 3), ("b".to_string(), 4)],
"seq 1 is at/below the watermark (checkpointed, not re-invoked); \
the poison event is checkpointed past without wedging the cursor"
);
assert_eq!(
stream.get_offset("idem").await.unwrap(),
4,
"every observation was checkpointed (monotone saves)"
);
assert_eq!(
applied.get(),
3,
"only applied events' sequences advance the watermark"
);
}
#[tokio::test]
async fn second_consumer_converges_via_the_feed_and_reconcile_is_empty() {
let (reactor, registry, _) = assembly();
let s1 = registry.put(&record("a")).await.unwrap();
let s2 = registry.put(&record("b")).await.unwrap();
stage(&reactor, &[event(s1, "a"), event(s2, "b")]).await;
let applied = AppliedWatermark::new();
let seen: Seen = Arc::new(Mutex::new(Vec::new()));
let (stop, join) =
spawn_consumer(reactor.clone(), "replica", applied.clone(), seen.clone());
wait_until(|| seen.lock().unwrap().len() == 2).await;
stop_and_join(stop, join).await;
assert_eq!(
&*seen.lock().unwrap(),
&[("a".to_string(), 1), ("b".to_string(), 2)]
);
assert_eq!(applied.get(), 2);
let caught = reactor.reconcile(&applied).await.unwrap();
assert!(
caught.is_empty(),
"the watermark caught up from the feed — the reconcile pass is empty"
);
}
#[tokio::test]
async fn enqueue_is_the_producer_side_round_trip() {
let (reactor, _, _) = assembly();
let store = reactor.store().clone();
let job_id = reactor
.enqueue(&MaintenanceJob::PackHousekeeping {
repo_id: "packed".to_string(),
})
.await
.unwrap();
let sweep_id = reactor.enqueue(&MaintenanceJob::Sweep).await.unwrap();
let queue = store
.queue(MAINTENANCE_QUEUE, QueueOpts::default())
.await
.unwrap();
let job = queue.get_job(job_id).await.unwrap().expect("job row");
assert_eq!(
job.payload_as::<MaintenanceJob>().unwrap(),
MaintenanceJob::PackHousekeeping {
repo_id: "packed".to_string(),
}
);
let sweep = queue.get_job(sweep_id).await.unwrap().expect("sweep row");
assert_eq!(
sweep.payload_as::<MaintenanceJob>().unwrap(),
MaintenanceJob::Sweep
);
}
#[tokio::test]
async fn registry_event_payloads_round_trip_and_poison_decodes_none() {
let stream = mem_store().stream(EVENTS_STREAM).await.unwrap();
for original in [
RegistryEvent::Put {
seq: 7,
record: record("owner/r"),
},
RegistryEvent::Mutation {
seq: 8,
repo_id: "owner/r".to_string(),
},
RegistryEvent::Removed {
seq: 9,
repo_id: "owner/r".to_string(),
},
] {
let value = serde_json::to_value(&original).unwrap();
assert_eq!(value["kind"], expected_kind(&original));
assert_eq!(value["seq"], original.seq());
let decoded: RegistryEvent = serde_json::from_value(value).unwrap();
assert_eq!(decoded, original);
}
let offset = stream
.publish(serde_json::json!("a foreign producer's event"))
.await
.unwrap();
let events = stream.read_since(offset - 1, 1).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(RegistryEvent::decode_event(&events[0]), None);
}
fn expected_kind(event: &RegistryEvent) -> &str {
match event {
RegistryEvent::Put { .. } => "put",
RegistryEvent::Mutation { .. } => "mutation",
RegistryEvent::Removed { .. } => "removed",
}
}
#[cfg(feature = "sqlite")]
async fn sqlite_store(path: &std::path::Path) -> Arc<dyn Store> {
let boxed = alkstore_sqlite::open(path.to_str().unwrap(), Default::default()).unwrap();
Arc::from(boxed)
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn announce_and_stream_consumer_round_trip_over_sqlite() {
let path = unique_db_path("reactor", "announce-cursor");
let store = sqlite_store(&path).await;
let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
let provider = Arc::new(CachedRegistryProvider::new());
let reactor = Arc::new(Reactor::new(
store.clone(),
registry.clone(),
provider.clone(),
));
let mut listener = store.listen(WAKE_CHANNEL).await.unwrap();
let seq = registry.put(&record("s")).await.unwrap();
let report = reactor.announce(&event(seq, "s")).await;
assert_eq!(
report,
AnnounceReport {
published: true,
notified: true,
enqueued_job_id: Some(1),
}
);
let wake = listener
.recv_timeout(Duration::from_secs(2))
.await
.unwrap()
.expect("wake source open");
assert_eq!(wake.channel, WAKE_CHANNEL);
let stream = store.stream(EVENTS_STREAM).await.unwrap();
let events = stream.read_since(0, 10).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].key.as_deref(), Some("s"));
assert_eq!(
RegistryEvent::decode_event(&events[0]),
Some(event(seq, "s"))
);
let applied = AppliedWatermark::new();
let seen: Seen = Arc::new(Mutex::new(Vec::new()));
let (stop, join) =
spawn_consumer(reactor.clone(), "sqlite-c", applied.clone(), seen.clone());
wait_until(|| seen.lock().unwrap().len() == 1).await;
stop_and_join(stop, join).await;
assert_eq!(&*seen.lock().unwrap(), &[("s".to_string(), 1)]);
assert_eq!(stream.get_offset("sqlite-c").await.unwrap(), 1);
let caught = reactor.reconcile(&applied).await.unwrap();
assert!(caught.is_empty(), "announced — reconcile is empty");
drop(reactor);
drop(store);
let _ = std::fs::remove_file(&path);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_backstop_unannounced_write_wakes_the_runner() {
let path = unique_db_path("reactor", "backstop");
let store = sqlite_store(&path).await;
let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
let provider = Arc::new(CachedRegistryProvider::new());
let seq = registry.put(&record("silent")).await.unwrap();
assert_eq!(seq, 1);
assert_eq!(provider.resolve("silent"), None, "cold cache");
let reactor = Arc::new(Reactor::new(
store.clone(),
registry.clone(),
provider.clone(),
));
let (stop, runner) = spawn_wake_runner(reactor.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let queue = store
.queue(MAINTENANCE_QUEUE, QueueOpts::default())
.await
.unwrap();
queue
.enqueue(serde_json::json!("probe"), EnqueueOpts::default())
.await
.unwrap();
wait_until(|| provider.resolve("silent").is_some()).await;
assert_eq!(provider.resolve("silent"), Some(record("silent")));
tokio::time::sleep(Duration::from_millis(400)).await;
let before = provider.refresh_count();
tokio::time::sleep(Duration::from_millis(600)).await;
assert_eq!(
before,
provider.refresh_count(),
"no commits → no refreshes — the runner does not poll"
);
stop_and_join(stop, runner).await;
drop(reactor);
drop(store);
let _ = std::fs::remove_file(&path);
}
}