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}