Skip to main content

stt_optimize/
packed.rs

1//! Packed-tileset reading for post-build analysis (`stt-optimize inspect`/`diff`).
2//!
3//! Thin wrapper over [`stt_core::pack::PackedReader`] that also keeps the
4//! parsed [`Manifest`] around, so the profiler passes can see container facts
5//! the reader alone doesn't expose (pack table, directory layout). Both
6//! directory container shapes — single whole-load and paged — decode through
7//! the same stt-core path ([`PackedReader::open`] branches on
8//! `directory.layout`), so callers here never need to care which one a
9//! dataset ships.
10
11use anyhow::{Context, Result};
12use std::fs;
13use std::path::{Path, PathBuf};
14use stt_core::TileEntry;
15use stt_core::arrow_tile::DecodedLayer;
16use stt_core::metadata::Metadata;
17use stt_core::pack::{Manifest, PackedReader};
18use stt_core::types::TimeRange;
19
20/// A local packed STT dataset (`manifest.json` + `index/*.sttd` + `packs/*.sttp`)
21/// opened for analysis.
22///
23/// Directory entries are cheap (decoded once at open); payload reads go
24/// through the pack mmaps on demand, so directory-only passes never touch
25/// tile bytes.
26pub struct PackedTileset {
27    reader: PackedReader,
28    manifest: Manifest,
29    manifest_path: PathBuf,
30}
31
32impl PackedTileset {
33    /// Open a packed dataset. `path` may be either the dataset directory
34    /// (containing `manifest.json`) or the `manifest.json` file itself.
35    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
36        let path = path.as_ref();
37        let manifest_path = if path.is_dir() {
38            path.join("manifest.json")
39        } else {
40            path.to_path_buf()
41        };
42        let manifest = Manifest::from_json_bytes(
43            &fs::read(&manifest_path)
44                .with_context(|| format!("reading {}", manifest_path.display()))?,
45        )?;
46        let reader = PackedReader::open(&manifest_path)
47            .with_context(|| format!("opening packed dataset {}", manifest_path.display()))?;
48        Ok(Self {
49            reader,
50            manifest,
51            manifest_path,
52        })
53    }
54
55    /// Path of the `manifest.json` this tileset was opened from.
56    pub fn manifest_path(&self) -> &Path {
57        &self.manifest_path
58    }
59
60    /// The parsed packed manifest (pack table, directory pointer, metadata).
61    pub fn manifest(&self) -> &Manifest {
62        &self.manifest
63    }
64
65    /// Dataset metadata (the manifest's folded `Metadata`).
66    pub fn metadata(&self) -> &Metadata {
67        self.reader.metadata()
68    }
69
70    /// Dataset name from the metadata.
71    pub fn name(&self) -> &str {
72        &self.reader.metadata().name
73    }
74
75    /// `(min_zoom, max_zoom)` from the metadata.
76    pub fn zoom_range(&self) -> (u8, u8) {
77        let m = self.reader.metadata();
78        (m.min_zoom, m.max_zoom)
79    }
80
81    /// Time range (Unix ms) from the metadata.
82    pub fn time_range(&self) -> TimeRange {
83        self.reader.metadata().time_range
84    }
85
86    /// Total directory entry count (derived from the directory at write time).
87    pub fn tile_count(&self) -> u64 {
88        self.reader.metadata().tile_count
89    }
90
91    /// Total feature records summed across tiles (a feature landing in N
92    /// tiles counts N times — the index-weighted total).
93    pub fn feature_count(&self) -> u64 {
94        self.reader.metadata().feature_count
95    }
96
97    /// Base temporal bucket size in milliseconds.
98    pub fn temporal_bucket_ms(&self) -> u64 {
99        self.reader.metadata().temporal_bucket_ms
100    }
101
102    /// Number of pack objects in the manifest's pack table.
103    pub fn pack_count(&self) -> usize {
104        self.manifest.packs.len()
105    }
106
107    /// Whether the directory ships in the paged container (root + leaf pages)
108    /// rather than the single whole-load object. Reading is identical either
109    /// way; this only matters for reporting.
110    pub fn is_paged(&self) -> bool {
111        self.manifest.directory.is_paged()
112    }
113
114    /// All directory entries, sorted by (zoom, hilbert, time_start).
115    ///
116    /// Each [`TileEntry`] carries the tile address (`zoom`, `x`, `y`,
117    /// `time_start`/`time_end`, `temporal_bucket_ms`) and the blob location
118    /// (`pack_id`, `offset`, `length`, `crc32c`). Byte-identical tiles were
119    /// deduped at write time, so `(pack_id, offset)` identifies the physical
120    /// blob: entries sharing that pair share one blob.
121    pub fn entries(&self) -> &[TileEntry] {
122        self.reader.entries()
123    }
124
125    /// Read and decompress one tile's raw payload bytes (CRC-verified).
126    pub fn read_payload(&self, entry: &TileEntry) -> Result<Vec<u8>> {
127        Ok(self.reader.read_payload(entry)?)
128    }
129
130    /// Read one tile and decode it into its Arrow layers (each a named
131    /// [`RecordBatch`](arrow::array::RecordBatch)).
132    pub fn read_layers(&self, entry: &TileEntry) -> Result<Vec<DecodedLayer>> {
133        Ok(self.reader.read_layers(entry)?)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use stt_core::arrow_tile::{
141        encode_tile_with, ColumnarLayer, EncoderConfig, GeometryColumn,
142    };
143    use stt_core::curve::BlobOrdering;
144    use stt_core::pack::PackWriter;
145    use stt_core::tile::TileId;
146
147    /// Frames must match the writer's (default v2) manifest declaration —
148    /// mixed-version datasets are a reader hard error.
149    fn tiny_tile(seed: u64, w: &PackWriter) -> Vec<u8> {
150        let n = 4usize;
151        let cfg = EncoderConfig {
152            format_version: w.format_version(),
153            template_collector: Some(w.template_collector()),
154            ..EncoderConfig::default()
155        };
156        encode_tile_with(
157            &[ColumnarLayer {
158                name: "default".to_string(),
159                feature_ids: (0..n as u64).map(|i| seed * 100 + i).collect(),
160                start_times: vec![0; n],
161                end_times: vec![100; n],
162                geometry: GeometryColumn::Point(vec![[-73.6, 45.5]; n]),
163                vertex_times: None,
164                vertex_values: None,
165                triangles: None,
166                vertex_value_matrix: None,
167                properties: vec![],
168            }],
169            &cfg,
170        )
171        .unwrap()
172    }
173
174    /// `open` accepts both the dataset dir and the manifest path, and the
175    /// wrapper's accessors agree with the written dataset.
176    #[test]
177    fn open_by_dir_or_manifest_and_read_back() {
178        let dir = tempfile::tempdir().unwrap();
179        let out = dir.path().join("dataset");
180        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 64 * 1024).unwrap();
181        let payloads: Vec<Vec<u8>> = (0..3).map(|k| tiny_tile(k, &w)).collect();
182        for (k, p) in payloads.iter().enumerate() {
183            let id = TileId::new(7, k as u32, 0, 0);
184            w.add_tile_full(&id, 0, 3_599_999, Some(0), 4, Some(3_600_000), p)
185                .unwrap();
186        }
187        let meta =
188            stt_core::metadata::Metadata::new("packed-open").with_temporal_bucket_ms(3_600_000);
189        w.finalize(&meta).unwrap();
190
191        let by_dir = PackedTileset::open(&out).unwrap();
192        let by_manifest = PackedTileset::open(out.join("manifest.json")).unwrap();
193        for ts in [&by_dir, &by_manifest] {
194            assert_eq!(ts.name(), "packed-open");
195            assert_eq!(ts.tile_count(), 3);
196            assert_eq!(ts.feature_count(), 12);
197            assert_eq!(ts.temporal_bucket_ms(), 3_600_000);
198            assert_eq!(ts.entries().len(), 3);
199            assert!(!ts.is_paged());
200            assert!(ts.pack_count() >= 1);
201        }
202
203        // Payload + Arrow decode round-trip through the wrapper.
204        let e = &by_dir.entries()[0];
205        let payload = by_dir.read_payload(e).unwrap();
206        assert!(payloads.contains(&payload));
207        let layers = by_dir.read_layers(e).unwrap();
208        assert_eq!(layers.len(), 1);
209        assert_eq!(layers[0].name, "default");
210        assert_eq!(layers[0].batch.num_rows(), 4);
211    }
212
213    /// A paged-directory dataset opens and reads identically (the stt-core
214    /// reader subsumes both container layouts).
215    #[test]
216    fn paged_layout_reads_like_single() {
217        let dir = tempfile::tempdir().unwrap();
218        let out = dir.path().join("paged");
219        let mut w = PackWriter::create(&out, BlobOrdering::Auto, 64 * 1024)
220            .unwrap()
221            .with_paging(Some(2));
222        for k in 0..6u64 {
223            let p = tiny_tile(k, &w);
224            let id = TileId::new(9, k as u32, 0, 0);
225            w.add_tile_full(&id, 0, 100, None, 4, None, &p).unwrap();
226        }
227        w.finalize(&stt_core::metadata::Metadata::new("paged-open"))
228            .unwrap();
229
230        let ts = PackedTileset::open(&out).unwrap();
231        assert!(ts.is_paged());
232        assert_eq!(ts.entries().len(), 6);
233        for e in ts.entries() {
234            assert_eq!(ts.read_layers(e).unwrap()[0].batch.num_rows(), 4);
235        }
236    }
237}