1use std::sync::Arc;
74use std::time::Duration;
75
76use alkstore::{EnqueueOpts, QueueOpts, StopToken, Store, StreamEvent};
77use async_trait::async_trait;
78use serde::{Deserialize, Serialize};
79use serde_json::Value;
80
81use crate::registry::{CachedRegistryProvider, RegistryRow, RegistryWrite};
82
83use alkgit::RegistryError;
84
85pub const WAKE_CHANNEL: &str = "alkgit-registry";
92
93pub const EVENTS_STREAM: &str = "alkgit-registry-events";
99
100pub const MAINTENANCE_QUEUE: &str = "alkgit-maintenance";
105
106pub const MAINTENANCE_LOCK: &str = "alkgit-sweeper";
111
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(tag = "kind", rename_all = "snake_case")]
120pub enum RegistryEvent {
121 Put {
123 seq: i64,
125 record: alkgit::RepoRecord,
127 },
128 Mutation {
132 seq: i64,
134 repo_id: String,
136 },
137 Removed {
140 seq: i64,
142 repo_id: String,
144 },
145}
146
147impl RegistryEvent {
148 pub fn repo_id(&self) -> &str {
150 match self {
151 RegistryEvent::Put { record, .. } => &record.repo_id,
152 RegistryEvent::Mutation { repo_id, .. } | RegistryEvent::Removed { repo_id, .. } => {
153 repo_id
154 }
155 }
156 }
157
158 pub fn seq(&self) -> i64 {
160 match self {
161 RegistryEvent::Put { seq, .. }
162 | RegistryEvent::Mutation { seq, .. }
163 | RegistryEvent::Removed { seq, .. } => *seq,
164 }
165 }
166
167 pub fn decode_event(event: &StreamEvent) -> Option<RegistryEvent> {
172 let value = event.payload_as::<Value>().ok()?;
173 serde_json::from_value(value).ok()
174 }
175}
176
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(tag = "kind", rename_all = "snake_case")]
182pub enum MaintenanceJob {
183 PackHousekeeping {
187 repo_id: String,
189 },
190 Sweep,
195}
196
197#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
201pub struct AnnounceReport {
202 pub published: bool,
204 pub notified: bool,
206 pub enqueued_job_id: Option<i64>,
208}
209
210#[derive(Default)]
223pub struct AppliedWatermark {
224 seq: std::sync::atomic::AtomicI64,
225}
226
227impl AppliedWatermark {
228 pub fn new() -> Arc<Self> {
231 Arc::new(AppliedWatermark::default())
232 }
233
234 pub fn observe(&self, seq: i64) {
236 self.seq
237 .fetch_max(seq, std::sync::atomic::Ordering::Relaxed);
238 }
239
240 pub fn get(&self) -> i64 {
242 self.seq.load(std::sync::atomic::Ordering::Relaxed)
243 }
244}
245
246pub struct Reactor {
256 store: Arc<dyn Store>,
257 registry: Arc<dyn RegistryWrite>,
258 provider: Arc<CachedRegistryProvider>,
259}
260
261const STOP_GRACE_MS: u64 = 50;
265
266const WAKE_WAIT_MS: u64 = 250;
269
270impl Reactor {
271 pub fn new(
273 store: Arc<dyn Store>,
274 registry: Arc<dyn RegistryWrite>,
275 provider: Arc<CachedRegistryProvider>,
276 ) -> Self {
277 Reactor {
278 store,
279 registry,
280 provider,
281 }
282 }
283
284 pub fn store(&self) -> &Arc<dyn Store> {
287 &self.store
288 }
289
290 pub fn registry(&self) -> &Arc<dyn RegistryWrite> {
292 &self.registry
293 }
294
295 pub fn provider(&self) -> &Arc<CachedRegistryProvider> {
297 &self.provider
298 }
299
300 pub async fn announce(&self, event: &RegistryEvent) -> AnnounceReport {
315 let payload = match serde_json::to_value(event) {
316 Ok(payload) => payload,
317 Err(_) => {
318 return AnnounceReport::default();
319 }
320 };
321
322 let published = match self.store.stream(EVENTS_STREAM).await {
323 Ok(stream) => stream
324 .publish_with_key(Some(event.repo_id().to_owned()), payload.clone())
325 .await
326 .is_ok(),
327 Err(_) => false,
328 };
329
330 let notified = self.store.notify(WAKE_CHANNEL, payload).await.is_ok();
331
332 let enqueued_job_id = self
333 .enqueue(&MaintenanceJob::PackHousekeeping {
334 repo_id: event.repo_id().to_owned(),
335 })
336 .await
337 .ok();
338
339 AnnounceReport {
340 published,
341 notified,
342 enqueued_job_id,
343 }
344 }
345
346 pub async fn enqueue(&self, job: &MaintenanceJob) -> alkstore::Result<i64> {
351 let payload =
352 serde_json::to_value(job).map_err(|e| alkstore::Error::Codec(e.to_string()))?;
353 let queue = self
354 .store
355 .queue(MAINTENANCE_QUEUE, QueueOpts::default())
356 .await?;
357 queue.enqueue(payload, EnqueueOpts::default()).await
358 }
359
360 pub async fn reconcile(
370 &self,
371 applied: &AppliedWatermark,
372 ) -> Result<Vec<RegistryRow>, alkgit::RegistryError> {
373 let rows = self.registry.rows_since(applied.get()).await?;
374 self.provider.refresh_from(&*self.registry).await?;
375 applied.observe(self.registry.watermark().await?);
376 Ok(rows)
377 }
378
379 pub async fn run_wake_runner(&self, stop: StopToken) -> alkstore::Result<()> {
394 let mut rx = self.store.listen(WAKE_CHANNEL).await?;
395 while !stop.is_cancelled() {
396 match rx.recv_timeout(Duration::from_millis(WAKE_WAIT_MS)).await {
397 Ok(Some(_)) => {
398 self.provider.refresh_from(&*self.registry).await.ok();
399 }
400 Ok(None) => {}
401 Err(_) => return Ok(()),
402 }
403 }
404 Ok(())
405 }
406
407 pub async fn run_stream_consumer(
427 &self,
428 consumer: &str,
429 applied: &AppliedWatermark,
430 stop: StopToken,
431 mut on_event: impl FnMut(RegistryEvent, StreamEvent) + Send,
432 ) -> alkstore::Result<()> {
433 let stream = self.store.stream(EVENTS_STREAM).await?;
434 let mut rx = stream.subscribe(consumer).await?;
435 loop {
436 tokio::select! {
437 _ = stop_grace(&stop) => return Ok(()),
438 event = rx.recv() => {
439 match event {
440 None => return Ok(()),
441 Some(Err(_)) => {
442 tokio::time::sleep(Duration::from_millis(STOP_GRACE_MS)).await;
443 }
444 Some(Ok(event)) => {
445 if let Some(decoded) = RegistryEvent::decode_event(&event) {
446 let seq = decoded.seq();
447 if seq > applied.get() {
448 on_event(decoded, event.clone());
449 applied.observe(seq);
450 }
451 }
452 rx.save_offset()?;
453 }
454 }
455 }
456 }
457 }
458 }
459}
460
461pub struct AnnouncingRegistry {
478 durable: Arc<dyn RegistryWrite>,
479 reactor: Arc<Reactor>,
480}
481
482impl AnnouncingRegistry {
483 pub fn new(durable: Arc<dyn RegistryWrite>, reactor: Arc<Reactor>) -> Self {
485 AnnouncingRegistry { durable, reactor }
486 }
487
488 pub fn durable(&self) -> &Arc<dyn RegistryWrite> {
491 &self.durable
492 }
493
494 pub fn reactor(&self) -> &Arc<Reactor> {
496 &self.reactor
497 }
498}
499
500#[async_trait]
501impl RegistryWrite for AnnouncingRegistry {
502 async fn put(&self, record: &alkgit::RepoRecord) -> Result<i64, RegistryError> {
503 let seq = self.durable.put(record).await?;
504 self.reactor
505 .announce(&RegistryEvent::Put {
506 seq,
507 record: record.clone(),
508 })
509 .await;
510 Ok(seq)
511 }
512
513 async fn update(&self, record: &alkgit::RepoRecord) -> Result<i64, RegistryError> {
514 let seq = self.durable.update(record).await?;
515 self.reactor
516 .announce(&RegistryEvent::Put {
517 seq,
518 record: record.clone(),
519 })
520 .await;
521 Ok(seq)
522 }
523
524 async fn remove(&self, repo_id: &str) -> Result<i64, RegistryError> {
525 let seq = self.durable.remove(repo_id).await?;
526 self.reactor
527 .announce(&RegistryEvent::Removed {
528 seq,
529 repo_id: repo_id.to_owned(),
530 })
531 .await;
532 Ok(seq)
533 }
534
535 async fn watermark(&self) -> Result<i64, RegistryError> {
536 self.durable.watermark().await
537 }
538
539 async fn rows_since(&self, seq: i64) -> Result<Vec<RegistryRow>, RegistryError> {
540 self.durable.rows_since(seq).await
541 }
542}
543
544async fn stop_grace(stop: &StopToken) {
545 let mut tick = tokio::time::interval(Duration::from_millis(STOP_GRACE_MS));
546 loop {
547 tick.tick().await;
548 if stop.is_cancelled() {
549 return;
550 }
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use std::sync::atomic::AtomicBool;
558 use std::sync::atomic::Ordering::Relaxed;
559 use std::sync::Mutex;
560
561 use alkstore::StreamHandle;
562 use alkstore::WakeReceiver;
563
564 use crate::registry::{InMemoryRegistry, RegistryRead};
565 #[cfg(feature = "sqlite")]
566 use crate::testutil::unique_db_path;
567 use crate::testutil::{record, spawn_wake_runner, stop_and_join, wait_until};
568
569 struct LegFailureStore {
573 inner: Box<dyn Store>,
574 fail_publish: AtomicBool,
575 fail_notify: AtomicBool,
576 fail_enqueue: AtomicBool,
577 }
578
579 #[derive(Clone, Copy, PartialEq)]
580 enum Leg {
581 Publish,
582 Notify,
583 Enqueue,
584 }
585
586 impl LegFailureStore {
587 fn suppressing(leg: Leg) -> Arc<Self> {
588 let store = Arc::new(LegFailureStore {
589 inner: Box::new(alkstore_mem::MemStore::new()),
590 fail_publish: AtomicBool::new(false),
591 fail_notify: AtomicBool::new(false),
592 fail_enqueue: AtomicBool::new(false),
593 });
594 match leg {
595 Leg::Publish => store.fail_publish.store(true, Relaxed),
596 Leg::Notify => store.fail_notify.store(true, Relaxed),
597 Leg::Enqueue => store.fail_enqueue.store(true, Relaxed),
598 }
599 store
600 }
601 }
602
603 impl Store for LegFailureStore {
604 fn begin_tx(
605 &self,
606 ) -> alkstore::BoxedFuture<'_, alkstore::Result<Box<dyn alkstore::TxHandle + Send>>>
607 {
608 self.inner.begin_tx()
609 }
610
611 fn notify<'a>(
612 &'a self,
613 channel: &str,
614 payload: Value,
615 ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
616 if self.fail_notify.load(Relaxed) {
617 return Box::pin(async {
618 Err(alkstore::Error::database("notify leg suppressed (probe)"))
619 });
620 }
621 self.inner.notify(channel, payload)
622 }
623
624 fn listen<'a>(
625 &'a self,
626 channel: &str,
627 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn WakeReceiver>>> {
628 self.inner.listen(channel)
629 }
630
631 fn stream<'a>(
632 &'a self,
633 name: &str,
634 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn StreamHandle>>> {
635 if self.fail_publish.load(Relaxed) {
636 return Box::pin(async {
637 Err(alkstore::Error::database("publish leg suppressed (probe)"))
638 });
639 }
640 self.inner.stream(name)
641 }
642
643 fn queue<'a>(
644 &'a self,
645 name: &str,
646 opts: QueueOpts,
647 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Queue>>> {
648 if self.fail_enqueue.load(Relaxed) {
649 return Box::pin(async {
650 Err(alkstore::Error::database("enqueue leg suppressed (probe)"))
651 });
652 }
653 self.inner.queue(name, opts)
654 }
655
656 fn outbox<'a>(
657 &'a self,
658 name: &str,
659 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Outbox>>> {
660 self.inner.outbox(name)
661 }
662
663 fn try_lock<'a>(
664 &'a self,
665 name: &str,
666 owner: &str,
667 ttl: i64,
668 ) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<Box<dyn alkstore::Lock>>>> {
669 self.inner.try_lock(name, owner, ttl)
670 }
671
672 fn schedule<'a>(
673 &'a self,
674 name: &str,
675 spec: &str,
676 queue: &str,
677 payload: Value,
678 opts: alkstore::ScheduleOpts,
679 ) -> alkstore::BoxedFuture<'a, alkstore::Result<alkstore::Schedule>> {
680 self.inner.schedule(name, spec, queue, payload, opts)
681 }
682
683 fn unschedule<'a>(
684 &'a self,
685 name: &str,
686 ) -> alkstore::BoxedFuture<'a, alkstore::Result<bool>> {
687 self.inner.unschedule(name)
688 }
689
690 fn run_schedules<'a>(
691 &'a self,
692 stop: StopToken,
693 ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
694 self.inner.run_schedules(stop)
695 }
696 }
697
698 fn event(seq: i64, id: &str) -> RegistryEvent {
699 RegistryEvent::Put {
700 seq,
701 record: record(id),
702 }
703 }
704
705 fn assembly() -> (
706 Arc<Reactor>,
707 Arc<dyn RegistryWrite>,
708 Arc<CachedRegistryProvider>,
709 ) {
710 let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
711 let provider = Arc::new(CachedRegistryProvider::new());
712 let reactor = Arc::new(Reactor::new(
713 mem_store(),
714 registry.clone(),
715 provider.clone(),
716 ));
717 (reactor, registry, provider)
718 }
719
720 fn mem_store() -> Arc<dyn Store> {
721 Arc::new(alkstore_mem::MemStore::new())
722 }
723
724 async fn stage(reactor: &Reactor, events: &[RegistryEvent]) {
728 let stream = reactor.store().stream(EVENTS_STREAM).await.unwrap();
729 for event in events {
730 stream
731 .publish_with_key(
732 Some(event.repo_id().to_owned()),
733 serde_json::to_value(event).unwrap(),
734 )
735 .await
736 .unwrap();
737 }
738 }
739
740 type Seen = Arc<Mutex<Vec<(String, i64)>>>;
741
742 fn spawn_consumer(
743 reactor: Arc<Reactor>,
744 consumer: &'static str,
745 applied: Arc<AppliedWatermark>,
746 seen: Seen,
747 ) -> (StopToken, tokio::task::JoinHandle<()>) {
748 let stop = StopToken::new();
749 let join = tokio::spawn({
750 let stop = stop.clone();
751 async move {
752 reactor
753 .run_stream_consumer(consumer, &applied, stop, move |event, raw| {
754 seen.lock()
755 .unwrap()
756 .push((event.repo_id().to_owned(), raw.offset));
757 })
758 .await
759 .expect("the consumer ran clean");
760 }
761 });
762 (stop, join)
763 }
764
765 #[tokio::test]
766 async fn announce_lands_all_three_legs() {
767 let (reactor, registry, _) = assembly();
768 let store = reactor.store().clone();
769
770 let mut listener = store.listen(WAKE_CHANNEL).await.unwrap();
771 let seq = registry.put(&record("demo")).await.unwrap();
772 let report = reactor
773 .announce(&RegistryEvent::Put {
774 seq,
775 record: record("demo"),
776 })
777 .await;
778 assert_eq!(
779 report,
780 AnnounceReport {
781 published: true,
782 notified: true,
783 enqueued_job_id: Some(1),
784 }
785 );
786
787 let wake = listener
788 .recv_timeout(Duration::from_secs(2))
789 .await
790 .unwrap()
791 .expect("wake source open");
792 assert_eq!(wake.channel, WAKE_CHANNEL);
793
794 let stream = store.stream(EVENTS_STREAM).await.unwrap();
795 let events = stream.read_since(0, 10).await.unwrap();
796 assert_eq!(
797 events.len(),
798 1,
799 "one announced mutation = one durable feed event"
800 );
801 assert_eq!(events[0].key.as_deref(), Some("demo"), "keyed by repo id");
802 assert_eq!(
803 RegistryEvent::decode_event(&events[0]),
804 Some(RegistryEvent::Put {
805 seq,
806 record: record("demo"),
807 })
808 );
809
810 let queue = store
811 .queue(MAINTENANCE_QUEUE, QueueOpts::default())
812 .await
813 .unwrap();
814 let job = queue
815 .get_job(report.enqueued_job_id.unwrap())
816 .await
817 .unwrap()
818 .expect("the enqueue leg's job row");
819 assert_eq!(
820 job.payload_as::<MaintenanceJob>().unwrap(),
821 MaintenanceJob::PackHousekeeping {
822 repo_id: "demo".to_string(),
823 }
824 );
825 }
826
827 #[tokio::test]
830 async fn announcing_registry_writes_and_announces_post_commit() {
831 let store = mem_store();
832 let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
833 let provider = Arc::new(CachedRegistryProvider::new());
834 let reactor = Arc::new(Reactor::new(
835 store.clone(),
836 registry.clone(),
837 provider.clone(),
838 ));
839 let announced = AnnouncingRegistry::new(registry.clone(), reactor.clone());
840
841 let mut listener = store.listen(WAKE_CHANNEL).await.unwrap();
842 let seq = announced.put(&record("demo")).await.unwrap();
843 assert_eq!(seq, 1, "the durable mutation's seq is the wrapper's return");
844 assert_eq!(
845 registry.watermark().await.unwrap(),
846 1,
847 "the durable side is the watermark's truth"
848 );
849
850 let wake = listener
851 .recv_timeout(Duration::from_secs(2))
852 .await
853 .unwrap()
854 .expect("wake source open");
855 assert_eq!(wake.channel, WAKE_CHANNEL);
856 let stream = store.stream(EVENTS_STREAM).await.unwrap();
857 let events = stream.read_since(0, 10).await.unwrap();
858 assert_eq!(events.len(), 1);
859 assert_eq!(events[0].key.as_deref(), Some("demo"));
860 assert_eq!(
861 RegistryEvent::decode_event(&events[0]),
862 Some(event(seq, "demo"))
863 );
864 }
865
866 #[tokio::test]
870 async fn announcing_registry_fail_soft_legs_do_not_fail_the_write() {
871 let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
872 let provider = Arc::new(CachedRegistryProvider::new());
873 let reactor = Arc::new(Reactor::new(
874 LegFailureStore::suppressing(Leg::Publish) as Arc<dyn Store>,
875 registry.clone(),
876 provider.clone(),
877 ));
878 let announced = AnnouncingRegistry::new(registry.clone(), reactor.clone());
879
880 let seq = announced.put(&record("x")).await.unwrap();
881 assert_eq!(
882 seq, 1,
883 "the durable seq is exact though the announce's leg failed"
884 );
885 assert_eq!(registry.watermark().await.unwrap(), 1);
886
887 let applied = AppliedWatermark::new();
888 let caught = reactor.reconcile(&applied).await.unwrap();
889 assert_eq!(caught.len(), 1, "the suppressed window closes in one pass");
890 assert_eq!(provider.resolve("x"), Some(record("x")));
891 }
892
893 #[tokio::test]
894 async fn announce_legs_are_independent_and_fail_soft() {
895 let registry = Arc::new(InMemoryRegistry::new());
896 let provider = Arc::new(CachedRegistryProvider::new());
897
898 for (leg, expect) in [
899 (
900 Leg::Publish,
901 AnnounceReport {
902 published: false,
903 notified: true,
904 enqueued_job_id: Some(1),
905 },
906 ),
907 (
908 Leg::Notify,
909 AnnounceReport {
910 published: true,
911 notified: false,
912 enqueued_job_id: Some(1),
913 },
914 ),
915 (
916 Leg::Enqueue,
917 AnnounceReport {
918 published: true,
919 notified: true,
920 enqueued_job_id: None,
921 },
922 ),
923 ] {
924 let store = LegFailureStore::suppressing(leg);
925 let reactor = Reactor::new(
926 store.clone() as Arc<dyn Store>,
927 registry.clone(),
928 provider.clone(),
929 );
930
931 let seq = registry.put(&record("leg")).await.unwrap();
932 let event = RegistryEvent::Put {
933 seq,
934 record: record("leg"),
935 };
936 let report = reactor.announce(&event).await;
937 assert_eq!(
938 report, expect,
939 "the other legs are untouched by the suppressed leg's loss"
940 );
941
942 registry.remove("leg").await.unwrap();
943 }
944 }
945
946 #[tokio::test]
947 async fn suppressed_announce_closes_by_one_reconcile_pass() {
948 let (reactor, registry, provider) = assembly();
949 let applied = AppliedWatermark::new();
950
951 let seq = registry.put(&record("crash")).await.unwrap();
952 let _ = registry.put(&record("also-durable")).await.unwrap();
953
954 assert_eq!(
955 provider.resolve("crash"),
956 None,
957 "no announce: the cache is stale until reconcile (the crash window)"
958 );
959
960 let caught = reactor.reconcile(&applied).await.unwrap();
961 assert_eq!(
962 caught
963 .iter()
964 .map(|row| row.record.repo_id.as_str())
965 .collect::<Vec<_>>(),
966 vec!["crash", "also-durable"],
967 "rows_since(watermark) → snapshot install → watermark advance"
968 );
969 assert_eq!(provider.resolve("crash"), Some(record("crash")));
970 assert_eq!(
971 provider.resolve("also-durable"),
972 Some(record("also-durable"))
973 );
974 assert!(applied.get() >= seq);
975
976 let second = reactor.reconcile(&applied).await.unwrap();
977 assert!(
978 second.is_empty(),
979 "the second pass is empty — idempotent by construction"
980 );
981 }
982
983 #[tokio::test]
984 async fn reconcile_covers_removals_and_the_marker_watermark() {
985 let (reactor, registry, provider) = assembly();
986 let applied = AppliedWatermark::new();
987
988 let seq = registry.put(&record("a")).await.unwrap();
989 let removals = registry.remove("a").await.unwrap();
990
991 let caught = reactor.reconcile(&applied).await.unwrap();
992 assert!(
993 caught.is_empty(),
994 "the removal leaves no row — the catch-up read carries none (the snapshot install surfaces it)"
995 );
996 assert_eq!(
997 provider.resolve("a"),
998 None,
999 "the removal is in the snapshot"
1000 );
1001 assert!(
1002 applied.get() >= removals,
1003 "the removal consumed a sequence and the watermark advanced past it (past `seq` {seq})"
1004 );
1005
1006 registry.put(&record("a")).await.unwrap();
1007 let caught = reactor.reconcile(&applied).await.unwrap();
1008 assert_eq!(
1009 caught
1010 .iter()
1011 .map(|row| row.record.repo_id.as_str())
1012 .collect::<Vec<_>>(),
1013 vec!["a"],
1014 "the marker leg's re-created row is caught up (per-seq mutation markers only move the watermark)"
1015 );
1016 assert_eq!(provider.resolve("a"), Some(record("a")));
1017
1018 let second = reactor.reconcile(&applied).await.unwrap();
1019 assert!(second.is_empty());
1020 }
1021
1022 #[tokio::test]
1023 async fn wake_runner_refreshes_from_wakes_and_never_polls() {
1024 let (reactor, registry, provider) = assembly();
1025 let (stop, runner) = spawn_wake_runner(reactor.clone());
1026 tokio::time::sleep(Duration::from_millis(200)).await;
1027
1028 let seq = registry.put(&record("wake-me")).await.unwrap();
1029 reactor.announce(&event(seq, "wake-me")).await;
1030
1031 wait_until(|| provider.resolve("wake-me").is_some()).await;
1032 assert_eq!(provider.resolve("wake-me"), Some(record("wake-me")));
1033
1034 tokio::time::sleep(Duration::from_millis(400)).await;
1035 let before = provider.refresh_count();
1036 tokio::time::sleep(Duration::from_millis(600)).await;
1037 assert_eq!(
1038 before,
1039 provider.refresh_count(),
1040 "no commits → no refreshes — the runner does not poll"
1041 );
1042
1043 stop_and_join(stop, runner).await;
1044 }
1045
1046 #[tokio::test]
1047 async fn stream_consumer_replays_resumes_and_checkpoints() {
1048 let (reactor, registry, _) = assembly();
1049 let stream = reactor.store().stream(EVENTS_STREAM).await.unwrap();
1050
1051 let s1 = registry.put(&record("a")).await.unwrap();
1052 stage(&reactor, &[event(s1, "a")]).await;
1053
1054 let applied = AppliedWatermark::new();
1055 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1056 let (stop, join) =
1057 spawn_consumer(reactor.clone(), "consumer-1", applied.clone(), seen.clone());
1058 wait_until(|| seen.lock().unwrap().len() == 1).await;
1059 stop_and_join(stop, join).await;
1060 assert_eq!(
1061 &*seen.lock().unwrap(),
1062 &[("a".to_string(), 1)],
1063 "a fresh consumer replays from the log head"
1064 );
1065 assert_eq!(
1066 stream.get_offset("consumer-1").await.unwrap(),
1067 1,
1068 "the durable cursor landed at the applied event's offset"
1069 );
1070
1071 let s2 = registry.put(&record("b")).await.unwrap();
1072 stage(&reactor, &[event(s2, "b")]).await;
1073 seen.lock().unwrap().clear();
1074 let (stop, join) =
1075 spawn_consumer(reactor.clone(), "consumer-1", applied.clone(), seen.clone());
1076 wait_until(|| seen.lock().unwrap().len() == 1).await;
1077 stop_and_join(stop, join).await;
1078 assert_eq!(
1079 &*seen.lock().unwrap(),
1080 &[("b".to_string(), 2)],
1081 "restart resumes AFTER the saved checkpoint"
1082 );
1083 assert_eq!(stream.get_offset("consumer-1").await.unwrap(), 2);
1084
1085 let s3 = registry.put(&record("c")).await.unwrap();
1086 stage(&reactor, &[event(s3, "c")]).await;
1087 let fresh_seen: Seen = Arc::new(Mutex::new(Vec::new()));
1088 let fresh_watermark = AppliedWatermark::new();
1089 let (stop, join) = spawn_consumer(
1090 reactor.clone(),
1091 "consumer-2",
1092 fresh_watermark,
1093 fresh_seen.clone(),
1094 );
1095 wait_until(|| fresh_seen.lock().unwrap().len() == 3).await;
1096 stop_and_join(stop, join).await;
1097 assert_eq!(
1098 &*fresh_seen.lock().unwrap(),
1099 &[
1100 ("a".to_string(), 1),
1101 ("b".to_string(), 2),
1102 ("c".to_string(), 3)
1103 ],
1104 "a fresh consumer name has no cursor — the whole feed replays"
1105 );
1106 }
1107
1108 #[tokio::test]
1109 async fn stream_consumer_is_per_seq_idempotent_and_skips_poison_events() {
1110 let (reactor, registry, _) = assembly();
1111
1112 assert_eq!(registry.put(&record("a")).await.unwrap(), 1);
1113 registry.remove("a").await.unwrap();
1114 assert_eq!(registry.put(&record("b")).await.unwrap(), 3);
1115
1116 let stream = reactor.store().stream(EVENTS_STREAM).await.unwrap();
1117 stream
1118 .publish(serde_json::json!("not-a-registry-event"))
1119 .await
1120 .unwrap();
1121 stage(
1122 &reactor,
1123 &[
1124 RegistryEvent::Put {
1125 seq: 1,
1126 record: record("a"),
1127 },
1128 RegistryEvent::Removed {
1129 seq: 2,
1130 repo_id: "a".to_string(),
1131 },
1132 RegistryEvent::Mutation {
1133 seq: 3,
1134 repo_id: "b".to_string(),
1135 },
1136 ],
1137 )
1138 .await;
1139
1140 let applied = AppliedWatermark::new();
1141 applied.observe(1);
1142
1143 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1144 let (stop, join) = spawn_consumer(reactor.clone(), "idem", applied.clone(), seen.clone());
1145 wait_until(|| seen.lock().unwrap().len() == 2).await;
1146 stop_and_join(stop, join).await;
1147
1148 assert_eq!(
1149 &*seen.lock().unwrap(),
1150 &[("a".to_string(), 3), ("b".to_string(), 4)],
1151 "seq 1 is at/below the watermark (checkpointed, not re-invoked); \
1152 the poison event is checkpointed past without wedging the cursor"
1153 );
1154 assert_eq!(
1155 stream.get_offset("idem").await.unwrap(),
1156 4,
1157 "every observation was checkpointed (monotone saves)"
1158 );
1159 assert_eq!(
1160 applied.get(),
1161 3,
1162 "only applied events' sequences advance the watermark"
1163 );
1164 }
1165
1166 #[tokio::test]
1167 async fn second_consumer_converges_via_the_feed_and_reconcile_is_empty() {
1168 let (reactor, registry, _) = assembly();
1169
1170 let s1 = registry.put(&record("a")).await.unwrap();
1171 let s2 = registry.put(&record("b")).await.unwrap();
1172 stage(&reactor, &[event(s1, "a"), event(s2, "b")]).await;
1173
1174 let applied = AppliedWatermark::new();
1175 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1176 let (stop, join) =
1177 spawn_consumer(reactor.clone(), "replica", applied.clone(), seen.clone());
1178 wait_until(|| seen.lock().unwrap().len() == 2).await;
1179 stop_and_join(stop, join).await;
1180
1181 assert_eq!(
1182 &*seen.lock().unwrap(),
1183 &[("a".to_string(), 1), ("b".to_string(), 2)]
1184 );
1185 assert_eq!(applied.get(), 2);
1186
1187 let caught = reactor.reconcile(&applied).await.unwrap();
1188 assert!(
1189 caught.is_empty(),
1190 "the watermark caught up from the feed — the reconcile pass is empty"
1191 );
1192 }
1193
1194 #[tokio::test]
1195 async fn enqueue_is_the_producer_side_round_trip() {
1196 let (reactor, _, _) = assembly();
1197 let store = reactor.store().clone();
1198
1199 let job_id = reactor
1200 .enqueue(&MaintenanceJob::PackHousekeeping {
1201 repo_id: "packed".to_string(),
1202 })
1203 .await
1204 .unwrap();
1205 let sweep_id = reactor.enqueue(&MaintenanceJob::Sweep).await.unwrap();
1206
1207 let queue = store
1208 .queue(MAINTENANCE_QUEUE, QueueOpts::default())
1209 .await
1210 .unwrap();
1211 let job = queue.get_job(job_id).await.unwrap().expect("job row");
1212 assert_eq!(
1213 job.payload_as::<MaintenanceJob>().unwrap(),
1214 MaintenanceJob::PackHousekeeping {
1215 repo_id: "packed".to_string(),
1216 }
1217 );
1218 let sweep = queue.get_job(sweep_id).await.unwrap().expect("sweep row");
1219 assert_eq!(
1220 sweep.payload_as::<MaintenanceJob>().unwrap(),
1221 MaintenanceJob::Sweep
1222 );
1223 }
1224
1225 #[tokio::test]
1226 async fn registry_event_payloads_round_trip_and_poison_decodes_none() {
1227 let stream = mem_store().stream(EVENTS_STREAM).await.unwrap();
1228
1229 for original in [
1230 RegistryEvent::Put {
1231 seq: 7,
1232 record: record("owner/r"),
1233 },
1234 RegistryEvent::Mutation {
1235 seq: 8,
1236 repo_id: "owner/r".to_string(),
1237 },
1238 RegistryEvent::Removed {
1239 seq: 9,
1240 repo_id: "owner/r".to_string(),
1241 },
1242 ] {
1243 let value = serde_json::to_value(&original).unwrap();
1244 assert_eq!(value["kind"], expected_kind(&original));
1245 assert_eq!(value["seq"], original.seq());
1246 let decoded: RegistryEvent = serde_json::from_value(value).unwrap();
1247 assert_eq!(decoded, original);
1248 }
1249
1250 let offset = stream
1251 .publish(serde_json::json!("a foreign producer's event"))
1252 .await
1253 .unwrap();
1254 let events = stream.read_since(offset - 1, 1).await.unwrap();
1255 assert_eq!(events.len(), 1);
1256 assert_eq!(RegistryEvent::decode_event(&events[0]), None);
1257 }
1258
1259 fn expected_kind(event: &RegistryEvent) -> &str {
1260 match event {
1261 RegistryEvent::Put { .. } => "put",
1262 RegistryEvent::Mutation { .. } => "mutation",
1263 RegistryEvent::Removed { .. } => "removed",
1264 }
1265 }
1266
1267 #[cfg(feature = "sqlite")]
1268 async fn sqlite_store(path: &std::path::Path) -> Arc<dyn Store> {
1269 let boxed = alkstore_sqlite::open(path.to_str().unwrap(), Default::default()).unwrap();
1270 Arc::from(boxed)
1271 }
1272
1273 #[cfg(feature = "sqlite")]
1278 #[tokio::test]
1279 async fn announce_and_stream_consumer_round_trip_over_sqlite() {
1280 let path = unique_db_path("reactor", "announce-cursor");
1281 let store = sqlite_store(&path).await;
1282 let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
1283 let provider = Arc::new(CachedRegistryProvider::new());
1284 let reactor = Arc::new(Reactor::new(
1285 store.clone(),
1286 registry.clone(),
1287 provider.clone(),
1288 ));
1289
1290 let mut listener = store.listen(WAKE_CHANNEL).await.unwrap();
1291 let seq = registry.put(&record("s")).await.unwrap();
1292 let report = reactor.announce(&event(seq, "s")).await;
1293 assert_eq!(
1294 report,
1295 AnnounceReport {
1296 published: true,
1297 notified: true,
1298 enqueued_job_id: Some(1),
1299 }
1300 );
1301
1302 let wake = listener
1303 .recv_timeout(Duration::from_secs(2))
1304 .await
1305 .unwrap()
1306 .expect("wake source open");
1307 assert_eq!(wake.channel, WAKE_CHANNEL);
1308
1309 let stream = store.stream(EVENTS_STREAM).await.unwrap();
1310 let events = stream.read_since(0, 10).await.unwrap();
1311 assert_eq!(events.len(), 1);
1312 assert_eq!(events[0].key.as_deref(), Some("s"));
1313 assert_eq!(
1314 RegistryEvent::decode_event(&events[0]),
1315 Some(event(seq, "s"))
1316 );
1317
1318 let applied = AppliedWatermark::new();
1319 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1320 let (stop, join) =
1321 spawn_consumer(reactor.clone(), "sqlite-c", applied.clone(), seen.clone());
1322 wait_until(|| seen.lock().unwrap().len() == 1).await;
1323 stop_and_join(stop, join).await;
1324 assert_eq!(&*seen.lock().unwrap(), &[("s".to_string(), 1)]);
1325 assert_eq!(stream.get_offset("sqlite-c").await.unwrap(), 1);
1326
1327 let caught = reactor.reconcile(&applied).await.unwrap();
1328 assert!(caught.is_empty(), "announced — reconcile is empty");
1329 drop(reactor);
1330 drop(store);
1331 let _ = std::fs::remove_file(&path);
1332 }
1333
1334 #[cfg(feature = "sqlite")]
1341 #[tokio::test]
1342 async fn sqlite_backstop_unannounced_write_wakes_the_runner() {
1343 let path = unique_db_path("reactor", "backstop");
1344 let store = sqlite_store(&path).await;
1345 let registry: Arc<dyn RegistryWrite> = Arc::new(InMemoryRegistry::new());
1346 let provider = Arc::new(CachedRegistryProvider::new());
1347
1348 let seq = registry.put(&record("silent")).await.unwrap();
1349 assert_eq!(seq, 1);
1350 assert_eq!(provider.resolve("silent"), None, "cold cache");
1351
1352 let reactor = Arc::new(Reactor::new(
1353 store.clone(),
1354 registry.clone(),
1355 provider.clone(),
1356 ));
1357 let (stop, runner) = spawn_wake_runner(reactor.clone());
1358 tokio::time::sleep(Duration::from_millis(200)).await;
1359
1360 let queue = store
1361 .queue(MAINTENANCE_QUEUE, QueueOpts::default())
1362 .await
1363 .unwrap();
1364 queue
1365 .enqueue(serde_json::json!("probe"), EnqueueOpts::default())
1366 .await
1367 .unwrap();
1368
1369 wait_until(|| provider.resolve("silent").is_some()).await;
1370 assert_eq!(provider.resolve("silent"), Some(record("silent")));
1371
1372 tokio::time::sleep(Duration::from_millis(400)).await;
1373 let before = provider.refresh_count();
1374 tokio::time::sleep(Duration::from_millis(600)).await;
1375 assert_eq!(
1376 before,
1377 provider.refresh_count(),
1378 "no commits → no refreshes — the runner does not poll"
1379 );
1380
1381 stop_and_join(stop, runner).await;
1382 drop(reactor);
1383 drop(store);
1384 let _ = std::fs::remove_file(&path);
1385 }
1386}