Skip to main content

tpchgen_cli/
runner.rs

1//! [`PlanRunner`] for running [`OutputPlan`]s.
2
3use crate::csv::*;
4use crate::generate::generate_in_chunks_with_progress;
5use crate::generate::Source;
6use crate::output_plan::{OutputLocation, OutputPlan};
7use crate::parquet::generate_parquet_with_progress;
8#[cfg(feature = "progress")]
9use crate::progress::ProgressTracker;
10use crate::progress::RunProgress;
11use crate::tbl::*;
12use crate::tbl::{LineItemTblSource, NationTblSource, RegionTblSource};
13use crate::{OutputFormat, Table, WriterSink};
14use log::{debug, info};
15use std::io;
16use std::io::BufWriter;
17#[cfg(feature = "progress")]
18use std::sync::Arc;
19use tokio::task::{JoinError, JoinSet};
20use tpchgen::generators::{
21    CustomerGenerator, LineItemGenerator, NationGenerator, OrderGenerator, PartGenerator,
22    PartSuppGenerator, RegionGenerator, SupplierGenerator,
23};
24use tpchgen_arrow::{
25    CustomerArrow, LineItemArrow, NationArrow, OrderArrow, PartArrow, PartSuppArrow,
26    RecordBatchIterator, RegionArrow, SupplierArrow,
27};
28
29/// Runs multiple [`OutputPlan`]s in parallel, managing the number of threads
30/// used to run them.
31#[derive(Debug)]
32pub struct PlanRunner {
33    plans: Vec<OutputPlan>,
34    num_threads: usize,
35    progress: RunProgress,
36}
37
38impl PlanRunner {
39    /// Create a new [`PlanRunner`] with the given plans and number of threads.
40    /// Progress reporting is disabled by default.
41    pub fn new(plans: Vec<OutputPlan>, num_threads: usize) -> Self {
42        Self {
43            plans,
44            num_threads,
45            progress: RunProgress::default(),
46        }
47    }
48
49    /// Attach a [`ProgressTracker`].
50    ///
51    /// The runner pre-registers each table's output-unit total with the
52    /// tracker before scheduling, calls [`ProgressTracker::increment`]
53    /// after output units are written, and calls [`ProgressTracker::finish`]
54    /// once on the success path. Implementations needing cleanup on the
55    /// error or panic path should use `Drop` as a fallback.
56    #[cfg(feature = "progress")]
57    pub fn with_progress_tracker(mut self, tracker: Arc<dyn ProgressTracker>) -> Self {
58        self.progress = RunProgress::with_tracker(tracker);
59        self
60    }
61
62    /// Run all the plans in the runner.
63    pub async fn run(self) -> Result<(), io::Error> {
64        debug!(
65            "Running {} plans with {} threads...",
66            self.plans.len(),
67            self.num_threads
68        );
69        let Self {
70            mut plans,
71            num_threads,
72            progress,
73        } = self;
74
75        // Sort the plans by the number of parts so the largest are first
76        plans.sort_unstable_by(|a, b| {
77            let a_cnt = a.chunk_count();
78            let b_cnt = b.chunk_count();
79            a_cnt.cmp(&b_cnt)
80        });
81
82        // Pre-register per-table output-unit totals so trackers can size their
83        // bars before the first `increment`.
84        progress.register_totals(&plans);
85
86        // Do the actual work in parallel, using a worker queue
87        let mut worker_queue = WorkerQueue::new(num_threads, progress.clone());
88        while let Some(plan) = plans.pop() {
89            worker_queue.schedule_plan(plan).await?;
90        }
91        worker_queue.join_all().await?;
92        progress.finish();
93        Ok(())
94    }
95}
96
97/// Manages worker tasks, limiting the number of total outstanding threads
98/// to some fixed number
99///
100/// The runner executes each plan with a number of threads equal to the
101/// number of parts in the plan, but no more than the total number of
102/// threads specified when creating the runner. If a plan does not need all
103/// the threads, the remaining threads are used to run other plans.
104///
105/// This is important to keep all cores busy for smaller tables that may not
106/// have sufficient parts to keep all threads busy (see [`GenerationPlan`]
107/// for more details), but not schedule more tasks than we have threads for.
108///
109/// Scheduling too many tasks requires more memory and leads to context
110/// switching overhead, which can slow down the generation process.
111///
112/// [`GenerationPlan`]: crate::plan::GenerationPlan
113struct WorkerQueue {
114    join_set: JoinSet<io::Result<usize>>,
115    /// Current number of threads available to commit
116    available_threads: usize,
117    progress: RunProgress,
118}
119
120impl WorkerQueue {
121    pub fn new(max_threads: usize, progress: RunProgress) -> Self {
122        assert!(max_threads > 0);
123        Self {
124            join_set: JoinSet::new(),
125            available_threads: max_threads,
126            progress,
127        }
128    }
129
130    /// Spawns a task to run the plan with as many threads as possible
131    /// without exceeding the maximum number of threads.
132    ///
133    /// If there are no threads available, it will wait for one to finish
134    /// before spawning the new task.
135    ///
136    /// Note this algorithm does not guarantee that all threads are always busy,
137    /// but it should be good enough for most cases. For best thread utilization
138    /// spawn the largest plans first.
139    pub async fn schedule_plan(&mut self, plan: OutputPlan) -> io::Result<()> {
140        debug!("scheduling plan {plan}");
141        loop {
142            if self.available_threads == 0 {
143                debug!("no threads left, wait for one to finish");
144                let Some(result) = self.join_set.join_next().await else {
145                    return Err(io::Error::other(
146                        "Internal Error No more tasks to wait for, but had no threads",
147                    ));
148                };
149                self.available_threads += task_result(result)?;
150                continue; // look for threads again
151            }
152
153            // Check for any other jobs done so we can reuse their threads
154            if let Some(result) = self.join_set.try_join_next() {
155                self.available_threads += task_result(result)?;
156                continue;
157            }
158
159            debug_assert!(
160                self.available_threads > 0,
161                "should have at least one thread to continue"
162            );
163
164            // figure out how many threads to allocate to this plan. Each plan
165            // can use up to `part_count` threads.
166            let chunk_count = plan.chunk_count();
167
168            let num_plan_threads = self.available_threads.min(chunk_count);
169
170            // run the plan in a separate task, which returns the number of threads it used
171            debug!("Spawning plan {plan} with {num_plan_threads} threads");
172
173            let progress = self.progress.clone();
174            self.join_set
175                .spawn(async move { run_plan(plan, num_plan_threads, progress).await });
176            self.available_threads -= num_plan_threads;
177            return Ok(());
178        }
179    }
180
181    // Wait for all tasks to finish
182    pub async fn join_all(mut self) -> io::Result<()> {
183        debug!("Waiting for tasks to finish...");
184        while let Some(result) = self.join_set.join_next().await {
185            task_result(result)?;
186        }
187        debug!("Tasks finished.");
188        Ok(())
189    }
190}
191
192/// unwraps the result of a task and converts it to an `io::Result<T>`.
193fn task_result<T>(result: Result<io::Result<T>, JoinError>) -> io::Result<T> {
194    result.map_err(|e| io::Error::other(format!("Task Panic: {e}")))?
195}
196
197/// Run a single [`OutputPlan`]
198async fn run_plan(
199    plan: OutputPlan,
200    num_threads: usize,
201    progress: RunProgress,
202) -> io::Result<usize> {
203    match plan.table() {
204        Table::Nation => run_nation_plan(plan, num_threads, progress).await,
205        Table::Region => run_region_plan(plan, num_threads, progress).await,
206        Table::Part => run_part_plan(plan, num_threads, progress).await,
207        Table::Supplier => run_supplier_plan(plan, num_threads, progress).await,
208        Table::Partsupp => run_partsupp_plan(plan, num_threads, progress).await,
209        Table::Customer => run_customer_plan(plan, num_threads, progress).await,
210        Table::Orders => run_orders_plan(plan, num_threads, progress).await,
211        Table::Lineitem => run_lineitem_plan(plan, num_threads, progress).await,
212    }
213}
214
215/// If `path` already exists, log a warning, advance progress by the full
216/// output-unit count for this plan, and return `true` so the caller can skip
217/// generation. Returns `false` otherwise.
218fn maybe_skip_existing(path: &std::path::Path, plan: &OutputPlan, progress: &RunProgress) -> bool {
219    if !path.exists() {
220        return false;
221    }
222    log::warn!("{} already exists, skipping generation", path.display());
223    progress.increment_for_existing(plan);
224    true
225}
226
227/// Writes a CSV/TSV output from the sources
228async fn write_file<I>(
229    plan: OutputPlan,
230    num_threads: usize,
231    sources: I,
232    progress: RunProgress,
233) -> Result<(), io::Error>
234where
235    I: Iterator<Item: Source> + 'static,
236{
237    let table = plan.table();
238    let table_progress = progress.for_table(table);
239    // Since generate_in_chunks already buffers, there is no need to buffer
240    // again (aka don't use BufWriter here)
241    match plan.output_location() {
242        OutputLocation::Stdout => {
243            let sink = WriterSink::new(io::stdout());
244            generate_in_chunks_with_progress(sink, sources, num_threads, table_progress).await
245        }
246        OutputLocation::File(path) => {
247            if maybe_skip_existing(path, &plan, &progress) {
248                return Ok(());
249            }
250            // write to a temp file and then rename to avoid partial files
251            let temp_path = path.with_extension("inprogress");
252            let file = std::fs::File::create(&temp_path).map_err(|err| {
253                io::Error::other(format!("Failed to create {temp_path:?}: {err}"))
254            })?;
255            let sink = WriterSink::new(file);
256            generate_in_chunks_with_progress(sink, sources, num_threads, table_progress).await?;
257            // rename the temp file to the final path
258            std::fs::rename(&temp_path, path).map_err(|e| {
259                io::Error::other(format!(
260                    "Failed to rename {temp_path:?} to {path:?} file: {e}"
261                ))
262            })?;
263            Ok(())
264        }
265    }
266}
267
268/// Generates an output parquet file from the sources
269async fn write_parquet<I>(
270    plan: OutputPlan,
271    num_threads: usize,
272    sources: I,
273    progress: RunProgress,
274) -> Result<(), io::Error>
275where
276    I: Iterator<Item: RecordBatchIterator> + 'static,
277{
278    let table = plan.table();
279    let table_progress = progress.for_table(table);
280    match plan.output_location() {
281        OutputLocation::Stdout => {
282            let writer = BufWriter::with_capacity(32 * 1024 * 1024, io::stdout()); // 32MB buffer
283            generate_parquet_with_progress(
284                writer,
285                sources,
286                num_threads,
287                plan.parquet_compression(),
288                table_progress,
289            )
290            .await
291        }
292        OutputLocation::File(path) => {
293            if maybe_skip_existing(path, &plan, &progress) {
294                return Ok(());
295            }
296            // write to a temp file and then rename to avoid partial files
297            let temp_path = path.with_extension("inprogress");
298            let file = std::fs::File::create(&temp_path).map_err(|err| {
299                io::Error::other(format!("Failed to create {temp_path:?}: {err}"))
300            })?;
301            let writer = BufWriter::with_capacity(32 * 1024 * 1024, file); // 32MB buffer
302            generate_parquet_with_progress(
303                writer,
304                sources,
305                num_threads,
306                plan.parquet_compression(),
307                table_progress,
308            )
309            .await?;
310            // rename the temp file to the final path
311            std::fs::rename(&temp_path, path).map_err(|e| {
312                io::Error::other(format!(
313                    "Failed to rename {temp_path:?} to {path:?} file: {e}"
314                ))
315            })?;
316            Ok(())
317        }
318    }
319}
320
321/// macro to create a function for generating a part of a particular able
322///
323/// Arguments:
324/// $FUN_NAME: name of the function to create
325/// $GENERATOR: The generator type to use
326/// $TBL_SOURCE: The [`Source`] type to use for TBL format
327/// $CSV_SOURCE: The [`Source`] type to use for CSV format
328/// $PARQUET_SOURCE: The [`RecordBatchIterator`] type to use for Parquet format
329macro_rules! define_run {
330    ($FUN_NAME:ident, $GENERATOR:ident, $TBL_SOURCE:ty, $CSV_SOURCE:ty, $PARQUET_SOURCE:ty) => {
331        async fn $FUN_NAME(
332            plan: OutputPlan,
333            num_threads: usize,
334            progress: RunProgress,
335        ) -> io::Result<usize> {
336            use crate::GenerationPlan;
337            let scale_factor = plan.scale_factor();
338            info!("Writing {plan} using {num_threads} threads");
339
340            /// These interior functions are used to tell the compiler that the lifetime is 'static
341            /// (when these were closures, the compiler could not figure out the lifetime) and
342            /// resulted in errors like this:
343            ///          let _ = join_set.spawn(async move {
344            ///                 |  _____________________^
345            ///              96 | |                 run_plan(plan, num_plan_threads).await
346            ///              97 | |             });
347            ///                 | |______________^ implementation of `FnOnce` is not general enough
348            fn tbl_sources(
349                generation_plan: &GenerationPlan,
350                scale_factor: f64,
351            ) -> impl Iterator<Item: Source> + 'static {
352                generation_plan
353                    .clone()
354                    .into_iter()
355                    .map(move |(part, num_parts)| $GENERATOR::new(scale_factor, part, num_parts))
356                    .map(<$TBL_SOURCE>::new)
357            }
358
359            fn csv_sources(
360                generation_plan: &GenerationPlan,
361                scale_factor: f64,
362                delimiter: char,
363            ) -> impl Iterator<Item: Source> + 'static {
364                generation_plan
365                    .clone()
366                    .into_iter()
367                    .map(move |(part, num_parts)| $GENERATOR::new(scale_factor, part, num_parts))
368                    .map(move |gen| <$CSV_SOURCE>::new(gen, delimiter))
369            }
370
371            fn parquet_sources(
372                generation_plan: &GenerationPlan,
373                scale_factor: f64,
374            ) -> impl Iterator<Item: RecordBatchIterator> + 'static {
375                generation_plan
376                    .clone()
377                    .into_iter()
378                    .map(move |(part, num_parts)| $GENERATOR::new(scale_factor, part, num_parts))
379                    .map(<$PARQUET_SOURCE>::new)
380            }
381
382            // Dispatch to the appropriate output format
383            match plan.output_format() {
384                OutputFormat::Tbl => {
385                    let gens = tbl_sources(plan.generation_plan(), scale_factor);
386                    write_file(plan, num_threads, gens, progress).await?
387                }
388                OutputFormat::Csv => {
389                    let delimiter = plan.csv_delimiter();
390                    let gens = csv_sources(plan.generation_plan(), scale_factor, delimiter);
391                    write_file(plan, num_threads, gens, progress).await?
392                }
393                OutputFormat::Parquet => {
394                    let gens = parquet_sources(plan.generation_plan(), scale_factor);
395                    write_parquet(plan, num_threads, gens, progress).await?
396                }
397            };
398            Ok(num_threads)
399        }
400    };
401}
402
403define_run!(
404    run_lineitem_plan,
405    LineItemGenerator,
406    LineItemTblSource,
407    LineItemCsvSource,
408    LineItemArrow
409);
410
411define_run!(
412    run_nation_plan,
413    NationGenerator,
414    NationTblSource,
415    NationCsvSource,
416    NationArrow
417);
418
419define_run!(
420    run_region_plan,
421    RegionGenerator,
422    RegionTblSource,
423    RegionCsvSource,
424    RegionArrow
425);
426
427define_run!(
428    run_part_plan,
429    PartGenerator,
430    PartTblSource,
431    PartCsvSource,
432    PartArrow
433);
434
435define_run!(
436    run_supplier_plan,
437    SupplierGenerator,
438    SupplierTblSource,
439    SupplierCsvSource,
440    SupplierArrow
441);
442define_run!(
443    run_partsupp_plan,
444    PartSuppGenerator,
445    PartSuppTblSource,
446    PartSuppCsvSource,
447    PartSuppArrow
448);
449
450define_run!(
451    run_customer_plan,
452    CustomerGenerator,
453    CustomerTblSource,
454    CustomerCsvSource,
455    CustomerArrow
456);
457
458define_run!(
459    run_orders_plan,
460    OrderGenerator,
461    OrderTblSource,
462    OrderCsvSource,
463    OrderArrow
464);
465
466#[cfg(all(test, feature = "progress"))]
467mod tests {
468    use super::*;
469    use crate::progress::ProgressTracker;
470    use crate::{Compression, GenerationPlan, DEFAULT_PARQUET_ROW_GROUP_BYTES};
471    use std::sync::{
472        atomic::{AtomicU64, Ordering},
473        Arc,
474    };
475
476    #[derive(Debug)]
477    struct CountingProgress {
478        increments: AtomicU64,
479    }
480
481    impl ProgressTracker for CountingProgress {
482        fn increment(&self, _table: Table, units: u64) {
483            self.increments.fetch_add(units, Ordering::Relaxed);
484        }
485    }
486
487    #[test]
488    fn skip_existing_advances_progress_by_full_plan() {
489        let output_dir = tempfile::tempdir().unwrap();
490        let output_path = output_dir.path().join("lineitem.tbl");
491        std::fs::write(&output_path, b"already here").unwrap();
492
493        let generation_plan = GenerationPlan::try_new(
494            Table::Lineitem,
495            OutputFormat::Tbl,
496            1.0,
497            Some(1),
498            Some(4),
499            DEFAULT_PARQUET_ROW_GROUP_BYTES,
500        )
501        .unwrap();
502        let plan = OutputPlan::new(
503            Table::Lineitem,
504            1.0,
505            OutputFormat::Tbl,
506            Compression::SNAPPY,
507            OutputLocation::File(output_path.clone()),
508            generation_plan,
509            ',',
510        );
511        let expected_units = plan.chunk_count() as u64;
512        assert!(expected_units > 1);
513
514        let tracker = Arc::new(CountingProgress {
515            increments: AtomicU64::new(0),
516        });
517        let progress: Arc<dyn ProgressTracker> = tracker.clone();
518        let progress = RunProgress::with_tracker(progress);
519
520        assert!(maybe_skip_existing(&output_path, &plan, &progress));
521        assert_eq!(tracker.increments.load(Ordering::Relaxed), expected_units);
522    }
523}