Skip to main content

liminal/durability/
head_checkpoint.rs

1//! Recorded partition heads: where a durable channel's restart resumes reading its log.
2//!
3//! A durable channel partition's only folded state is its next append sequence. Without
4//! a record of it, every construction walks the partition's whole log to find the head,
5//! so a restart costs the channel's entire history. The head is recorded here, beside
6//! the log, as the sequence it covers; a restart verifies the record against the log and
7//! reads only the rows appended after it.
8//!
9//! The record is written by count: once every [`HEAD_CHECKPOINT_EVERY`] appends, and
10//! at recovery whenever the tail read past the record reached that count or the record
11//! was refused. Nothing here runs on a clock.
12//!
13//! A missing record is the ordinary first checkpoint, recorded after a walk at INFO.
14//! A record claiming rows the log does not hold is refused by name at WARN, then
15//! rebuilt from a full walk. A clean stop records every appended stream's final head.
16
17use std::fmt;
18
19use super::{DurabilityError, DurableStore};
20
21/// How many rows a partition's log may grow past its recorded head before the head is
22/// recorded again. This bounds the rows a restart reads per partition.
23pub const HEAD_CHECKPOINT_EVERY: u64 = 256;
24
25/// How many rows one recovery read asks the store for.
26pub(crate) const READ_BATCH_SIZE: usize = 1_024;
27
28/// Namespace of the recorded partition heads in the store's value keyspace.
29const HEAD_CHECKPOINT_NAMESPACE: &str = "liminal:channel-head";
30
31/// The value key under which a partition stream's recorded head is kept.
32#[must_use]
33pub fn head_checkpoint_key(stream_key: &str) -> String {
34    format!("{HEAD_CHECKPOINT_NAMESPACE}:{stream_key}")
35}
36
37/// Why a recorded head was not trusted.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum HeadCheckpointRefusal {
40    /// The record claims `covered` rows and the row before that position is absent.
41    BeyondLog {
42        /// The next sequence the record claims.
43        covered: u64,
44    },
45    /// The row the record names carries a different sequence than its position.
46    WrongSequence {
47        /// The next sequence the record claims.
48        covered: u64,
49        /// The sequence found at the position the record names.
50        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
69/// What one walk of a partition log found.
70struct Walk {
71    /// Rows the walk read.
72    rows: u64,
73    /// The highest sequence the walk read, if it read any row.
74    highest: Option<u64>,
75}
76
77impl Walk {
78    /// The next append sequence after this walk, never below `floor`.
79    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
92/// Recovers one partition's next append sequence, reading only the rows appended after
93/// its recorded head when that head verifies against the log.
94///
95/// # Errors
96///
97/// Propagates store read and write errors, and returns
98/// [`DurabilityError::ConfigError`] on offset or sequence overflow, or when a row the
99/// walk reads lies before the position it was asked to read from.
100pub 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
142/// Records `next` as the partition's head when it is past the recorded one.
143///
144/// A concurrent writer that recorded a head in between is read back from the refusal,
145/// and the record is retried only while it is still behind `next`, so the loop ends
146/// once either this write lands or another writer has recorded at least as far.
147///
148/// # Errors
149///
150/// Propagates store read and write errors other than a lost race.
151pub 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
173/// Replaces a refused record with the head a full walk found.
174///
175/// An empty log cannot be recorded, because the value store keeps no physical zero; the
176/// refused record then stays, and the next start refuses it again by name.
177async 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
197/// Checks a recorded head against the log: the row just before `covered` must exist and
198/// carry the sequence of its position. Answers the refusal, or `None` when it holds.
199async 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
217/// Reads a partition log from `offset` to its end, keeping only the row count and the
218/// highest sequence.
219///
220/// Each read starts past the highest sequence the last one answered, never at a count
221/// of rows: a pruned log answers a read below its first retained sequence from that
222/// sequence, so a count-based position would ask for the same retained page again
223/// until it had counted past every pruned row.
224async 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;