Skip to main content

delta_kernel/parallel/
sequential_phase.rs

1//! Sequential log replay processor that happens before the parallel phase.
2//!
3//! This module provides sequential phase log replay that processes commits and
4//! single-part checkpoint manifests, then returns the processor and any files (sidecars or
5//! multi-part checkpoint parts) for parallel processing by the parallel phase. This phase
6//! must be completed before the parallel phase can start.
7//!
8//! For multi-part checkpoints, the sequential phase skips manifest processing and returns
9//! the checkpoint parts for parallel processing.
10#![allow(unused)]
11
12use std::sync::Arc;
13
14use delta_kernel_derive::internal_api;
15use itertools::Itertools;
16
17use crate::log_reader::checkpoint_manifest::CheckpointManifestReader;
18use crate::log_reader::commit::CommitReader;
19use crate::log_replay::LogReplayProcessor;
20use crate::log_segment::LogSegment;
21use crate::scan::COMMIT_READ_SCHEMA;
22use crate::utils::require;
23use crate::{DeltaResult, Engine, Error, FileMeta};
24
25/// Sequential log replay processor for parallel execution.
26///
27/// This iterator processes log replay sequentially:
28/// 1. Commit files (JSON)
29/// 2. Manifest (single-part checkpoint, if present)
30///
31/// After exhaustion, call `finish()` to extract:
32/// - The processor (for serialization and distribution)
33/// - Files (sidecars or multi-part checkpoint parts) for parallel processing
34///
35/// # Type Parameters
36/// - `P`: A [`LogReplayProcessor`] implementation that processes action batches
37///
38/// # Example
39///
40/// ```ignore
41/// let mut sequential = SequentialPhase::try_new(processor, log_segment, engine)?;
42///
43/// // Iterate over sequential batches
44/// for batch in sequential.by_ref() {
45///     let metadata = batch?;
46///     // Process metadata
47/// }
48///
49/// // Extract processor and files for distribution (if needed)
50/// match sequential.finish()? {
51///     AfterSequential::Parallel { processor, files } => {
52///         // Parallel phase needed - distribute files for parallel processing.
53///         // If crossing the network boundary, the processor must be serialized.
54///         let serialized = processor.serialize()?;
55///         let partitions = partition_files(files, num_workers);
56///         for (worker, partition) in partitions {
57///             worker.send(serialized.clone(), partition)?;
58///         }
59///     }
60///     AfterSequential::Done(processor) => {
61///         // No parallel phase needed - all processing complete sequentially
62///         println!("Log replay complete");
63///     }
64/// }
65/// ```
66/// cbindgen:ignore
67#[internal_api]
68pub(crate) struct SequentialPhase<P: LogReplayProcessor> {
69    // The processor that will be used to process the action batches
70    processor: P,
71    // The commit reader that will be used to read the commit files
72    commit_phase: Option<CommitReader>,
73    // The checkpoint manifest reader that will be used to read the checkpoint manifest files.
74    // If the checkpoint is single-part, this will be Some(CheckpointManifestReader).
75    checkpoint_manifest_phase: Option<CheckpointManifestReader>,
76    // Whether the iterator has been fully exhausted
77    is_finished: bool,
78    // Checkpoint parts for potential parallel phase processing
79    checkpoint_parts: Vec<FileMeta>,
80}
81
82/// Result of sequential log replay processing.
83/// cbindgen:ignore
84#[internal_api]
85pub(crate) enum AfterSequential<P: LogReplayProcessor> {
86    /// All processing complete sequentially - no parallel phase needed.
87    Done(P),
88    /// Parallel phase needed - distribute files for parallel processing.
89    Parallel { processor: P, files: Vec<FileMeta> },
90}
91
92impl<P: LogReplayProcessor> SequentialPhase<P> {
93    /// Create a new sequential phase log replay.
94    ///
95    /// # Parameters
96    /// - `processor`: The log replay processor
97    /// - `log_segment`: The log segment to process
98    /// - `engine`: Engine for reading files
99    #[internal_api]
100    pub(crate) fn try_new(
101        processor: P,
102        log_segment: &LogSegment,
103        engine: Arc<dyn Engine>,
104    ) -> DeltaResult<Self> {
105        let commit_phase = Some(CommitReader::try_new(
106            engine.as_ref(),
107            log_segment,
108            COMMIT_READ_SCHEMA.clone(),
109            None,
110        )?);
111
112        // Concurrently start reading the checkpoint manifest. Only create a checkpoint manifest
113        // reader if the checkpoint is single-part.
114        let checkpoint_manifest_phase = match log_segment.listed.checkpoint_parts.as_slice() {
115            [single_part] => Some(CheckpointManifestReader::try_new(
116                engine,
117                single_part,
118                log_segment.log_root.clone(),
119            )?),
120            _ => None,
121        };
122
123        let checkpoint_parts = log_segment
124            .listed
125            .checkpoint_parts
126            .iter()
127            .map(|path| path.location.clone())
128            .collect_vec();
129
130        Ok(Self {
131            processor,
132            commit_phase,
133            checkpoint_manifest_phase,
134            is_finished: false,
135            checkpoint_parts,
136        })
137    }
138
139    /// Complete sequential phase and extract processor + files for distribution.
140    ///
141    /// Must be called after the iterator is exhausted.
142    ///
143    /// # Returns
144    /// - `Done`: All processing done sequentially - no parallel phase needed
145    /// - `Parallel`: Parallel phase needed. The resulting files may be processed in parallel.
146    ///
147    /// # Errors
148    /// Returns an error if called before iterator exhaustion.
149    #[internal_api]
150    pub(crate) fn finish(self) -> DeltaResult<AfterSequential<P>> {
151        if !self.is_finished {
152            return Err(Error::generic(
153                "Must exhaust iterator before calling finish()",
154            ));
155        }
156
157        let parallel_files = match self.checkpoint_manifest_phase {
158            Some(manifest_reader) => manifest_reader.extract_sidecars()?,
159            None => {
160                let parts = self.checkpoint_parts;
161                require!(
162                    parts.len() != 1,
163                    Error::generic(
164                        "Invariant violation: If there is exactly one checkpoint part,
165                        there must be a manifest reader"
166                    )
167                );
168                // If this is a multi-part checkpoint, use the checkpoint parts for parallel phase
169                parts
170            }
171        };
172
173        if parallel_files.is_empty() {
174            Ok(AfterSequential::Done(self.processor))
175        } else {
176            Ok(AfterSequential::Parallel {
177                processor: self.processor,
178                files: parallel_files,
179            })
180        }
181    }
182}
183
184impl<P: LogReplayProcessor> Iterator for SequentialPhase<P> {
185    type Item = DeltaResult<P::Output>;
186
187    fn next(&mut self) -> Option<Self::Item> {
188        let next = self
189            .commit_phase
190            .as_mut()
191            .and_then(|commit_phase| commit_phase.next())
192            .or_else(|| {
193                self.commit_phase = None;
194                self.checkpoint_manifest_phase.as_mut()?.next()
195            });
196
197        let Some(result) = next else {
198            self.is_finished = true;
199            return None;
200        };
201
202        Some(result.and_then(|batch| self.processor.process_actions_batch(batch)))
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::scan::AfterSequentialScanMetadata;
210    use crate::unit_test_utils::{assert_result_error_with_message, load_test_table};
211
212    /// Core helper function to verify sequential processing with expected adds and sidecars.
213    fn verify_sequential_processing(
214        table_name: &str,
215        expected_adds: &[&str],
216        expected_sidecars: &[&str],
217    ) -> DeltaResult<()> {
218        let (engine, snapshot, _tempdir) = load_test_table(table_name)?;
219
220        let scan = snapshot.scan_builder().build()?;
221        let mut sequential = scan.parallel_scan_metadata(engine)?;
222
223        // Process all batches and collect Add file paths
224        let mut file_paths = Vec::new();
225        for result in sequential.by_ref() {
226            let metadata = result?;
227            file_paths =
228                metadata.visit_scan_files(file_paths, |ps: &mut Vec<String>, file_stat| {
229                    ps.push(file_stat.path);
230                })?;
231        }
232
233        // Assert collected adds match expected
234        file_paths.sort();
235        assert_eq!(
236            file_paths, expected_adds,
237            "Sequential phase should collect expected Add file paths"
238        );
239
240        // Call finish() and verify result based on expected sidecars
241        let result = sequential.finish()?;
242        match (expected_sidecars, result) {
243            (sidecars, AfterSequentialScanMetadata::Done) => {
244                assert!(
245                    sidecars.is_empty(),
246                    "Expected Done but got sidecars {sidecars:?}"
247                );
248            }
249            (expected_sidecars, AfterSequentialScanMetadata::Parallel { files, .. }) => {
250                assert_eq!(
251                    files.len(),
252                    expected_sidecars.len(),
253                    "Should collect exactly {} sidecar files",
254                    expected_sidecars.len()
255                );
256
257                // Extract and verify sidecar file paths
258                let mut collected_paths = files
259                    .iter()
260                    .map(|fm| {
261                        fm.location
262                            .path_segments()
263                            .and_then(|mut segments| segments.next_back())
264                            .unwrap_or("")
265                            .to_string()
266                    })
267                    .collect_vec();
268
269                collected_paths.sort();
270                assert_eq!(collected_paths, expected_sidecars);
271            }
272        }
273
274        Ok(())
275    }
276
277    #[test]
278    fn test_sequential_v2_with_commits_only() -> DeltaResult<()> {
279        verify_sequential_processing(
280            "table-without-dv-small",
281            &["part-00000-517f5d32-9c95-48e8-82b4-0229cc194867-c000.snappy.parquet"],
282            &[], // No sidecars
283        )
284    }
285
286    #[test]
287    fn test_sequential_v2_with_sidecars() -> DeltaResult<()> {
288        verify_sequential_processing(
289            "v2-checkpoints-json-with-sidecars",
290            &[], // No adds in sequential phase (all in checkpoint sidecars)
291            &[
292                "00000000000000000006.checkpoint.0000000001.0000000002.19af1366-a425-47f4-8fa6-8d6865625573.parquet",
293                "00000000000000000006.checkpoint.0000000002.0000000002.5008b69f-aa8a-4a66-9299-0733a56a7e63.parquet",
294            ],
295        )
296    }
297
298    #[test]
299    fn test_sequential_finish_before_exhaustion_error() -> DeltaResult<()> {
300        let (engine, snapshot, _tempdir) = load_test_table("table-without-dv-small")?;
301
302        let scan = snapshot.scan_builder().build()?;
303        let sequential = scan.parallel_scan_metadata(engine)?;
304
305        // Try to call finish() before exhausting the iterator
306        let result = sequential.finish();
307        assert_result_error_with_message(result, "Must exhaust iterator before calling finish()");
308
309        Ok(())
310    }
311
312    #[test]
313    fn test_sequential_checkpoint_without_sidecars() -> DeltaResult<()> {
314        verify_sequential_processing(
315            "v2-checkpoints-json-without-sidecars",
316            &[
317                // Adds from checkpoint manifest processed in sequential phase
318                "test%25file%25prefix-part-00000-0e32f92c-e232-4daa-b734-369d1a800502-c000.snappy.parquet",
319                "test%25file%25prefix-part-00000-91daf7c5-9ba0-4f76-aefd-0c3b21d33c6c-c000.snappy.parquet",
320                "test%25file%25prefix-part-00001-a5c41be1-ded0-4b18-a638-a927d233876e-c000.snappy.parquet",
321            ],
322            &[], // No sidecars
323        )
324    }
325
326    #[test]
327    fn test_sequential_parquet_checkpoint_with_sidecars() -> DeltaResult<()> {
328        verify_sequential_processing(
329            "v2-checkpoints-parquet-with-sidecars",
330            &[], // No adds in sequential phase
331            &[
332                // Expected sidecars
333                "00000000000000000006.checkpoint.0000000001.0000000002.76931b15-ead3-480d-b86c-afe55a577fc3.parquet",
334                "00000000000000000006.checkpoint.0000000002.0000000002.4367b29c-0e87-447f-8e81-9814cc01ad1f.parquet",
335            ],
336        )
337    }
338
339    #[test]
340    fn test_sequential_checkpoint_no_commits() -> DeltaResult<()> {
341        verify_sequential_processing(
342            "with_checkpoint_no_last_checkpoint",
343            &["part-00000-70b1dcdf-0236-4f63-a072-124cdbafd8a0-c000.snappy.parquet"], /* Add from commit 3 */
344            &[], // No sidecars
345        )
346    }
347}