Skip to main content

risingwave_meta/hummock/manager/
checkpoint.rs

1// Copyright 2023 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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
39/// Computes xxhash64 checksum of the given data, using seed 0.
40/// This matches the xxhash64 used in block checksum (see `sstable/utils.rs`).
41pub(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    /// stale objects of versions before the current checkpoint.
53    ///
54    /// Previously we stored the stale object of each single version.
55    /// Currently we will merge the stale object between two checkpoints, and only the
56    /// id of the checkpointed hummock version are included in the map.
57    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    /// Convert an owned `PbHummockVersionCheckpoint` to `HummockVersionCheckpoint`,
75    /// moving data instead of cloning for better performance on large checkpoints.
76    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
96/// Decodes checkpoint data, supporting both envelope (compressed) and legacy (raw) formats.
97///
98/// Format detection: our writer always sets `checksum` in the envelope, so
99/// `checksum.is_some()` reliably distinguishes envelope from legacy format.
100/// Legacy bytes may happen to decode as an envelope (field 1 wire-type mismatch
101/// is skipped, field 2 LEN matches `payload`), but will never have a checksum.
102///
103/// Decoding logic:
104/// 1. Try to decode as `HummockVersionCheckpointEnvelope`
105/// 2. If `checksum.is_some()`:
106///    - Verify xxhash64 checksum
107///    - Decompress payload according to `compression_algorithm`
108///    - Decode decompressed bytes as `PbHummockVersionCheckpoint`
109/// 3. If decode fails or `checksum.is_none()`:
110///    - Decode bytes directly as legacy `PbHummockVersionCheckpoint`
111fn 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    // Legacy uncompressed format
158    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
193/// Compresses checkpoint data using the specified algorithm.
194pub(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            // Level 3: good balance between compression ratio and speed
205            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
266/// A hummock version checkpoint compacts previous hummock version delta logs, and stores stale
267/// objects from those delta logs.
268impl HummockManager {
269    /// Returns Ok(None) if not found.
270    ///
271    /// Reads large checkpoints using bounded parallel chunked reads to avoid
272    /// single-request timeout issues. Chunk size and concurrency are configurable
273    /// via `checkpoint_read_chunk_size` and `checkpoint_read_max_in_flight_chunks`.
274    /// Supports both compressed (envelope) and uncompressed (legacy) checkpoint formats.
275    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    /// Creates a hummock version checkpoint.
390    /// Returns the diff between new and old checkpoint id.
391    /// Note that this method must not be called concurrently, because internally it doesn't hold
392    /// lock throughout the method.
393    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        // 1. hold read lock briefly and snapshot checkpoint inputs.
396        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        // `object_sizes` is used to calculate size of stale objects.
453        let mut object_sizes = version_object_size_map(old_checkpoint_version.as_ref());
454        // The set of object ids that once exist in any hummock version
455        let mut versions_object_ids: HashSet<_> = old_checkpoint_version.get_object_ids().collect();
456        for version_delta in &version_deltas {
457            // DO NOT REMOVE THIS LINE
458            // This is to ensure that when adding new variant to `HummockObjectId`,
459            // the compiler will warn us if we forget to handle it here.
460            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        // Object ids that once exist in any hummock version but not exist in the latest hummock version
481        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 - &current_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        // 2. persist the new checkpoint without holding lock
551        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        // 3. hold write lock briefly and update in memory state
565        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            &current_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                    // Add a small delay to simulate real I/O and allow multiple reads
822                    // to be in-flight concurrently. This tests that max_in_flight limit
823                    // is respected (should not exceed 3 concurrent reads).
824                    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}