Skip to main content

liminal/durability/dedup/
in_flight.rs

1//! The in-flight index: where a start finds the claims still in flight without
2//! walking the dedup namespace.
3//!
4//! A claim made with evidence first appends a row naming its key and the
5//! sequence its claim takes in the key's own stream to the namespace's index
6//! stream, then writes the claim there. A start reads the index from its
7//! recorded start and, for each named key, reads one bounded page of two rows
8//! from the key's stream at that claim sequence: the claim and the row after
9//! it. It never reads a key's head or its history, so a start costs the same
10//! however many claims a key has had (DUR-008 A12). A key whose claim was
11//! completed or released (a row after the claim, or a receipt on it) or never
12//! written (no row at the claim) is dropped there; the others are in flight.
13//! A completion or release writes nothing to the index: the process forgets the
14//! key, and the recorded start moves past rows that name no claim it holds.
15//!
16//! The start is recorded by count, as a channel's head is: once the rows behind
17//! the oldest claim held in flight reach [`IN_FLIGHT_CHECKPOINT_EVERY`]. A
18//! claim held so long that the index grows twice that far past the recorded
19//! start is carried: its key is appended again at the tail, with every other
20//! such key in one append, and the start moves past its old row. A start
21//! therefore reads at most about twice [`IN_FLIGHT_CHECKPOINT_EVERY`] rows plus
22//! one per claim in flight, however much the namespace holds. Nothing here runs
23//! on a clock.
24//!
25//! An index row written before the claim sequence was carried (`LDX1`, by a
26//! binary older than A12's) names its key alone; a start looks that key up in
27//! full, as it did before. Every row this binary writes carries the sequence,
28//! so such a row is read until the start moves past it.
29//!
30//! Crash windows, each repaired by the next start's look-up of the named keys:
31//! after the index row and before the claim, the key's stream holds no claim
32//! and the row is dropped; after a completion or release and before the start
33//! moves, the claim is found settled and the row is dropped; after a carry and
34//! before the start moves, the key is read twice and kept once.
35//!
36//! A store written before the index existed (liminal-server 0.16.0) has no
37//! recorded start. The first start over it walks the namespace once, appends a
38//! row for every claim it finds in flight in one append, and records the start
39//! at those rows. A crash before that record repeats the walk; nothing after it
40//! does.
41//!
42//! A namespace the caller knows is empty (a store this process created, or a
43//! namespace no earlier process used) is neither read nor walked: its index
44//! starts empty, and its start is recorded by the caller that knows it is
45//! empty ([`DedupCache::record_empty_start`], DUR-023) or, failing that, by
46//! its first claim, before that claim is written, so a later start never
47//! mistakes it for a store that predates the index. A namespace begun empty,
48//! never recorded and never claimed in reaches its next start with no
49//! recorded start and is walked once then, like a store written before the
50//! index existed.
51use std::collections::{BTreeMap, HashMap};
52use std::sync::{Mutex, MutexGuard, PoisonError};
53
54use super::codec::{self, DedupRecord};
55use super::{DedupCache, DedupEntry, InFlightClaim};
56use crate::durability::DurabilityError;
57use crate::durability::head_checkpoint::READ_BATCH_SIZE;
58
59/// How many index rows may lie behind the oldest claim held in flight before
60/// the start is recorded again. This bounds the rows a start reads.
61pub(super) const IN_FLIGHT_CHECKPOINT_EVERY: u64 = 256;
62
63/// The rows a start reads from a named key's stream: the claim the index row
64/// names and the one after it, which settles it.
65const CLAIM_PAGE: usize = 2;
66
67/// How many times one index append retries after another writer took its row.
68const INDEX_APPEND_ATTEMPTS: usize = 64;
69
70/// A claim this process holds in flight: the index row naming it and the
71/// sequence of the claim in its key's stream.
72#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
73struct OpenClaim {
74    row: u64,
75    claim: u64,
76}
77
78/// What the process knows of its namespace's index.
79#[derive(Debug)]
80struct IndexState {
81    /// The first row a start reads, as last recorded.
82    start: u64,
83    /// The index stream's next row, as this process last saw it.
84    tail: u64,
85    /// Claims held in flight, by key.
86    open: HashMap<String, OpenClaim>,
87    /// Appends under way, by the row they were reserved at: none of them lands
88    /// below it, so the start never moves past it.
89    reserved: BTreeMap<u64, usize>,
90    /// Whether a carry is under way.
91    carrying: bool,
92    /// Whether the start is in the store. Only an index begun empty by this
93    /// process lacks it, until its first join records it.
94    recorded: bool,
95}
96
97impl IndexState {
98    /// The index of a namespace nothing has written to.
99    fn empty() -> Self {
100        Self {
101            start: 0,
102            tail: 0,
103            open: HashMap::new(),
104            reserved: BTreeMap::new(),
105            carrying: false,
106            recorded: false,
107        }
108    }
109
110    /// The lowest row a start must still read.
111    fn floor(&self) -> u64 {
112        let open = self.open.values().map(|claim| claim.row).min();
113        let reserved = self.reserved.keys().next().copied();
114        [open, reserved]
115            .into_iter()
116            .flatten()
117            .fold(self.tail, u64::min)
118    }
119
120    fn reserve(&mut self) -> u64 {
121        let floor = self.tail;
122        *self.reserved.entry(floor).or_insert(0) += 1;
123        floor
124    }
125
126    fn unreserve(&mut self, floor: u64) {
127        if let Some(count) = self.reserved.get_mut(&floor) {
128            *count = count.saturating_sub(1);
129            if *count == 0 {
130                self.reserved.remove(&floor);
131            }
132        }
133    }
134
135    fn appended(&mut self, after: u64) {
136        self.tail = self.tail.max(after);
137    }
138
139    fn leave(&mut self, key: &str, settled: u64) -> bool {
140        if self.open.get(key).is_some_and(|open| open.claim <= settled) {
141            self.open.remove(key);
142            return true;
143        }
144        false
145    }
146
147    fn plan(&mut self) -> Option<Advance> {
148        if self.carrying {
149            return None;
150        }
151        let floor = self.floor();
152        if floor >= self.start.saturating_add(IN_FLIGHT_CHECKPOINT_EVERY) {
153            return Some(Advance::Record(floor));
154        }
155        if self.tail < self.start.saturating_add(2 * IN_FLIGHT_CHECKPOINT_EVERY) {
156            return None;
157        }
158        let horizon = self.tail.saturating_sub(IN_FLIGHT_CHECKPOINT_EVERY);
159        let mut keys: Vec<(String, OpenClaim)> = self
160            .open
161            .iter()
162            .filter(|(_, open)| open.row < horizon)
163            .map(|(key, open)| (key.clone(), *open))
164            .collect();
165        if keys.is_empty() {
166            return None;
167        }
168        keys.sort_unstable();
169        self.carrying = true;
170        let at = self.reserve();
171        Some(Advance::Carry { keys, at })
172    }
173
174    fn carried(
175        &mut self,
176        keys: &[(String, OpenClaim)],
177        first: Option<u64>,
178        at: u64,
179    ) -> Option<u64> {
180        if let Some(first) = first {
181            let mut row = first;
182            for (key, carried) in keys {
183                if let Some(open) = self.open.get_mut(key) {
184                    if open.row == carried.row {
185                        open.row = row;
186                    }
187                }
188                row = row.saturating_add(1);
189            }
190            self.appended(row);
191        }
192        self.unreserve(at);
193        self.carrying = false;
194        let floor = self.floor();
195        (floor > self.start).then_some(floor)
196    }
197}
198
199/// What moving the start needs next.
200enum Advance {
201    /// Record the start at this row.
202    Record(u64),
203    /// Append these keys again, each with the claim held for it, at the
204    /// reserved row, then record.
205    Carry {
206        keys: Vec<(String, OpenClaim)>,
207        at: u64,
208    },
209}
210
211/// The process's view of one namespace's index, shared by the cache's clones.
212#[derive(Debug, Default)]
213pub(super) struct InFlightIndex(Mutex<Option<IndexState>>);
214
215impl InFlightIndex {
216    /// The index of a namespace the caller knows is empty, loaded without
217    /// reading the store.
218    pub(super) fn empty() -> Self {
219        Self(Mutex::new(Some(IndexState::empty())))
220    }
221
222    fn state(&self) -> MutexGuard<'_, Option<IndexState>> {
223        // The state is rebuilt from the durable index on the next start; a
224        // panic elsewhere cannot make it claim less than the store holds.
225        self.0.lock().unwrap_or_else(PoisonError::into_inner)
226    }
227
228    fn is_loaded(&self) -> bool {
229        self.state().is_some()
230    }
231
232    /// Installs a loaded state unless another load got there first.
233    fn install(&self, loaded: IndexState) -> bool {
234        let mut state = self.state();
235        if state.is_some() {
236            return false;
237        }
238        *state = Some(loaded);
239        true
240    }
241
242    fn reserve(&self) -> Option<u64> {
243        self.state().as_mut().map(IndexState::reserve)
244    }
245
246    fn unreserve(&self, floor: u64) {
247        if let Some(state) = self.state().as_mut() {
248            state.unreserve(floor);
249        }
250    }
251
252    fn joined(&self, key: &str, row: u64, claim: u64) {
253        if let Some(state) = self.state().as_mut() {
254            state.appended(row.saturating_add(1));
255            state.open.insert(key.to_owned(), OpenClaim { row, claim });
256        }
257    }
258
259    /// Forgets a join whose claim was not made, when it is still the one held.
260    fn unjoin(&self, key: &str, row: u64) {
261        if let Some(state) = self.state().as_mut() {
262            if state.open.get(key).is_some_and(|open| open.row == row) {
263                state.open.remove(key);
264            }
265        }
266    }
267
268    /// Forgets `key` when the claim held for it is the one settled at
269    /// `settled` or an earlier one; a claim made after the settlement stays.
270    fn leave(&self, key: &str, settled: u64) -> bool {
271        self.with_state(|state| state.leave(key, settled))
272            .unwrap_or(false)
273    }
274
275    /// The claims held, each key with its claim sequence.
276    fn open_claims(&self) -> Vec<(String, u64)> {
277        self.state()
278            .as_ref()
279            .map(|state| {
280                state
281                    .open
282                    .iter()
283                    .map(|(key, open)| (key.clone(), open.claim))
284                    .collect()
285            })
286            .unwrap_or_default()
287    }
288
289    /// Whether this is an index begun empty whose start is not yet recorded.
290    fn awaits_record(&self) -> bool {
291        self.state().as_ref().is_some_and(|state| !state.recorded)
292    }
293
294    fn start_recorded(&self) {
295        if let Some(state) = self.state().as_mut() {
296            state.recorded = true;
297        }
298    }
299
300    fn start(&self) -> Option<u64> {
301        self.state().as_ref().map(|state| state.start)
302    }
303
304    fn recorded(&self, start: u64) {
305        if let Some(state) = self.state().as_mut() {
306            state.start = state.start.max(start);
307        }
308    }
309
310    fn plan(&self) -> Option<Advance> {
311        self.with_state(IndexState::plan).flatten()
312    }
313
314    /// Moves each carried key that is still held to its new row, answering
315    /// the start the carry lets the index record.
316    fn carried(&self, keys: &[(String, OpenClaim)], first: Option<u64>, at: u64) -> Option<u64> {
317        self.with_state(|state| state.carried(keys, first, at))
318            .flatten()
319    }
320
321    fn with_state<T>(&self, apply: impl FnOnce(&mut IndexState) -> T) -> Option<T> {
322        self.state().as_mut().map(apply)
323    }
324}
325
326impl DedupCache {
327    /// The stream holding this namespace's in-flight index rows. It sits
328    /// outside the `namespace:` prefix the key streams share, so no walk of
329    /// the key streams reads it.
330    fn index_stream(&self) -> String {
331        format!("{}/in-flight", self.namespace)
332    }
333
334    /// The value key recording the index start, kept as the start plus one
335    /// because the value store keeps no physical zero.
336    fn index_start_key(&self) -> String {
337        format!("{}/in-flight-start", self.namespace)
338    }
339
340    /// The claims in flight in this namespace, each with its claim time and
341    /// the evidence it carries, in key order, read from the in-flight index and never by walking the
342    /// namespace (except once, on the first start over a store written before
343    /// the index existed).
344    ///
345    /// # Errors
346    /// Refuses an unreadable index or key stream, or a malformed record; never
347    /// answers a partial list.
348    pub async fn in_flight_claims(&self) -> Result<Vec<InFlightClaim>, DurabilityError> {
349        if !self.in_flight.is_loaded() {
350            let (state, claims) = self.load_in_flight_index().await?;
351            if self.in_flight.install(state) {
352                self.advance_in_flight().await;
353                return Ok(claims);
354            }
355        }
356        let mut held = self.in_flight.open_claims();
357        held.sort_unstable();
358        let mut claims = Vec::new();
359        for (key, claim) in held {
360            if let Some(entry) = self.claim_at(&key, claim).await? {
361                claims.push(InFlightClaim::of(key, &entry));
362            }
363        }
364        Ok(claims)
365    }
366
367    /// Loads the index state when this process has not yet.
368    pub(super) async fn ensure_in_flight_index(&self) -> Result<(), DurabilityError> {
369        if self.in_flight.is_loaded() {
370            return Ok(());
371        }
372        let (state, _) = self.load_in_flight_index().await?;
373        if self.in_flight.install(state) {
374            self.advance_in_flight().await;
375        }
376        Ok(())
377    }
378
379    /// Appends the index row naming `key` before its claim is written,
380    /// answering the row. `claim` is the sequence the claim will take in the
381    /// key's stream.
382    pub(super) async fn join_in_flight(
383        &self,
384        key: &str,
385        claim: u64,
386    ) -> Result<u64, DurabilityError> {
387        self.ensure_in_flight_index().await?;
388        self.record_empty_start().await?;
389        let floor = self.in_flight.reserve().ok_or_else(|| {
390            DurabilityError::ConfigError("in-flight index is not loaded".to_owned())
391        })?;
392        let appended = self
393            .append_index_rows(vec![codec::encode_join(key, claim)?], floor)
394            .await;
395        if let Ok(row) = appended {
396            self.in_flight.joined(key, row, claim);
397        }
398        self.in_flight.unreserve(floor);
399        appended
400    }
401
402    /// Records the start of an index begun empty (a cache opened with
403    /// [`DedupCache::new_empty`]) whose start is not yet recorded: one
404    /// compare-and-set of the index start key from 0 to 1, after which a
405    /// later process opening the namespace with [`DedupCache::new`] reads
406    /// the index and never walks the namespace. The first claim calls this
407    /// before it writes; the start that created the store calls it earlier
408    /// (DUR-023), so a namespace nothing is ever claimed in is not mistaken
409    /// at its next start for a store written before the index existed and
410    /// walked, shard by shard, to find nothing.
411    ///
412    /// Makes no store call for a cache opened with [`DedupCache::new`], for
413    /// an index whose start is already recorded, or on a second call. Another
414    /// join or another start recording the same start first counts as
415    /// recorded.
416    ///
417    /// # Errors
418    ///
419    /// Propagates a store error from the compare-and-set other than the
420    /// cursor regression that says it was recorded first.
421    pub async fn record_empty_start(&self) -> Result<(), DurabilityError> {
422        if !self.in_flight.awaits_record() {
423            return Ok(());
424        }
425        match self.store.cas(&self.index_start_key(), 0, 1).await {
426            // Another join recorded it first, at the same start.
427            Ok(()) | Err(DurabilityError::CursorRegression { .. }) => {
428                self.in_flight.start_recorded();
429                Ok(())
430            }
431            Err(error) => Err(error),
432        }
433    }
434
435    /// Forgets the join at `row` whose claim was not made.
436    pub(super) fn unjoin_in_flight(&self, key: &str, row: u64) {
437        self.in_flight.unjoin(key, row);
438    }
439
440    /// Forgets `key`'s claim settled at `settled` and moves the index start
441    /// when enough rows lie behind it.
442    pub(super) async fn leave_in_flight(&self, key: &str, settled: u64) {
443        if self.in_flight.leave(key, settled) {
444            self.advance_in_flight().await;
445        }
446    }
447
448    /// Moves the recorded start as far as the claims held allow. The rows are
449    /// already durable, so a failure here fails nothing: the old start stays
450    /// valid and the next start reads more rows.
451    async fn advance_in_flight(&self) {
452        if let Err(error) = self.try_advance_in_flight().await {
453            tracing::warn!(
454                namespace = %self.namespace,
455                %error,
456                "the in-flight index start could not be recorded; the next start reads the \
457                 rows since the previous record"
458            );
459        }
460    }
461
462    async fn try_advance_in_flight(&self) -> Result<(), DurabilityError> {
463        let target = match self.in_flight.plan() {
464            None => return Ok(()),
465            Some(Advance::Record(target)) => target,
466            Some(Advance::Carry { keys, at }) => {
467                let rows = keys
468                    .iter()
469                    .map(|(key, open)| codec::encode_join(key, open.claim))
470                    .collect::<Result<Vec<_>, _>>();
471                let appended = match rows {
472                    Ok(rows) => self.append_index_rows(rows, at).await,
473                    Err(error) => Err(error),
474                };
475                let target = self
476                    .in_flight
477                    .carried(&keys, appended.as_ref().ok().copied(), at);
478                appended?;
479                let Some(target) = target else {
480                    return Ok(());
481                };
482                target
483            }
484        };
485        let Some(start) = self.in_flight.start() else {
486            return Ok(());
487        };
488        match self
489            .store
490            .cas(
491                &self.index_start_key(),
492                start.saturating_add(1),
493                target.saturating_add(1),
494            )
495            .await
496        {
497            Ok(()) => self.in_flight.recorded(target),
498            // Another advance recorded first; take its start.
499            Err(DurabilityError::CursorRegression { stored, .. }) => {
500                self.in_flight.recorded(stored.saturating_sub(1));
501            }
502            Err(error) => return Err(error),
503        }
504        Ok(())
505    }
506
507    /// Appends `rows` to the index at the first row the stream has free at or
508    /// after `expected`, answering where the first one landed.
509    async fn append_index_rows(
510        &self,
511        rows: Vec<Vec<u8>>,
512        expected: u64,
513    ) -> Result<u64, DurabilityError> {
514        let stream = self.index_stream();
515        let mut expected = expected;
516        let mut last_conflict = None;
517        for _ in 0..INDEX_APPEND_ATTEMPTS {
518            match self.store.append_all(&stream, rows.clone(), expected).await {
519                Ok(_) => return Ok(expected),
520                // Another writer took the row; the stream's head is later, so
521                // the append moves there and never below its reservation.
522                Err(DurabilityError::SequenceConflict { actual, .. }) if actual > expected => {
523                    last_conflict = Some(actual);
524                    expected = actual;
525                }
526                Err(error) => return Err(error),
527            }
528        }
529        Err(DurabilityError::SequenceConflict {
530            expected,
531            actual: last_conflict.unwrap_or(expected),
532        })
533    }
534
535    /// Reads the index from its recorded start, migrating a store that has
536    /// none, and reads each named key's claim from its own stream: one page
537    /// of two rows at the claim sequence the row carries, or, for a row
538    /// written before the sequence was carried, the key's full look-up.
539    async fn load_in_flight_index(
540        &self,
541    ) -> Result<(IndexState, Vec<InFlightClaim>), DurabilityError> {
542        let start = match self.store.read_value(&self.index_start_key()).await? {
543            Some(recorded) if recorded > 0 => recorded - 1,
544            _ => self.migrate_in_flight_index().await?,
545        };
546        let (named, tail) = self.read_index_rows(start).await?;
547        let mut open = HashMap::new();
548        let mut claims = Vec::new();
549        for (key, (row, claim)) in named {
550            let held = match claim {
551                Some(claim) => self
552                    .claim_at(&key, claim)
553                    .await?
554                    .map(|entry| (claim, entry)),
555                None => self.claim_without_sequence(&key).await?,
556            };
557            let Some((claim, entry)) = held else {
558                continue;
559            };
560            open.insert(key.clone(), OpenClaim { row, claim });
561            claims.push(InFlightClaim::of(key, &entry));
562        }
563        let state = IndexState {
564            start,
565            tail,
566            open,
567            reserved: BTreeMap::new(),
568            carrying: false,
569            recorded: true,
570        };
571        Ok((state, claims))
572    }
573
574    /// The index rows from `start` to the stream's end: each named key with
575    /// the last row naming it and that row's claim sequence, and the row after
576    /// the last one read.
577    async fn read_index_rows(
578        &self,
579        start: u64,
580    ) -> Result<(BTreeMap<String, (u64, Option<u64>)>, u64), DurabilityError> {
581        let stream = self.index_stream();
582        let mut named = BTreeMap::new();
583        let mut tail = start;
584        loop {
585            let page = self.store.read_from(&stream, tail, READ_BATCH_SIZE).await?;
586            for row in &page {
587                if row.sequence < tail {
588                    return Err(DurabilityError::ConfigError(format!(
589                        "stream {stream} answered sequence {} to a read from {tail}",
590                        row.sequence
591                    )));
592                }
593                let (key, claim) = codec::decode_join(&row.payload)?;
594                named.insert(key, (row.sequence, claim));
595                tail = row.sequence.checked_add(1).ok_or_else(|| {
596                    DurabilityError::ConfigError(format!("stream {stream} sequence overflow"))
597                })?;
598            }
599            if page.len() < READ_BATCH_SIZE {
600                break;
601            }
602        }
603        Ok((named, tail))
604    }
605
606    /// `key`'s claim at `claim` when it is still in flight, from one bounded
607    /// read of two rows: the claim's and the one after it. The key's head and
608    /// its history before `claim` are never read.
609    ///
610    /// No row at `claim` (the crash window after the index row and before
611    /// the claim), a receipt on it, a tombstone there, or a row after it (a
612    /// completion or release) answers `None`.
613    ///
614    /// # Errors
615    /// Refuses a row at `claim` naming another key
616    /// ([`DurabilityError::DedupCollision`]), a row answered at a sequence it
617    /// was not read at, or a malformed record.
618    async fn claim_at(&self, key: &str, claim: u64) -> Result<Option<DedupEntry>, DurabilityError> {
619        let stream = self.stream_key_for(key);
620        let rows = self.store.read_from(&stream, claim, CLAIM_PAGE).await?;
621        let mut expected = claim;
622        for row in &rows {
623            if row.sequence != expected {
624                return Err(DurabilityError::ConfigError(format!(
625                    "stream {stream} answered sequence {} where {expected} was read",
626                    row.sequence
627                )));
628            }
629            expected = expected.saturating_add(1);
630        }
631        let Some(claimed) = rows.first() else {
632            return Ok(None);
633        };
634        let record = DedupRecord::deserialize(&claimed.payload)?;
635        if record.idempotency_key() != key {
636            return Err(DurabilityError::DedupCollision {
637                key: key.to_owned(),
638            });
639        }
640        if rows.len() > 1 {
641            return Ok(None);
642        }
643        Ok(record
644            .into_active()
645            .filter(|entry| entry.receipt().is_none()))
646    }
647
648    /// `key`'s claim and its sequence when it is in flight, looked up in
649    /// full: for an index row written before the claim sequence was carried.
650    async fn claim_without_sequence(
651        &self,
652        key: &str,
653    ) -> Result<Option<(u64, DedupEntry)>, DurabilityError> {
654        let snapshot = self.load_snapshot(&self.stream_key_for(key), key).await?;
655        let claim = match snapshot.current.as_ref() {
656            Some(entry) if entry.receipt().is_none() => snapshot.claim_sequence()?,
657            _ => return Ok(None),
658        };
659        Ok(snapshot.current.map(|entry| (claim, entry)))
660    }
661
662    /// Builds the index of a store that has none: one walk of the namespace,
663    /// one append of a row per claim in flight, and the start recorded at
664    /// those rows. Answers the recorded start.
665    async fn migrate_in_flight_index(&self) -> Result<u64, DurabilityError> {
666        let mut keys = Vec::new();
667        let mut walk = self.latest_records();
668        while let Some(batch) = walk.next_batch().await? {
669            for candidate in batch {
670                if let DedupRecord::Active(entry) = candidate.record {
671                    if entry.receipt().is_none() {
672                        keys.push((entry.idempotency_key, candidate.sequence));
673                    }
674                }
675            }
676        }
677        let start = if keys.is_empty() {
678            // Nothing to name. A row left by an earlier walk that crashed
679            // before its record is read once and dropped.
680            0
681        } else {
682            let rows = keys
683                .iter()
684                .map(|(key, claim)| codec::encode_join(key, *claim))
685                .collect::<Result<Vec<_>, _>>()?;
686            self.append_index_rows(rows, 0).await?
687        };
688        let recorded = match self
689            .store
690            .cas(&self.index_start_key(), 0, start.saturating_add(1))
691            .await
692        {
693            Ok(()) => start,
694            // A concurrent first start recorded the index first.
695            Err(DurabilityError::CursorRegression { stored, .. }) if stored > 0 => stored - 1,
696            Err(error) => return Err(error),
697        };
698        tracing::info!(
699            namespace = %self.namespace,
700            claims = keys.len(),
701            scanned = walk.scanned(),
702            "the dedup namespace was walked once to build its in-flight index"
703        );
704        Ok(recorded)
705    }
706}