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}