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