1use std::fmt;
18
19use super::{DurabilityError, DurableStore};
20
21pub const HEAD_CHECKPOINT_EVERY: u64 = 256;
24
25pub(crate) const READ_BATCH_SIZE: usize = 1_024;
27
28const HEAD_CHECKPOINT_NAMESPACE: &str = "liminal:channel-head";
30
31#[must_use]
33pub fn head_checkpoint_key(stream_key: &str) -> String {
34 format!("{HEAD_CHECKPOINT_NAMESPACE}:{stream_key}")
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum HeadCheckpointRefusal {
40 BeyondLog {
42 covered: u64,
44 },
45 WrongSequence {
47 covered: u64,
49 found: u64,
51 },
52}
53
54impl fmt::Display for HeadCheckpointRefusal {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 Self::BeyondLog { covered } => write!(
58 formatter,
59 "the recorded head {covered} claims rows the log does not hold"
60 ),
61 Self::WrongSequence { covered, found } => write!(
62 formatter,
63 "the recorded head {covered} names a row whose sequence is {found}"
64 ),
65 }
66 }
67}
68
69struct Walk {
71 rows: u64,
73 highest: Option<u64>,
75}
76
77impl Walk {
78 fn next_sequence(&self, floor: u64) -> Result<u64, DurabilityError> {
80 let Some(highest) = self.highest else {
81 return Ok(floor);
82 };
83 let next = highest.checked_add(1).ok_or_else(|| {
84 DurabilityError::ConfigError(
85 "sequence number overflow after channel recovery".to_owned(),
86 )
87 })?;
88 Ok(next.max(floor))
89 }
90}
91
92pub async fn recover_partition_head(
101 store: &dyn DurableStore,
102 stream_key: &str,
103) -> Result<u64, DurabilityError> {
104 let key = head_checkpoint_key(stream_key);
105 let recorded = store.read_value(&key).await?.filter(|covered| *covered > 0);
106 let Some(covered) = recorded else {
107 let walk = walk_from(store, stream_key, 0).await?;
108 if walk.rows == 0 {
109 return Ok(0);
110 }
111 let next = walk.next_sequence(0)?;
112 advance_head(store, stream_key, next).await?;
113 tracing::info!(
114 stream = stream_key,
115 rows = walk.rows,
116 "durable channel head recorded for a stream under its first checkpoint"
117 );
118 return Ok(next);
119 };
120 match verify(store, stream_key, covered).await? {
121 None => {
122 if store.read_stream_next_seq(stream_key).await? == Some(covered) {
123 return Ok(covered);
124 }
125 let tail = walk_from(store, stream_key, covered).await?;
126 let next = tail.next_sequence(covered)?;
127 if tail.rows >= HEAD_CHECKPOINT_EVERY {
128 advance_head(store, stream_key, next).await?;
129 }
130 Ok(next)
131 }
132 Some(refusal) => {
133 let walk = walk_from(store, stream_key, 0).await?;
134 let next = walk.next_sequence(0)?;
135 warn_refused(stream_key, refusal, next);
136 replace_refused_head(store, stream_key, covered, next).await?;
137 Ok(next)
138 }
139 }
140}
141
142pub async fn advance_head(
152 store: &dyn DurableStore,
153 stream_key: &str,
154 next: u64,
155) -> Result<(), DurabilityError> {
156 if next == 0 {
157 return Ok(());
158 }
159 let key = head_checkpoint_key(stream_key);
160 let mut stored = store.read_value(&key).await?.unwrap_or(0);
161 while stored < next {
162 match store.cas(&key, stored, next).await {
163 Ok(()) => return Ok(()),
164 Err(DurabilityError::CursorRegression {
165 stored: observed, ..
166 }) if observed != stored => stored = observed,
167 Err(error) => return Err(error),
168 }
169 }
170 Ok(())
171}
172
173async fn replace_refused_head(
178 store: &dyn DurableStore,
179 stream_key: &str,
180 refused: u64,
181 next: u64,
182) -> Result<(), DurabilityError> {
183 if next == 0 {
184 tracing::warn!(
185 stream = stream_key,
186 refused,
187 "a refused durable channel head cannot be replaced while its log is empty; the \
188 next start refuses it again and walks the empty log"
189 );
190 return Ok(());
191 }
192 store
193 .cas(&head_checkpoint_key(stream_key), refused, next)
194 .await
195}
196
197async fn verify(
200 store: &dyn DurableStore,
201 stream_key: &str,
202 covered: u64,
203) -> Result<Option<HeadCheckpointRefusal>, DurabilityError> {
204 let last = covered.checked_sub(1).ok_or_else(|| {
205 DurabilityError::ConfigError("a recorded head of zero has no row to verify".to_owned())
206 })?;
207 Ok(match store.read_at(stream_key, last).await? {
208 None => Some(HeadCheckpointRefusal::BeyondLog { covered }),
209 Some(entry) if entry.sequence != last => Some(HeadCheckpointRefusal::WrongSequence {
210 covered,
211 found: entry.sequence,
212 }),
213 Some(_) => None,
214 })
215}
216
217async fn walk_from(
225 store: &dyn DurableStore,
226 stream_key: &str,
227 offset: u64,
228) -> Result<Walk, DurabilityError> {
229 let mut position = offset;
230 let mut walk = Walk {
231 rows: 0,
232 highest: None,
233 };
234 loop {
235 let batch = store
236 .read_from(stream_key, position, READ_BATCH_SIZE)
237 .await?;
238 let batch_len = batch.len();
239 for stored in &batch {
240 if stored.sequence < position {
241 return Err(DurabilityError::ConfigError(format!(
242 "stream {stream_key} answered sequence {} to a read from {position}",
243 stored.sequence
244 )));
245 }
246 walk.highest = Some(
247 walk.highest
248 .map_or(stored.sequence, |s| s.max(stored.sequence)),
249 );
250 }
251 let read = u64::try_from(batch_len).map_err(|error| {
252 DurabilityError::ConfigError(format!(
253 "channel recovery entry count cannot fit u64: {error}"
254 ))
255 })?;
256 walk.rows = walk.rows.checked_add(read).ok_or_else(|| {
257 DurabilityError::ConfigError("channel recovery row count overflow".to_owned())
258 })?;
259 let Some(last) = batch.last() else {
260 return Ok(walk);
261 };
262 position = last.sequence.checked_add(1).ok_or_else(|| {
263 DurabilityError::ConfigError("channel recovery read offset overflow".to_owned())
264 })?;
265 if batch_len < READ_BATCH_SIZE {
266 return Ok(walk);
267 }
268 }
269}
270
271fn warn_refused(stream_key: &str, refusal: HeadCheckpointRefusal, next: u64) {
272 tracing::warn!(
273 stream = stream_key,
274 reason = %refusal,
275 rebuilt_head = next,
276 "durable channel head record refused; the whole log was walked and the head it \
277 found is recorded again"
278 );
279}
280
281#[cfg(test)]
282mod tests;