1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
//! When the services start over a durable store, every in-flight claim in the
//! durable delivery dedup namespace is looked up by its key before any publish
//! is judged: a claim left by a crash or by an unresolved publish is completed
//! when its row is in the stream, released when it is not, and held when
//! nothing can say yet. The claims are read from the namespace's in-flight
//! index (`liminal` `durability/dedup/in_flight.rs`), never by walking the
//! namespace; the first start over a store written before the index existed
//! walks it once to build the index. A store created empty by this process
//! holds no claim, so it is not read at all.
use std::path::Path;
use std::sync::Arc;
use super::unresolved::HeldKey;
use super::{
DELIVERY_DEDUP_NAMESPACE, LiminalConnectionServices, database_exists, database_untouched,
durable_data_dir,
};
use std::future::Future;
use std::time::Instant;
use crate::ServerError;
use liminal::durability::{DedupCache, DurableStore};
/// The name of the thread that lists the in-flight claims at start.
const CLAIMS_RESOLVE_THREAD: &str = "liminal-claims-resolve";
/// Why a start-up store future could not be driven to its answer.
#[derive(Debug, thiserror::Error)]
enum StartupThreadError {
#[error("the start-up thread could not be spawned: {0}")]
Spawn(std::io::Error),
#[error("the start-up executor could not be built: {0}")]
Executor(std::io::Error),
#[error("the start-up thread panicked")]
Panicked,
}
/// Drives one start-up store future on a thread of its own under an executor
/// of its own, and answers what it answered.
///
/// The synchronous bridge (`liminal::durability::bridge::block_on`) polls a
/// future with no waker and is sound only for a future that is ready on its
/// first poll, as its module says. The haematite store answers a listing from
/// a shard actor on another thread, so the future that waits for that answer
/// is woken, not re-polled: driven by the bridge, the boot thread parks on the
/// answer and the server never listens (the door, 10 October 2026, boots 1 to
/// 4 at `resolve_claims_at_start`). A current-thread tokio executor on a
/// dedicated thread drives the future to its answer, and the calling thread,
/// the boot thread, which owns no executor, joins it. `make` builds the future
/// on the dedicated thread, so the future never crosses a thread.
fn run_on_startup_thread<F, Make>(name: &str, make: Make) -> Result<F::Output, StartupThreadError>
where
F: Future,
F::Output: Send,
Make: FnOnce() -> F + Send,
{
std::thread::scope(|scope| {
let worker = std::thread::Builder::new()
.name(name.to_owned())
.spawn_scoped(scope, move || {
let executor = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(StartupThreadError::Executor)?;
Ok(executor.block_on(make()))
})
.map_err(StartupThreadError::Spawn)?;
worker.join().map_err(|_| StartupThreadError::Panicked)?
})
}
/// Whether the durable store the services start over can already hold
/// in-flight claims.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum StoreHistory {
/// The store was created empty for this process and dies with it: no
/// earlier server wrote to it, so it holds no claim to look up.
#[cfg(any(test, feature = "test-support"))]
Fresh,
/// The store is persistent and holds nothing yet: this start creates it,
/// or an earlier start created it and nothing was ever written to it. It
/// holds no claim, and outlives the process.
Created,
/// The store may hold claims an earlier server left: a persistent store
/// that already exists, or one the caller handed in.
Recorded,
}
impl StoreHistory {
/// The history of the store that will be built for `persistence_path`,
/// answered before it is opened: a persistent store holds nothing when its database
/// does not exist yet or holds only what its creation wrote, and otherwise
/// may hold what the servers before this one wrote.
pub(super) fn of(path: &Path) -> Self {
let data_dir = durable_data_dir(path);
if database_exists(&data_dir) && !database_untouched(&data_dir) {
Self::Recorded
} else {
Self::Created
}
}
/// The durable delivery dedup cache over `store`: a store created by this
/// process holds no claim, so its in-flight index starts empty without
/// reading it.
pub(super) fn delivery_dedup(self, store: Arc<dyn DurableStore>) -> DedupCache {
if self == Self::Recorded {
DedupCache::new(store, DELIVERY_DEDUP_NAMESPACE)
} else {
DedupCache::new_empty(store, DELIVERY_DEDUP_NAMESPACE)
}
}
}
impl LiminalConnectionServices {
/// Resolves the durable namespace's in-flight claims, once, at start.
///
/// A store this process created (`Fresh` or `Created`) is not read. It
/// holds nothing, and its index is recorded by the first keyed publish
/// instead of on the boot path, before the listener binds.
///
/// # Errors
///
/// Returns [`ServerError::ListenerAccept`] naming the dedup namespace when
/// its claims cannot be listed. A claim whose stream cannot be read is not
/// an error: it stays held for the channel's next admission.
pub(super) fn resolve_claims_at_start(&self, history: StoreHistory) -> Result<(), ServerError> {
if history != StoreHistory::Recorded {
return Ok(());
}
let listing_failed = |message: String| ServerError::ListenerAccept {
message: format!(
"in-flight claims in dedup namespace '{}' could not be listed: {message}",
self.dedup.namespace()
),
};
tracing::info!(
namespace = self.dedup.namespace(),
"in-flight claims resolve started"
);
let started = Instant::now();
let claims = run_on_startup_thread(CLAIMS_RESOLVE_THREAD, || self.dedup.in_flight_claims())
.map_err(|error| listing_failed(error.to_string()))?
.map_err(|error| listing_failed(error.to_string()))?;
tracing::info!(
namespace = self.dedup.namespace(),
claims = claims.len(),
elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
"in-flight claims resolve finished"
);
for (idempotency_key, evidence) in claims {
let Some(evidence) = evidence else {
tracing::error!(
idempotency_key = idempotency_key.as_str(),
"in-flight claim cannot be checked"
);
self.held_keys.hold(&idempotency_key, HeldKey::Uncheckable);
continue;
};
let channel = evidence.channel().to_owned();
self.held_keys.hold(
&idempotency_key,
HeldKey::Checkable {
channel: channel.clone(),
from: evidence.positions().to_vec(),
},
);
// A channel not registered at start keeps the claim held; its first
// keyed admission after registration looks the row up.
let Ok(configured) = self.admit_channel(&channel) else {
continue;
};
if let Err(error) =
self.settle_held_with(&configured.handle, &channel, &idempotency_key)
{
tracing::warn!(
channel = channel.as_str(),
idempotency_key = idempotency_key.as_str(),
%error,
"in-flight claim could not be resolved at start; held for the next admission"
);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, PoisonError};
use std::task::{Context, Poll, Waker};
use std::time::Duration;
use liminal::durability::bridge::{BridgeError, block_on};
use super::run_on_startup_thread;
/// A future answered from another thread, the way a shard actor answers a
/// listing: pending until that thread stores the answer and wakes the
/// waker it was polled with.
struct AnsweredElsewhere {
slot: Arc<Mutex<(Option<u32>, Option<Waker>)>>,
}
impl AnsweredElsewhere {
fn new(answer: u32, after: Duration) -> Self {
let slot: Arc<Mutex<(Option<u32>, Option<Waker>)>> = Arc::new(Mutex::new((None, None)));
let answering = Arc::clone(&slot);
std::thread::spawn(move || {
std::thread::sleep(after);
let mut slot = answering.lock().unwrap_or_else(PoisonError::into_inner);
slot.0 = Some(answer);
if let Some(waker) = slot.1.take() {
waker.wake();
}
});
Self { slot }
}
}
impl Future for AnsweredElsewhere {
type Output = u32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(answer) = slot.0 {
return Poll::Ready(answer);
}
slot.1 = Some(cx.waker().clone());
Poll::Pending
}
}
/// A listing that panics instead of answering.
async fn panicking_listing() -> u32 {
std::panic::resume_unwind(Box::new("the listing panicked"))
}
const ANSWER: u32 = 7;
const ANSWER_AFTER: Duration = Duration::from_millis(50);
#[test]
fn a_future_answered_from_another_thread_is_driven_to_its_answer() {
let answered = run_on_startup_thread("claims-resolve-test", || {
AnsweredElsewhere::new(ANSWER, ANSWER_AFTER)
});
assert!(
matches!(answered, Ok(ANSWER)),
"the dedicated executor must wait for the answer: {answered:?}"
);
}
#[test]
fn the_bridge_gives_up_on_the_same_future() {
// The bridge is the thing the start-up resolve no longer uses: with no
// waker and its bounded polls it reports the answer as never coming.
let given_up = block_on(AnsweredElsewhere::new(ANSWER, ANSWER_AFTER));
assert!(
matches!(given_up, Err(BridgeError::DidNotComplete { .. })),
"{given_up:?}"
);
}
#[test]
fn a_panic_on_the_start_up_thread_is_an_error_not_a_hang() {
let outcome = run_on_startup_thread("claims-resolve-panic-test", panicking_listing);
assert!(
matches!(outcome, Err(super::StartupThreadError::Panicked)),
"{outcome:?}"
);
}
}