Skip to main content

alkgit_substrate/
maintenance.rs

1//! The GC maintenance chain's consumer side (wave 7 — substrate.md
2//! §GC; ADR-019 §6; POC-R's validated cross-process run, POC-R's
3//! `maintenance.rs`): the schedule registration + the runner, the
4//! maintenance worker's claim loop, the leader TTL lock, and the sweep
5//! dispatch — the chain that turns [`crate::reactor::MAINTENANCE_QUEUE`]'s
6//! payload vocabulary ([`MaintenanceJob`]) into GC.
7//!
8//! ```text
9//! MaintenanceChain::register_sweep_schedule (the `@every` schedule)
10//!   → the alkstore leader-elected scheduler (run_schedules — one
11//!     leader per fleet, the reserved `__alkstore_scheduler` lock)
12//!   → firing lands in the maintenance queue (the `Sweep` payload)
13//!   → worker claim (the queue's stamped visibility window is the
14//!     discipline: the worker acks inside it — a Sweep dispatch longer
15//!     than its stamped window simply lapses, and the at-least-once
16//!     redelivery is held off by these same locks: the redelivered
17//!     sweep is either a locked-out skip or an alkblobs `SweeperLock`
18//!     abort, never a concurrent double reclaim)
19//!   → named-TTL leader lock ([`MAINTENANCE_LOCK`], cross-process GC
20//!        exclusion; a locked-out sweep is a skip + short retry, a
21//!        value not an error)
22//!   → alkblobs Store::sweep() -> Result<SweepReport, Error>
23//!        (the report is the GC outcome carrier — stages, deletes,
24//!        aborts; Err is substrate-level failure: a data verdict + a
25//!        queue retry, the no-log-dep posture — the outcome is
26//!        observable through the [`WorkerEvent`] seam and get_job)
27//! ```
28//!
29//! Wave 6 landed the enqueue producer side only (the recorded
30//! interpretation, gate-confirmed); this module is the consumer side —
31//! and, the wave-6 gate's N-b rider, the **runner supervision** the
32//! chain owns:
33//!
34//! - the **wake runner** ([`Reactor::run_wake_runner`]) returns
35//!   `Ok(())` on both `recv_timeout` `Err` arms (the alkstore
36//!   contract's `Closed` source-death and `Database` storage-failure
37//!   arms collapsed by design), so until supervised a runner stop
38//!   means wake-driven refresh stops silently — [`MaintenanceChain`]'s
39//!   supervision restarts it on **both** stop shapes (recovery is a
40//!   fresh `listen`, [`Reactor::run_wake_runner`]'s posture; the
41//!   stale window between stop and restart is exactly what the
42//!   reconcile tick exists to bound).
43//! - the **reconcile tick's driving mechanism** (the wave-6 recorded
44//!   interpretation's pinned answer): the chain itself ticks it —
45//!   [`MaintenanceOpts::reconcile_tick`], one
46//!   [`Reactor::reconcile`] pass per tick alongside the worker loop.
47//!   (Of the three candidates — a schedule firing, a worker-loop
48//!   tick, a dedicated job — the chain tick is the shape the
49//!   supervision already owns: schedule firings land *GC* jobs into
50//!   the queue, and a reconcile job variant would be a second
51//!   vocabulary member for what the runner owns anyway.)
52//! - the **scheduler runner** supervision: `run_schedules`
53//!   respawns on `Err(LeadershipLost)` (the alkstore recipe) and
54//!   propagates any other stop — a substrate-level failure of the
55//!   scheduler leg is the chain's failure (the closed-store posture:
56//!   respawning against a dead store is misdirected).
57//!
58//! # The pack-housekeeping consumer (pinned in-task)
59//!
60//! The `PackHousekeeping` job's consumer action is the through-store
61//! pack cache's release path ([`PackCache::release_all`]) — the
62//! released packs' pins drop, and the next sweep reclaims their pack
63//! blobs (substrate.md §object storage). The whole cache releases:
64//! the request-digest cache is process-local, per-repo scoping is a
65//! non-goal, and the enqueue leg's repo id is observation data only. An
66//! assembly without a through-store [`PackGen`](crate::packgen::PackGen)
67//! carries no cache ([`MaintenanceChain::new`]'s `packs: None`) — such
68//! a job acks with nothing to release, the composition-honest shape.
69//!
70//! # The sweep verdicts (data, never errors)
71//!
72//! - a completed report (`SweepReport.aborted` included — an aborted
73//!   sweep is still a ran sweep, its result carried) — acked;
74//! - a locked-out sweep ([`MAINTENANCE_LOCK`] held elsewhere —
75//!   cross-process GC exclusion is the point) — a skip + short retry,
76//!   a value: the job goes back to pending with
77//!   [`LOCKED_OUT_RETRY_SECS`];
78//! - a substrate-level sweep failure (alkblobs `Err`) — a data
79//!   verdict + a queue retry (the at-least-once curve runs it again;
80//!   max attempts dead-letters it, observable via get_job).
81//!
82//! A poison payload (a foreign producer on the queue, codec drift)
83//! dead-letters immediately (`fail`) — the stream consumer's poison
84//! posture, queue-shaped: never a wedge, never a retry loop.
85
86use std::sync::Arc;
87use std::time::Duration;
88
89use alkstore::{JobHandle, Queue, QueueOpts, Schedule, ScheduleOpts, StopToken, Store};
90
91use alkblobs::Store as BlobStore;
92use serde_json::json;
93
94use crate::packgen::PackCache;
95use crate::reactor::{
96    AppliedWatermark, MaintenanceJob, Reactor, MAINTENANCE_LOCK, MAINTENANCE_QUEUE,
97};
98
99/// The sweep schedule's name — registered (upsert by name) firing
100/// into [`MAINTENANCE_QUEUE`] with the `MaintenanceJob::Sweep` payload
101/// (the POC-R-validated schedule; the chain's runner supervision owns
102/// the `run_schedules` side).
103pub const SWEEP_SCHEDULE: &str = "alkgit-sweep-schedule";
104
105/// Seconds a locked-out sweep goes back to pending for (the short
106/// retry — a value, not an error; ADR-019 §6; the schedule's own
107/// cadence is the backstop after it).
108pub const LOCKED_OUT_RETRY_SECS: i64 = 2;
109
110/// The stop loop's granularity for the chain's stop-responsive arms
111/// (the reactor's posture: responsiveness, not a wake source).
112const STOP_GRACE_MS: u64 = 50;
113
114/// The chain's tuning datums (assembly-level knobs, not structure —
115/// the schedule spec is the sweep cadence; the tick is the postgres
116/// staleness bound; the rest is the loop granularity).
117///
118/// The defaults: a 30-second sweep cadence (`@every 30s`), a
119/// 30-second reconcile tick, a 500 ms worker poll, a 1 s wake-restart
120/// backoff, and a 30 s leader-lock TTL. Every value exists for the
121/// tests' short forms too — pass a `MaintenanceOpts` with the fields
122/// set.
123#[derive(Clone, Debug)]
124pub struct MaintenanceOpts {
125    /// The `@every` spec the sweep schedule registers with
126    /// (`schedule()`'s v1 grammar).
127    pub sweep_spec: String,
128    /// The reconcile tick's cadence — one [`Reactor::reconcile`] pass
129    /// per tick (the pinned driving mechanism; on postgres this tick
130    /// is the cache staleness's real bound after a lost hint).
131    pub reconcile_tick: Duration,
132    /// The worker claim loop's poll cadence.
133    pub worker_poll: Duration,
134    /// The supervised wake runner's restart backoff (both stop
135    /// shapes; a fresh `listen` after the sleep).
136    pub wake_retry_backoff: Duration,
137    /// The leader lock's TTL seconds (`try_lock` — held across the
138    /// sweep; a sweep that outgrows the TTL lapses it silently and the
139    /// sweep's own `SweeperLock` lease still serializes the stores).
140    pub lock_ttl_secs: i64,
141}
142
143impl Default for MaintenanceOpts {
144    fn default() -> Self {
145        MaintenanceOpts {
146            sweep_spec: "@every 30s".to_owned(),
147            reconcile_tick: Duration::from_secs(30),
148            worker_poll: Duration::from_millis(500),
149            wake_retry_backoff: Duration::from_secs(1),
150            lock_ttl_secs: 30,
151        }
152    }
153}
154
155/// What the maintenance worker did — the observation seam (the
156/// logged-as-data posture: the outcome carriers surface here, no
157/// tracing dependency exists anywhere). A consumer of
158/// [`MaintenanceChain::run`] sees every executed or skipped job.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub enum WorkerEvent {
161    /// A `MaintenanceJob::Sweep` job executed — the verdict below.
162    Sweep(SweepVerdict),
163    /// A `MaintenanceJob::PackHousekeeping` job executed — the
164    /// released count ([`PackCache::release_all`]).
165    PackHousekeeping(usize),
166}
167
168/// A sweep job's outcome, as data (substrate.md §GC — the chain never
169/// turns a sweep outcome into an error).
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub enum SweepVerdict {
172    /// The store ran the sweep and reported — the [`alkblobs::SweepReport`]
173    /// carries stages, deletes, aborts (an aborted sweep is a ran
174    /// sweep whose cause is carried; the report is the outcome
175    /// carrier).
176    Report(alkblobs::SweepReport),
177    /// The leader lock was held elsewhere: a skip + short retry — a
178    /// value, not an error (the cross-process exclusion doing its
179    /// job; the job goes back to pending for
180    /// [`LOCKED_OUT_RETRY_SECS`]).
181    LockedOut,
182    /// A substrate-level sweep failure (the store's `Err`): the
183    /// string is the error's display for the data carrier; the job
184    /// retries on the queue's curve (at-least-once; the attempt
185    /// budget dead-letters it — observable via get_job).
186    Failed(String),
187}
188
189/// The maintenance chain's consumer side — the schedule registration +
190/// the runner, the worker's claim loop, the leader TTL lock, the sweep
191/// dispatch, and the supervised halves the wave-6 gate booked the
192/// chain to own (the wake runner's restart on both stop shapes; the
193/// reconcile tick's driving).
194///
195/// Contract stamp: substrate.md §GC; ADR-019 §6.
196///
197/// Constructed from the assembly's pieces: the engine handle (the
198/// same one the reactor rides), the alkblobs store (the
199/// `sweep()` leg's target — the liveness source itself is
200/// registered at the composition construction, e.g.
201/// [`crate::liveness::RefClosure::register`]; **a sweep dispatched
202/// without one aborts with `NoLivenessSources`** — the report carries
203/// it), the reactor + the applied watermark (the supervision +
204/// reconcile legs), the shared through-store pack cache when the
205/// assembly serves packs through-store, and the tuning datums.
206///
207/// The wave-8 single composition entry unifies the construction;
208/// this is the per-piece constructor (the recorded interpretation —
209/// `substrate/w6-scaffold-manifest`'s shape).
210pub struct MaintenanceChain {
211    store: Arc<dyn Store>,
212    blobs: Arc<BlobStore>,
213    reactor: Arc<Reactor>,
214    applied: Arc<AppliedWatermark>,
215    packs: Option<Arc<PackCache>>,
216    opts: MaintenanceOpts,
217}
218
219impl MaintenanceChain {
220    /// The chain over the assembly's pieces. `packs` is the
221    /// through-store pack cache when the assembly serves packs
222    /// through-store (`None` on the direct shape — the
223    /// pack-housekeeping job then acks with nothing to release).
224    pub fn new(
225        store: Arc<dyn Store>,
226        blobs: Arc<BlobStore>,
227        reactor: Arc<Reactor>,
228        applied: Arc<AppliedWatermark>,
229        packs: Option<Arc<PackCache>>,
230        opts: MaintenanceOpts,
231    ) -> Self {
232        MaintenanceChain {
233            store,
234            blobs,
235            reactor,
236            applied,
237            packs,
238            opts,
239        }
240    }
241
242    /// The engine handle (the schedule, the queue and the lock ride
243    /// it — the same handle the reactor rides).
244    pub fn store(&self) -> &Arc<dyn Store> {
245        &self.store
246    }
247
248    /// The alkblobs store (the sweep dispatch's target).
249    pub fn blobs(&self) -> &Arc<BlobStore> {
250        &self.blobs
251    }
252
253    /// The chain's tuning datums.
254    pub fn opts(&self) -> &MaintenanceOpts {
255        &self.opts
256    }
257
258    /// **The schedule registration** — upsert [`SWEEP_SCHEDULE`]
259    /// firing into [`MAINTENANCE_QUEUE`] with the
260    /// `MaintenanceJob::Sweep` payload (the worker's dispatch
261    /// vocabulary). Idempotent by upsert; the spec is
262    /// [`schedule()`](alkstore::Store::schedule)'s v1 grammar.
263    pub async fn register_sweep_schedule(&self, spec: &str) -> alkstore::Result<Schedule> {
264        let payload = json!(MaintenanceJob::Sweep);
265        self.store
266            .schedule(
267                SWEEP_SCHEDULE,
268                spec,
269                MAINTENANCE_QUEUE,
270                payload,
271                ScheduleOpts::default(),
272            )
273            .await
274    }
275
276    /// **The chain's run** — registration, the supervised legs, the
277    /// reconciliation tick, and the worker's claim loop, all under
278    /// one stop token:
279    ///
280    /// - the **sweep schedule** is upserted first (loud — a chain
281    ///   without its schedule never fires);
282    /// - the **scheduler runner** runs under supervision — respawned
283    ///   on `Err(LeadershipLost)`; any other stop (the alkstore
284    ///   contract's closed-store/storage-failure posture) is
285    ///   propagated: the chain returns it. The legs wind down via the
286    ///   chain's own internal token — cancelled on every return (the
287    ///   wave-7 gate's O-b disposition: the wake leg's restart loop
288    ///   exits on it without waiting out the caller's token; the
289    ///   caller's stop token is never cancelled by the chain).
290    /// - the **wake runner** is supervised — restarted on both stop
291    ///   shapes (the N-b rider: the runner's `Ok(())` on a
292    ///   `recv_timeout`/`listen` failure is the collapsed stop; the
293    ///   restart's backoff is
294    ///   [`MaintenanceOpts::wake_retry_backoff`], the recovery a
295    ///   fresh `listen`);
296    /// - the **reconcile tick** drives one [`Reactor::reconcile`]
297    ///   pass every [`MaintenanceOpts::reconcile_tick`] (fail-soft —
298    ///   a failed pass leaves the previous snapshot serving);
299    /// - the **worker claim loop** drains [`MAINTENANCE_QUEUE`] on
300    ///   every poll tick (claim until no work, dispatch on the
301    ///   [`MaintenanceJob`] vocabulary, observation through
302    ///   `on_event`).
303    ///
304    /// Returns `Ok(())` on a cancelled stop token (legs joined) and
305    /// `Err(alkstore::Error)` on a scheduler-leg substrate failure.
306    pub async fn run(
307        &self,
308        worker_id: &str,
309        stop: StopToken,
310        mut on_event: impl FnMut(WorkerEvent) + Send,
311    ) -> alkstore::Result<()> {
312        self.register_sweep_schedule(&self.opts.sweep_spec).await?;
313        let queue = self
314            .store
315            .queue(MAINTENANCE_QUEUE, QueueOpts::default())
316            .await?;
317
318        let chain_stop = StopToken::new();
319
320        // The scheduler leg reports through a channel — the leg's
321        // outcome (and a panic's silent close) reaches the run loop
322        // without ever double-polling a completed task handle (the
323        // O-b-era shape's latent bug the probe caught).
324        let (sched_done, sched_rx) = tokio::sync::oneshot::channel();
325        let sched_store = self.store.clone();
326        let sched_stop = stop.clone();
327        let handler = tokio::spawn(async move {
328            let outcome = loop {
329                match sched_store.run_schedules(sched_stop.clone()).await {
330                    Err(alkstore::Error::LeadershipLost) => continue,
331                    outcome => break outcome,
332                }
333            };
334            let _ = sched_done.send(outcome);
335        });
336
337        let wake_reactor = self.reactor.clone();
338        let wake_stop = stop.clone();
339        let wake_chain_stop = chain_stop.clone();
340        let backoff = self.opts.wake_retry_backoff;
341        let wake = tokio::spawn(async move {
342            loop {
343                if wake_stop.is_cancelled() || wake_chain_stop.is_cancelled() {
344                    return;
345                }
346                let runner = wake_reactor.run_wake_runner(wake_stop.clone());
347                tokio::pin!(runner);
348                tokio::select! {
349                    _ = &mut runner => {}
350                    _ = stop_grace(&wake_chain_stop) => return,
351                }
352                if wake_stop.is_cancelled() || wake_chain_stop.is_cancelled() {
353                    return;
354                }
355                tokio::time::sleep(backoff).await;
356            }
357        });
358
359        let mut poll = tokio::time::interval(self.opts.worker_poll);
360        poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
361        let mut rec_tick = tokio::time::interval(self.opts.reconcile_tick);
362        rec_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
363        tokio::pin!(sched_rx);
364
365        let outcome = loop {
366            tokio::select! {
367                _ = stop_grace(&stop) => break Ok(()),
368                sched_result = &mut sched_rx => break match sched_result {
369                    Ok(result) => result,
370                    Err(_) => Err(alkstore::Error::database(
371                        "the scheduler runner task failed without reporting".to_owned(),
372                    )),
373                },
374                _ = rec_tick.tick() => {
375                    let _ = self.reactor.reconcile(&self.applied).await;
376                }
377                _ = poll.tick() => {
378                    let _ = self.drain(&*queue, worker_id, &stop, &mut on_event).await;
379                }
380            }
381        };
382
383        // The O-b rider's wired disposition: the chain's internal token
384        // is the legs' wind-down — cancelled on every chain return (the
385        // caller's stop, the scheduler leg's substrate failure — the
386        // wake leg's runner is cut and the restart loop exits without
387        // waiting out the caller's own token, and the join's window
388        // stays a safety net, not the mechanism). Nothing cancels it
389        // while the loop runs, so the arm it used to own is dropped,
390        // not kept dead.
391        chain_stop.cancel();
392        join_legs(handler, wake).await;
393        outcome
394    }
395
396    /// One poll tick's claim loop: claim until no work (or a stop),
397    /// dispatching each job on the [`MaintenanceJob`] vocabulary.
398    /// Claim failures are transient (a busy or closing storage) — the
399    /// next poll tick retries; the queue's own visibility discipline
400    /// governs re-delivery. `Err` ends the tick's drain; the chain
401    /// keeps running (its scheduler leg owns the substrate-death
402    /// detection).
403    async fn drain(
404        &self,
405        queue: &dyn Queue,
406        worker_id: &str,
407        stop: &StopToken,
408        on_event: &mut (impl FnMut(WorkerEvent) + Send),
409    ) -> alkstore::Result<()> {
410        loop {
411            if stop.is_cancelled() {
412                return Ok(());
413            }
414            let Some(handle) = queue.claim_one(worker_id).await? else {
415                return Ok(());
416            };
417            self.dispatch(handle, worker_id, stop, on_event).await;
418        }
419    }
420
421    /// Dispatch one claimed job on the [`MaintenanceJob`] vocabulary
422    /// (substrate.md §GC — the wave-7 pinned actions). A poison
423    /// payload (undecodable) dead-letters; `Sweep` rides the leader
424    /// lock + the store's sweep; `PackHousekeeping` rides the pack
425    /// cache's release path.
426    async fn dispatch(
427        &self,
428        handle: Box<dyn JobHandle>,
429        worker_id: &str,
430        stop: &StopToken,
431        on_event: &mut (impl FnMut(WorkerEvent) + Send),
432    ) {
433        match handle.job().payload_as::<MaintenanceJob>() {
434            Err(_) => {
435                let _ = handle
436                    .fail(Some("unparseable maintenance payload".to_owned()))
437                    .await;
438            }
439            Ok(MaintenanceJob::PackHousekeeping { .. }) => {
440                let released = self
441                    .packs
442                    .as_ref()
443                    .map(|packs| packs.release_all())
444                    .unwrap_or(0);
445                let _ = handle.ack().await;
446                on_event(WorkerEvent::PackHousekeeping(released));
447            }
448            Ok(MaintenanceJob::Sweep) => {
449                match self
450                    .store
451                    .try_lock(MAINTENANCE_LOCK, worker_id, self.opts.lock_ttl_secs)
452                    .await
453                {
454                    Ok(Some(lease)) => {
455                        let verdict = self.sweep_under_lock(stop).await;
456                        let _ = lease.release().await;
457                        match &verdict {
458                            None => {}
459                            Some(SweepVerdict::Report(_)) => {
460                                let _ = handle.ack().await;
461                            }
462                            Some(SweepVerdict::Failed(reason)) => {
463                                let _ = handle.retry(Some(reason.clone()), None).await;
464                            }
465                            Some(SweepVerdict::LockedOut) => {
466                                let _ = handle
467                                    .retry(
468                                        Some("the sweeper lock is held elsewhere".to_owned()),
469                                        Some(LOCKED_OUT_RETRY_SECS),
470                                    )
471                                    .await;
472                            }
473                        }
474                        if let Some(verdict) = verdict {
475                            on_event(WorkerEvent::Sweep(verdict));
476                        }
477                    }
478                    Ok(None) => {
479                        let _ = handle
480                            .retry(
481                                Some("the sweeper lock is held elsewhere".to_owned()),
482                                Some(LOCKED_OUT_RETRY_SECS),
483                            )
484                            .await;
485                        on_event(WorkerEvent::Sweep(SweepVerdict::LockedOut));
486                    }
487                    Err(err) => {
488                        let _ = handle.retry(Some(err.to_string()), None).await;
489                        on_event(WorkerEvent::Sweep(SweepVerdict::Failed(err.to_string())));
490                    }
491                }
492            }
493        }
494    }
495
496    /// The sweep dispatch under the leader lease: the store's sweep.
497    /// The stop token cuts a *stopped* chain out of a long sweep (the
498    /// dropped sweep is interruption-safe by design — its candidate
499    /// windows are durable and its claim's at-least-once redelivery
500    /// governs the job). `None` = the chain is stopping — no verdict,
501    /// no job op.
502    async fn sweep_under_lock(&self, stop: &StopToken) -> Option<SweepVerdict> {
503        let sweep = self.blobs.sweep();
504        tokio::pin!(sweep);
505        tokio::select! {
506            report = &mut sweep => {
507                Some(match report {
508                    Ok(report) => SweepVerdict::Report(report),
509                    Err(err) => SweepVerdict::Failed(err.to_string()),
510                })
511            }
512            _ = stop_grace(stop) => None,
513        }
514    }
515}
516
517/// The stop-responsive wait arm ([`crate::reactor`]'s posture): a
518/// canceled token returns promptly instead of at the interval's
519/// cadence.
520async fn stop_grace(stop: &StopToken) {
521    let mut tick = tokio::time::interval(Duration::from_millis(STOP_GRACE_MS));
522    loop {
523        tick.tick().await;
524        if stop.is_cancelled() {
525            return;
526        }
527    }
528}
529/// Wind the supervised legs down: both legs exit on their cancelled
530/// tokens within a bounded window (the wake leg's sleep, the
531/// scheduler's tick); join within the bound, abort past it.
532async fn join_legs(handler: tokio::task::JoinHandle<()>, wake: tokio::task::JoinHandle<()>) {
533    let handler_abort = handler.abort_handle();
534    let wake_abort = wake.abort_handle();
535    let joins = async move {
536        let _ = handler.await;
537        let _ = wake.await;
538    };
539    if tokio::time::timeout(Duration::from_millis(STOP_GRACE_MS * 20), joins)
540        .await
541        .is_err()
542    {
543        handler_abort.abort();
544        wake_abort.abort();
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use std::sync::atomic::{AtomicU64, Ordering};
551    use std::sync::{Arc, Mutex};
552    use std::time::Duration;
553
554    use alkstore::{JobState, QueueOpts, StopToken, Store, WakeReceiver};
555    use serde_json::Value;
556
557    use alkblobs::Store as BlobStore;
558    #[cfg(feature = "sqlite")]
559    use alkgit::GitRegistryStore as _;
560    use alkgit::{GitPackGen as _, GitRefs, Limits, RefUpdate};
561    use gix_hash::ObjectId;
562    use gix_object::Kind;
563
564    use crate::odb::{key_to_oid, ObjectFront};
565    use crate::packgen::{PackCache, PackGen};
566    use crate::reactor::{AppliedWatermark, MaintenanceJob, Reactor, RegistryEvent};
567    use crate::refs::InMemoryRefTable;
568    use crate::registry::{
569        CachedRegistryProvider, InMemoryRegistry, RegistryRead as _, RegistryWrite,
570    };
571    #[cfg(feature = "sqlite")]
572    use crate::testutil::scratch_dir;
573    use crate::testutil::{record, wait_until};
574
575    use super::{
576        MaintenanceChain, MaintenanceOpts, SweepVerdict, WorkerEvent, MAINTENANCE_LOCK,
577        MAINTENANCE_QUEUE,
578    };
579
580    type Seen = Arc<Mutex<Vec<WorkerEvent>>>;
581
582    fn saw_sweep_verdict(seen: &Seen, pred: impl Fn(&SweepVerdict) -> bool) -> bool {
583        seen.lock()
584            .unwrap()
585            .iter()
586            .any(|event| matches!(event, WorkerEvent::Sweep(v) if pred(v)))
587    }
588
589    /// The mem-engine fixture: the alkstore mem engine + the alkblobs
590    /// mem store + the in-memory registry behind the cached provider —
591    /// the chain over the completeness assembly.
592    struct MemFixture {
593        store: Arc<dyn Store>,
594        blobs: Arc<BlobStore>,
595        reactor: Arc<Reactor>,
596        registry: Arc<InMemoryRegistry>,
597        provider: Arc<CachedRegistryProvider>,
598        applied: Arc<AppliedWatermark>,
599        chain: Arc<MaintenanceChain>,
600    }
601
602    fn mem_fixture(opts: MaintenanceOpts, packs: Option<Arc<PackCache>>) -> MemFixture {
603        let store: Arc<dyn Store> = Arc::new(alkstore_mem::MemStore::new());
604        let blobs = Arc::new(BlobStore::mem_only());
605        let registry = Arc::new(InMemoryRegistry::new());
606        let provider = Arc::new(CachedRegistryProvider::new());
607        let reactor = Arc::new(Reactor::new(
608            store.clone(),
609            registry.clone() as Arc<dyn RegistryWrite>,
610            provider.clone(),
611        ));
612        let applied = AppliedWatermark::new();
613        let chain = Arc::new(MaintenanceChain::new(
614            store.clone(),
615            blobs.clone(),
616            reactor.clone(),
617            applied.clone(),
618            packs,
619            opts,
620        ));
621        MemFixture {
622            store,
623            blobs,
624            reactor,
625            registry,
626            provider,
627            applied,
628            chain,
629        }
630    }
631
632    /// The liveness wiring over the fixture's blob store: the
633    /// `RefClosure` registered (the sweep dispatch's requirement — a
634    /// sweep without one aborts `NoLivenessSources`) with an in-memory
635    /// ref table the tests write through.
636    struct WiredLiveness {
637        front: ObjectFront,
638        refs: Arc<InMemoryRefTable>,
639        _source: Arc<crate::liveness::RefClosure>,
640    }
641
642    fn wire_liveness(fixture: &MemFixture) -> WiredLiveness {
643        let front = ObjectFront::new(fixture.blobs.clone());
644        let refs = Arc::new(InMemoryRefTable::new());
645        let writes = Arc::new(AtomicU64::new(0));
646        let source = crate::liveness::RefClosure::new(
647            front.clone(),
648            refs.clone() as Arc<dyn crate::liveness::RefRows>,
649            writes,
650        );
651        source.register(fixture.blobs.as_ref()).unwrap();
652        WiredLiveness {
653            front,
654            refs,
655            _source: source,
656        }
657    }
658
659    async fn put_object(front: &ObjectFront, kind: Kind, content: &[u8]) -> ObjectId {
660        let (key, _pin) = front.put(kind, content).await.unwrap();
661        key_to_oid(&key).expect("the put's key is the family key")
662    }
663
664    async fn create_ref<T: GitRefs + ?Sized>(refs: &T, id: &str, name: &str, oid: ObjectId) {
665        let outcomes = GitRefs::apply_updates(
666            refs,
667            &record(id),
668            vec![RefUpdate {
669                name: name.to_owned(),
670                expected: None,
671                new: Some(oid),
672            }],
673            false,
674        )
675        .await
676        .unwrap();
677        assert!(outcomes.iter().all(|o| o.result.is_ok()));
678    }
679
680    /// Fast the loops, slow the schedule — the deterministic-enqueue
681    /// tests' opts (the firing cadence rides its own test).
682    fn quiet_opts() -> MaintenanceOpts {
683        MaintenanceOpts {
684            sweep_spec: "@every 600s".to_owned(),
685            reconcile_tick: Duration::from_secs(600),
686            worker_poll: Duration::from_millis(25),
687            wake_retry_backoff: Duration::from_millis(50),
688            lock_ttl_secs: 30,
689        }
690    }
691
692    fn spawn_run(
693        chain: Arc<MaintenanceChain>,
694        stop: StopToken,
695        seen: Seen,
696    ) -> tokio::task::JoinHandle<alkstore::Result<()>> {
697        tokio::spawn(async move {
698            chain
699                .run("worker", stop, move |event| {
700                    seen.lock().unwrap().push(event)
701                })
702                .await
703        })
704    }
705
706    async fn stop_and_join(stop: StopToken, join: tokio::task::JoinHandle<alkstore::Result<()>>) {
707        stop.cancel();
708        let outcome = tokio::time::timeout(Duration::from_secs(5), join)
709            .await
710            .expect("the run ended inside the deadline")
711            .expect("the run ended clean");
712        if let Err(err) = outcome {
713            panic!("the run ended with an error: {err}");
714        }
715    }
716
717    /// Poll an object away (the async predicate's poll loop — a sync
718    /// `wait_until` closure cannot await the front's reads).
719    async fn wait_object_gone(front: &ObjectFront, oid: &ObjectId) {
720        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
721        let mut gone = false;
722        while tokio::time::Instant::now() < deadline {
723            gone = front.stat(oid).await.unwrap().is_none();
724            if gone {
725                return;
726            }
727            tokio::time::sleep(Duration::from_millis(10)).await;
728        }
729        assert!(gone, "the object did not reclaim inside the deadline");
730    }
731
732    /// The full scheduled sweep chain on the mem assembly: the
733    /// registration + the scheduler runner fire the `Sweep` payload
734    /// into the maintenance queue, the worker claims it, takes the
735    /// leader lock, and dispatches the store's sweep — the orphan is
736    /// reclaimed (the registered ref closure decides liveness), the
737    /// covered closure survives, the job acked away.
738    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
739    async fn the_schedule_fires_and_the_worker_sweeps() {
740        let fixture = mem_fixture(
741            MaintenanceOpts {
742                sweep_spec: "@every 1s".to_owned(),
743                ..quiet_opts()
744            },
745            None,
746        );
747        let wired = wire_liveness(&fixture);
748
749        let covered = put_object(&wired.front, Kind::Blob, b"covered\n").await;
750        let orphan = put_object(&wired.front, Kind::Blob, b"orphan\n").await;
751        create_ref(&*wired.refs, "repo", "refs/heads/main", covered).await;
752
753        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
754        let stop = StopToken::new();
755        let runner = spawn_run(fixture.chain.clone(), stop.clone(), seen.clone());
756
757        wait_until(|| {
758            saw_sweep_verdict(&seen, |verdict| {
759                matches!(
760                    verdict,
761                    SweepVerdict::Report(report) if report.aborted.is_none()
762                )
763            })
764        })
765        .await;
766        wait_object_gone(&wired.front, &orphan).await;
767        assert!(
768            wired.front.stat(&covered).await.unwrap().is_some(),
769            "the covered closure survives the chain's sweep"
770        );
771
772        stop_and_join(stop, runner).await;
773    }
774
775    /// A locked-out sweep is a skip + short retry — a value, not an
776    /// error: with theMaintenanceLock held by an outsider, the
777    /// worker's sweep verdict is LockedOut, the job returns to
778    /// pending (a live row with no error), and once the outsider
779    /// releases, the retried job sweeps for real and acks away.
780    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
781    async fn a_locked_out_sweep_is_a_skip_and_short_retry() {
782        let fixture = mem_fixture(quiet_opts(), None);
783        let wired = wire_liveness(&fixture);
784
785        let covered = put_object(&wired.front, Kind::Blob, b"covered\n").await;
786        let orphan = put_object(&wired.front, Kind::Blob, b"orphan\n").await;
787        create_ref(&*wired.refs, "repo", "refs/heads/main", covered).await;
788
789        let lease = fixture
790            .store
791            .try_lock(MAINTENANCE_LOCK, "an-external-holder", 30)
792            .await
793            .unwrap()
794            .expect("the outsider holds the lock");
795
796        let queue = fixture
797            .store
798            .queue(MAINTENANCE_QUEUE, QueueOpts::default())
799            .await
800            .unwrap();
801        let sweep_id = fixture
802            .reactor
803            .enqueue(&MaintenanceJob::Sweep)
804            .await
805            .unwrap();
806
807        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
808        let stop = StopToken::new();
809        let runner = spawn_run(fixture.chain.clone(), stop.clone(), seen.clone());
810
811        wait_until(|| saw_sweep_verdict(&seen, |v| matches!(v, SweepVerdict::LockedOut))).await;
812        let row = queue.get_job(sweep_id).await.unwrap().expect("a live row");
813        assert_eq!(
814            row.state,
815            JobState::Pending,
816            "the locked-out sweep is a retry — back in pending for the short delay"
817        );
818        assert_eq!(
819            row.last_error, None,
820            "a retried (live) row carries no error the locked-out skip surfaces"
821        );
822
823        lease.release().await.unwrap();
824        wait_until(|| {
825            saw_sweep_verdict(&seen, |v| {
826                matches!(
827                    v,
828                    SweepVerdict::Report(report) if report.aborted.is_none()
829                )
830            })
831        })
832        .await;
833        wait_object_gone(&wired.front, &orphan).await;
834        assert!(
835            queue.get_job(sweep_id).await.unwrap().is_none(),
836            "the sweep job acked away"
837        );
838
839        stop_and_join(stop, runner).await;
840    }
841
842    /// The N-b supervision, the `Closed` shape: the wake runner's
843    /// source dies (the sqlite watcher-death class, probe-pinned with
844    /// an immediately-closing receiver), `run_wake_runner` returns
845    /// quietly — and the chain restarts it (a fresh `listen`), so the
846    /// wake-driven refresh lands and the silent-stop window is closed.
847    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
848    async fn the_wake_runner_is_supervised_through_a_closed_source() {
849        wake_supervision_shape(FaultyListenStore::closed_once(Box::new(
850            alkstore_mem::MemStore::new(),
851        )))
852        .await;
853    }
854
855    /// The N-b supervision, the `Database` shape: the wake runner's
856    /// `listen` fails substrate-level (the storage-failure arm) — the
857    /// chain restarts it and the refresh lands.
858    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
859    async fn the_wake_runner_is_supervised_through_a_listen_failure() {
860        wake_supervision_shape(FaultyListenStore::database_once(Box::new(
861            alkstore_mem::MemStore::new(),
862        )))
863        .await;
864    }
865
866    async fn wake_supervision_shape(store: Arc<FaultyListenStore>) {
867        let blobs = Arc::new(BlobStore::mem_only());
868        let registry = Arc::new(InMemoryRegistry::new());
869        let provider = Arc::new(CachedRegistryProvider::new());
870        let reactor = Arc::new(Reactor::new(
871            store.clone() as Arc<dyn Store>,
872            registry.clone() as Arc<dyn RegistryWrite>,
873            provider.clone(),
874        ));
875        let chain = Arc::new(MaintenanceChain::new(
876            store.clone() as Arc<dyn Store>,
877            blobs,
878            reactor.clone(),
879            AppliedWatermark::new(),
880            None,
881            quiet_opts(),
882        ));
883
884        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
885        let stop = StopToken::new();
886        let runner = spawn_run(chain, stop.clone(), seen.clone());
887        tokio::time::sleep(Duration::from_millis(300)).await;
888
889        let seq = registry.put(&record("owner/wake")).await.unwrap();
890        reactor
891            .announce(&RegistryEvent::Put {
892                seq,
893                record: record("owner/wake"),
894            })
895            .await;
896
897        wait_until(|| provider.resolve("owner/wake").is_some()).await;
898        assert_eq!(
899            provider.resolve("owner/wake"),
900            Some(record("owner/wake")),
901            "the wake-driven refresh landed after the supervised restart"
902        );
903        let listens = store.listens.load(Ordering::Relaxed);
904        assert!(
905            listens >= 2,
906            "the runner restarted — a fresh `listen` re-attached (listens: {listens})"
907        );
908
909        stop_and_join(stop, runner).await;
910    }
911
912    /// The reconcile tick's driving (the pinned mechanism): a durable
913    /// registry write with no announce at all leaves the provider
914    /// stale, and the chain's tick closes the window (the
915    /// postgres-lost-hint posture's backstop) and advances the applied
916    /// watermark.
917    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
918    async fn the_reconcile_tick_drives_the_stale_provider() {
919        let fixture = mem_fixture(
920            MaintenanceOpts {
921                reconcile_tick: Duration::from_millis(100),
922                ..quiet_opts()
923            },
924            None,
925        );
926
927        fixture.registry.put(&record("owner/tick")).await.unwrap();
928        assert_eq!(
929            fixture.provider.resolve("owner/tick"),
930            None,
931            "no announce: the cache is stale until the tick"
932        );
933
934        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
935        let stop = StopToken::new();
936        let runner = spawn_run(fixture.chain.clone(), stop.clone(), seen);
937        wait_until(|| fixture.provider.resolve("owner/tick").is_some()).await;
938        assert!(
939            fixture.applied.get() > 0,
940            "the tick also advanced the applied watermark"
941        );
942        stop_and_join(stop, runner).await;
943    }
944
945    /// The `PackHousekeeping` consumer action (pinned in-task): the
946    /// through-store pack cache's release path — the job drains the
947    /// cache (the generated pack's pin released), acks away, and the
948    /// next sweep can reclaim the pack blob.
949    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
950    async fn pack_housekeeping_runs_the_release_path() {
951        let cache = Arc::new(PackCache::new(2));
952        let fixture = mem_fixture(quiet_opts(), Some(cache.clone()));
953        let front = ObjectFront::new(fixture.blobs.clone());
954        let blob = put_object(&front, Kind::Blob, b"pack me\n").await;
955        let tree = put_object(&front, Kind::Tree, &tree_bytes(&[("b", &blob)])).await;
956        let commit = put_object(&front, Kind::Commit, &commit_bytes(&tree, &[])).await;
957
958        let packgen = PackGen::through_store_with(front.clone(), cache.clone());
959        let sink = VecSink::default();
960        packgen
961            .generate(
962                &record("repo"),
963                vec![commit],
964                vec![],
965                Limits::default(),
966                Box::new(sink.clone()),
967            )
968            .await
969            .unwrap();
970        assert!(
971            sink.bytes().starts_with(b"PACK"),
972            "generation produced a pack (the cache holds it)"
973        );
974
975        fixture
976            .reactor
977            .enqueue(&MaintenanceJob::PackHousekeeping {
978                repo_id: "repo".to_owned(),
979            })
980            .await
981            .unwrap();
982
983        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
984        let stop = StopToken::new();
985        let runner = spawn_run(fixture.chain.clone(), stop.clone(), seen.clone());
986        wait_until(|| {
987            seen.lock()
988                .unwrap()
989                .iter()
990                .any(|event| matches!(event, WorkerEvent::PackHousekeeping(1)))
991        })
992        .await;
993        assert_eq!(
994            cache.release_all(),
995            0,
996            "the job's release path drained the cache"
997        );
998        stop_and_join(stop, runner).await;
999    }
1000
1001    /// The repo-deletion-is-GC proof through the full chain (the
1002    /// acceptance line): removing a repo's registry + ref rows (the
1003    /// removal's composition semantics) uncovers its objects, and the
1004    /// chain's sweep dispatch reclaims them — the scheduled GC, not a
1005    /// bulk delete.
1006    #[cfg(feature = "sqlite")]
1007    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1008    async fn the_chain_reclaims_a_deleted_repos_objects() {
1009        use crate::liveness::{RefClosure, RefRows};
1010
1011        let dir = scratch_dir("maintenance", "deletion");
1012        let db = dir.join("deletion.sqlite3");
1013        let assembly = crate::sqlite::SqliteRegistryAssembly::open(&db)
1014            .await
1015            .unwrap();
1016        let blobs = Arc::new(
1017            BlobStore::kv_only_sqlite(&db, 128 * 1024, Duration::ZERO)
1018                .await
1019                .unwrap(),
1020        );
1021        let front = ObjectFront::new(blobs.clone());
1022        let source = RefClosure::new(
1023            front.clone(),
1024            assembly.refs().clone() as Arc<dyn RefRows>,
1025            assembly.refs_version().clone(),
1026        );
1027        source.register(blobs.as_ref()).unwrap();
1028
1029        let victim = record("owner/victimized");
1030        let survivor = record("owner/surviving");
1031        assembly.backend().put_repo(&victim).await.unwrap();
1032        assembly.backend().put_repo(&survivor).await.unwrap();
1033
1034        let survivor_covered = put_object(&front, Kind::Blob, b"survivor\n").await;
1035        create_ref(
1036            &**assembly.refs(),
1037            "owner/surviving",
1038            "refs/heads/main",
1039            survivor_covered,
1040        )
1041        .await;
1042
1043        let covered = put_object(&front, Kind::Blob, b"victim's only object\n").await;
1044        let orphan = put_object(&front, Kind::Blob, b"victim's stray\n").await;
1045        create_ref(
1046            &**assembly.refs(),
1047            "owner/victimized",
1048            "refs/heads/main",
1049            covered,
1050        )
1051        .await;
1052
1053        assembly
1054            .backend()
1055            .remove_repo("owner/victimized")
1056            .await
1057            .unwrap();
1058
1059        let chain = Arc::new(MaintenanceChain::new(
1060            assembly.store().clone(),
1061            blobs.clone(),
1062            assembly.reactor().clone(),
1063            AppliedWatermark::new(),
1064            None,
1065            quiet_opts(),
1066        ));
1067        let queue = assembly
1068            .store()
1069            .queue(MAINTENANCE_QUEUE, QueueOpts::default())
1070            .await
1071            .unwrap();
1072        for _ in 0..3 {
1073            queue
1074                .enqueue(
1075                    serde_json::to_value(MaintenanceJob::Sweep).unwrap(),
1076                    Default::default(),
1077                )
1078                .await
1079                .unwrap();
1080        }
1081
1082        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1083        let stop = StopToken::new();
1084        let runner = spawn_run(chain, stop.clone(), seen.clone());
1085        wait_object_gone(&front, &covered).await;
1086        wait_object_gone(&front, &orphan).await;
1087        assert!(
1088            front.stat(&survivor_covered).await.unwrap().is_some(),
1089            "the surviving repo's covered object is untouched"
1090        );
1091        // The object's reclamation is observable inside the sweep's
1092        // run — before the worker's own report event lands (the
1093        // poll-versus-callback ordering). Wait for the verdict the
1094        // reclaiming sweep carries, not for the objects alone.
1095        wait_until(|| {
1096            saw_sweep_verdict(&seen, |v| {
1097                matches!(
1098                    v,
1099                    SweepVerdict::Report(report) if report.aborted.is_none() && report.deleted >= 2
1100                )
1101            })
1102        })
1103        .await;
1104
1105        stop_and_join(stop, runner).await;
1106        drop(queue);
1107        drop(assembly);
1108        drop(blobs);
1109        drop(front);
1110        let _ = std::fs::remove_dir_all(&dir);
1111    }
1112
1113    fn tree_bytes(entries: &[(&str, &ObjectId)]) -> Vec<u8> {
1114        let mut tree = Vec::new();
1115        for (name, oid) in entries {
1116            tree.extend_from_slice(format!("100644 {name}\0").as_bytes());
1117            tree.extend_from_slice(oid.as_bytes());
1118        }
1119        tree
1120    }
1121
1122    fn commit_bytes(tree: &ObjectId, parents: &[&ObjectId]) -> Vec<u8> {
1123        let mut body = format!("tree {tree}\n");
1124        for parent in parents {
1125            body.push_str(&format!("parent {parent}\n"));
1126        }
1127        body.push_str(
1128            "author A <a@example.invalid> 1750000000 +0000\n\
1129             committer A <a@example.invalid> 1750000000 +0000\n\nthe message\n",
1130        );
1131        body.into_bytes()
1132    }
1133
1134    #[derive(Clone, Default)]
1135    struct VecSink(Arc<Mutex<Vec<u8>>>);
1136
1137    impl VecSink {
1138        fn bytes(&self) -> Vec<u8> {
1139            self.0.lock().unwrap().clone()
1140        }
1141    }
1142
1143    impl std::io::Write for VecSink {
1144        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1145            self.0.lock().unwrap().extend_from_slice(buf);
1146            Ok(buf.len())
1147        }
1148        fn flush(&mut self) -> std::io::Result<()> {
1149            Ok(())
1150        }
1151    }
1152
1153    /// The wake-runner supervision's probe store: `listen` fails the
1154    /// configured number of times (either arm of the N-b rider's stop
1155    /// shapes) before delegating; everything else delegates.
1156    struct FaultyListenStore {
1157        inner: Box<dyn Store>,
1158        closed_left: AtomicU64,
1159        database_left: AtomicU64,
1160        listens: AtomicU64,
1161    }
1162
1163    impl FaultyListenStore {
1164        fn closed_once(inner: Box<dyn Store>) -> Arc<Self> {
1165            Arc::new(FaultyListenStore {
1166                inner,
1167                closed_left: AtomicU64::new(1),
1168                database_left: AtomicU64::new(0),
1169                listens: AtomicU64::new(0),
1170            })
1171        }
1172
1173        fn database_once(inner: Box<dyn Store>) -> Arc<Self> {
1174            Arc::new(FaultyListenStore {
1175                inner,
1176                closed_left: AtomicU64::new(0),
1177                database_left: AtomicU64::new(1),
1178                listens: AtomicU64::new(0),
1179            })
1180        }
1181    }
1182
1183    /// The immediately-closing receiver: `recv_timeout` errs `Closed`
1184    /// — the runner's quiet-return arm (the source-death class).
1185    struct DeadReceiver;
1186
1187    impl WakeReceiver for DeadReceiver {
1188        fn recv<'a>(&'a mut self) -> alkstore::BoxedFuture<'a, Option<alkstore::Wake>> {
1189            Box::pin(async { None })
1190        }
1191
1192        fn try_recv(&mut self) -> alkstore::Result<Option<alkstore::Wake>> {
1193            Err(alkstore::Error::Closed)
1194        }
1195
1196        fn recv_timeout<'a>(
1197            &'a mut self,
1198            _timeout: Duration,
1199        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<alkstore::Wake>>> {
1200            Box::pin(async { Err(alkstore::Error::Closed) })
1201        }
1202    }
1203
1204    impl Store for FaultyListenStore {
1205        fn begin_tx(
1206            &self,
1207        ) -> alkstore::BoxedFuture<'_, alkstore::Result<Box<dyn alkstore::TxHandle + Send>>>
1208        {
1209            self.inner.begin_tx()
1210        }
1211
1212        fn notify<'a>(
1213            &'a self,
1214            channel: &str,
1215            payload: Value,
1216        ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
1217            self.inner.notify(channel, payload)
1218        }
1219
1220        fn listen<'a>(
1221            &'a self,
1222            channel: &str,
1223        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn WakeReceiver>>> {
1224            self.listens.fetch_add(1, Ordering::Relaxed);
1225            let inner = &self.inner;
1226            if self.closed_left.swap(0, Ordering::Relaxed) > 0 {
1227                return Box::pin(
1228                    async move { Ok(Box::new(DeadReceiver) as Box<dyn WakeReceiver>) },
1229                );
1230            }
1231            if self.database_left.swap(0, Ordering::Relaxed) > 0 {
1232                return Box::pin(async {
1233                    Err(alkstore::Error::database("listen storage failure (probe)"))
1234                });
1235            }
1236            inner.listen(channel)
1237        }
1238
1239        fn stream<'a>(
1240            &'a self,
1241            name: &str,
1242        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::StreamHandle>>> {
1243            self.inner.stream(name)
1244        }
1245
1246        fn queue<'a>(
1247            &'a self,
1248            name: &str,
1249            opts: QueueOpts,
1250        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Queue>>> {
1251            self.inner.queue(name, opts)
1252        }
1253
1254        fn outbox<'a>(
1255            &'a self,
1256            name: &str,
1257        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Box<dyn alkstore::Outbox>>> {
1258            self.inner.outbox(name)
1259        }
1260
1261        fn try_lock<'a>(
1262            &'a self,
1263            name: &str,
1264            owner: &str,
1265            ttl: i64,
1266        ) -> alkstore::BoxedFuture<'a, alkstore::Result<Option<Box<dyn alkstore::Lock>>>> {
1267            self.inner.try_lock(name, owner, ttl)
1268        }
1269
1270        fn schedule<'a>(
1271            &'a self,
1272            name: &str,
1273            spec: &str,
1274            queue: &str,
1275            payload: Value,
1276            opts: alkstore::ScheduleOpts,
1277        ) -> alkstore::BoxedFuture<'a, alkstore::Result<alkstore::Schedule>> {
1278            self.inner.schedule(name, spec, queue, payload, opts)
1279        }
1280
1281        fn unschedule<'a>(
1282            &'a self,
1283            name: &str,
1284        ) -> alkstore::BoxedFuture<'a, alkstore::Result<bool>> {
1285            self.inner.unschedule(name)
1286        }
1287
1288        fn run_schedules<'a>(
1289            &'a self,
1290            stop: StopToken,
1291        ) -> alkstore::BoxedFuture<'a, alkstore::Result<()>> {
1292            self.inner.run_schedules(stop)
1293        }
1294    }
1295}