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        let version = checkpoint.version.as_ref().unwrap();
63        warn_if_legacy_table_change_logs_are_present(version);
64        Self {
65            version: Arc::new(HummockVersion::from_persisted_protobuf(version)),
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        let version = checkpoint.version.unwrap();
78        warn_if_legacy_table_change_logs_are_present(&version);
79        Self {
80            version: HummockVersion::from_persisted_protobuf_owned(version).into(),
81            stale_objects: checkpoint.stale_objects,
82        }
83    }
84
85    pub fn to_protobuf(&self) -> PbHummockVersionCheckpoint {
86        PbHummockVersionCheckpoint {
87            version: Some(PbHummockVersion::from(self.version.as_ref())),
88            stale_objects: self
89                .stale_objects
90                .iter()
91                .map(|(version_id, objects)| (*version_id, objects.clone()))
92                .collect(),
93        }
94    }
95}
96
97fn warn_if_legacy_table_change_logs_are_present(version: &PbHummockVersion) {
98    if !version.table_change_logs.is_empty() {
99        warn!(
100            version_id = ?version.id,
101            table_count = version.table_change_logs.len(),
102            "deprecated table change logs found in hummock version checkpoint; ignoring them"
103        );
104    }
105}
106
107/// Decodes checkpoint data, supporting both envelope (compressed) and legacy (raw) formats.
108///
109/// Format detection: our writer always sets `checksum` in the envelope, so
110/// `checksum.is_some()` reliably distinguishes envelope from legacy format.
111/// Legacy bytes may happen to decode as an envelope (field 1 wire-type mismatch
112/// is skipped, field 2 LEN matches `payload`), but will never have a checksum.
113///
114/// Decoding logic:
115/// 1. Try to decode as `HummockVersionCheckpointEnvelope`
116/// 2. If `checksum.is_some()`:
117///    - Verify xxhash64 checksum
118///    - Decompress payload according to `compression_algorithm`
119///    - Decode decompressed bytes as `PbHummockVersionCheckpoint`
120/// 3. If decode fails or `checksum.is_none()`:
121///    - Decode bytes directly as legacy `PbHummockVersionCheckpoint`
122fn decode_checkpoint_data(data: bytes::Bytes) -> Result<PbHummockVersionCheckpoint> {
123    use anyhow::Context;
124    use prost::Message;
125
126    let data_size = data.len();
127
128    if let Ok(envelope) = PbHummockVersionCheckpointEnvelope::decode(data.clone())
129        && let Some(expected) = envelope.checksum
130    {
131        let actual = xxhash64_checksum(&envelope.payload);
132        if actual != expected {
133            return Err(anyhow::anyhow!(
134                "checkpoint checksum mismatch: expected {:#x}, got {:#x}",
135                expected,
136                actual
137            )
138            .into());
139        }
140
141        let algo = CheckpointCompressionAlgorithm::try_from(envelope.compression_algorithm)
142            .with_context(|| {
143                format!(
144                    "unknown checkpoint compression algorithm: {}",
145                    envelope.compression_algorithm
146                )
147            })?;
148
149        let decompressed = decompress_payload(algo, &envelope.payload)?;
150        let ckpt = PbHummockVersionCheckpoint::decode(decompressed.as_ref())
151            .context("failed to decode checkpoint envelope payload")?;
152        if ckpt.version.is_none() {
153            return Err(anyhow::anyhow!("checkpoint missing required field `version`").into());
154        }
155
156        tracing::info!(
157            compression = ?algo,
158            compressed_size = envelope.payload.len(),
159            decompressed_size = decompressed.len(),
160            compression_ratio =
161                format!("{:.2}x", decompressed.len() as f64 / envelope.payload.len().max(1) as f64),
162            checksum = format!("{expected:#x}"),
163            "decoded compressed checkpoint"
164        );
165        return Ok(ckpt);
166    }
167
168    // Legacy uncompressed format
169    tracing::info!(
170        data_size,
171        "decoding checkpoint in legacy uncompressed format"
172    );
173    let ckpt =
174        PbHummockVersionCheckpoint::decode(data).context("failed to decode legacy checkpoint")?;
175    if ckpt.version.is_none() {
176        return Err(anyhow::anyhow!("legacy checkpoint missing required field `version`").into());
177    }
178    Ok(ckpt)
179}
180
181fn decompress_payload(
182    algo: CheckpointCompressionAlgorithm,
183    payload: &[u8],
184) -> Result<std::borrow::Cow<'_, [u8]>> {
185    use anyhow::Context;
186
187    match algo {
188        CheckpointCompressionAlgorithm::CheckpointCompressionUnspecified => Ok(payload.into()),
189        CheckpointCompressionAlgorithm::CheckpointCompressionZstd => {
190            Ok(zstd::stream::decode_all(payload)
191                .map(std::borrow::Cow::Owned)
192                .context("zstd decompression failed")?)
193        }
194        CheckpointCompressionAlgorithm::CheckpointCompressionLz4 => {
195            let mut decoder = lz4::Decoder::new(payload).context("lz4 decoder init failed")?;
196            let mut decompressed = Vec::new();
197            std::io::Read::read_to_end(&mut decoder, &mut decompressed)
198                .context("lz4 decompression failed")?;
199            Ok(decompressed.into())
200        }
201    }
202}
203
204/// Compresses checkpoint data using the specified algorithm.
205pub(crate) fn compress_payload(
206    algo: risingwave_common::config::CheckpointCompression,
207    data: &[u8],
208) -> Result<Vec<u8>> {
209    use anyhow::Context;
210    use risingwave_common::config::CheckpointCompression;
211
212    match algo {
213        CheckpointCompression::None => Ok(data.to_vec()),
214        CheckpointCompression::Zstd => {
215            // Level 3: good balance between compression ratio and speed
216            Ok(zstd::stream::encode_all(data, 3).context("zstd compression failed")?)
217        }
218        CheckpointCompression::Lz4 => {
219            let mut compressed = Vec::new();
220            let mut encoder = lz4::EncoderBuilder::new()
221                .level(4)
222                .build(&mut compressed)
223                .context("lz4 encoder init failed")?;
224            std::io::Write::write_all(&mut encoder, data)
225                .context("lz4 compression write failed")?;
226            let (_writer, result) = encoder.finish();
227            result.context("lz4 compression finish failed")?;
228            Ok(compressed)
229        }
230    }
231}
232
233async fn read_bytes_in_chunks<F, Fut>(
234    total_size: usize,
235    chunk_size: usize,
236    max_in_flight_chunks: usize,
237    mut read_range: F,
238) -> anyhow::Result<bytes::Bytes>
239where
240    F: FnMut(std::ops::Range<usize>) -> Fut,
241    Fut: std::future::Future<Output = anyhow::Result<bytes::Bytes>>,
242{
243    use anyhow::Context;
244    use futures::StreamExt;
245
246    let num_chunks = total_size.div_ceil(chunk_size);
247    let mut buf = BytesMut::with_capacity(total_size);
248
249    let mut chunk_stream = futures::stream::iter((0..total_size).step_by(chunk_size))
250        .enumerate()
251        .map(|(chunk_idx, offset)| {
252            let end = std::cmp::min(offset + chunk_size, total_size);
253            let range = offset..end;
254            let fut = read_range(range.clone());
255            async move {
256                fut.await.with_context(|| {
257                    format!(
258                        "read checkpoint chunk {}/{} range {}..{}",
259                        chunk_idx + 1,
260                        num_chunks,
261                        range.start,
262                        range.end
263                    )
264                })
265            }
266        })
267        .buffered(max_in_flight_chunks);
268
269    while let Some(chunk) = chunk_stream.next().await {
270        let chunk = chunk?;
271        buf.extend_from_slice(&chunk);
272    }
273
274    Ok(buf.freeze())
275}
276
277/// A hummock version checkpoint compacts previous hummock version delta logs, and stores stale
278/// objects from those delta logs.
279impl HummockManager {
280    /// Returns Ok(None) if not found.
281    ///
282    /// Reads large checkpoints using bounded parallel chunked reads to avoid
283    /// single-request timeout issues. Chunk size and concurrency are configurable
284    /// via `checkpoint_read_chunk_size` and `checkpoint_read_max_in_flight_chunks`.
285    /// Supports both compressed (envelope) and uncompressed (legacy) checkpoint formats.
286    pub async fn try_read_checkpoint(&self) -> Result<Option<HummockVersionCheckpoint>> {
287        let object_metadata = match self
288            .object_store
289            .metadata(&self.version_checkpoint_path)
290            .await
291        {
292            Ok(metadata) => metadata,
293            Err(e) => {
294                if e.is_object_not_found_error() {
295                    return Ok(None);
296                }
297                return Err(e.into());
298            }
299        };
300        let total_size = object_metadata.total_size;
301
302        let chunk_size = self.env.opts.checkpoint_read_chunk_size;
303        let max_in_flight_chunks = self.env.opts.checkpoint_read_max_in_flight_chunks;
304
305        let download_start = std::time::Instant::now();
306        let data = if total_size <= chunk_size {
307            self.object_store
308                .read(&self.version_checkpoint_path, 0..total_size)
309                .await?
310        } else {
311            let num_chunks = total_size.div_ceil(chunk_size);
312            let data = read_bytes_in_chunks(
313                total_size,
314                chunk_size,
315                max_in_flight_chunks,
316                |range| async {
317                    Ok(self
318                        .object_store
319                        .read(&self.version_checkpoint_path, range)
320                        .await?)
321                },
322            )
323            .await?;
324
325            tracing::info!(
326                total_size,
327                num_chunks,
328                chunk_size,
329                max_in_flight_chunks,
330                "chunked read complete"
331            );
332            data
333        };
334        let download_duration = download_start.elapsed();
335
336        let decode_start = std::time::Instant::now();
337        let ckpt = decode_checkpoint_data(data)?;
338        let decode_duration = decode_start.elapsed();
339
340        tracing::info!(
341            total_size,
342            download_ms = download_duration.as_millis() as u64,
343            decode_ms = decode_duration.as_millis() as u64,
344            "checkpoint read complete"
345        );
346
347        Ok(Some(HummockVersionCheckpoint::from_protobuf_owned(ckpt)))
348    }
349
350    pub(super) async fn write_checkpoint(
351        &self,
352        checkpoint: &HummockVersionCheckpoint,
353    ) -> Result<()> {
354        use prost::Message;
355        let raw_bytes = checkpoint.to_protobuf().encode_to_vec();
356        let raw_size = raw_bytes.len();
357
358        let compression = self.env.opts.checkpoint_compression_algorithm;
359        let compressed = compress_payload(compression, &raw_bytes)?;
360        let checksum = xxhash64_checksum(&compressed);
361
362        tracing::info!(
363            raw_size,
364            compressed_size = compressed.len(),
365            compression_ratio =
366                format!("{:.2}x", raw_size as f64 / compressed.len().max(1) as f64),
367            compression = ?compression,
368            checksum = format!("{:#x}", checksum),
369            "writing compressed checkpoint"
370        );
371
372        let envelope = PbHummockVersionCheckpointEnvelope {
373            compression_algorithm: compression as i32,
374            payload: compressed,
375            checksum: Some(checksum),
376        };
377
378        let buf = envelope.encode_to_vec();
379        self.object_store
380            .upload(&self.version_checkpoint_path, buf.into())
381            .await?;
382        Ok(())
383    }
384
385    pub(super) async fn write_version_archive(
386        &self,
387        archive: &PbHummockVersionArchive,
388    ) -> Result<()> {
389        use prost::Message;
390        let buf = archive.encode_to_vec();
391        let archive_path = format!(
392            "{}/{}",
393            self.version_archive_dir,
394            archive.version.as_ref().unwrap().id
395        );
396        self.object_store.upload(&archive_path, buf.into()).await?;
397        Ok(())
398    }
399
400    /// Creates a hummock version checkpoint.
401    /// Returns the diff between new and old checkpoint id.
402    /// Note that this method must not be called concurrently, because internally it doesn't hold
403    /// lock throughout the method.
404    pub async fn create_version_checkpoint(&self, min_delta_log_num: u64) -> Result<u64> {
405        let timer = self.metrics.version_checkpoint_latency.start_timer();
406        // 1. hold read lock briefly and snapshot checkpoint inputs.
407        let (
408            current_version,
409            old_checkpoint_version,
410            mut stale_objects,
411            version_deltas,
412            current_table_change_log,
413            new_checkpoint_id,
414            old_checkpoint_id,
415        ) = {
416            let versioning_guard = self
417                .versioning
418                .read_with_process_name("version_checkpoint_build")
419                .await;
420            let versioning: &Versioning = versioning_guard.deref();
421            let current_version = versioning.current_version.clone();
422            let old_checkpoint: &HummockVersionCheckpoint = &versioning.checkpoint;
423            let new_checkpoint_id = current_version.id;
424            let old_checkpoint_id = old_checkpoint.version.id;
425            if new_checkpoint_id < old_checkpoint_id + min_delta_log_num {
426                return Ok(0);
427            }
428            if cfg!(test) && new_checkpoint_id == old_checkpoint_id {
429                drop(versioning_guard);
430                let versioning = self
431                    .versioning
432                    .read_with_process_name("version_checkpoint_build")
433                    .await;
434                let context_info = self
435                    .context_info
436                    .read_with_process_name("version_checkpoint_build")
437                    .await;
438                let min_pinned_version_id = context_info.min_pinned_version_id();
439                let stale_object_stats = gc_stale_object_stats(
440                    &versioning.checkpoint.stale_objects,
441                    min_pinned_version_id,
442                );
443                trigger_gc_stat(
444                    &self.metrics,
445                    versioning.checkpoint.version.as_ref(),
446                    &versioning.table_change_log,
447                    stale_object_stats,
448                );
449                return Ok(0);
450            }
451            let old_checkpoint_version = old_checkpoint.version.clone();
452            let version_deltas = versioning
453                .hummock_version_deltas
454                .range((Excluded(old_checkpoint_id), Included(new_checkpoint_id)))
455                .map(|(_, version_delta)| version_delta.clone())
456                .collect::<Vec<_>>();
457            (
458                current_version,
459                old_checkpoint_version,
460                old_checkpoint.stale_objects.clone(),
461                version_deltas,
462                versioning.table_change_log.clone(),
463                new_checkpoint_id,
464                old_checkpoint_id,
465            )
466        };
467        assert!(new_checkpoint_id > old_checkpoint_id);
468        let mut archive: Option<PbHummockVersionArchive> = None;
469        // `object_sizes` is used to calculate size of stale objects.
470        let mut object_sizes = version_object_size_map(old_checkpoint_version.as_ref());
471        // The set of object ids that once exist in any hummock version
472        let mut versions_object_ids: HashSet<_> = old_checkpoint_version.get_object_ids().collect();
473        for version_delta in &version_deltas {
474            // DO NOT REMOVE THIS LINE
475            // This is to ensure that when adding new variant to `HummockObjectId`,
476            // the compiler will warn us if we forget to handle it here.
477            match HummockObjectId::Sstable(0.into()) {
478                HummockObjectId::Sstable(_) => {}
479                HummockObjectId::VectorFile(_) => {}
480                HummockObjectId::HnswGraphFile(_) => {}
481            };
482            for (object_id, file_size) in version_delta
483                .newly_added_sst_infos()
484                .map(|sst| (HummockObjectId::Sstable(sst.object_id), sst.file_size))
485                .chain(
486                    version_delta
487                        .vector_index_delta
488                        .values()
489                        .flat_map(|delta| delta.newly_added_objects()),
490                )
491            {
492                object_sizes.insert(object_id, file_size);
493                versions_object_ids.insert(object_id);
494            }
495        }
496
497        // Object ids that once exist in any hummock version but not exist in the latest hummock version
498        let current_version_object_ids = current_version
499            .get_object_ids()
500            .chain(
501                current_table_change_log
502                    .values()
503                    .flat_map(|l| l.get_object_ids()),
504            )
505            .collect();
506        let removed_object_ids = &versions_object_ids - &current_version_object_ids;
507        let total_file_size = removed_object_ids
508            .iter()
509            .map(|t| {
510                object_sizes.get(t).copied().unwrap_or_else(|| {
511                    warn!(object_id = ?t, "unable to get size of removed object id");
512                    0
513                })
514            })
515            .sum::<u64>();
516        stale_objects.insert(current_version.id, {
517            let mut sst_ids = vec![];
518            let mut vector_files = vec![];
519            for object_id in removed_object_ids {
520                match object_id {
521                    HummockObjectId::Sstable(sst_id) => sst_ids.push(sst_id),
522                    HummockObjectId::VectorFile(vector_file_id) => {
523                        vector_files.push(PbVectorIndexObject {
524                            id: vector_file_id.as_raw(),
525                            object_type: PbVectorIndexObjectType::VectorIndexObjectVector as _,
526                        })
527                    }
528                    HummockObjectId::HnswGraphFile(graph_file_id) => {
529                        vector_files.push(PbVectorIndexObject {
530                            id: graph_file_id.as_raw(),
531                            object_type: PbVectorIndexObjectType::VectorIndexObjectHnswGraph as _,
532                        });
533                    }
534                }
535            }
536            StaleObjects {
537                id: sst_ids,
538                total_file_size,
539                vector_files,
540            }
541        });
542        if self.env.opts.enable_hummock_data_archive {
543            archive = Some(PbHummockVersionArchive {
544                version: Some(PbHummockVersion::from(old_checkpoint_version.as_ref())),
545                version_deltas: version_deltas
546                    .iter()
547                    .map(|version_delta| version_delta.into())
548                    .collect(),
549            });
550        }
551        let min_pinned_version_id = self
552            .context_info
553            .read_with_process_name("version_checkpoint_build")
554            .await
555            .min_pinned_version_id();
556        let may_delete_object = stale_objects
557            .iter()
558            .filter_map(|(version_id, object_ids)| {
559                if *version_id >= min_pinned_version_id {
560                    return None;
561                }
562                Some(get_stale_object_ids(object_ids))
563            })
564            .flatten();
565        self.gc_manager.add_may_delete_object_ids(may_delete_object);
566        stale_objects.retain(|version_id, _| *version_id >= min_pinned_version_id);
567        let new_checkpoint = HummockVersionCheckpoint {
568            version: current_version.clone(),
569            stale_objects,
570        };
571        // 2. persist the new checkpoint without holding lock
572        self.write_checkpoint(&new_checkpoint).await?;
573        if let Some(archive) = archive
574            && let Err(e) = self.write_version_archive(&archive).await
575        {
576            tracing::warn!(
577                error = %e.as_report(),
578                "failed to write version archive {}",
579                archive.version.as_ref().unwrap().id
580            );
581        }
582        let min_pinned_version_id = self
583            .context_info
584            .read_with_process_name("version_checkpoint_build")
585            .await
586            .min_pinned_version_id();
587        let stale_object_stats =
588            gc_stale_object_stats(&new_checkpoint.stale_objects, min_pinned_version_id);
589        // 3. hold write lock briefly and update in memory state
590        let current_version_for_metrics = {
591            let mut versioning_guard = self
592                .versioning
593                .write_with_process_name("version_checkpoint_install")
594                .await;
595            let versioning = versioning_guard.deref_mut();
596            assert!(new_checkpoint.version.id > versioning.checkpoint.version.id);
597            versioning.checkpoint = new_checkpoint;
598            versioning.current_version.clone()
599        };
600        trigger_gc_stat(
601            &self.metrics,
602            current_version.as_ref(),
603            &current_table_change_log,
604            stale_object_stats,
605        );
606        trigger_split_stat(&self.metrics, current_version_for_metrics.as_ref());
607        timer.observe_duration();
608        self.metrics
609            .checkpoint_version_id
610            .set(new_checkpoint_id.as_i64_id());
611
612        Ok(new_checkpoint_id - old_checkpoint_id)
613    }
614
615    pub fn pause_version_checkpoint(&self) {
616        self.pause_version_checkpoint.store(true, Ordering::Relaxed);
617        tracing::info!("hummock version checkpoint is paused.");
618    }
619
620    pub fn resume_version_checkpoint(&self) {
621        self.pause_version_checkpoint
622            .store(false, Ordering::Relaxed);
623        tracing::info!("hummock version checkpoint is resumed.");
624    }
625
626    pub fn is_version_checkpoint_paused(&self) -> bool {
627        self.pause_version_checkpoint.load(Ordering::Relaxed)
628    }
629
630    pub async fn get_checkpoint_version(&self) -> Arc<HummockVersion> {
631        let versioning_guard = self
632            .versioning
633            .read_with_process_name("get_checkpoint_version")
634            .await;
635        versioning_guard.checkpoint.version.clone()
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use bytes::Bytes;
642    use prost::Message;
643    use risingwave_common::config::CheckpointCompression;
644    use risingwave_pb::hummock::hummock_version_checkpoint::StaleObjects;
645    use risingwave_pb::hummock::{
646        PbHummockVersion, PbHummockVersionCheckpoint, PbHummockVersionCheckpointEnvelope,
647        PbTableChangeLog,
648    };
649
650    use super::{
651        HummockVersionCheckpoint, compress_payload, decode_checkpoint_data, read_bytes_in_chunks,
652        xxhash64_checksum,
653    };
654
655    #[expect(deprecated)]
656    fn make_version(id: u64) -> PbHummockVersion {
657        PbHummockVersion {
658            id: id.into(),
659            levels: Default::default(),
660            max_committed_epoch: 0,
661            table_watermarks: Default::default(),
662            table_change_logs: Default::default(),
663            state_table_info: Default::default(),
664            vector_indexes: Default::default(),
665        }
666    }
667
668    fn make_checkpoint(version_id: u64) -> PbHummockVersionCheckpoint {
669        let stale = StaleObjects {
670            id: vec![1u64.into(), 2u64.into(), 3u64.into()],
671            total_file_size: 123,
672            vector_files: vec![],
673        };
674
675        PbHummockVersionCheckpoint {
676            version: Some(make_version(version_id)),
677            stale_objects: [(1u64.into(), stale)].into_iter().collect(),
678        }
679    }
680
681    #[test]
682    fn deprecated_table_change_logs_are_ignored() {
683        let mut checkpoint = make_checkpoint(42);
684        checkpoint
685            .version
686            .as_mut()
687            .unwrap()
688            .table_change_logs
689            .insert(1.into(), PbTableChangeLog::default());
690
691        let checkpoint = HummockVersionCheckpoint::from_protobuf(&checkpoint);
692        assert!(
693            checkpoint
694                .to_protobuf()
695                .version
696                .unwrap()
697                .table_change_logs
698                .is_empty()
699        );
700    }
701
702    fn make_envelope_bytes(
703        checkpoint: &PbHummockVersionCheckpoint,
704        compression: CheckpointCompression,
705        checksum: Option<u64>,
706    ) -> Bytes {
707        let raw = checkpoint.encode_to_vec();
708        let payload = compress_payload(compression, &raw)
709            .expect("compress checkpoint payload should succeed");
710        let checksum = checksum.unwrap_or_else(|| xxhash64_checksum(&payload));
711        let envelope = PbHummockVersionCheckpointEnvelope {
712            compression_algorithm: compression as i32,
713            payload,
714            checksum: Some(checksum),
715        };
716        Bytes::from(envelope.encode_to_vec())
717    }
718
719    #[test]
720    fn decode_checkpoint_data_falls_back_to_legacy_format() {
721        let checkpoint = make_checkpoint(42);
722        let raw = Bytes::from(checkpoint.encode_to_vec());
723        let decoded = decode_checkpoint_data(raw).expect("legacy checkpoint should decode");
724        assert_eq!(decoded, checkpoint);
725    }
726
727    #[test]
728    fn decode_checkpoint_data_roundtrips_envelope_with_checksum() {
729        let checkpoint = make_checkpoint(42);
730        for compression in [
731            CheckpointCompression::None,
732            CheckpointCompression::Zstd,
733            CheckpointCompression::Lz4,
734        ] {
735            let data = make_envelope_bytes(&checkpoint, compression, None);
736            let decoded = decode_checkpoint_data(data).expect("envelope checkpoint should decode");
737            assert_eq!(decoded, checkpoint);
738        }
739    }
740
741    #[test]
742    fn decode_checkpoint_data_returns_error_on_checksum_mismatch() {
743        let checkpoint = make_checkpoint(42);
744        let raw = checkpoint.encode_to_vec();
745        let mut payload = compress_payload(CheckpointCompression::Zstd, &raw)
746            .expect("compress checkpoint payload should succeed");
747        let expected = xxhash64_checksum(&payload);
748        payload[0] ^= 0x01;
749        let envelope = PbHummockVersionCheckpointEnvelope {
750            compression_algorithm: CheckpointCompression::Zstd as i32,
751            payload,
752            checksum: Some(expected),
753        };
754        let data = Bytes::from(envelope.encode_to_vec());
755        let err = decode_checkpoint_data(data).expect_err("checksum mismatch should error");
756        assert!(err.to_string().contains("checksum mismatch"), "{err:?}");
757    }
758
759    #[test]
760    fn decode_checkpoint_data_returns_error_on_unknown_compression_algorithm() {
761        let checkpoint = make_checkpoint(42);
762        let payload = checkpoint.encode_to_vec();
763        let checksum = xxhash64_checksum(&payload);
764        let envelope = PbHummockVersionCheckpointEnvelope {
765            compression_algorithm: 123,
766            payload,
767            checksum: Some(checksum),
768        };
769        let data = Bytes::from(envelope.encode_to_vec());
770        let err =
771            decode_checkpoint_data(data).expect_err("unknown compression algorithm should error");
772        assert!(
773            err.to_string()
774                .contains("unknown checkpoint compression algorithm"),
775            "{err:?}"
776        );
777    }
778
779    #[test]
780    fn decode_checkpoint_data_returns_error_on_legacy_missing_version() {
781        let checkpoint = PbHummockVersionCheckpoint {
782            version: None,
783            stale_objects: Default::default(),
784        };
785        let data = Bytes::from(checkpoint.encode_to_vec());
786        let err = decode_checkpoint_data(data).expect_err("missing version should error");
787        assert!(
788            err.to_string()
789                .contains("legacy checkpoint missing required field `version`"),
790            "{err:?}"
791        );
792    }
793
794    #[test]
795    fn decode_checkpoint_data_returns_error_on_corrupt_envelope_payload() {
796        let garbage = b"not a valid protobuf";
797        let checksum = xxhash64_checksum(garbage);
798        let envelope = PbHummockVersionCheckpointEnvelope {
799            compression_algorithm: CheckpointCompression::None as i32,
800            payload: garbage.to_vec(),
801            checksum: Some(checksum),
802        };
803        let data = Bytes::from(envelope.encode_to_vec());
804        let err = decode_checkpoint_data(data).expect_err("corrupt envelope payload should error");
805        assert!(
806            err.to_string()
807                .contains("failed to decode checkpoint envelope payload"),
808            "{err:?}"
809        );
810    }
811
812    #[test]
813    fn decode_checkpoint_data_returns_error_on_empty_input() {
814        let err = decode_checkpoint_data(Bytes::new()).expect_err("empty checkpoint should fail");
815        assert!(
816            err.to_string()
817                .contains("legacy checkpoint missing required field `version`"),
818            "{err:?}"
819        );
820    }
821
822    #[test]
823    fn decode_checkpoint_data_returns_error_on_envelope_missing_version() {
824        let checkpoint = PbHummockVersionCheckpoint {
825            version: None,
826            stale_objects: Default::default(),
827        };
828        let raw = checkpoint.encode_to_vec();
829        let checksum = xxhash64_checksum(&raw);
830        let envelope = PbHummockVersionCheckpointEnvelope {
831            compression_algorithm: CheckpointCompression::None as i32,
832            payload: raw,
833            checksum: Some(checksum),
834        };
835        let data = Bytes::from(envelope.encode_to_vec());
836        let err =
837            decode_checkpoint_data(data).expect_err("envelope with missing version should error");
838        assert!(
839            err.to_string()
840                .contains("checkpoint missing required field `version`"),
841            "{err:?}"
842        );
843    }
844
845    #[tokio::test]
846    async fn read_bytes_in_chunks_respects_concurrency_limit_and_reassembles() {
847        use std::sync::Arc;
848        use std::sync::atomic::{AtomicUsize, Ordering};
849
850        use tokio::time::{Duration, sleep};
851
852        let total_size = 100usize;
853        let chunk_size = 10usize;
854        let max_in_flight = 3usize;
855
856        let data: Arc<Vec<u8>> = Arc::new((0..total_size).map(|i| (i % 256) as u8).collect());
857        let in_flight = Arc::new(AtomicUsize::new(0));
858        let max_seen = Arc::new(AtomicUsize::new(0));
859
860        let out = read_bytes_in_chunks(total_size, chunk_size, max_in_flight, {
861            let data = data.clone();
862            let in_flight = in_flight.clone();
863            let max_seen = max_seen.clone();
864            move |range: std::ops::Range<usize>| {
865                let data = data.clone();
866                let in_flight = in_flight.clone();
867                let max_seen = max_seen.clone();
868                async move {
869                    let cur = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
870                    max_seen.fetch_max(cur, Ordering::SeqCst);
871
872                    // Add a small delay to simulate real I/O and allow multiple reads
873                    // to be in-flight concurrently. This tests that max_in_flight limit
874                    // is respected (should not exceed 3 concurrent reads).
875                    sleep(Duration::from_millis(30)).await;
876
877                    let bytes = Bytes::copy_from_slice(&data[range]);
878                    in_flight.fetch_sub(1, Ordering::SeqCst);
879                    Ok(bytes)
880                }
881            }
882        })
883        .await
884        .expect("chunked read should succeed");
885
886        assert_eq!(out.as_ref(), data.as_slice());
887        let max_seen = max_seen.load(Ordering::SeqCst);
888        assert!(max_seen <= max_in_flight, "max_seen={max_seen}");
889        assert!(
890            max_seen > 1,
891            "expected some concurrency, max_seen={max_seen}"
892        );
893    }
894
895    #[tokio::test]
896    async fn read_bytes_in_chunks_adds_range_context_on_error() {
897        let total_size = 30usize;
898        let chunk_size = 10usize;
899        let max_in_flight = 2usize;
900
901        let err = read_bytes_in_chunks(total_size, chunk_size, max_in_flight, |range| async move {
902            if range.start == 10 {
903                anyhow::bail!("boom");
904            }
905            Ok(Bytes::copy_from_slice(&vec![0u8; range.len()]))
906        })
907        .await
908        .expect_err("should fail");
909
910        let msg = err.to_string();
911        assert!(
912            msg.contains("read checkpoint chunk 2/3 range 10..20"),
913            "unexpected error message: {msg}"
914        );
915        let msg_with_chain = format!("{err:#}");
916        assert!(
917            msg_with_chain.contains("boom"),
918            "unexpected error message: {msg_with_chain}"
919        );
920    }
921}