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}