Skip to main content

alkgit_substrate/
reactor.rs

1//! The reactive halves — the engine-generic reactor (ADR-019 §5,
2//! substrate.md §the reactive halves, POC-R's validated composition).
3//!
4//! Per record/mutation write, **after the durable commit**, the writing
5//! side announces its effects independently and fail-soft:
6//!
7//! 1. **Publish** → the durable change feed ([`EVENTS_STREAM`], keyed
8//!    by repo id) — per-consumer durable cursors, monotone saves, replay
9//!    from head; *per-seq idempotency is the consumer's rule* (keyed
10//!    publishes are carried metadata, not partitioning/dedup — the
11//!    alkstore streams contract).
12//! 2. **Notify** → the wake hint (`notify`/`listen` on [`WAKE_CHANNEL`])
13//!    — the invalidation trigger for every cached provider. Publish
14//!    precedes notify (the durable carrier before the latency hint).
15//! 3. **Enqueue** → the maintenance queue ([`MAINTENANCE_QUEUE`]) — the
16//!    housekeeping job riding the scheduler chain. Wave 6 landed the
17//!    enqueue producer side only (the recorded interpretation,
18//!    gate-confirmed); the **consumer side** — the chain (schedule →
19//!    queue → worker claim → leader TTL lock → sweep dispatch) and the
20//!    liveness sources — lives in [`crate::maintenance`] and
21//!    [`crate::liveness`].
22//!
23//! Every leg is fail-soft: its loss is exactly what the **reconcile
24//! sweep** exists to close — [`Reactor::reconcile`] re-reads
25//! `rows_since(applied_watermark)` → snapshot install → watermark
26//! advance, idempotent by construction (pure reads, monotone
27//! watermark), callable on demand and driven by the maintenance
28//! chain's scheduler tick. No
29//! cross-store transaction machinery exists anywhere: alkstore's
30//! `TxHandle` carries no consumer SQL, so an announce is always
31//! post-commit.
32//!
33//! # The sqlite/postgres asymmetry (designed-in, ADR-019 §5)
34//!
35//! - **sqlite**: the co-tenant file is itself the invalidation bus —
36//!   alkstore's watcher `data_version` poll fans a wake to `listen`
37//!   receivers on *any* commit to the file, including alkgit's own
38//!   registry writes on their own connection, including cross-process
39//!   (~3.2 ms measured, POC-R). A lost hint cannot exist while any
40//!   write lands; the reconcile sweep is a correctness backstop, not a
41//!   latency bound. The post-commit notify is redundant but kept — for
42//!   semantics and for postgres.
43//! - **postgres**: LISTEN/NOTIFY is **load-bearing** — no file-level
44//!   bump exists. A lost/failed notify leaves caches stale until the
45//!   reconcile tick: **the tick's cadence is the real staleness bound
46//!   on postgres**. (The live-pg asymmetry probe rides
47//!   `substrate/w6-registry-postgres`'s tests.)
48//!
49//! # The construction wiring
50//!
51//! [`Reactor`] is constructed from the assembly's engine handle
52//! (`Arc<dyn alkstore::Store>`) + the registry write side + the cached
53//! provider ([`CachedRegistryProvider`]) — the per-piece constructor
54//! the engine-twin adapter constructors wire (and the wave-8 single
55//! composition entry will unify; the recorded interpretation, flagged
56//! for the gate). Constructing the struct is infallible — every
57//! consumed surface is a fallible call at use time; the adapter-twin
58//! constructors own the loud-failure assembly posture
59//! ([`OpenError`](crate::OpenError)).
60//!
61//! # Names pinned here (the OQ-03 substrate-side inventory for this
62//! wave's members)
63//!
64//! `WAKE_CHANNEL`, `EVENTS_STREAM`, `MAINTENANCE_QUEUE`,
65//! `MAINTENANCE_LOCK`, `RegistryEvent` (the change-feed payload:
66//! `Put`/`Mutation`/`Removed` — `Mutation` is the per-push marker's
67//! kind), `MaintenanceJob` (the maintenance-queue payload vocabulary),
68//! `AnnounceReport`, `AppliedWatermark`, `Reactor`, and the announced
69//! writing surface (`AnnouncingRegistry` — the durable write side +
70//! the reactor's post-commit announce, the wrapper the engine-twin
71//! assembly constructors compose).
72
73use 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
85/// The wake channel — the notify/listen channel the announce's notify
86/// leg signals and the wake runner listens on. Wakes are opaque
87/// best-effort hints (coalesced, replay-free, possibly repeated);
88/// consumers re-read — the wake receiver's idempotency obligation is
89/// the provider's whole-table refresh (a swap, delta-free).
90/// Contract stamp: ADR-019 §5 (the notify wake leg).
91pub const WAKE_CHANNEL: &str = "alkgit-registry";
92
93/// The durable change feed — the record/mutation event log. Keyed
94/// publishes carry the repo id as metadata (per-seq idempotency, not
95/// the key, is the consumer's dedup). The feed is the notify leg's
96/// durable carrier: a wake is latency, an event is correctness.
97/// Contract stamp: ADR-019 §5 (the durable event leg).
98pub const EVENTS_STREAM: &str = "alkgit-registry-events";
99
100/// The maintenance queue — the enqueue leg's target and (from wave 7)
101/// the chain the scheduler fires and the maintenance worker claims
102/// from. Contract stamp: ADR-019 §5 (the maintenance enqueue leg's
103/// target; §6 — the chain consumes it).
104pub const MAINTENANCE_QUEUE: &str = "alkgit-maintenance";
105
106/// The named TTL lock the sweep dispatch takes for cross-process GC
107/// exclusion (a locked-out sweep is a skip + short retry, a value not
108/// an error — the chain's consumer side pins the usage, wave 7;
109/// ADR-019 §6).
110pub const MAINTENANCE_LOCK: &str = "alkgit-sweeper";
111
112/// The registry mutation event — the durable change feed's payload.
113/// Every variant carries the mutation's sequence number: the
114/// consumer's per-seq idempotency key and the reconcile watermark's
115/// currency. The `Mutation` variant is the per-push mutation-marker
116/// event's kind (ADR-019 §7 — pushes do not modify `RepoRecord`; the
117/// marker write's announce carries the bare mutation).
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(tag = "kind", rename_all = "snake_case")]
120pub enum RegistryEvent {
121    /// A record was created or replaced (the `put`/`update` mutations).
122    Put {
123        /// The mutation's sequence number.
124        seq: i64,
125        /// The record as committed.
126        record: alkgit::RepoRecord,
127    },
128    /// The repo mutated without a record change — the per-push
129    /// mutation-marker write's event (the watermark leg that drives
130    /// reconcile and diff-driven replication on every push).
131    Mutation {
132        /// The mutation's sequence number.
133        seq: i64,
134        /// The repo id the mutation anchored.
135        repo_id: String,
136    },
137    /// A record was removed (the removal consumed a sequence; the row
138    /// is gone — the event is the removal's only row-carrying trace).
139    Removed {
140        /// The mutation's sequence number.
141        seq: i64,
142        /// The removed repo id.
143        repo_id: String,
144    },
145}
146
147impl RegistryEvent {
148    /// The event's repo id (the publish key).
149    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    /// The event's sequence number.
159    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    /// Decode a feed event. Undecodable payloads (a foreign producer on
168    /// the stream, codec drift) yield `None` — the consumer skips and
169    /// checkpoints past them rather than wedging its cursor on the
170    /// poison event.
171    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/// The maintenance-queue payload vocabulary — what the enqueue leg
178/// lands and what the wave-7 worker dispatches on; additions extend
179/// the set.
180#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(tag = "kind", rename_all = "snake_case")]
182pub enum MaintenanceJob {
183    /// The per-mutation housekeeping job the announce's enqueue leg
184    /// lands — the pack work the mutated repo now owes the maintenance
185    /// worker (the wave-7 dispatch).
186    PackHousekeeping {
187        /// The repo whose mutation landed the job.
188        repo_id: String,
189    },
190    /// The scheduled GC sweep firing — the maintenance chain's
191    /// `@every` schedule payload (the chain's consumer side is wave
192    /// 7's: schedule → queue → worker claim → leader TTL lock →
193    /// sweep dispatch, which needs a registered liveness source).
194    Sweep,
195}
196
197/// The announce's per-leg report — fail-soft outcomes as observation
198/// data, never an error: every leg's loss is the reconcile sweep's
199/// closure, so a suppressed announce carries no error path.
200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
201pub struct AnnounceReport {
202    /// The publish leg landed (the durable event is in the feed).
203    pub published: bool,
204    /// The notify leg landed (the wake hint was signaled).
205    pub notified: bool,
206    /// The enqueue leg landed (`Some` carries the job id).
207    pub enqueued_job_id: Option<i64>,
208}
209
210/// The consumer-side high-water mark — the maximum sequence this
211/// composition has applied (stream events or a reconcile sweep).
212/// Monotone: per-repo keys reorder nothing (the feed is global FIFO by
213/// offset), but a reconcile may apply a sequence a live event never
214/// carried; a watermark only advances, so the composition converges to
215/// the durable side's own truth.
216///
217/// Process-local by design: the durable per-consumer cursor is the
218/// stream's own checkpoint (monotone saves; restart resumes *after*
219/// the saved checkpoint), so a restart re-delivers only the window
220/// between apply and checkpoint — and per-seq idempotency is the
221/// consumer's rule, exactly that re-application posture.
222#[derive(Default)]
223pub struct AppliedWatermark {
224    seq: std::sync::atomic::AtomicI64,
225}
226
227impl AppliedWatermark {
228    /// A fresh watermark at 0 (the whole table is unapplied —
229    /// `rows_since(0)`).
230    pub fn new() -> Arc<Self> {
231        Arc::new(AppliedWatermark::default())
232    }
233
234    /// Raise the watermark to `seq` if it is higher (monotone).
235    pub fn observe(&self, seq: i64) {
236        self.seq
237            .fetch_max(seq, std::sync::atomic::Ordering::Relaxed);
238    }
239
240    /// The current high-water mark.
241    pub fn get(&self) -> i64 {
242        self.seq.load(std::sync::atomic::Ordering::Relaxed)
243    }
244}
245
246/// The reactor — the composition's wired reactive halves, constructed
247/// from the substrate engine handle + the registry write side + the
248/// cached provider (the assembly-level pieces; the wave-8 composition
249/// entry unifies the whole backend's construction).
250///
251/// The registry write side is held for the wake runner's and the
252/// reconcile sweep's refresh reads (`rows_since`) — the pair the
253/// engine-twin adapters implement, so the reactor wiring is
254/// engine-generic (it ran unchanged over all three engines in POC-R).
255pub struct Reactor {
256    store: Arc<dyn Store>,
257    registry: Arc<dyn RegistryWrite>,
258    provider: Arc<CachedRegistryProvider>,
259}
260
261/// The stop-grace loop granularity of the stop-responsive consumer
262/// arms (responsiveness only — no consumer loop here wakes on a
263/// timer).
264const STOP_GRACE_MS: u64 = 50;
265
266/// The wake runner's wait ceiling — stop-token responsiveness on the
267/// receiver's timeout form; wakes never wait for it.
268const WAKE_WAIT_MS: u64 = 250;
269
270impl Reactor {
271    /// Construct the reactor from the assembly's pieces.
272    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    /// The substrate engine handle (the wave-7 maintenance chain rides
285    /// the same handle).
286    pub fn store(&self) -> &Arc<dyn Store> {
287        &self.store
288    }
289
290    /// The registry write side (the refresh source).
291    pub fn registry(&self) -> &Arc<dyn RegistryWrite> {
292        &self.registry
293    }
294
295    /// The cached provider (the invalidation target).
296    pub fn provider(&self) -> &Arc<CachedRegistryProvider> {
297        &self.provider
298    }
299
300    /// **The announce composition** — the one call a writing side makes
301    /// after its durable row-write transaction commits: publish
302    /// ([`EVENTS_STREAM`], keyed by repo id), notify ([`WAKE_CHANNEL`]),
303    /// and enqueue ([`MAINTENANCE_QUEUE`], a pack-housekeeping job for
304    /// the mutated repo) — each leg independent, each fail-soft. The
305    /// legs' outcomes are report data ([`AnnounceReport`]); this call
306    /// never fails, and any leg that failed is closed by the reconcile
307    /// sweep.
308    ///
309    /// A suppression (the process dying between the durable commit and
310    /// this call, or mid-legs) is the same posture — the sweep closes
311    /// the window in one pass. On postgres the notify leg is
312    /// load-bearing (the module docs' asymmetry): its loss leaves
313    /// caches stale until the reconcile tick.
314    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    /// The enqueue leg as a direct producer API — one housekeeping job
347    /// on [`MAINTENANCE_QUEUE`]. The consumer side (the claim → leader
348    /// lock → dispatch chain) is wave 7's; the job's payload
349    /// (`MaintenanceJob`) is that worker's dispatch vocabulary.
350    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    /// **The reconcile sweep** — the closure for any lost post-commit
361    /// effect: `rows_since(applied_watermark)` → snapshot install →
362    /// watermark advance. Idempotent by construction (pure reads, a
363    /// monotone watermark — the second pass is empty). Driven by the
364    /// scheduler tick once the maintenance chain's consumer side lands
365    /// (wave 7), callable on demand. Returns the rows it caught up on
366    /// (empty = nothing missed). A failed sweep changes nothing — the
367    /// watermark does not advance and the previous snapshot serves; the
368    /// next wake, write, or tick retries.
369    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    /// **The wake runner** — the invalidation trigger's delivery leg:
380    /// `listen([`WAKE_CHANNEL`])` → one whole-table refresh
381    /// ([`CachedRegistryProvider::refresh_from`]) per wake. Coalesced
382    /// bursts collapse engine-side (the sqlite watcher's burst
383    /// coalescing; per-notify on postgres) and one refresh carries a
384    /// whole burst — the count of refreshes is correct by the snapshot
385    /// swap. There is no polling at this layer: no commits → no
386    /// refreshes (the refresh count is stable across idle windows).
387    /// Refresh failures leave the previous snapshot serving (the next
388    /// wake, write, or tick retries).
389    ///
390    /// Returns when the source closes — a closed receiver never
391    /// reopens; recovery is a fresh `listen` (the runner attached
392    /// again; the reconcile tick is the staleness bound in between).
393    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    /// **The stream consumer** — the durable change feed's consumer
408    /// side under the alkstore cursor model: `subscribe(consumer)`
409    /// attaches at the consumer's stored checkpoint (absent = 0 = the
410    /// log head — a fresh consumer replays from the head; a restarted
411    /// one resumes *after* the saved checkpoint); each observation is
412    /// checkpointed after the apply (monotone saves — saves cannot
413    /// regress); **per-seq idempotency is applied as the consumer's
414    /// rule** — an event whose seq is at or below the applied watermark
415    /// is checkpointed past without re-invoking the callback.
416    ///
417    /// `on_event` receives the decoded event and its raw feed position.
418    /// An undecodable payload is checkpointed past (a foreign producer
419    /// must not wedge the cursor). A storage failure backs off and
420    /// retries — the feed is durable, silence loses nothing. Returns on
421    /// the receiver's terminal close (recovery is a fresh re-attach —
422    /// the saved checkpoint resumes) or a stopped token; only a
423    /// checkpoint save failure fails the run (the durable cursor is the
424    /// restart boundary — losing it silently would widen the replay),
425    /// and re-attaching retries it from the last landed checkpoint.
426    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
461/// **The announced write side** — the reactive composition's writing
462/// surface: the durable registry write side folded with the reactor,
463/// so every mutation's post-commit announce ([`Reactor::announce`],
464/// fail-soft each) fires right after its durable commit. This is the
465/// wrapper the engine-twin assembly constructors compose — the pair's
466/// durable contract below is unchanged (announce is never the pair's;
467/// the wrapper is the *caller*, and the announce's machinery is the
468/// reactor's).
469///
470/// Consumers bind this as the write side of their
471/// [`RegistryBackend`](crate::RegistryBackend) (the store methods then
472/// announce post-commit); a suppressed announce is the crash-window
473/// shape the reconcile sweep closes — the mutation still committed
474/// (the seq is transactionally exact, never announce-dependent).
475/// Contract stamp: ADR-019 §5 (the post-commit announce posture) and
476/// §7 (the announced receive flow).
477pub struct AnnouncingRegistry {
478    durable: Arc<dyn RegistryWrite>,
479    reactor: Arc<Reactor>,
480}
481
482impl AnnouncingRegistry {
483    /// Wrap the durable write side + the reactor that announces for it.
484    pub fn new(durable: Arc<dyn RegistryWrite>, reactor: Arc<Reactor>) -> Self {
485        AnnouncingRegistry { durable, reactor }
486    }
487
488    /// The durable write side below the announce (the marker write's
489    /// and the reconcile machinery's legs run at this layer).
490    pub fn durable(&self) -> &Arc<dyn RegistryWrite> {
491        &self.durable
492    }
493
494    /// The reactor firing the announce legs.
495    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    /// A `Store` double that suppresses one announce leg's entry point
570    /// (everything else delegates to the mem engine) — the
571    /// fail-soft/independence pin's driver.
572    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    /// Publish `RegistryEvent`s straight onto the feed — the test
725    /// producer path that bypasses the announce (the cursor tests
726    /// stage feeds precisely with it).
727    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    /// The announced write side composes the pair + the reactor: the
828    /// durable mutation commits and the announce fires post-commit.
829    #[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    /// A suppressed announce never fails the mutation — the seq is
867    /// transactionally exact, and the crash-window shape closes by one
868    /// reconcile pass.
869    #[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    /// The reactive halves ran unchanged over the sqlite engine in
1274    /// POC-R; this leg re-pins the announce + cursor composition over
1275    /// it (the wake backstop of the sqlite co-tenant file is the
1276    /// registry twin's probe — its file shape is `w6-registry-sqlite`'s).
1277    #[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    /// The sqlite backstop (POC-R's DQ-8 probe, re-landed at the
1335    /// reactor layer): an announced co-tenant write pops the
1336    /// listener's wake via the file-wide `data_version` bump — no
1337    /// explicit notify involved — and the runner's refresh surfaces
1338    /// the durable record; then no commits → no refreshes (no
1339    /// polling).
1340    #[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}