Skip to main content

liminal/durability/dedup/
head.rs

1//! The recorded head of a long dedup key stream (DUR-008 A12).
2//!
3//! A lookup reads a key stream's first page; only a stream that fills it is
4//! read past it, from the head recorded under the stream's `head_checkpoint`
5//! key. The head is used only once it verifies: it lies inside the stream, the
6//! row before it sits at its own position, and that row is a record of the key
7//! looked up. A head that fails any of these is ignored, counted by name, and
8//! the stream is walked from its first page on.
9//!
10//! No append records a head. A lookup records one after a walk, or when the
11//! rows past a verified head reach [`HEAD_CHECKPOINT_EVERY`], and only ever
12//! forward: a compare-and-swap from the head it read, retried while another
13//! writer's head is still behind its own. Finding another writer's head at or
14//! past its own is a lost race, which is not an error and is counted. A head
15//! record that fails, as on a store that refuses writes, is logged and the
16//! lookup still answers what it read.
17//!
18//! A refused head is provably wrong, so it is repaired: the walk's verified
19//! result replaces it by one compare-and-swap whose expected value is the
20//! exact refused head. A writer that moved the head in between makes that swap
21//! fail; its head stands, the loss is counted as a lost race, and the repair
22//! is not retried, so a repair never moves back over a newer head. Each repair
23//! that lands is counted.
24//!
25//! The three counters are read in a running process through
26//! [`dedup_head_counts`].
27
28use std::fmt;
29use std::sync::atomic::{AtomicU64, Ordering};
30
31use super::codec::DedupRecord;
32use super::{DedupCache, fold_page, len_to_u64};
33use crate::durability::head_checkpoint::{HEAD_CHECKPOINT_EVERY, READ_BATCH_SIZE};
34use crate::durability::{DurabilityError, StoredEntry, head_checkpoint_key};
35
36/// How many times one head record retries after another writer moved the
37/// head, still behind this one, in between.
38const HEAD_RECORD_ATTEMPTS: usize = 8;
39
40/// Head records that found another writer's head already at or past their
41/// own since the process started.
42static HEAD_RECORD_LOST_RACES: AtomicU64 = AtomicU64::new(0);
43
44/// Recorded heads a lookup refused (beyond the stream, at the wrong sequence,
45/// or naming another key's record) since the process started.
46static HEAD_REFUSALS: AtomicU64 = AtomicU64::new(0);
47
48/// Refused heads replaced by the walk's verified result since the process
49/// started.
50static HEAD_REPAIRS: AtomicU64 = AtomicU64::new(0);
51
52/// The dedup head counters of this process, read at one moment.
53///
54/// Counters only grow; each is read once, without a lock.
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
56pub struct DedupHeadCounts {
57    /// Recorded heads a lookup refused: beyond the stream, at the wrong
58    /// sequence, or naming another key's record.
59    pub refusals: u64,
60    /// Head records and repairs that found another writer's head already in
61    /// place of the one they expected.
62    pub record_lost_races: u64,
63    /// Refused heads replaced by the walk's verified result.
64    pub repairs: u64,
65}
66
67impl DedupHeadCounts {
68    /// Every counter with its name, in one fixed order.
69    #[must_use]
70    pub const fn named(&self) -> [(&'static str, u64); 3] {
71        [
72            ("liminal_dedup_head_refusals_total", self.refusals),
73            (
74                "liminal_dedup_head_record_lost_races_total",
75                self.record_lost_races,
76            ),
77            ("liminal_dedup_head_repairs_total", self.repairs),
78        ]
79    }
80}
81
82/// The dedup head counters of this process as they stand now: refusals
83/// (`HEAD_REFUSALS`), lost races (`HEAD_RECORD_LOST_RACES`) and repairs
84/// (`HEAD_REPAIRS`).
85#[must_use]
86pub fn dedup_head_counts() -> DedupHeadCounts {
87    DedupHeadCounts {
88        refusals: HEAD_REFUSALS.load(Ordering::Relaxed),
89        record_lost_races: HEAD_RECORD_LOST_RACES.load(Ordering::Relaxed),
90        repairs: HEAD_REPAIRS.load(Ordering::Relaxed),
91    }
92}
93
94/// Why a recorded head was not used.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96enum HeadRefusal {
97    /// The head claims rows the stream does not hold.
98    BeyondLog { covered: u64 },
99    /// The row before the head carries another sequence than its position.
100    WrongSequence { covered: u64, found: u64 },
101    /// The row before the head is a record of another idempotency key.
102    ForeignKey { covered: u64 },
103}
104
105impl fmt::Display for HeadRefusal {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            Self::BeyondLog { covered } => write!(
109                formatter,
110                "the recorded head {covered} claims rows the stream does not hold"
111            ),
112            Self::WrongSequence { covered, found } => write!(
113                formatter,
114                "the recorded head {covered} names a row whose sequence is {found}"
115            ),
116            Self::ForeignKey { covered } => write!(
117                formatter,
118                "the recorded head {covered} names a record of another key"
119            ),
120        }
121    }
122}
123
124impl DedupCache {
125    /// The latest record and next sequence of a stream whose first page,
126    /// `first_page`, was full.
127    ///
128    /// # Errors
129    /// Propagates store reads, malformed records, and a record of another key
130    /// ([`DurabilityError::DedupCollision`]) among the rows read.
131    pub(super) async fn latest_past_first_page(
132        &self,
133        stream_key: &str,
134        idempotency_key: &str,
135        first_page: &[StoredEntry],
136    ) -> Result<(Option<DedupRecord>, u64), DurabilityError> {
137        let recorded = self
138            .store
139            .read_value(&head_checkpoint_key(stream_key))
140            .await?
141            .unwrap_or(0);
142        let mut head_refused = false;
143        if recorded > 0 {
144            match self
145                .verified_head(stream_key, idempotency_key, recorded)
146                .await?
147            {
148                Ok(record) => {
149                    let (latest, next) = self
150                        .fold_from(stream_key, idempotency_key, recorded)
151                        .await?;
152                    if next.saturating_sub(recorded) >= HEAD_CHECKPOINT_EVERY {
153                        self.record_head(stream_key, recorded, next).await;
154                    }
155                    return Ok((latest.or(Some(record)), next));
156                }
157                Err(refusal) => {
158                    HEAD_REFUSALS.fetch_add(1, Ordering::Relaxed);
159                    tracing::warn!(
160                        stream = stream_key,
161                        reason = %refusal,
162                        "dedup stream head refused; the stream is walked from its first page"
163                    );
164                    head_refused = true;
165                }
166            }
167        }
168        let first = fold_page(stream_key, idempotency_key, first_page, 0)?;
169        let (rest, next) = self
170            .fold_from(stream_key, idempotency_key, len_to_u64(first_page.len())?)
171            .await?;
172        if head_refused {
173            self.repair_head(stream_key, recorded, next).await;
174        } else {
175            self.record_head(stream_key, recorded, next).await;
176        }
177        Ok((rest.or(first), next))
178    }
179
180    /// Checks the head `covered` against the stream: the row at `covered - 1`
181    /// exists, sits at its position and is a record of `idempotency_key`.
182    /// Answers that record, or why the head is refused.
183    async fn verified_head(
184        &self,
185        stream_key: &str,
186        idempotency_key: &str,
187        covered: u64,
188    ) -> Result<Result<DedupRecord, HeadRefusal>, DurabilityError> {
189        let last = covered.saturating_sub(1);
190        let Some(stored) = self.store.read_at(stream_key, last).await? else {
191            return Ok(Err(HeadRefusal::BeyondLog { covered }));
192        };
193        if stored.sequence != last {
194            return Ok(Err(HeadRefusal::WrongSequence {
195                covered,
196                found: stored.sequence,
197            }));
198        }
199        let record = DedupRecord::deserialize(&stored.payload)?;
200        if record.idempotency_key() != idempotency_key {
201            return Ok(Err(HeadRefusal::ForeignKey { covered }));
202        }
203        Ok(Ok(record))
204    }
205
206    /// Reads the stream from `offset` to its end, checking every row, and
207    /// answers the last record read and the stream's next sequence.
208    async fn fold_from(
209        &self,
210        stream_key: &str,
211        idempotency_key: &str,
212        offset: u64,
213    ) -> Result<(Option<DedupRecord>, u64), DurabilityError> {
214        let mut latest = None;
215        let mut next = offset;
216        loop {
217            let page = self
218                .store
219                .read_from(stream_key, next, READ_BATCH_SIZE)
220                .await?;
221            if let Some(last) = fold_page(stream_key, idempotency_key, &page, next)? {
222                latest = Some(last);
223            }
224            next = next.checked_add(len_to_u64(page.len())?).ok_or_else(|| {
225                DurabilityError::ConfigError(format!("dedup stream {stream_key} offset overflow"))
226            })?;
227            if page.len() < READ_BATCH_SIZE {
228                return Ok((latest, next));
229            }
230        }
231    }
232
233    /// Records `next` as the stream's head, forward only, from `recorded`,
234    /// the head this lookup read. A head at or past `next` already stored by
235    /// another writer is a lost race, counted and not an error; any other
236    /// failure is logged, since the lookup has already read what it answers.
237    async fn record_head(&self, stream_key: &str, recorded: u64, next: u64) {
238        let key = head_checkpoint_key(stream_key);
239        let mut stored = recorded;
240        for _ in 0..HEAD_RECORD_ATTEMPTS {
241            if stored >= next {
242                return;
243            }
244            match self.store.cas(&key, stored, next).await {
245                Ok(()) => return,
246                Err(DurabilityError::CursorRegression {
247                    stored: observed, ..
248                }) if observed >= next => {
249                    HEAD_RECORD_LOST_RACES.fetch_add(1, Ordering::Relaxed);
250                    return;
251                }
252                Err(DurabilityError::CursorRegression {
253                    stored: observed, ..
254                }) if observed != stored => stored = observed,
255                Err(error) => {
256                    tracing::warn!(
257                        stream = stream_key,
258                        %error,
259                        "dedup stream head was not recorded; the next lookup walks again"
260                    );
261                    return;
262                }
263            }
264        }
265    }
266
267    /// Replaces the refused head `refused` with `next`, the walk's verified
268    /// result, by one compare-and-swap expecting exactly `refused`. Another
269    /// writer's head found in its place stands: the loss is counted and the
270    /// repair not retried. Any other failure is logged, since the lookup has
271    /// already read what it answers.
272    async fn repair_head(&self, stream_key: &str, refused: u64, next: u64) {
273        if refused == next {
274            return;
275        }
276        if next == 0 {
277            // The value store keeps no physical zero, so an empty stream's
278            // head cannot be recorded; the refused head stays and is refused
279            // again by name.
280            tracing::warn!(
281                stream = stream_key,
282                refused,
283                "a refused dedup stream head cannot be repaired while its stream is empty"
284            );
285            return;
286        }
287        match self
288            .store
289            .cas(&head_checkpoint_key(stream_key), refused, next)
290            .await
291        {
292            Ok(()) => {
293                HEAD_REPAIRS.fetch_add(1, Ordering::Relaxed);
294            }
295            Err(DurabilityError::CursorRegression { .. }) => {
296                HEAD_RECORD_LOST_RACES.fetch_add(1, Ordering::Relaxed);
297            }
298            Err(error) => {
299                tracing::warn!(
300                    stream = stream_key,
301                    %error,
302                    "refused dedup stream head was not repaired; the next lookup walks again"
303                );
304            }
305        }
306    }
307}