Skip to main content

liminal_server/server/connection/
unresolved_start.rs

1//! When the services start over a durable store, every in-flight claim in the
2//! durable delivery dedup namespace is looked up by its key before any publish
3//! is judged: a claim left by a crash or by an unresolved publish is completed
4//! when its row is in the stream, released when it is not, and held when
5//! nothing can say yet; a claim that carries no evidence, left by a binary
6//! before the evidence lane, is released there and then (DUR-022). The claims are read from the namespace's in-flight
7//! index (`liminal` `durability/dedup/in_flight.rs`), never by walking the
8//! namespace; the first start over a store written before the index existed
9//! walks it once to build the index. A store created empty by this process
10//! holds no claim, so it is not read at all.
11//!
12//! The resolve runs behind the listening door, on a thread of its own, the
13//! way the participant warm does (12 October 2026: 1,098 ms of the door's
14//! 1,172 ms service construction was this resolve finding no claim). Nothing
15//! is judged before it: a keyed durable admission that comes first runs it
16//! itself, and one that comes while it runs waits for it.
17use std::path::Path;
18
19use std::sync::Arc;
20
21use super::unresolved::HeldKey;
22use super::{
23    DELIVERY_DEDUP_NAMESPACE, LiminalConnectionServices, database_exists, database_untouched,
24    durable_data_dir,
25};
26use std::future::Future;
27use std::time::Instant;
28
29use crate::ServerError;
30use liminal::durability::{DedupCache, DurableStore, InFlightClaim};
31
32/// The name of the thread that lists the in-flight claims at start.
33const CLAIMS_RESOLVE_THREAD: &str = "liminal-claims-resolve";
34
35/// The name of the thread that releases the claims nothing can check.
36const CLAIMS_RELEASE_THREAD: &str = "liminal-claims-release";
37/// The thread that records the delivery namespace's empty index start at the
38/// start that created its store (DUR-023).
39const INDEX_START_RECORD_THREAD: &str = "liminal-index-start-record";
40
41/// Why a start-up store future could not be driven to its answer.
42#[derive(Debug, thiserror::Error)]
43enum StartupThreadError {
44    #[error("the start-up thread could not be spawned: {0}")]
45    Spawn(std::io::Error),
46    #[error("the start-up executor could not be built: {0}")]
47    Executor(std::io::Error),
48    #[error("the start-up thread panicked")]
49    Panicked,
50}
51
52/// Drives one start-up store future on a thread of its own under an executor
53/// of its own, and answers what it answered.
54///
55/// The synchronous bridge (`liminal::durability::bridge::block_on`) polls a
56/// future with no waker and is sound only for a future that is ready on its
57/// first poll, as its module says. The haematite store answers a listing from
58/// a shard actor on another thread, so the future that waits for that answer
59/// is woken, not re-polled: driven by the bridge, the boot thread parks on the
60/// answer and the server never listens (the door, 10 October 2026, boots 1 to
61/// 4 at `resolve_claims_at_start`). A current-thread tokio executor on a
62/// dedicated thread drives the future to its answer, and the calling thread,
63/// the boot thread, which owns no executor, joins it. `make` builds the future
64/// on the dedicated thread, so the future never crosses a thread.
65fn run_on_startup_thread<F, Make>(name: &str, make: Make) -> Result<F::Output, StartupThreadError>
66where
67    F: Future,
68    F::Output: Send,
69    Make: FnOnce() -> F + Send,
70{
71    std::thread::scope(|scope| {
72        let worker = std::thread::Builder::new()
73            .name(name.to_owned())
74            .spawn_scoped(scope, move || {
75                let executor = tokio::runtime::Builder::new_current_thread()
76                    .enable_all()
77                    .build()
78                    .map_err(StartupThreadError::Executor)?;
79                Ok(executor.block_on(make()))
80            })
81            .map_err(StartupThreadError::Spawn)?;
82        worker.join().map_err(|_| StartupThreadError::Panicked)?
83    })
84}
85
86/// Whether the durable store the services start over can already hold
87/// in-flight claims.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub(super) enum StoreHistory {
90    /// The store was created empty for this process and dies with it: no
91    /// earlier server wrote to it, so it holds no claim to look up.
92    #[cfg(any(test, feature = "test-support"))]
93    Fresh,
94    /// The store is persistent and holds nothing yet: this start creates it,
95    /// or an earlier start created it and nothing was ever written to it. It
96    /// holds no claim, and outlives the process.
97    Created,
98    /// The store may hold claims an earlier server left: a persistent store
99    /// that already exists, or one the caller handed in.
100    Recorded,
101}
102
103impl StoreHistory {
104    /// The history of the store that will be built for `persistence_path`,
105    /// answered before it is opened: a persistent store holds nothing when its database
106    /// does not exist yet or holds only what its creation wrote, and otherwise
107    /// may hold what the servers before this one wrote.
108    pub(super) fn of(path: &Path) -> Self {
109        let data_dir = durable_data_dir(path);
110        if database_exists(&data_dir) && !database_untouched(&data_dir) {
111            Self::Recorded
112        } else {
113            Self::Created
114        }
115    }
116
117    /// The durable delivery dedup cache over `store`: a store created by this
118    /// process holds no claim, so its in-flight index starts empty without
119    /// reading it.
120    pub(super) fn delivery_dedup(self, store: Arc<dyn DurableStore>) -> DedupCache {
121        if self == Self::Recorded {
122            DedupCache::new(store, DELIVERY_DEDUP_NAMESPACE)
123        } else {
124            DedupCache::new_empty(store, DELIVERY_DEDUP_NAMESPACE)
125        }
126    }
127}
128
129/// The name of the thread that resolves the claims behind the listening door.
130const CLAIMS_BEHIND_LISTENER_THREAD: &str = "liminal-claims-behind-listener";
131
132impl LiminalConnectionServices {
133    /// Resolves the in-flight claims once, unless that has been done: every
134    /// keyed durable admission calls this before it is judged. The lock is
135    /// held across the resolve, so an admission that comes while it runs
136    /// waits for it; a resolve that fails stays pending, and the next
137    /// admission tries again and is refused by name if it fails again.
138    ///
139    /// # Errors
140    ///
141    /// Returns the resolve's error when the claims cannot be listed.
142    pub(crate) fn ensure_claims_resolved(&self) -> Result<(), ServerError> {
143        let mut pending = self
144            .claims_start
145            .lock()
146            .unwrap_or_else(std::sync::PoisonError::into_inner);
147        // The guard is held across the resolve on purpose: an admission that
148        // comes while it runs waits here for its end.
149        let resolved = pending.map_or(Ok(()), |history| self.resolve_claims_at_start(history));
150        if resolved.is_ok() {
151            *pending = None;
152        }
153        drop(pending);
154        resolved
155    }
156
157    /// Resolves the in-flight claims on the caller's thread, or waits for the
158    /// resolve already running behind the listening door, and answers its
159    /// result. The store-count journeys call it beside
160    /// [`Self::finish_participant_warm`] so their windows hold the whole start
161    /// (a created store's empty index start, DUR-023, is one compare-and-set)
162    /// and never race that thread; the server itself never waits for it.
163    ///
164    /// # Errors
165    ///
166    /// Returns the resolve's error when the claims cannot be listed.
167    pub fn finish_claims_resolve(&self) -> Result<(), ServerError> {
168        self.ensure_claims_resolved()
169    }
170
171    /// Starts the resolve behind the listening door, on a thread of its own.
172    /// A failure is logged; the first keyed durable admission tries again.
173    pub(super) fn resolve_claims_behind_listener(
174        self: Arc<Self>,
175    ) -> Option<std::thread::JoinHandle<()>> {
176        let spawned = std::thread::Builder::new()
177            .name(CLAIMS_BEHIND_LISTENER_THREAD.to_owned())
178            .spawn(move || {
179                if let Err(error) = self.ensure_claims_resolved() {
180                    tracing::error!(
181                        %error,
182                        "in-flight claims could not be resolved behind the listening door; \
183                         the first keyed durable admission tries again"
184                    );
185                }
186            });
187        spawned
188            .inspect_err(|error| {
189                tracing::error!(
190                    %error,
191                    "the claims resolve thread could not start; the first keyed durable admission resolves them"
192                );
193            })
194            .ok()
195    }
196
197    /// Resolves the durable namespace's in-flight claims, once, at start.
198    ///
199    /// A store this process created (`Fresh` or `Created`) is not read: it
200    /// holds nothing. A `Created` store, which outlives the process, has its
201    /// empty index start recorded here, one compare-and-set on the start-up
202    /// thread (DUR-023), so the next start over it reads the index instead of
203    /// walking a namespace nothing was ever claimed in; a `Fresh` store dies
204    /// with the process and records nothing.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`ServerError::ListenerAccept`] naming the dedup namespace when
209    /// its claims cannot be listed. A claim whose stream cannot be read is not
210    /// an error: it stays held for the channel's next admission.
211    pub(super) fn resolve_claims_at_start(&self, history: StoreHistory) -> Result<(), ServerError> {
212        if history == StoreHistory::Created {
213            self.record_empty_index_start();
214            return Ok(());
215        }
216        if history != StoreHistory::Recorded {
217            return Ok(());
218        }
219        let listing_failed = |message: String| ServerError::ListenerAccept {
220            message: format!(
221                "in-flight claims in dedup namespace '{}' could not be listed: {message}",
222                self.dedup.namespace()
223            ),
224        };
225        tracing::info!(
226            namespace = self.dedup.namespace(),
227            "in-flight claims resolve started"
228        );
229        let started = Instant::now();
230        let claims = run_on_startup_thread(CLAIMS_RESOLVE_THREAD, || self.dedup.in_flight_claims())
231            .map_err(|error| listing_failed(error.to_string()))?
232            .map_err(|error| listing_failed(error.to_string()))?;
233        let listed = claims.len();
234        let (checkable, unchecked): (Vec<_>, Vec<_>) = claims
235            .into_iter()
236            .partition(|claim| claim.evidence.is_some());
237        let released_unchecked = self.release_unchecked(unchecked)?;
238        tracing::info!(
239            namespace = self.dedup.namespace(),
240            claims = listed,
241            released_unchecked,
242            elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
243            "in-flight claims resolve finished"
244        );
245        for claim in checkable {
246            let idempotency_key = claim.idempotency_key;
247            let Some(evidence) = claim.evidence else {
248                continue;
249            };
250            let channel = evidence.channel().to_owned();
251            self.held_keys.hold(
252                &idempotency_key,
253                HeldKey {
254                    channel: channel.clone(),
255                    from: evidence.positions().to_vec(),
256                },
257            );
258            // A channel not registered at start keeps the claim held; its first
259            // keyed admission after registration looks the row up.
260            let Ok(configured) = self.admit_channel(&channel) else {
261                continue;
262            };
263            if let Err(error) =
264                self.settle_held_with(&configured.handle, &channel, &idempotency_key)
265            {
266                tracing::warn!(
267                    channel = channel.as_str(),
268                    idempotency_key = idempotency_key.as_str(),
269                    %error,
270                    "in-flight claim could not be resolved at start; held for the next admission"
271                );
272            }
273        }
274        Ok(())
275    }
276
277    /// Records the delivery namespace's empty index start at the start that
278    /// created its store (DUR-023), on a start-up thread of its own.
279    ///
280    /// Before this, the start of an index begun empty was recorded only by
281    /// its first claim, so a store nothing was ever claimed in (every seeded
282    /// fixture, every fresh install until its first keyed publish) reached
283    /// its next start with no recorded start and was taken for a store
284    /// written before the index existed and walked, shard by shard, to find
285    /// nothing: 150 ms of the door's 420 ms resolve over an empty namespace
286    /// (DUR-023's purpose). One compare-and-set here, on a store this boot is
287    /// already creating, and that start reads the index and never walks.
288    ///
289    /// A refused record is logged and not fatal: the next start finds no
290    /// recorded start and walks once, as before this change. Nothing else is
291    /// read or written: the namespace holds no claim to list.
292    fn record_empty_index_start(&self) {
293        let started = Instant::now();
294        let recorded = run_on_startup_thread(INDEX_START_RECORD_THREAD, || {
295            self.dedup.record_empty_start()
296        });
297        let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
298        match recorded {
299            Ok(Ok(())) => tracing::info!(
300                namespace = self.dedup.namespace(),
301                elapsed_ms,
302                "dedup index start recorded at a created store"
303            ),
304            Ok(Err(error)) => tracing::error!(
305                namespace = self.dedup.namespace(),
306                elapsed_ms,
307                %error,
308                "dedup index start could not be recorded at a created store; the next start walks the namespace once"
309            ),
310            Err(error) => tracing::error!(
311                namespace = self.dedup.namespace(),
312                elapsed_ms,
313                %error,
314                "dedup index start could not be recorded at a created store; the next start walks the namespace once"
315            ),
316        }
317    }
318
319    /// Releases every listed claim that carries no evidence and answers how
320    /// many were released (DUR-022).
321    ///
322    /// Such a claim was written by a binary before the evidence lane: nothing
323    /// names the channel its row would be in, so no publish can ever settle it
324    /// by looking the row up, and the one rule that ends it, its TTL in the
325    /// dedup sweep, runs on a timer the server never has. Held, it refuses its
326    /// producer's every retry by name, forever. Released, the producer's retry
327    /// publishes; the cost is bounded by one row per key, if and only if the
328    /// crashed publish had landed its row, and that row sits in a channel the
329    /// producer reads back under its own key. Completed it is never: a receipt
330    /// vouches for a row, and nothing can say the row exists.
331    ///
332    /// The releases run on their own thread under their own executor, as the
333    /// listing does. A release the store refuses is logged with its key and
334    /// not held: the next start lists the claim again and releases it then.
335    ///
336    /// # Errors
337    ///
338    /// Returns [`ServerError::ListenerAccept`] when the release thread cannot
339    /// run at all; a single refused release is not an error.
340    fn release_unchecked(&self, unchecked: Vec<InFlightClaim>) -> Result<usize, ServerError> {
341        if unchecked.is_empty() {
342            return Ok(0);
343        }
344        let now_millis = super::dedup_timestamp_millis();
345        let dedup = &self.dedup;
346        let outcomes = run_on_startup_thread(CLAIMS_RELEASE_THREAD, || async move {
347            let mut outcomes = Vec::with_capacity(unchecked.len());
348            for claim in unchecked {
349                let released = dedup.release_claim(&claim.idempotency_key).await;
350                outcomes.push((claim, released));
351            }
352            outcomes
353        })
354        .map_err(|error| ServerError::ListenerAccept {
355            message: format!(
356                "in-flight claims in dedup namespace '{}' could not be released: {error}",
357                self.dedup.namespace()
358            ),
359        })?;
360        let mut released = 0_usize;
361        for (claim, outcome) in outcomes {
362            match outcome {
363                Ok(()) => {
364                    released = released.saturating_add(1);
365                    tracing::warn!(
366                        idempotency_key = claim.idempotency_key.as_str(),
367                        claimed_at_millis = claim.claimed_at_millis,
368                        age_ms = now_millis.saturating_sub(claim.claimed_at_millis),
369                        "in-flight claim released: no evidence"
370                    );
371                }
372                Err(error) => tracing::error!(
373                    idempotency_key = claim.idempotency_key.as_str(),
374                    claimed_at_millis = claim.claimed_at_millis,
375                    %error,
376                    "in-flight claim with no evidence could not be released; the next start releases it"
377                ),
378            }
379        }
380        Ok(released)
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use std::future::Future;
387    use std::pin::Pin;
388    use std::sync::mpsc::{Receiver, Sender, channel};
389    use std::sync::{Arc, Mutex, PoisonError};
390    use std::task::{Context, Poll, Waker};
391
392    use liminal::durability::bridge::{BridgeError, block_on};
393
394    use super::run_on_startup_thread;
395
396    /// A future answered from another thread, the way a shard actor answers a
397    /// listing: pending until that thread stores the answer and wakes the
398    /// waker it was polled with. The thread answers on an event, never after
399    /// a delay: when `release` is sent.
400    struct AnsweredElsewhere {
401        slot: Arc<Mutex<(Option<u32>, Option<Waker>)>>,
402        /// Sent once, by the first poll that parks, when the answer is to come
403        /// only after the future is waiting.
404        parked: Option<Sender<()>>,
405    }
406
407    impl AnsweredElsewhere {
408        fn new(answer: u32, release: Receiver<()>, parked: Option<Sender<()>>) -> Self {
409            let slot: Arc<Mutex<(Option<u32>, Option<Waker>)>> = Arc::new(Mutex::new((None, None)));
410            let answering = Arc::clone(&slot);
411            std::thread::spawn(move || {
412                if release.recv().is_err() {
413                    return;
414                }
415                let mut slot = answering.lock().unwrap_or_else(PoisonError::into_inner);
416                slot.0 = Some(answer);
417                if let Some(waker) = slot.1.take() {
418                    waker.wake();
419                }
420            });
421            Self { slot, parked }
422        }
423
424        /// Answered once the future has parked its waker: the executor must
425        /// wait for a wake it did not cause.
426        fn once_parked(answer: u32) -> Self {
427            let (release, released) = channel();
428            Self::new(answer, released, Some(release))
429        }
430    }
431
432    impl Future for AnsweredElsewhere {
433        type Output = u32;
434
435        fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
436            let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
437            if let Some(answer) = slot.0 {
438                return Poll::Ready(answer);
439            }
440            slot.1 = Some(cx.waker().clone());
441            drop(slot);
442            if let Some(parked) = self.parked.take() {
443                // The answering thread may already be gone with its answer
444                // undelivered only if the test ended; nothing waits on this.
445                let _sent = parked.send(());
446            }
447            Poll::Pending
448        }
449    }
450
451    /// A listing that panics instead of answering.
452    async fn panicking_listing() -> u32 {
453        std::panic::resume_unwind(Box::new("the listing panicked"))
454    }
455
456    const ANSWER: u32 = 7;
457
458    #[test]
459    fn a_future_answered_from_another_thread_is_driven_to_its_answer() {
460        let answered = run_on_startup_thread("claims-resolve-test", || {
461            AnsweredElsewhere::once_parked(ANSWER)
462        });
463        assert!(
464            matches!(answered, Ok(ANSWER)),
465            "the dedicated executor must wait for the answer: {answered:?}"
466        );
467    }
468
469    #[test]
470    fn the_bridge_gives_up_on_the_same_future() {
471        // The bridge is the thing the start-up resolve no longer uses: with no
472        // waker and its bounded polls it reports the answer as never coming.
473        // The answer is released only after the bridge has returned, so no
474        // poll of its can ever see it.
475        let (release, released) = channel();
476        let given_up = block_on(AnsweredElsewhere::new(ANSWER, released, None));
477        let _sent = release.send(());
478        assert!(
479            matches!(given_up, Err(BridgeError::DidNotComplete { .. })),
480            "{given_up:?}"
481        );
482    }
483
484    #[test]
485    fn a_panic_on_the_start_up_thread_is_an_error_not_a_hang() {
486        let outcome = run_on_startup_thread("claims-resolve-panic-test", panicking_listing);
487        assert!(
488            matches!(outcome, Err(super::StartupThreadError::Panicked)),
489            "{outcome:?}"
490        );
491    }
492}