1use {
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
14const CAPTURE_TIMEOUT: Duration = Duration::from_secs(30);
16
17pub 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
42async 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
58struct CommittedIndex {
60 projection: WalCaptureProjection,
61 cutoffs: Vec<i64>,
62}
63
64async 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 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 #[derive(Clone, Copy, Default)]
147 enum Fault {
148 #[default]
150 None,
151 HangOnHighWatermarks,
153 ShortHighWatermarks,
155 HighWatermarksPastTheEnd,
158 }
159
160 #[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 struct ScriptedLog {
180 inner: Arc<InProcessMetadataEventLog>,
181 fault: Fault,
182 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 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}