Skip to main content

oxigeo_pmtiles/
parallel_encode.rs

1//! Parallel tile compression pipeline for PMTiles v3 archives.
2//!
3//! This module provides a data-parallel encoding path that compresses raw
4//! (uncompressed) tile payloads using [`rayon`] before handing the compressed
5//! bytes to a [`PmTilesBuilder`].
6//!
7//! # Feature gate
8//! This entire module is gated behind the `parallel` feature, which implies
9//! `compression`.  All public functions also carry an explicit
10//! `#[cfg(feature = "compression")]` guard so that the API surface remains
11//! consistent even if the feature graph changes in future.
12//!
13//! # Design
14//! The pipeline operates in three distinct phases:
15//!
16//! 1. **Parallel compression** — [`compress_tiles_parallel`] fans out each
17//!    [`RawTile`] to rayon's global thread pool (or an explicitly-sized pool
18//!    configured via [`ParallelEncodeConfig::threads`]).  Each tile is
19//!    compressed independently; results are collected as
20//!    `Vec<(tile_id, compressed_bytes)>`.
21//!
22//! 2. **Sort by tile ID** — The compressed results are sorted by `tile_id` so
23//!    that [`PmTilesBuilder`] can deduplicate and run-length encode entries
24//!    efficiently (the builder itself also sorts, but pre-sorting here avoids
25//!    O(n log n) work inside the builder).
26//!
27//! 3. **Archive assembly** — [`build_pmtiles_parallel`] feeds the sorted,
28//!    compressed tiles into a caller-provided [`PmTilesBuilder`] and calls
29//!    [`PmTilesBuilder::build`] to produce the final byte buffer together with
30//!    a [`ParallelBuildStats`] summary.
31//!
32//! # Thread pool customisation
33//! Setting [`ParallelEncodeConfig::threads`] to `Some(n)` installs a local
34//! rayon `ThreadPool` that is scoped to the compression phase only; the global
35//! pool is not affected.  This is useful for benchmarks, tests that need
36//! determinism, and environments where rayon's default thread count is
37//! inappropriate (e.g. cloud Lambda functions with limited vCPUs).
38
39#![cfg(feature = "parallel")]
40
41use rayon::prelude::*;
42
43use crate::error::PmTilesError;
44use crate::header::Compression;
45use crate::writer::PmTilesBuilder;
46
47// ---------------------------------------------------------------------------
48// Public data types
49// ---------------------------------------------------------------------------
50
51/// A single raw (uncompressed) tile payload together with its PMTiles tile ID.
52///
53/// Create one of these for every tile you want to include in the archive, then
54/// pass a slice of them to [`compress_tiles_parallel`] or
55/// [`build_pmtiles_parallel`].
56///
57/// # Tile IDs
58/// Tile IDs must be valid Hilbert-curve-encoded values as produced by
59/// [`crate::hilbert::zxy_to_tile_id`].  Invalid or duplicate tile IDs will
60/// propagate through to [`PmTilesBuilder::add_tile_by_id`], which will
61/// deduplicate them silently via FNV-1a content hashing.
62#[derive(Debug, Clone)]
63pub struct RawTile {
64    /// PMTiles Hilbert-curve tile ID (as produced by [`crate::hilbert::zxy_to_tile_id`]).
65    pub tile_id: u64,
66    /// Uncompressed tile payload bytes.
67    pub raw_data: Vec<u8>,
68}
69
70/// Configuration for the parallel compression pipeline.
71///
72/// All fields have sensible defaults via [`Default`]; only override what you
73/// need.
74#[derive(Debug, Clone)]
75pub struct ParallelEncodeConfig {
76    /// Number of tiles per rayon work chunk when using
77    /// [`compress_tiles_parallel`].
78    ///
79    /// Larger chunks reduce scheduling overhead but may cause load imbalance
80    /// when tile sizes vary greatly.  The default of 64 is a practical
81    /// compromise for typical web-map tile archives.
82    ///
83    /// Default: `64`.
84    pub chunk_size: usize,
85
86    /// Override the number of threads in the rayon pool used for compression.
87    ///
88    /// When `Some(n)`, a local [`rayon::ThreadPool`] with exactly `n` threads
89    /// is created and used only for the compression phase; the global pool is
90    /// not modified.  When `None`, the global pool (whose size is determined by
91    /// rayon's heuristics, typically the number of logical CPUs) is used.
92    ///
93    /// Default: `None` (use global pool).
94    pub threads: Option<usize>,
95}
96
97impl Default for ParallelEncodeConfig {
98    fn default() -> Self {
99        Self {
100            chunk_size: 64,
101            threads: None,
102        }
103    }
104}
105
106/// Statistics produced by [`build_pmtiles_parallel`].
107#[derive(Debug, Clone)]
108pub struct ParallelBuildStats {
109    /// Total number of tiles that were encoded (compressed and inserted).
110    pub tiles_encoded: usize,
111    /// Number of rayon work chunks that were dispatched.
112    pub chunks_processed: usize,
113    /// Sum of all input (uncompressed) tile payload sizes in bytes.
114    pub total_bytes_raw: u64,
115    /// Sum of all output (compressed) tile payload sizes in bytes.
116    pub total_bytes_compressed: u64,
117    /// `total_bytes_compressed / total_bytes_raw`.
118    ///
119    /// Returns `1.0` when `total_bytes_raw == 0` to avoid division by zero.
120    pub compression_ratio: f64,
121}
122
123// ---------------------------------------------------------------------------
124// Low-level single-tile compression
125// ---------------------------------------------------------------------------
126
127/// Compress a single tile payload using the given [`Compression`] codec.
128///
129/// This is the fundamental building block of the parallel pipeline.  It is
130/// exposed publicly so that callers can benchmark individual codecs or
131/// pre-compress tiles outside of the parallel pipeline.
132///
133/// # Behaviour by codec
134/// * [`Compression::None`] / [`Compression::Unknown`] — the input bytes are
135///   returned as a new `Vec<u8>` without any transformation (pass-through).
136/// * [`Compression::Gzip`] — RFC 1952 gzip at level 6 via
137///   [`oxiarc_archive::gzip::compress`].
138/// * [`Compression::Brotli`] — Brotli at quality 6 via
139///   [`oxiarc_archive::brotli::compress_with_quality`].
140/// * [`Compression::Zstd`] — Zstandard at the codec default level via
141///   [`oxiarc_archive::zstd::compress`].
142///
143/// # Errors
144/// Returns [`PmTilesError::Decompression`] (reused for compression errors
145/// throughout the crate) when the underlying OxiARC codec reports a failure.
146#[cfg(feature = "compression")]
147pub fn compress_tile(data: &[u8], compression: Compression) -> Result<Vec<u8>, PmTilesError> {
148    match compression {
149        Compression::None | Compression::Unknown => Ok(data.to_vec()),
150        Compression::Gzip => oxiarc_archive::gzip::compress(data, 6)
151            .map_err(|e| PmTilesError::Decompression(format!("Gzip compression failed: {e}"))),
152        Compression::Brotli => oxiarc_archive::brotli::compress_with_quality(data, 6)
153            .map_err(|e| PmTilesError::Decompression(format!("Brotli compression failed: {e}"))),
154        Compression::Zstd => oxiarc_archive::zstd::compress(data)
155            .map_err(|e| PmTilesError::Decompression(format!("Zstd compression failed: {e}"))),
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Parallel compression of a tile slice
161// ---------------------------------------------------------------------------
162
163/// Compress all tiles in parallel using rayon, preserving tile IDs.
164///
165/// Fans out compression work across rayon's thread pool (or a local pool when
166/// [`ParallelEncodeConfig::threads`] is `Some`).  Tiles within each chunk are
167/// processed sequentially; chunks themselves run in parallel.
168///
169/// # Return value
170/// Returns a `Vec<(tile_id, compressed_bytes)>` whose order is **not
171/// guaranteed** to match the input order (rayon may reorder chunks).  Call
172/// `.sort_by_key(|(id, _)| *id)` before inserting into a
173/// [`PmTilesBuilder`] if ordering is required.
174///
175/// # Empty input
176/// An empty `raw_tiles` slice returns an empty `Vec` without spawning any
177/// tasks.
178///
179/// # Errors
180/// Returns the first [`PmTilesError`] encountered by any compression task.
181/// Remaining tasks may or may not have completed at the point of the error
182/// (rayon's `collect::<Result<…>>()` semantics).
183#[cfg(feature = "compression")]
184pub fn compress_tiles_parallel(
185    raw_tiles: &[RawTile],
186    compression: Compression,
187    config: &ParallelEncodeConfig,
188) -> Result<Vec<(u64, Vec<u8>)>, PmTilesError> {
189    if raw_tiles.is_empty() {
190        return Ok(Vec::new());
191    }
192
193    // Helper closure that performs the actual per-tile compression.
194    // `compression` is cloned once per tile since the enum is not `Copy`.
195    let compress_one = |tile: &RawTile| -> Result<(u64, Vec<u8>), PmTilesError> {
196        let compressed = compress_tile(&tile.raw_data, compression.clone())?;
197        Ok((tile.tile_id, compressed))
198    };
199
200    let chunk_size = config.chunk_size.max(1);
201
202    // When an explicit thread count is requested, spin up a scoped local pool
203    // so we do not permanently alter the global rayon configuration.
204    if let Some(n_threads) = config.threads {
205        let pool = rayon::ThreadPoolBuilder::new()
206            .num_threads(n_threads)
207            .build()
208            .map_err(|e| {
209                PmTilesError::InvalidFormat(format!(
210                    "Failed to create rayon thread pool with {n_threads} threads: {e}"
211                ))
212            })?;
213
214        pool.install(|| {
215            raw_tiles
216                .par_chunks(chunk_size)
217                .map(|chunk| {
218                    chunk
219                        .iter()
220                        .map(compress_one)
221                        .collect::<Result<Vec<_>, PmTilesError>>()
222                })
223                .collect::<Result<Vec<Vec<_>>, PmTilesError>>()
224                .map(|nested| nested.into_iter().flatten().collect())
225        })
226    } else {
227        raw_tiles
228            .par_chunks(chunk_size)
229            .map(|chunk| {
230                chunk
231                    .iter()
232                    .map(compress_one)
233                    .collect::<Result<Vec<_>, PmTilesError>>()
234            })
235            .collect::<Result<Vec<Vec<_>>, PmTilesError>>()
236            .map(|nested| nested.into_iter().flatten().collect())
237    }
238}
239
240// ---------------------------------------------------------------------------
241// Full parallel build pipeline
242// ---------------------------------------------------------------------------
243
244/// Compress tiles in parallel, then assemble a complete PMTiles v3 archive.
245///
246/// This is the primary high-level API of this module.  It combines all three
247/// pipeline phases described in the [module documentation](self):
248///
249/// 1. Record raw byte counts for statistics.
250/// 2. Fan out compression via [`compress_tiles_parallel`].
251/// 3. Sort compressed results by tile ID (ascending).
252/// 4. Configure `builder` with the chosen [`Compression`] for the tile header
253///    byte (the pre-compressed bytes are inserted as-is; the builder does NOT
254///    re-compress them).
255/// 5. Insert each tile via [`PmTilesBuilder::add_tile_by_id`].
256/// 6. Finalise the archive with [`PmTilesBuilder::build`].
257/// 7. Compute and return [`ParallelBuildStats`].
258///
259/// # Panics
260/// Does not panic.  All error paths surface as [`PmTilesError`].
261///
262/// # Errors
263/// * [`PmTilesError::Decompression`] — a codec failed during compression.
264/// * [`PmTilesError::InvalidFormat`] — rayon thread pool creation failed
265///   (only when [`ParallelEncodeConfig::threads`] is `Some`), or a tile ID
266///   caused a directory encoding error inside the builder.
267/// * Any error returned by [`PmTilesBuilder::build`].
268#[cfg(feature = "compression")]
269pub fn build_pmtiles_parallel(
270    raw_tiles: Vec<RawTile>,
271    compression: Compression,
272    mut builder: PmTilesBuilder,
273    config: &ParallelEncodeConfig,
274) -> Result<(Vec<u8>, ParallelBuildStats), PmTilesError> {
275    // Phase 1: Measure raw byte totals before we move `raw_tiles`.
276    let tiles_encoded = raw_tiles.len();
277    let total_bytes_raw: u64 = raw_tiles
278        .iter()
279        .map(|t| t.raw_data.len() as u64)
280        .fold(0u64, u64::saturating_add);
281
282    let chunks_processed = if tiles_encoded == 0 {
283        0
284    } else {
285        let chunk_size = config.chunk_size.max(1);
286        tiles_encoded.div_ceil(chunk_size)
287    };
288
289    // Phase 2: Compress all tiles in parallel.
290    let mut compressed_pairs = compress_tiles_parallel(&raw_tiles, compression.clone(), config)?;
291
292    // Phase 3: Sort by tile_id so the builder receives a clustered layout.
293    // This ensures deterministic archive layout and optimal run-length
294    // encoding even if rayon reordered chunks.
295    compressed_pairs.sort_unstable_by_key(|(id, _)| *id);
296
297    // Phase 4: Collect compressed byte totals and record compression on builder.
298    let total_bytes_compressed: u64 = compressed_pairs
299        .iter()
300        .map(|(_, bytes)| bytes.len() as u64)
301        .fold(0u64, u64::saturating_add);
302
303    // Tell the builder which compression codec was applied to the tile payloads
304    // so the header byte is written correctly.  The builder will not attempt to
305    // re-compress the bytes; it stores them verbatim.
306    builder.set_tile_compression(compression);
307
308    // Phase 5: Feed compressed tiles into the builder.
309    for (tile_id, compressed_bytes) in compressed_pairs {
310        builder.add_tile_by_id(tile_id, &compressed_bytes)?;
311    }
312
313    // Phase 6: Assemble the archive.
314    let archive = builder.build()?;
315
316    // Phase 7: Compute derived stats.
317    let compression_ratio = if total_bytes_raw == 0 {
318        1.0
319    } else {
320        total_bytes_compressed as f64 / total_bytes_raw as f64
321    };
322
323    let stats = ParallelBuildStats {
324        tiles_encoded,
325        chunks_processed,
326        total_bytes_raw,
327        total_bytes_compressed,
328        compression_ratio,
329    };
330
331    Ok((archive, stats))
332}