1use std::collections::{HashMap, HashSet};
16use std::ops::Bound::{Excluded, Included};
17use std::ops::{Deref, DerefMut};
18use std::sync::Arc;
19use std::sync::atomic::Ordering;
20
21use bytes::BytesMut;
22use risingwave_hummock_sdk::compaction_group::hummock_version_ext::version_object_size_map;
23use risingwave_hummock_sdk::version::HummockVersion;
24use risingwave_hummock_sdk::{HummockObjectId, HummockVersionId, get_stale_object_ids};
25use risingwave_pb::hummock::hummock_version_checkpoint::{PbStaleObjects, StaleObjects};
26use risingwave_pb::hummock::{
27 CheckpointCompressionAlgorithm, PbHummockVersion, PbHummockVersionArchive,
28 PbHummockVersionCheckpoint, PbHummockVersionCheckpointEnvelope, PbVectorIndexObject,
29 PbVectorIndexObjectType,
30};
31use thiserror_ext::AsReport;
32use tracing::warn;
33
34use crate::hummock::HummockManager;
35use crate::hummock::error::Result;
36use crate::hummock::manager::versioning::Versioning;
37use crate::hummock::metrics_utils::{gc_stale_object_stats, trigger_gc_stat, trigger_split_stat};
38
39pub(crate) fn xxhash64_checksum(data: &[u8]) -> u64 {
42 use std::hash::Hasher;
43 let mut hasher = twox_hash::XxHash64::with_seed(0);
44 hasher.write(data);
45 hasher.finish()
46}
47
48#[derive(Default)]
49pub struct HummockVersionCheckpoint {
50 pub version: Arc<HummockVersion>,
51
52 pub stale_objects: HashMap<HummockVersionId, PbStaleObjects>,
58}
59
60impl HummockVersionCheckpoint {
61 pub fn from_protobuf(checkpoint: &PbHummockVersionCheckpoint) -> Self {
62 Self {
63 version: Arc::new(HummockVersion::from_persisted_protobuf(
64 checkpoint.version.as_ref().unwrap(),
65 )),
66 stale_objects: checkpoint
67 .stale_objects
68 .iter()
69 .map(|(version_id, objects)| (*version_id, objects.clone()))
70 .collect(),
71 }
72 }
73
74 pub fn from_protobuf_owned(checkpoint: PbHummockVersionCheckpoint) -> Self {
77 Self {
78 version: HummockVersion::from_persisted_protobuf_owned(checkpoint.version.unwrap())
79 .into(),
80 stale_objects: checkpoint.stale_objects,
81 }
82 }
83
84 pub fn to_protobuf(&self) -> PbHummockVersionCheckpoint {
85 PbHummockVersionCheckpoint {
86 version: Some(PbHummockVersion::from(self.version.as_ref())),
87 stale_objects: self
88 .stale_objects
89 .iter()
90 .map(|(version_id, objects)| (*version_id, objects.clone()))
91 .collect(),
92 }
93 }
94}
95
96fn decode_checkpoint_data(data: bytes::Bytes) -> Result<PbHummockVersionCheckpoint> {
112 use anyhow::Context;
113 use prost::Message;
114
115 let data_size = data.len();
116
117 if let Ok(envelope) = PbHummockVersionCheckpointEnvelope::decode(data.clone())
118 && let Some(expected) = envelope.checksum
119 {
120 let actual = xxhash64_checksum(&envelope.payload);
121 if actual != expected {
122 return Err(anyhow::anyhow!(
123 "checkpoint checksum mismatch: expected {:#x}, got {:#x}",
124 expected,
125 actual
126 )
127 .into());
128 }
129
130 let algo = CheckpointCompressionAlgorithm::try_from(envelope.compression_algorithm)
131 .with_context(|| {
132 format!(
133 "unknown checkpoint compression algorithm: {}",
134 envelope.compression_algorithm
135 )
136 })?;
137
138 let decompressed = decompress_payload(algo, &envelope.payload)?;
139 let ckpt = PbHummockVersionCheckpoint::decode(decompressed.as_ref())
140 .context("failed to decode checkpoint envelope payload")?;
141 if ckpt.version.is_none() {
142 return Err(anyhow::anyhow!("checkpoint missing required field `version`").into());
143 }
144
145 tracing::info!(
146 compression = ?algo,
147 compressed_size = envelope.payload.len(),
148 decompressed_size = decompressed.len(),
149 compression_ratio =
150 format!("{:.2}x", decompressed.len() as f64 / envelope.payload.len().max(1) as f64),
151 checksum = format!("{expected:#x}"),
152 "decoded compressed checkpoint"
153 );
154 return Ok(ckpt);
155 }
156
157 tracing::info!(
159 data_size,
160 "decoding checkpoint in legacy uncompressed format"
161 );
162 let ckpt =
163 PbHummockVersionCheckpoint::decode(data).context("failed to decode legacy checkpoint")?;
164 if ckpt.version.is_none() {
165 return Err(anyhow::anyhow!("legacy checkpoint missing required field `version`").into());
166 }
167 Ok(ckpt)
168}
169
170fn decompress_payload(
171 algo: CheckpointCompressionAlgorithm,
172 payload: &[u8],
173) -> Result<std::borrow::Cow<'_, [u8]>> {
174 use anyhow::Context;
175
176 match algo {
177 CheckpointCompressionAlgorithm::CheckpointCompressionUnspecified => Ok(payload.into()),
178 CheckpointCompressionAlgorithm::CheckpointCompressionZstd => {
179 Ok(zstd::stream::decode_all(payload)
180 .map(std::borrow::Cow::Owned)
181 .context("zstd decompression failed")?)
182 }
183 CheckpointCompressionAlgorithm::CheckpointCompressionLz4 => {
184 let mut decoder = lz4::Decoder::new(payload).context("lz4 decoder init failed")?;
185 let mut decompressed = Vec::new();
186 std::io::Read::read_to_end(&mut decoder, &mut decompressed)
187 .context("lz4 decompression failed")?;
188 Ok(decompressed.into())
189 }
190 }
191}
192
193pub(crate) fn compress_payload(
195 algo: risingwave_common::config::CheckpointCompression,
196 data: &[u8],
197) -> Result<Vec<u8>> {
198 use anyhow::Context;
199 use risingwave_common::config::CheckpointCompression;
200
201 match algo {
202 CheckpointCompression::None => Ok(data.to_vec()),
203 CheckpointCompression::Zstd => {
204 Ok(zstd::stream::encode_all(data, 3).context("zstd compression failed")?)
206 }
207 CheckpointCompression::Lz4 => {
208 let mut compressed = Vec::new();
209 let mut encoder = lz4::EncoderBuilder::new()
210 .level(4)
211 .build(&mut compressed)
212 .context("lz4 encoder init failed")?;
213 std::io::Write::write_all(&mut encoder, data)
214 .context("lz4 compression write failed")?;
215 let (_writer, result) = encoder.finish();
216 result.context("lz4 compression finish failed")?;
217 Ok(compressed)
218 }
219 }
220}
221
222async fn read_bytes_in_chunks<F, Fut>(
223 total_size: usize,
224 chunk_size: usize,
225 max_in_flight_chunks: usize,
226 mut read_range: F,
227) -> anyhow::Result<bytes::Bytes>
228where
229 F: FnMut(std::ops::Range<usize>) -> Fut,
230 Fut: std::future::Future<Output = anyhow::Result<bytes::Bytes>>,
231{
232 use anyhow::Context;
233 use futures::StreamExt;
234
235 let num_chunks = total_size.div_ceil(chunk_size);
236 let mut buf = BytesMut::with_capacity(total_size);
237
238 let mut chunk_stream = futures::stream::iter((0..total_size).step_by(chunk_size))
239 .enumerate()
240 .map(|(chunk_idx, offset)| {
241 let end = std::cmp::min(offset + chunk_size, total_size);
242 let range = offset..end;
243 let fut = read_range(range.clone());
244 async move {
245 fut.await.with_context(|| {
246 format!(
247 "read checkpoint chunk {}/{} range {}..{}",
248 chunk_idx + 1,
249 num_chunks,
250 range.start,
251 range.end
252 )
253 })
254 }
255 })
256 .buffered(max_in_flight_chunks);
257
258 while let Some(chunk) = chunk_stream.next().await {
259 let chunk = chunk?;
260 buf.extend_from_slice(&chunk);
261 }
262
263 Ok(buf.freeze())
264}
265
266impl HummockManager {
269 pub async fn try_read_checkpoint(&self) -> Result<Option<HummockVersionCheckpoint>> {
276 let object_metadata = match self
277 .object_store
278 .metadata(&self.version_checkpoint_path)
279 .await
280 {
281 Ok(metadata) => metadata,
282 Err(e) => {
283 if e.is_object_not_found_error() {
284 return Ok(None);
285 }
286 return Err(e.into());
287 }
288 };
289 let total_size = object_metadata.total_size;
290
291 let chunk_size = self.env.opts.checkpoint_read_chunk_size;
292 let max_in_flight_chunks = self.env.opts.checkpoint_read_max_in_flight_chunks;
293
294 let download_start = std::time::Instant::now();
295 let data = if total_size <= chunk_size {
296 self.object_store
297 .read(&self.version_checkpoint_path, 0..total_size)
298 .await?
299 } else {
300 let num_chunks = total_size.div_ceil(chunk_size);
301 let data = read_bytes_in_chunks(
302 total_size,
303 chunk_size,
304 max_in_flight_chunks,
305 |range| async {
306 Ok(self
307 .object_store
308 .read(&self.version_checkpoint_path, range)
309 .await?)
310 },
311 )
312 .await?;
313
314 tracing::info!(
315 total_size,
316 num_chunks,
317 chunk_size,
318 max_in_flight_chunks,
319 "chunked read complete"
320 );
321 data
322 };
323 let download_duration = download_start.elapsed();
324
325 let decode_start = std::time::Instant::now();
326 let ckpt = decode_checkpoint_data(data)?;
327 let decode_duration = decode_start.elapsed();
328
329 tracing::info!(
330 total_size,
331 download_ms = download_duration.as_millis() as u64,
332 decode_ms = decode_duration.as_millis() as u64,
333 "checkpoint read complete"
334 );
335
336 Ok(Some(HummockVersionCheckpoint::from_protobuf_owned(ckpt)))
337 }
338
339 pub(super) async fn write_checkpoint(
340 &self,
341 checkpoint: &HummockVersionCheckpoint,
342 ) -> Result<()> {
343 use prost::Message;
344 let raw_bytes = checkpoint.to_protobuf().encode_to_vec();
345 let raw_size = raw_bytes.len();
346
347 let compression = self.env.opts.checkpoint_compression_algorithm;
348 let compressed = compress_payload(compression, &raw_bytes)?;
349 let checksum = xxhash64_checksum(&compressed);
350
351 tracing::info!(
352 raw_size,
353 compressed_size = compressed.len(),
354 compression_ratio =
355 format!("{:.2}x", raw_size as f64 / compressed.len().max(1) as f64),
356 compression = ?compression,
357 checksum = format!("{:#x}", checksum),
358 "writing compressed checkpoint"
359 );
360
361 let envelope = PbHummockVersionCheckpointEnvelope {
362 compression_algorithm: compression as i32,
363 payload: compressed,
364 checksum: Some(checksum),
365 };
366
367 let buf = envelope.encode_to_vec();
368 self.object_store
369 .upload(&self.version_checkpoint_path, buf.into())
370 .await?;
371 Ok(())
372 }
373
374 pub(super) async fn write_version_archive(
375 &self,
376 archive: &PbHummockVersionArchive,
377 ) -> Result<()> {
378 use prost::Message;
379 let buf = archive.encode_to_vec();
380 let archive_path = format!(
381 "{}/{}",
382 self.version_archive_dir,
383 archive.version.as_ref().unwrap().id
384 );
385 self.object_store.upload(&archive_path, buf.into()).await?;
386 Ok(())
387 }
388
389 pub async fn create_version_checkpoint(&self, min_delta_log_num: u64) -> Result<u64> {
394 let timer = self.metrics.version_checkpoint_latency.start_timer();
395 let (
397 current_version,
398 old_checkpoint_version,
399 mut stale_objects,
400 version_deltas,
401 current_table_change_log,
402 new_checkpoint_id,
403 old_checkpoint_id,
404 ) = {
405 let versioning_guard = self
406 .versioning
407 .read_with_process_name("version_checkpoint_build")
408 .await;
409 let versioning: &Versioning = versioning_guard.deref();
410 let current_version = versioning.current_version.clone();
411 let old_checkpoint: &HummockVersionCheckpoint = &versioning.checkpoint;
412 let new_checkpoint_id = current_version.id;
413 let old_checkpoint_id = old_checkpoint.version.id;
414 if new_checkpoint_id < old_checkpoint_id + min_delta_log_num {
415 return Ok(0);
416 }
417 if cfg!(test) && new_checkpoint_id == old_checkpoint_id {
418 drop(versioning_guard);
419 let versioning = self.versioning.read().await;
420 let context_info = self.context_info.read().await;
421 let min_pinned_version_id = context_info.min_pinned_version_id();
422 let stale_object_stats = gc_stale_object_stats(
423 &versioning.checkpoint.stale_objects,
424 min_pinned_version_id,
425 );
426 trigger_gc_stat(
427 &self.metrics,
428 versioning.checkpoint.version.as_ref(),
429 &versioning.table_change_log,
430 stale_object_stats,
431 );
432 return Ok(0);
433 }
434 let old_checkpoint_version = old_checkpoint.version.clone();
435 let version_deltas = versioning
436 .hummock_version_deltas
437 .range((Excluded(old_checkpoint_id), Included(new_checkpoint_id)))
438 .map(|(_, version_delta)| version_delta.clone())
439 .collect::<Vec<_>>();
440 (
441 current_version,
442 old_checkpoint_version,
443 old_checkpoint.stale_objects.clone(),
444 version_deltas,
445 versioning.table_change_log.clone(),
446 new_checkpoint_id,
447 old_checkpoint_id,
448 )
449 };
450 assert!(new_checkpoint_id > old_checkpoint_id);
451 let mut archive: Option<PbHummockVersionArchive> = None;
452 let mut object_sizes = version_object_size_map(old_checkpoint_version.as_ref());
454 let mut versions_object_ids: HashSet<_> = old_checkpoint_version.get_object_ids().collect();
456 for version_delta in &version_deltas {
457 match HummockObjectId::Sstable(0.into()) {
461 HummockObjectId::Sstable(_) => {}
462 HummockObjectId::VectorFile(_) => {}
463 HummockObjectId::HnswGraphFile(_) => {}
464 };
465 for (object_id, file_size) in version_delta
466 .newly_added_sst_infos(false)
467 .map(|sst| (HummockObjectId::Sstable(sst.object_id), sst.file_size))
468 .chain(
469 version_delta
470 .vector_index_delta
471 .values()
472 .flat_map(|delta| delta.newly_added_objects()),
473 )
474 {
475 object_sizes.insert(object_id, file_size);
476 versions_object_ids.insert(object_id);
477 }
478 }
479
480 let current_version_object_ids = current_version
482 .get_object_ids()
483 .chain(
484 current_table_change_log
485 .values()
486 .flat_map(|l| l.get_object_ids()),
487 )
488 .collect();
489 let removed_object_ids = &versions_object_ids - ¤t_version_object_ids;
490 let total_file_size = removed_object_ids
491 .iter()
492 .map(|t| {
493 object_sizes.get(t).copied().unwrap_or_else(|| {
494 warn!(object_id = ?t, "unable to get size of removed object id");
495 0
496 })
497 })
498 .sum::<u64>();
499 stale_objects.insert(current_version.id, {
500 let mut sst_ids = vec![];
501 let mut vector_files = vec![];
502 for object_id in removed_object_ids {
503 match object_id {
504 HummockObjectId::Sstable(sst_id) => sst_ids.push(sst_id),
505 HummockObjectId::VectorFile(vector_file_id) => {
506 vector_files.push(PbVectorIndexObject {
507 id: vector_file_id.as_raw(),
508 object_type: PbVectorIndexObjectType::VectorIndexObjectVector as _,
509 })
510 }
511 HummockObjectId::HnswGraphFile(graph_file_id) => {
512 vector_files.push(PbVectorIndexObject {
513 id: graph_file_id.as_raw(),
514 object_type: PbVectorIndexObjectType::VectorIndexObjectHnswGraph as _,
515 });
516 }
517 }
518 }
519 StaleObjects {
520 id: sst_ids,
521 total_file_size,
522 vector_files,
523 }
524 });
525 if self.env.opts.enable_hummock_data_archive {
526 archive = Some(PbHummockVersionArchive {
527 version: Some(PbHummockVersion::from(old_checkpoint_version.as_ref())),
528 version_deltas: version_deltas
529 .iter()
530 .map(|version_delta| version_delta.into())
531 .collect(),
532 });
533 }
534 let min_pinned_version_id = self.context_info.read().await.min_pinned_version_id();
535 let may_delete_object = stale_objects
536 .iter()
537 .filter_map(|(version_id, object_ids)| {
538 if *version_id >= min_pinned_version_id {
539 return None;
540 }
541 Some(get_stale_object_ids(object_ids))
542 })
543 .flatten();
544 self.gc_manager.add_may_delete_object_ids(may_delete_object);
545 stale_objects.retain(|version_id, _| *version_id >= min_pinned_version_id);
546 let new_checkpoint = HummockVersionCheckpoint {
547 version: current_version.clone(),
548 stale_objects,
549 };
550 self.write_checkpoint(&new_checkpoint).await?;
552 if let Some(archive) = archive
553 && let Err(e) = self.write_version_archive(&archive).await
554 {
555 tracing::warn!(
556 error = %e.as_report(),
557 "failed to write version archive {}",
558 archive.version.as_ref().unwrap().id
559 );
560 }
561 let min_pinned_version_id = self.context_info.read().await.min_pinned_version_id();
562 let stale_object_stats =
563 gc_stale_object_stats(&new_checkpoint.stale_objects, min_pinned_version_id);
564 let current_version_for_metrics = {
566 let mut versioning_guard = self
567 .versioning
568 .write_with_process_name("version_checkpoint_install")
569 .await;
570 let versioning = versioning_guard.deref_mut();
571 assert!(new_checkpoint.version.id > versioning.checkpoint.version.id);
572 versioning.checkpoint = new_checkpoint;
573 versioning.current_version.clone()
574 };
575 trigger_gc_stat(
576 &self.metrics,
577 current_version.as_ref(),
578 ¤t_table_change_log,
579 stale_object_stats,
580 );
581 trigger_split_stat(&self.metrics, current_version_for_metrics.as_ref());
582 timer.observe_duration();
583 self.metrics
584 .checkpoint_version_id
585 .set(new_checkpoint_id.as_i64_id());
586
587 Ok(new_checkpoint_id - old_checkpoint_id)
588 }
589
590 pub fn pause_version_checkpoint(&self) {
591 self.pause_version_checkpoint.store(true, Ordering::Relaxed);
592 tracing::info!("hummock version checkpoint is paused.");
593 }
594
595 pub fn resume_version_checkpoint(&self) {
596 self.pause_version_checkpoint
597 .store(false, Ordering::Relaxed);
598 tracing::info!("hummock version checkpoint is resumed.");
599 }
600
601 pub fn is_version_checkpoint_paused(&self) -> bool {
602 self.pause_version_checkpoint.load(Ordering::Relaxed)
603 }
604
605 pub async fn get_checkpoint_version(&self) -> Arc<HummockVersion> {
606 let versioning_guard = self.versioning.read().await;
607 versioning_guard.checkpoint.version.clone()
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use bytes::Bytes;
614 use prost::Message;
615 use risingwave_common::config::CheckpointCompression;
616 use risingwave_pb::hummock::hummock_version_checkpoint::StaleObjects;
617 use risingwave_pb::hummock::{
618 PbHummockVersion, PbHummockVersionCheckpoint, PbHummockVersionCheckpointEnvelope,
619 };
620
621 use super::{
622 compress_payload, decode_checkpoint_data, read_bytes_in_chunks, xxhash64_checksum,
623 };
624
625 #[expect(deprecated)]
626 fn make_version(id: u64) -> PbHummockVersion {
627 PbHummockVersion {
628 id: id.into(),
629 levels: Default::default(),
630 max_committed_epoch: 0,
631 table_watermarks: Default::default(),
632 table_change_logs: Default::default(),
633 state_table_info: Default::default(),
634 vector_indexes: Default::default(),
635 }
636 }
637
638 fn make_checkpoint(version_id: u64) -> PbHummockVersionCheckpoint {
639 let stale = StaleObjects {
640 id: vec![1u64.into(), 2u64.into(), 3u64.into()],
641 total_file_size: 123,
642 vector_files: vec![],
643 };
644
645 PbHummockVersionCheckpoint {
646 version: Some(make_version(version_id)),
647 stale_objects: [(1u64.into(), stale)].into_iter().collect(),
648 }
649 }
650
651 fn make_envelope_bytes(
652 checkpoint: &PbHummockVersionCheckpoint,
653 compression: CheckpointCompression,
654 checksum: Option<u64>,
655 ) -> Bytes {
656 let raw = checkpoint.encode_to_vec();
657 let payload = compress_payload(compression, &raw)
658 .expect("compress checkpoint payload should succeed");
659 let checksum = checksum.unwrap_or_else(|| xxhash64_checksum(&payload));
660 let envelope = PbHummockVersionCheckpointEnvelope {
661 compression_algorithm: compression as i32,
662 payload,
663 checksum: Some(checksum),
664 };
665 Bytes::from(envelope.encode_to_vec())
666 }
667
668 #[test]
669 fn decode_checkpoint_data_falls_back_to_legacy_format() {
670 let checkpoint = make_checkpoint(42);
671 let raw = Bytes::from(checkpoint.encode_to_vec());
672 let decoded = decode_checkpoint_data(raw).expect("legacy checkpoint should decode");
673 assert_eq!(decoded, checkpoint);
674 }
675
676 #[test]
677 fn decode_checkpoint_data_roundtrips_envelope_with_checksum() {
678 let checkpoint = make_checkpoint(42);
679 for compression in [
680 CheckpointCompression::None,
681 CheckpointCompression::Zstd,
682 CheckpointCompression::Lz4,
683 ] {
684 let data = make_envelope_bytes(&checkpoint, compression, None);
685 let decoded = decode_checkpoint_data(data).expect("envelope checkpoint should decode");
686 assert_eq!(decoded, checkpoint);
687 }
688 }
689
690 #[test]
691 fn decode_checkpoint_data_returns_error_on_checksum_mismatch() {
692 let checkpoint = make_checkpoint(42);
693 let raw = checkpoint.encode_to_vec();
694 let mut payload = compress_payload(CheckpointCompression::Zstd, &raw)
695 .expect("compress checkpoint payload should succeed");
696 let expected = xxhash64_checksum(&payload);
697 payload[0] ^= 0x01;
698 let envelope = PbHummockVersionCheckpointEnvelope {
699 compression_algorithm: CheckpointCompression::Zstd as i32,
700 payload,
701 checksum: Some(expected),
702 };
703 let data = Bytes::from(envelope.encode_to_vec());
704 let err = decode_checkpoint_data(data).expect_err("checksum mismatch should error");
705 assert!(err.to_string().contains("checksum mismatch"), "{err:?}");
706 }
707
708 #[test]
709 fn decode_checkpoint_data_returns_error_on_unknown_compression_algorithm() {
710 let checkpoint = make_checkpoint(42);
711 let payload = checkpoint.encode_to_vec();
712 let checksum = xxhash64_checksum(&payload);
713 let envelope = PbHummockVersionCheckpointEnvelope {
714 compression_algorithm: 123,
715 payload,
716 checksum: Some(checksum),
717 };
718 let data = Bytes::from(envelope.encode_to_vec());
719 let err =
720 decode_checkpoint_data(data).expect_err("unknown compression algorithm should error");
721 assert!(
722 err.to_string()
723 .contains("unknown checkpoint compression algorithm"),
724 "{err:?}"
725 );
726 }
727
728 #[test]
729 fn decode_checkpoint_data_returns_error_on_legacy_missing_version() {
730 let checkpoint = PbHummockVersionCheckpoint {
731 version: None,
732 stale_objects: Default::default(),
733 };
734 let data = Bytes::from(checkpoint.encode_to_vec());
735 let err = decode_checkpoint_data(data).expect_err("missing version should error");
736 assert!(
737 err.to_string()
738 .contains("legacy checkpoint missing required field `version`"),
739 "{err:?}"
740 );
741 }
742
743 #[test]
744 fn decode_checkpoint_data_returns_error_on_corrupt_envelope_payload() {
745 let garbage = b"not a valid protobuf";
746 let checksum = xxhash64_checksum(garbage);
747 let envelope = PbHummockVersionCheckpointEnvelope {
748 compression_algorithm: CheckpointCompression::None as i32,
749 payload: garbage.to_vec(),
750 checksum: Some(checksum),
751 };
752 let data = Bytes::from(envelope.encode_to_vec());
753 let err = decode_checkpoint_data(data).expect_err("corrupt envelope payload should error");
754 assert!(
755 err.to_string()
756 .contains("failed to decode checkpoint envelope payload"),
757 "{err:?}"
758 );
759 }
760
761 #[test]
762 fn decode_checkpoint_data_returns_error_on_empty_input() {
763 let err = decode_checkpoint_data(Bytes::new()).expect_err("empty checkpoint should fail");
764 assert!(
765 err.to_string()
766 .contains("legacy checkpoint missing required field `version`"),
767 "{err:?}"
768 );
769 }
770
771 #[test]
772 fn decode_checkpoint_data_returns_error_on_envelope_missing_version() {
773 let checkpoint = PbHummockVersionCheckpoint {
774 version: None,
775 stale_objects: Default::default(),
776 };
777 let raw = checkpoint.encode_to_vec();
778 let checksum = xxhash64_checksum(&raw);
779 let envelope = PbHummockVersionCheckpointEnvelope {
780 compression_algorithm: CheckpointCompression::None as i32,
781 payload: raw,
782 checksum: Some(checksum),
783 };
784 let data = Bytes::from(envelope.encode_to_vec());
785 let err =
786 decode_checkpoint_data(data).expect_err("envelope with missing version should error");
787 assert!(
788 err.to_string()
789 .contains("checkpoint missing required field `version`"),
790 "{err:?}"
791 );
792 }
793
794 #[tokio::test]
795 async fn read_bytes_in_chunks_respects_concurrency_limit_and_reassembles() {
796 use std::sync::Arc;
797 use std::sync::atomic::{AtomicUsize, Ordering};
798
799 use tokio::time::{Duration, sleep};
800
801 let total_size = 100usize;
802 let chunk_size = 10usize;
803 let max_in_flight = 3usize;
804
805 let data: Arc<Vec<u8>> = Arc::new((0..total_size).map(|i| (i % 256) as u8).collect());
806 let in_flight = Arc::new(AtomicUsize::new(0));
807 let max_seen = Arc::new(AtomicUsize::new(0));
808
809 let out = read_bytes_in_chunks(total_size, chunk_size, max_in_flight, {
810 let data = data.clone();
811 let in_flight = in_flight.clone();
812 let max_seen = max_seen.clone();
813 move |range: std::ops::Range<usize>| {
814 let data = data.clone();
815 let in_flight = in_flight.clone();
816 let max_seen = max_seen.clone();
817 async move {
818 let cur = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
819 max_seen.fetch_max(cur, Ordering::SeqCst);
820
821 sleep(Duration::from_millis(30)).await;
825
826 let bytes = Bytes::copy_from_slice(&data[range]);
827 in_flight.fetch_sub(1, Ordering::SeqCst);
828 Ok(bytes)
829 }
830 }
831 })
832 .await
833 .expect("chunked read should succeed");
834
835 assert_eq!(out.as_ref(), data.as_slice());
836 let max_seen = max_seen.load(Ordering::SeqCst);
837 assert!(max_seen <= max_in_flight, "max_seen={max_seen}");
838 assert!(
839 max_seen > 1,
840 "expected some concurrency, max_seen={max_seen}"
841 );
842 }
843
844 #[tokio::test]
845 async fn read_bytes_in_chunks_adds_range_context_on_error() {
846 let total_size = 30usize;
847 let chunk_size = 10usize;
848 let max_in_flight = 2usize;
849
850 let err = read_bytes_in_chunks(total_size, chunk_size, max_in_flight, |range| async move {
851 if range.start == 10 {
852 anyhow::bail!("boom");
853 }
854 Ok(Bytes::copy_from_slice(&vec![0u8; range.len()]))
855 })
856 .await
857 .expect_err("should fail");
858
859 let msg = err.to_string();
860 assert!(
861 msg.contains("read checkpoint chunk 2/3 range 10..20"),
862 "unexpected error message: {msg}"
863 );
864 let msg_with_chain = format!("{err:#}");
865 assert!(
866 msg_with_chain.contains("boom"),
867 "unexpected error message: {msg_with_chain}"
868 );
869 }
870}