Skip to main content

krabka_backup/
diskless.rs

1//! Committed diskless-WAL index capture.
2
3use {
4    krabka_remote_storage::diskless::{
5        capture::{DisklessWalCapture, WalCaptureProjection},
6        index::REPLAY_FENCE_KEY,
7    },
8    krabka_remote_storage_topic::{MetadataEventLog, MetadataLogError},
9    std::{collections::HashMap, hash::BuildHasher, sync::Arc, time::Duration},
10    tokio::time,
11    uuid::Uuid,
12};
13
14/// How long the whole index read may take before the capture gives up.
15const CAPTURE_TIMEOUT: Duration = Duration::from_secs(30);
16
17/// Replay the committed keyed index state up to each partition's high
18/// watermark and freeze it.
19///
20/// The capture only reads. Each partition's cutoff is its high watermark
21/// (`ListOffsets` `LATEST`) when the capture starts, and the capture reads
22/// from the partition's low watermark up to that cutoff. It applies each
23/// record as it arrives rather than buffer the range, so an index larger than
24/// memory still captures. An empty partition is complete at once. Replay
25/// fences that brokers publish into the index are not index state, so the
26/// capture skips them. Kafka refuses a `Produce` to an internal topic from any
27/// client but `__admin_client`, so a capture that wrote its own fence would
28/// fail against a Kafka-compatible broker.
29///
30/// # Errors
31/// Returns an error when the index watermarks cannot be read, when the read
32/// fails, when the watermark lookups and the read together take longer than
33/// 30 seconds, or when a record cannot be decoded or projected.
34pub async fn capture_projection<S: BuildHasher>(
35    log: Arc<dyn MetadataEventLog>,
36    topics: &HashMap<Uuid, (String, i32), S>,
37    captured_at_ms: u64,
38) -> Result<DisklessWalCapture, String> {
39    capture_projection_within(log.as_ref(), topics, captured_at_ms, CAPTURE_TIMEOUT).await
40}
41
42/// [`capture_projection`] with the deadline as a parameter.
43async fn capture_projection_within<S: BuildHasher>(
44    log: &dyn MetadataEventLog,
45    topics: &HashMap<Uuid, (String, i32), S>,
46    captured_at_ms: u64,
47    timeout: Duration,
48) -> Result<DisklessWalCapture, String> {
49    let CommittedIndex {
50        projection,
51        cutoffs,
52    } = time::timeout(timeout, project_committed_index(log))
53        .await
54        .map_err(|_| "diskless WAL index capture timed out".to_owned())??;
55    projection.capture(topics, cutoffs, captured_at_ms)
56}
57
58/// The projection of the committed index, with the high watermarks it was read up to.
59struct CommittedIndex {
60    projection: WalCaptureProjection,
61    cutoffs: Vec<i64>,
62}
63
64/// Read the watermarks, then project every partition from its low watermark
65/// up to its high watermark. Returns the projection and the high watermarks.
66async fn project_committed_index(log: &dyn MetadataEventLog) -> Result<CommittedIndex, String> {
67    let cutoffs = log
68        .high_water_marks()
69        .await
70        .map_err(|error| error.to_string())?;
71    let starts = log
72        .low_water_marks()
73        .await
74        .map_err(|error| error.to_string())?;
75    let partition_count = usize::try_from(log.partition_count())
76        .map_err(|_| "negative diskless WAL index partition count".to_owned())?;
77    if cutoffs.len() != partition_count || starts.len() != partition_count {
78        return Err(format!(
79            "diskless WAL index has {partition_count} partitions but reported {} high and {} low \
80             watermarks",
81            cutoffs.len(),
82            starts.len()
83        ));
84    }
85    let mut projection = WalCaptureProjection::default();
86    for (partition, (&start, &cutoff)) in starts.iter().zip(&cutoffs).enumerate() {
87        if start >= cutoff {
88            continue;
89        }
90        let partition = i32::try_from(partition)
91            .map_err(|_| "diskless WAL index partition overflow".to_owned())?;
92        // A record the projection rejects stops the read. The rejection, not
93        // the read error that carries it out, is what the capture reports.
94        let mut rejected = None;
95        let read = log
96            .visit_range(partition, start, cutoff, &mut |record| {
97                if record.key.as_deref() == Some(REPLAY_FENCE_KEY) {
98                    return Ok(());
99                }
100                projection
101                    .apply(
102                        record.key.as_deref(),
103                        (!record.tombstone).then_some(record.payload.as_ref()),
104                    )
105                    .map_err(|error| {
106                        let stop = MetadataLogError::Other(error.clone());
107                        rejected = Some((record.offset, error));
108                        stop
109                    })
110            })
111            .await;
112        if let Some((offset, error)) = rejected {
113            return Err(format!(
114                "diskless WAL index partition {partition} offset {offset}: {error}"
115            ));
116        }
117        read.map_err(|error| error.to_string())?;
118    }
119    Ok(CommittedIndex {
120        projection,
121        cutoffs,
122    })
123}
124
125#[cfg(test)]
126mod tests {
127    use {
128        super::*,
129        assert2::{assert, check},
130        async_trait::async_trait,
131        bytes::Bytes,
132        krabka_remote_storage::diskless::{
133            WalIndexEntry,
134            capture::{CapturedWalRange, DisklessPartitionCapture},
135            index::WalFlushRecord,
136        },
137        krabka_remote_storage_topic::{
138            InProcessMetadataEventLog, MetadataEventRecord, RangeVisitor,
139        },
140        std::{future, sync::Mutex},
141    };
142
143    const TOPIC_ID: Uuid = Uuid::from_u128(9);
144
145    /// How [`ScriptedLog`] departs from the log it wraps.
146    #[derive(Clone, Copy, Default)]
147    enum Fault {
148        /// Behave like the wrapped log.
149        #[default]
150        None,
151        /// Never answer the high-watermark lookup.
152        HangOnHighWatermarks,
153        /// Report one high watermark too few.
154        ShortHighWatermarks,
155        /// Report each high watermark one past the last record, so the range
156        /// read stops short of it.
157        HighWatermarksPastTheEnd,
158    }
159
160    /// The position of one record a range read handed over.
161    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
162    struct VisitedRecord {
163        partition: i32,
164        offset: i64,
165    }
166
167    const FIRST_TWO_VISITS: [VisitedRecord; 2] = [
168        VisitedRecord {
169            partition: 0,
170            offset: 0,
171        },
172        VisitedRecord {
173            partition: 0,
174            offset: 1,
175        },
176    ];
177    /// An in-process index that records what each range read handed over,
178    /// and can misbehave on its watermark lookups.
179    struct ScriptedLog {
180        inner: Arc<InProcessMetadataEventLog>,
181        fault: Fault,
182        /// Each visited record, in visit order.
183        visited: Mutex<Vec<VisitedRecord>>,
184    }
185
186    impl ScriptedLog {
187        fn new(inner: Arc<InProcessMetadataEventLog>, fault: Fault) -> Self {
188            Self {
189                inner,
190                fault,
191                visited: Mutex::new(Vec::new()),
192            }
193        }
194    }
195
196    #[krabka_macros::metadata_log_delegate(krabka_remote_storage_topic)]
197    #[async_trait]
198    impl MetadataEventLog for ScriptedLog {
199        async fn high_water_marks(&self) -> Result<Vec<i64>, MetadataLogError> {
200            let mut marks = self.inner.high_water_marks().await?;
201            match self.fault {
202                Fault::None => {}
203                Fault::HangOnHighWatermarks => future::pending::<()>().await,
204                Fault::ShortHighWatermarks => {
205                    marks.pop();
206                }
207                Fault::HighWatermarksPastTheEnd => {
208                    for mark in &mut marks {
209                        *mark += 1;
210                    }
211                }
212            }
213            Ok(marks)
214        }
215
216        async fn visit_range(
217            &self,
218            partition: i32,
219            start: i64,
220            end: i64,
221            visit: &mut RangeVisitor<'_>,
222        ) -> Result<(), MetadataLogError> {
223            self.inner
224                .visit_range(partition, start, end, &mut |record: MetadataEventRecord| {
225                    self.visited.lock().unwrap().push(VisitedRecord {
226                        partition: record.partition,
227                        offset: record.offset,
228                    });
229                    visit(record)
230                })
231                .await
232        }
233    }
234
235    fn entry(first_offset: i64, last_offset: i64) -> WalIndexEntry {
236        WalIndexEntry {
237            topic_id: TOPIC_ID,
238            partition: 0,
239            first_offset,
240            last_offset,
241            byte_start: 0,
242            byte_len: 10,
243            max_timestamp_ms: 1,
244        }
245    }
246
247    /// Publish the keyed index record a broker flush publishes for `entry`.
248    async fn publish_flush(
249        log: &InProcessMetadataEventLog,
250        object_key: &str,
251        entry: WalIndexEntry,
252    ) {
253        let (key, value) = WalFlushRecord::keyed_entry(object_key, entry).unwrap();
254        log.publish_keyed(0, key, Some(value)).await.unwrap();
255    }
256
257    krabka_macros::wal_capture_topic_fixture!(orders, TOPIC_ID, "orders", 1);
258
259    fn visited_offsets(log: &ScriptedLog) -> Vec<VisitedRecord> {
260        log.visited.lock().unwrap().clone()
261    }
262
263    #[tokio::test]
264    async fn capture_skips_replay_fences_and_projects_the_rest() {
265        let index = InProcessMetadataEventLog::new(1);
266        index
267            .publish_keyed(0, Bytes::from_static(REPLAY_FENCE_KEY), Some(Bytes::new()))
268            .await
269            .unwrap();
270        publish_flush(&index, "diskless-wal/1/a.ckwl", entry(0, 3)).await;
271        let log = ScriptedLog::new(index, Fault::None);
272
273        let capture = capture_projection_within(&log, &orders(), 5, CAPTURE_TIMEOUT)
274            .await
275            .unwrap();
276
277        assert!(
278            capture
279                == DisklessWalCapture {
280                    format_version: DisklessWalCapture::FORMAT_VERSION,
281                    captured_at_ms: 5,
282                    source_cutoffs: vec![2],
283                    partitions: vec![DisklessPartitionCapture {
284                        topic: "orders".to_owned(),
285                        topic_id: TOPIC_ID,
286                        partition: 0,
287                        delete_floor: 0,
288                        recovery_cutoff: 4,
289                        ranges: vec![CapturedWalRange {
290                            object_key: "diskless-wal/1/a.ckwl".to_owned(),
291                            entry: entry(0, 3),
292                        }],
293                    }],
294                    metadata_snapshot_sha256: None,
295                    rlmm_snapshot_sha256: None,
296                    group_offsets_sha256: None,
297                    authentication: None,
298                }
299        );
300        assert!(visited_offsets(&log) == FIRST_TWO_VISITS);
301    }
302
303    #[tokio::test]
304    async fn capture_preserves_prefixed_and_legacy_absolute_object_keys() {
305        let index = InProcessMetadataEventLog::new(1);
306        publish_flush(&index, "diskless-wal/1/legacy.ckwl", entry(0, 0)).await;
307        publish_flush(
308            &index,
309            "clusters/one/diskless-wal/1/current.ckwl",
310            entry(1, 1),
311        )
312        .await;
313        let log = ScriptedLog::new(index, Fault::None);
314
315        let capture = capture_projection_within(&log, &orders(), 5, CAPTURE_TIMEOUT)
316            .await
317            .unwrap();
318
319        assert!(
320            capture.partitions[0]
321                .ranges
322                .iter()
323                .map(|range| range.object_key.as_str())
324                .collect::<Vec<_>>()
325                == [
326                    "diskless-wal/1/legacy.ckwl",
327                    "clusters/one/diskless-wal/1/current.ckwl",
328                ]
329        );
330    }
331
332    #[tokio::test]
333    async fn a_record_that_does_not_project_stops_the_read_where_it_is() {
334        let index = InProcessMetadataEventLog::new(1);
335        publish_flush(&index, "diskless-wal/1/a.ckwl", entry(0, 3)).await;
336        index
337            .publish_keyed(
338                0,
339                Bytes::from_static(b"not an index key"),
340                Some(Bytes::new()),
341            )
342            .await
343            .unwrap();
344        publish_flush(&index, "diskless-wal/1/b.ckwl", entry(4, 7)).await;
345        let log = ScriptedLog::new(index, Fault::None);
346
347        let error = capture_projection_within(&log, &orders(), 5, CAPTURE_TIMEOUT)
348            .await
349            .unwrap_err();
350
351        check!(
352            error
353                == "diskless WAL index partition 0 offset 1: unknown diskless WAL index key version \
354                    28271"
355        );
356        check!(visited_offsets(&log) == FIRST_TWO_VISITS);
357    }
358
359    #[tokio::test]
360    async fn a_read_that_stops_short_of_the_cutoff_fails_the_capture() {
361        let index = InProcessMetadataEventLog::new(1);
362        publish_flush(&index, "diskless-wal/1/a.ckwl", entry(0, 3)).await;
363        let log = ScriptedLog::new(index, Fault::HighWatermarksPastTheEnd);
364
365        let error = capture_projection_within(&log, &orders(), 5, CAPTURE_TIMEOUT)
366            .await
367            .unwrap_err();
368
369        assert!(error == "metadata log error: partition 0 ends at 1, before 2");
370    }
371
372    #[tokio::test]
373    async fn watermarks_that_do_not_cover_every_partition_fail_the_capture() {
374        let log = ScriptedLog::new(
375            InProcessMetadataEventLog::new(2),
376            Fault::ShortHighWatermarks,
377        );
378
379        let error = capture_projection_within(&log, &orders(), 5, CAPTURE_TIMEOUT)
380            .await
381            .unwrap_err();
382
383        assert!(
384            error == "diskless WAL index has 2 partitions but reported 1 high and 2 low watermarks"
385        );
386    }
387
388    #[tokio::test(start_paused = true)]
389    async fn the_deadline_covers_the_watermark_lookups() {
390        let log = ScriptedLog::new(
391            InProcessMetadataEventLog::new(1),
392            Fault::HangOnHighWatermarks,
393        );
394
395        let error = capture_projection_within(&log, &orders(), 5, CAPTURE_TIMEOUT)
396            .await
397            .unwrap_err();
398
399        assert!(error == "diskless WAL index capture timed out");
400    }
401}