1use crate::compression;
27use crate::error::{Error, Result};
28use crate::tile::TileId;
29use crate::types::Compression;
30use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
31use memmap2::Mmap;
32use std::fs::{File, OpenOptions};
33use std::io::{Read, Seek, SeekFrom, Write};
34use std::path::Path;
35
36const MAGIC: &[u8; 4] = b"STT\x04";
38
39pub const FORMAT_VERSION: u8 = 4;
41
42const HEADER_SIZE: u64 = 64;
44
45const DICT_MAX_SIZE: usize = 112 * 1024;
48const DICT_SAMPLE_MAX_TILES: usize = 8192;
50const DICT_SAMPLE_MAX_BYTES: usize = 32 * 1024 * 1024;
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct TileEntry {
56 pub zoom: u8,
58 pub x: u32,
60 pub y: u32,
62 pub time_start: i64,
64 pub time_end: i64,
66 pub pack_id: u32,
74 pub offset: u64,
79 pub length: u32,
81 pub uncompressed_size: u32,
83 pub feature_count: u32,
85 pub hilbert: u64,
87 pub crc32c: u32,
89 pub temporal_bucket_ms: Option<u64>,
95 pub cover_t_min: Option<i64>,
103}
104
105impl TileEntry {
106 pub fn tile_id(&self) -> TileId {
108 TileId::new(self.zoom, self.x, self.y, self.time_start.max(0) as u64)
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct ArchiveHeader {
115 pub version: u8,
117 pub compression: Compression,
119 pub index_offset: u64,
121 pub index_length: u64,
123 pub metadata_offset: u64,
125 pub metadata_length: u64,
127 pub dictionary_offset: u64,
129 pub dictionary_length: u64,
131}
132
133fn compression_to_byte(c: Compression) -> u8 {
134 match c {
135 Compression::None => 0,
136 Compression::Zstd => 2,
137 }
138}
139
140fn compression_from_byte(b: u8) -> Result<Compression> {
141 match b {
142 0 => Ok(Compression::None),
143 2 => Ok(Compression::Zstd),
145 other => Err(Error::InvalidArchive(format!(
146 "unknown compression code {other}"
147 ))),
148 }
149}
150
151impl ArchiveHeader {
152 pub fn read<R: Read>(reader: &mut R) -> Result<Self> {
154 let mut magic = [0u8; 4];
155 reader.read_exact(&mut magic)?;
156 if &magic != MAGIC {
157 return Err(Error::InvalidMagic);
158 }
159 let version = reader.read_u8()?;
160 if version != FORMAT_VERSION {
163 return Err(Error::UnsupportedVersion(version));
164 }
165 let compression = compression_from_byte(reader.read_u8()?)?;
166 let index_offset = reader.read_u64::<LittleEndian>()?;
167 let index_length = reader.read_u64::<LittleEndian>()?;
168 let metadata_offset = reader.read_u64::<LittleEndian>()?;
169 let metadata_length = reader.read_u64::<LittleEndian>()?;
170 let dictionary_offset = reader.read_u64::<LittleEndian>()?;
171 let dictionary_length = reader.read_u64::<LittleEndian>()?;
172 let mut reserved = [0u8; (HEADER_SIZE as usize) - 54];
174 reader.read_exact(&mut reserved)?;
175 Ok(Self {
176 version,
177 compression,
178 index_offset,
179 index_length,
180 metadata_offset,
181 metadata_length,
182 dictionary_offset,
183 dictionary_length,
184 })
185 }
186
187 pub fn write<W: Write>(&self, writer: &mut W) -> Result<()> {
189 writer.write_all(MAGIC)?;
190 writer.write_u8(FORMAT_VERSION)?;
191 writer.write_u8(compression_to_byte(self.compression))?;
192 writer.write_u64::<LittleEndian>(self.index_offset)?;
193 writer.write_u64::<LittleEndian>(self.index_length)?;
194 writer.write_u64::<LittleEndian>(self.metadata_offset)?;
195 writer.write_u64::<LittleEndian>(self.metadata_length)?;
196 writer.write_u64::<LittleEndian>(self.dictionary_offset)?;
197 writer.write_u64::<LittleEndian>(self.dictionary_length)?;
198 writer.write_all(&[0u8; (HEADER_SIZE as usize) - 54])?;
199 Ok(())
200 }
201}
202
203fn crc32c_tag(bytes: &[u8]) -> u32 {
205 crc32c::crc32c(bytes)
206}
207
208#[derive(Debug, Default)]
219struct TemporalLookup {
220 boundaries: Vec<i64>,
221 bucket_offsets: Vec<u32>,
222 bucket_refs: Vec<u32>,
223}
224
225impl TemporalLookup {
226 fn build(entries: &[TileEntry]) -> Self {
236 if entries.is_empty() {
237 return Self::default();
238 }
239
240 const START: u8 = 0;
245 const END: u8 = 1;
246 let mut events: Vec<(i64, u8, u32)> = Vec::with_capacity(entries.len() * 2);
247 for (i, e) in entries.iter().enumerate() {
248 events.push((e.time_start, START, i as u32));
249 events.push((e.time_end.saturating_add(1), END, i as u32));
250 }
251 events.sort_unstable_by_key(|&(t, kind, _)| (t, kind));
252
253 let mut boundaries: Vec<i64> = Vec::new();
254 let mut bucket_offsets: Vec<u32> = Vec::new();
255 let mut bucket_refs: Vec<u32> = Vec::new();
256 let mut active: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
257
258 let mut i = 0;
259 let mut prev_bucket_start: usize = 0;
260 while i < events.len() {
261 let t = events[i].0;
262 while i < events.len() && events[i].0 == t {
263 let (_, kind, idx) = events[i];
264 if kind == START {
265 active.insert(idx);
266 } else {
267 active.remove(&idx);
268 }
269 i += 1;
270 }
271
272 let prev_bucket_end = bucket_refs.len();
275 let prev_active_len = prev_bucket_end - prev_bucket_start;
276 if active.len() == prev_active_len {
277 let mut matches = true;
278 let prev_slice = &bucket_refs[prev_bucket_start..prev_bucket_end];
279 for (a, b) in active.iter().zip(prev_slice.iter()) {
280 if a != b {
281 matches = false;
282 break;
283 }
284 }
285 if matches {
286 continue;
287 }
288 }
289 boundaries.push(t);
290 bucket_offsets.push(prev_bucket_end as u32);
291 prev_bucket_start = prev_bucket_end;
292 bucket_refs.extend(active.iter().copied());
293 }
294 bucket_offsets.push(bucket_refs.len() as u32);
296
297 Self {
298 boundaries,
299 bucket_offsets,
300 bucket_refs,
301 }
302 }
303
304 fn at(&self, t: i64) -> &[u32] {
306 if self.boundaries.is_empty() {
307 return &[];
308 }
309 let bucket = self.boundaries.partition_point(|&b| b <= t);
310 if bucket == 0 {
311 return &[];
312 }
313 let bucket = bucket - 1;
314 let start = self.bucket_offsets[bucket] as usize;
315 let end = self.bucket_offsets[bucket + 1] as usize;
316 &self.bucket_refs[start..end]
317 }
318}
319
320pub struct ArchiveReader {
330 #[allow(dead_code)]
332 file: File,
333 mmap: Mmap,
335 header: ArchiveHeader,
336 entries: Vec<TileEntry>,
337 metadata: crate::metadata::Metadata,
338 temporal: TemporalLookup,
339 dictionary: Option<Vec<u8>>,
341}
342
343impl ArchiveReader {
344 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
346 let file = File::open(path)?;
347 let mmap = unsafe { Mmap::map(&file) }
350 .map_err(|e| Error::Other(format!("mmap failed: {e}")))?;
351
352 let mut header_cursor = std::io::Cursor::new(&mmap[..HEADER_SIZE as usize]);
353 let header = ArchiveHeader::read(&mut header_cursor)?;
354
355 let index_end = (header.index_offset + header.index_length) as usize;
356 if index_end > mmap.len() {
357 return Err(Error::InvalidArchive(format!(
358 "directory range {}..{} exceeds archive size {}",
359 header.index_offset,
360 index_end,
361 mmap.len()
362 )));
363 }
364 let entries =
365 crate::directory::decode_directory(&mmap[header.index_offset as usize..index_end])?;
366
367 let metadata_end = (header.metadata_offset + header.metadata_length) as usize;
368 if metadata_end > mmap.len() {
369 return Err(Error::InvalidArchive(format!(
370 "metadata range {}..{} exceeds archive size {}",
371 header.metadata_offset,
372 metadata_end,
373 mmap.len()
374 )));
375 }
376 let metadata = crate::metadata::Metadata::from_json_bytes(
377 &mmap[header.metadata_offset as usize..metadata_end],
378 )?;
379
380 let dictionary = if header.dictionary_length > 0 {
381 let dict_end = (header.dictionary_offset + header.dictionary_length) as usize;
382 if dict_end > mmap.len() {
383 return Err(Error::InvalidArchive(format!(
384 "dictionary range {}..{} exceeds archive size {}",
385 header.dictionary_offset,
386 dict_end,
387 mmap.len()
388 )));
389 }
390 Some(mmap[header.dictionary_offset as usize..dict_end].to_vec())
391 } else {
392 None
393 };
394
395 let temporal = TemporalLookup::build(&entries);
396
397 Ok(Self {
398 file,
399 mmap,
400 header,
401 entries,
402 metadata,
403 temporal,
404 dictionary,
405 })
406 }
407
408 pub fn metadata(&self) -> &crate::metadata::Metadata {
410 &self.metadata
411 }
412
413 pub fn header(&self) -> &ArchiveHeader {
415 &self.header
416 }
417
418 pub fn dictionary(&self) -> Option<&[u8]> {
420 self.dictionary.as_deref()
421 }
422
423 pub fn entries(&self) -> &[TileEntry] {
425 &self.entries
426 }
427
428 pub fn tiles_at_time(&self, t: i64) -> Vec<&TileEntry> {
430 self.temporal
431 .at(t)
432 .iter()
433 .filter_map(|&idx| self.entries.get(idx as usize))
434 .filter(|e| e.time_start <= t && t <= e.time_end)
437 .collect()
438 }
439
440 pub fn find_tile(&self, zoom: u8, x: u32, y: u32, t: i64) -> Option<&TileEntry> {
442 self.tiles_at_time(t)
443 .into_iter()
444 .find(|e| e.zoom == zoom && e.x == x && e.y == y)
445 }
446
447 pub fn read_payload(&self, entry: &TileEntry) -> Result<Vec<u8>> {
449 let start = entry.offset as usize;
450 let end = start + entry.length as usize;
451 if end > self.mmap.len() {
452 return Err(Error::InvalidArchive(format!(
453 "tile {:?} blob range {start}..{end} exceeds archive size {}",
454 entry.tile_id(),
455 self.mmap.len()
456 )));
457 }
458 let compressed = &self.mmap[start..end];
459
460 if crc32c_tag(compressed) != entry.crc32c {
461 return Err(Error::InvalidArchive(format!(
462 "tile {:?} failed integrity check (corrupt archive)",
463 entry.tile_id()
464 )));
465 }
466
467 let payload = if self.header.compression == Compression::Zstd {
468 compression::decompress_zstd_with_dict(compressed, self.dictionary.as_deref())?
469 } else {
470 compression::decompress(compressed, self.header.compression)?
471 };
472 if payload.len() != entry.uncompressed_size as usize {
473 return Err(Error::InvalidArchive(format!(
474 "tile {:?} decompressed to {} bytes, expected {}",
475 entry.tile_id(),
476 payload.len(),
477 entry.uncompressed_size
478 )));
479 }
480 Ok(payload)
481 }
482
483 pub fn read_layers(&self, entry: &TileEntry) -> Result<Vec<crate::arrow_tile::DecodedLayer>> {
485 let payload = self.read_payload(entry)?;
486 crate::arrow_tile::decode_tile(&payload)
487 }
488}
489
490struct PendingTile {
496 z: u8,
497 x: u32,
498 y: u32,
499 hilbert: u64,
500 time_start: i64,
501 time_end: i64,
502 cover_t_min: Option<i64>,
503 feature_count: u32,
504 temporal_bucket_ms: Option<u64>,
505 payload: Vec<u8>,
506}
507
508pub struct ArchiveWriter {
518 file: File,
519 compression: Compression,
520 current_offset: u64,
521 entries: Vec<TileEntry>,
522 pending: Option<Vec<PendingTile>>,
524 ordering: crate::curve::BlobOrdering,
527 train_dict: bool,
532}
533
534impl ArchiveWriter {
535 pub fn create<P: AsRef<Path>>(path: P, compression: Compression) -> Result<Self> {
537 Self::open_file(path, compression, None, crate::curve::BlobOrdering::default(), false)
538 }
539
540 pub fn create_optimized<P: AsRef<Path>>(path: P) -> Result<Self> {
548 Self::create_optimized_with_ordering(path, crate::curve::BlobOrdering::SpatialMajor)
549 }
550
551 pub fn create_optimized_with_ordering<P: AsRef<Path>>(
560 path: P,
561 ordering: crate::curve::BlobOrdering,
562 ) -> Result<Self> {
563 Self::open_file(path, Compression::Zstd, Some(Vec::new()), ordering, true)
564 }
565
566 pub fn create_reordered<P: AsRef<Path>>(
573 path: P,
574 ordering: crate::curve::BlobOrdering,
575 ) -> Result<Self> {
576 Self::open_file(path, Compression::Zstd, Some(Vec::new()), ordering, false)
577 }
578
579 fn open_file<P: AsRef<Path>>(
580 path: P,
581 compression: Compression,
582 pending: Option<Vec<PendingTile>>,
583 ordering: crate::curve::BlobOrdering,
584 train_dict: bool,
585 ) -> Result<Self> {
586 let mut file = OpenOptions::new()
587 .write(true)
588 .read(true)
589 .create(true)
590 .truncate(true)
591 .open(path)?;
592 file.seek(SeekFrom::Start(HEADER_SIZE))?;
593 Ok(Self {
594 file,
595 compression,
596 current_offset: HEADER_SIZE,
597 entries: Vec::new(),
598 pending,
599 ordering,
600 train_dict,
601 })
602 }
603
604 pub fn add_tile(
607 &mut self,
608 id: &TileId,
609 time_start: i64,
610 time_end: i64,
611 feature_count: u32,
612 payload: &[u8],
613 ) -> Result<()> {
614 self.add_tile_with_bucket(id, time_start, time_end, feature_count, None, payload)
615 }
616
617 pub fn add_tile_with_bucket(
620 &mut self,
621 id: &TileId,
622 time_start: i64,
623 time_end: i64,
624 feature_count: u32,
625 temporal_bucket_ms: Option<u64>,
626 payload: &[u8],
627 ) -> Result<()> {
628 self.add_tile_full(id, time_start, time_end, None, feature_count, temporal_bucket_ms, payload)
629 }
630
631 #[allow(clippy::too_many_arguments)]
636 pub fn add_tile_full(
637 &mut self,
638 id: &TileId,
639 time_start: i64,
640 time_end: i64,
641 cover_t_min: Option<i64>,
642 feature_count: u32,
643 temporal_bucket_ms: Option<u64>,
644 payload: &[u8],
645 ) -> Result<()> {
646 if let Some(pending) = self.pending.as_mut() {
647 pending.push(PendingTile {
648 z: id.z,
649 x: id.x,
650 y: id.y,
651 hilbert: id.hilbert_index(),
652 time_start,
653 time_end,
654 cover_t_min,
655 feature_count,
656 temporal_bucket_ms,
657 payload: payload.to_vec(),
658 });
659 return Ok(());
660 }
661
662 let uncompressed_size = payload.len() as u32;
663 let compressed = compression::compress(payload, self.compression)?;
664 let crc = crc32c_tag(&compressed);
665 let offset = self.current_offset;
666 let length = compressed.len() as u32;
667 self.file.write_all(&compressed)?;
668 self.current_offset += length as u64;
669
670 self.entries.push(TileEntry {
671 zoom: id.z,
672 x: id.x,
673 y: id.y,
674 time_start,
675 time_end,
676 pack_id: 0,
678 offset,
679 length,
680 uncompressed_size,
681 feature_count,
682 hilbert: id.hilbert_index(),
683 crc32c: crc,
684 temporal_bucket_ms,
685 cover_t_min,
686 });
687 Ok(())
688 }
689
690 pub fn tile_count(&self) -> usize {
692 match &self.pending {
693 Some(p) => p.len(),
694 None => self.entries.len(),
695 }
696 }
697
698 pub fn finalize(self, metadata: &crate::metadata::Metadata) -> Result<()> {
700 if self.pending.is_some() {
701 self.finalize_buffered(metadata)
702 } else {
703 self.finalize_eager(metadata)
704 }
705 }
706
707 fn finalize_eager(mut self, metadata: &crate::metadata::Metadata) -> Result<()> {
710 self.entries
711 .sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
712 self.write_tail(metadata, 0, 0)
713 }
714
715 fn finalize_buffered(mut self, metadata: &crate::metadata::Metadata) -> Result<()> {
723 let mut pending = self.pending.take().unwrap_or_default();
724
725 let base_bucket = metadata.temporal_bucket_ms.max(1) as i64;
732 let tb = |p: &PendingTile| {
733 let b = p.temporal_bucket_ms.map(|v| v as i64).unwrap_or(base_bucket).max(1);
734 p.time_start.div_euclid(b)
735 };
736 let (tb_min, tb_max) = pending.iter().fold((i64::MAX, i64::MIN), |(lo, hi), p| {
737 let t = tb(p);
738 (lo.min(t), hi.max(t))
739 });
740 let tb_span = if pending.is_empty() { 0 } else { tb_max - tb_min };
741 let ordering = match self.ordering {
745 crate::curve::BlobOrdering::Auto => {
746 let max_z = pending.iter().map(|p| p.z).max().unwrap_or(0) as u32;
747 let time_bits = crate::curve::bits_for((tb_span.max(0) + 1) as u64);
748 crate::curve::BlobOrdering::choose(max_z, time_bits)
749 }
750 other => other,
751 };
752 pending.sort_by_key(|p| {
756 (
757 crate::curve::space_time_key(
758 ordering, p.z, p.x, p.y, p.hilbert, p.time_start, tb(p), tb_min, tb_span,
759 ),
760 p.z,
761 p.x,
762 p.y,
763 p.time_start,
764 p.temporal_bucket_ms,
765 )
766 });
767
768 let dictionary = if self.train_dict {
776 let mut sample: Vec<&[u8]> = Vec::new();
777 let mut sample_bytes = 0usize;
778 for p in &pending {
779 if sample.len() >= DICT_SAMPLE_MAX_TILES || sample_bytes >= DICT_SAMPLE_MAX_BYTES {
780 break;
781 }
782 sample_bytes += p.payload.len();
783 sample.push(p.payload.as_slice());
784 }
785 compression::train_zstd_dictionary_from_slices(&sample, DICT_MAX_SIZE)
786 } else {
787 None
788 };
789
790 self.file.seek(SeekFrom::Start(HEADER_SIZE))?;
795 self.current_offset = HEADER_SIZE;
796 let mut blob_dedup: std::collections::HashMap<[u8; 32], (u64, u32, u32, u32)> =
797 std::collections::HashMap::new();
798 for p in &pending {
799 let compressed =
800 compression::compress_zstd_with_dict(&p.payload, dictionary.as_deref())?;
801 let uncompressed_size = p.payload.len() as u32;
802 let key = *blake3::hash(&compressed).as_bytes();
805 let (offset, length, crc) = if let Some(&(off, len, _unc, crc)) = blob_dedup.get(&key) {
806 (off, len, crc)
807 } else {
808 let offset = self.current_offset;
809 let length = compressed.len() as u32;
810 let crc = crc32c_tag(&compressed);
811 self.file.write_all(&compressed)?;
812 self.current_offset += length as u64;
813 blob_dedup.insert(key, (offset, length, uncompressed_size, crc));
814 (offset, length, crc)
815 };
816 self.entries.push(TileEntry {
817 zoom: p.z,
818 x: p.x,
819 y: p.y,
820 time_start: p.time_start,
821 time_end: p.time_end,
822 pack_id: 0,
824 offset,
825 length,
826 uncompressed_size,
827 feature_count: p.feature_count,
828 hilbert: p.hilbert,
829 crc32c: crc,
830 temporal_bucket_ms: p.temporal_bucket_ms,
831 cover_t_min: p.cover_t_min,
832 });
833 }
834
835 let (dictionary_offset, dictionary_length) = if let Some(dict) = &dictionary {
838 let off = self.current_offset;
839 self.file.write_all(dict)?;
840 self.current_offset += dict.len() as u64;
841 (off, dict.len() as u64)
842 } else {
843 (0, 0)
844 };
845
846 self.entries
849 .sort_by_key(|e| (e.zoom, e.hilbert, e.time_start, e.temporal_bucket_ms));
850 self.write_tail(metadata, dictionary_offset, dictionary_length)
851 }
852
853 fn write_tail(
856 mut self,
857 metadata: &crate::metadata::Metadata,
858 dictionary_offset: u64,
859 dictionary_length: u64,
860 ) -> Result<()> {
861 let index_bytes = crate::directory::encode_directory(&self.entries);
862 let index_offset = self.current_offset;
863 let index_length = index_bytes.len() as u64;
864 self.file.write_all(&index_bytes)?;
865 self.current_offset += index_length;
866
867 let metadata_bytes = metadata.to_json_bytes()?;
868 let metadata_offset = self.current_offset;
869 let metadata_length = metadata_bytes.len() as u64;
870 self.file.write_all(&metadata_bytes)?;
871 self.current_offset += metadata_length;
872
873 self.file.flush()?;
874 self.file.set_len(self.current_offset)?;
875
876 let header = ArchiveHeader {
877 version: FORMAT_VERSION,
878 compression: self.compression,
879 index_offset,
880 index_length,
881 metadata_offset,
882 metadata_length,
883 dictionary_offset,
884 dictionary_length,
885 };
886 self.file.seek(SeekFrom::Start(0))?;
887 header.write(&mut self.file)?;
888 self.file.flush()?;
889 Ok(())
890 }
891}
892
893pub struct Archive;
895
896impl Archive {
897 pub fn open<P: AsRef<Path>>(path: P) -> Result<ArchiveReader> {
899 ArchiveReader::open(path)
900 }
901
902 pub fn create<P: AsRef<Path>>(path: P, compression: Compression) -> Result<ArchiveWriter> {
904 ArchiveWriter::create(path, compression)
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911 use crate::arrow_tile::{ColumnarLayer, GeometryColumn};
912 use tempfile::NamedTempFile;
913
914 fn point_layer(name: &str, ids: Vec<u64>, t0: i64) -> ColumnarLayer {
915 let n = ids.len();
916 ColumnarLayer {
917 name: name.to_string(),
918 feature_ids: ids,
919 start_times: vec![t0; n],
920 end_times: vec![t0 + 100; n],
921 geometry: GeometryColumn::Point(vec![[-122.4, 37.7]; n]),
922 vertex_times: None,
923 vertex_values: None,
924 triangles: None,
925 vertex_value_matrix: None,
926 properties: vec![],
927 }
928 }
929
930 #[test]
931 fn compression_byte_set_is_frozen() {
932 assert_eq!(compression_to_byte(Compression::None), 0);
937 assert_eq!(compression_to_byte(Compression::Zstd), 2);
938
939 assert_eq!(compression_from_byte(0).unwrap(), Compression::None);
940 assert_eq!(compression_from_byte(2).unwrap(), Compression::Zstd);
941
942 assert!(compression_from_byte(1).is_err(), "byte 1 (gzip) must be rejected");
944 assert!(compression_from_byte(3).is_err());
945 assert!(compression_from_byte(255).is_err());
946
947 for c in [Compression::None, Compression::Zstd] {
949 assert_eq!(compression_from_byte(compression_to_byte(c)).unwrap(), c);
950 }
951 }
952
953 #[test]
954 fn header_roundtrips() {
955 let header = ArchiveHeader {
956 version: FORMAT_VERSION,
957 compression: Compression::Zstd,
958 index_offset: 1234,
959 index_length: 56,
960 metadata_offset: 1290,
961 metadata_length: 78,
962 dictionary_offset: 1100,
963 dictionary_length: 32,
964 };
965 let mut buf = Vec::new();
966 header.write(&mut buf).unwrap();
967 assert_eq!(buf.len(), HEADER_SIZE as usize);
968 assert_eq!(&buf[..4], MAGIC);
969 let read = ArchiveHeader::read(&mut std::io::Cursor::new(buf)).unwrap();
970 assert_eq!(read.version, FORMAT_VERSION);
971 assert_eq!(read.index_offset, 1234);
972 assert_eq!(read.dictionary_offset, 1100);
973 assert_eq!(read.dictionary_length, 32);
974 assert_eq!(read.compression, Compression::Zstd);
975 }
976
977 #[test]
978 fn rejects_foreign_magic() {
979 let mut buf = vec![b'X', b'Y', b'Z', 9];
980 buf.resize(HEADER_SIZE as usize, 0);
981 assert!(ArchiveHeader::read(&mut std::io::Cursor::new(buf)).is_err());
982 }
983
984 #[test]
985 fn eager_archive_roundtrips_tiles_and_temporal_lookup() {
986 let path = NamedTempFile::new().unwrap().into_temp_path();
987
988 let mut writer = ArchiveWriter::create(&path, Compression::Zstd).unwrap();
989 let tile_a = crate::arrow_tile::encode_tile(&[point_layer("default", vec![1], 1000)]).unwrap();
990 let tile_b = crate::arrow_tile::encode_tile(&[point_layer("default", vec![2, 3], 1500)]).unwrap();
991 let tile_c = crate::arrow_tile::encode_tile(&[point_layer("default", vec![4], 5000)]).unwrap();
992 writer.add_tile(&TileId::new(10, 1, 1, 1000), 1000, 2000, 1, &tile_a).unwrap();
993 writer.add_tile(&TileId::new(10, 2, 2, 1500), 1500, 3000, 2, &tile_b).unwrap();
994 writer.add_tile(&TileId::new(11, 4, 4, 5000), 5000, 6000, 1, &tile_c).unwrap();
995 writer.finalize(&crate::metadata::Metadata::new("test-archive")).unwrap();
996
997 let reader = ArchiveReader::open(&path).unwrap();
998 assert_eq!(reader.header().version, FORMAT_VERSION);
999 assert_eq!(reader.entries().len(), 3);
1000 assert_eq!(reader.metadata().name, "test-archive");
1001
1002 assert_eq!(reader.tiles_at_time(1800).len(), 2);
1003 let at = reader.tiles_at_time(2500);
1004 assert_eq!(at.len(), 1);
1005 assert_eq!(at[0].x, 2);
1006 assert!(reader.tiles_at_time(4000).is_empty());
1007
1008 let entry = reader.find_tile(10, 2, 2, 2000).unwrap().clone();
1009 let layers = reader.read_layers(&entry).unwrap();
1010 assert_eq!(layers.len(), 1);
1011 assert_eq!(layers[0].name, "default");
1012 assert_eq!(layers[0].batch.num_rows(), 2);
1013 }
1014
1015 #[test]
1016 fn corrupt_blob_is_detected() {
1017 let path = NamedTempFile::new().unwrap().into_temp_path();
1018 let mut writer = ArchiveWriter::create(&path, Compression::None).unwrap();
1019 let payload = crate::arrow_tile::encode_tile(&[point_layer("default", vec![1], 1000)]).unwrap();
1020 writer.add_tile(&TileId::new(5, 0, 0, 1000), 1000, 2000, 1, &payload).unwrap();
1021 writer.finalize(&crate::metadata::Metadata::new("corrupt")).unwrap();
1022
1023 let mut bytes = std::fs::read(&path).unwrap();
1024 bytes[HEADER_SIZE as usize] ^= 0xFF;
1025 std::fs::write(&path, &bytes).unwrap();
1026
1027 let reader = ArchiveReader::open(&path).unwrap();
1028 let entry = reader.entries()[0].clone();
1029 assert!(reader.read_payload(&entry).is_err());
1030 }
1031
1032 #[test]
1035 fn temporal_lookup_build_is_fast_and_correct() {
1036 let bucket = 3_600_000i64; let mut entries = Vec::with_capacity(50_000);
1038 for cell in 0..200u32 {
1039 for b in 0..250u32 {
1040 let t_start = (b as i64) * bucket;
1041 entries.push(TileEntry {
1042 zoom: 10,
1043 x: cell,
1044 y: 0,
1045 time_start: t_start,
1046 time_end: t_start + bucket - 1,
1047 pack_id: 0,
1048 offset: 0,
1049 length: 0,
1050 uncompressed_size: 0,
1051 feature_count: 0,
1052 hilbert: 0,
1053 crc32c: 0,
1054 temporal_bucket_ms: None,
1055 cover_t_min: None,
1056 });
1057 }
1058 }
1059
1060 let started = std::time::Instant::now();
1061 let lookup = TemporalLookup::build(&entries);
1062 let elapsed_ms = started.elapsed().as_millis();
1063 assert!(elapsed_ms < 1000, "TemporalLookup::build took {elapsed_ms}ms for 50k entries");
1064
1065 let query_t = bucket * 123 + bucket / 2;
1066 let got: std::collections::BTreeSet<u32> = lookup.at(query_t).iter().copied().collect();
1067 let expected: std::collections::BTreeSet<u32> = entries
1068 .iter()
1069 .enumerate()
1070 .filter(|(_, e)| e.time_start <= query_t && query_t <= e.time_end)
1071 .map(|(i, _)| i as u32)
1072 .collect();
1073 assert_eq!(got, expected);
1074 assert!(lookup.at(-1).is_empty());
1075 assert!(lookup.at(bucket * 1_000_000).is_empty());
1076 }
1077
1078 #[test]
1081 fn optimized_archive_dedups_and_roundtrips() {
1082 let path = NamedTempFile::new().unwrap().into_temp_path();
1083 let mut writer = ArchiveWriter::create_optimized(&path).unwrap();
1084
1085 let static_payload =
1086 crate::arrow_tile::encode_tile(&[point_layer("default", vec![1], 0)]).unwrap();
1087 for b in 0..5i64 {
1088 let t = b * 3_600_000;
1089 writer
1090 .add_tile(&TileId::new(9, 3, 4, t as u64), t, t + 3_599_999, 1, &static_payload)
1091 .unwrap();
1092 }
1093 let other =
1094 crate::arrow_tile::encode_tile(&[point_layer("default", vec![2, 3], 1000)]).unwrap();
1095 writer.add_tile(&TileId::new(9, 5, 6, 0), 0, 1000, 2, &other).unwrap();
1096 writer.finalize(&crate::metadata::Metadata::new("opt")).unwrap();
1097
1098 let reader = ArchiveReader::open(&path).unwrap();
1099 assert_eq!(reader.header().version, FORMAT_VERSION);
1100 assert_eq!(reader.entries().len(), 6);
1101
1102 let static_offsets: std::collections::BTreeSet<u64> = reader
1103 .entries()
1104 .iter()
1105 .filter(|e| e.x == 3 && e.y == 4)
1106 .map(|e| e.offset)
1107 .collect();
1108 assert_eq!(static_offsets.len(), 1, "static-across-time tiles should dedup to one blob");
1109
1110 let mid = reader.find_tile(9, 3, 4, 3 * 3_600_000).unwrap().clone();
1111 assert_eq!(reader.read_layers(&mid).unwrap()[0].batch.num_rows(), 1);
1112 let distinct = reader.entries().iter().find(|e| e.x == 5).unwrap().clone();
1113 assert_eq!(reader.read_layers(&distinct).unwrap()[0].batch.num_rows(), 2);
1114 }
1115
1116 #[test]
1121 fn reordered_archive_has_no_dict_and_roundtrips() {
1122 use crate::curve::{space_time_key, BlobOrdering};
1123 let path = NamedTempFile::new().unwrap().into_temp_path();
1124 let mut writer = ArchiveWriter::create_reordered(&path, BlobOrdering::Hilbert3).unwrap();
1125
1126 let mut expected = 0usize;
1129 for x in 0..4u32 {
1130 for y in 0..4u32 {
1131 for b in 0..3i64 {
1132 let t = b * 3_600_000;
1133 let p = crate::arrow_tile::encode_tile(&[point_layer(
1134 "default",
1135 vec![(x * 16 + y) as u64 + b as u64 * 1000],
1136 1000,
1137 )])
1138 .unwrap();
1139 writer
1140 .add_tile(&TileId::new(10, x, y, t as u64), t, t + 3_599_999, 1, &p)
1141 .unwrap();
1142 expected += 1;
1143 }
1144 }
1145 }
1146 writer
1147 .finalize(&crate::metadata::Metadata::new("reordered"))
1148 .unwrap();
1149
1150 let reader = ArchiveReader::open(&path).unwrap();
1151 assert!(reader.dictionary().is_none(), "reorder path must not ship a dict");
1153 assert_eq!(reader.entries().len(), expected);
1154 for e in reader.entries().to_vec() {
1156 assert_eq!(reader.read_layers(&e).unwrap()[0].batch.num_rows(), 1);
1157 }
1158 let mut ents = reader.entries().to_vec();
1161 ents.sort_by_key(|e| e.offset);
1162 let key = |e: &TileEntry| {
1163 space_time_key(
1164 BlobOrdering::Hilbert3,
1165 e.zoom,
1166 e.x,
1167 e.y,
1168 e.hilbert,
1169 e.time_start,
1170 e.time_start.div_euclid(3_600_000),
1171 0,
1172 2,
1173 )
1174 };
1175 for w in ents.windows(2) {
1176 assert!(key(&w[0]) <= key(&w[1]), "blobs must be in Hilbert order on disk");
1177 }
1178 }
1179
1180 #[test]
1183 fn optimized_dictionary_shrinks_and_roundtrips() {
1184 use crate::arrow_tile::{ColumnarLayer, GeometryColumn, PropertyColumn};
1185
1186 let kinds = ["bike", "car", "bus", "scooter"];
1187 let make_payload = |seed: u64| -> Vec<u8> {
1188 let f = 8usize;
1189 let mut feature_ids = Vec::with_capacity(f);
1190 let mut start_times = Vec::with_capacity(f);
1191 let mut end_times = Vec::with_capacity(f);
1192 let mut paths: Vec<Vec<[f64; 2]>> = Vec::with_capacity(f);
1193 let mut vtimes: Vec<Vec<i64>> = Vec::with_capacity(f);
1194 let mut kind: Vec<Option<String>> = Vec::with_capacity(f);
1195 for i in 0..f {
1196 feature_ids.push(seed * 100 + i as u64);
1197 let t0 = i as i64 * 1000;
1198 start_times.push(t0);
1199 end_times.push(t0 + 1000);
1200 paths.push(
1201 (0..16)
1202 .map(|j| {
1203 [
1204 -122.4 + i as f64 * 0.001 + j as f64 * 0.0001,
1205 37.7 + seed as f64 * 0.0001 + j as f64 * 0.0001,
1206 ]
1207 })
1208 .collect(),
1209 );
1210 vtimes.push((0..16).map(|j| t0 + j * 60).collect());
1211 kind.push(Some(kinds[(seed as usize + i) % kinds.len()].to_string()));
1212 }
1213 crate::arrow_tile::encode_tile(&[ColumnarLayer {
1214 name: "trips".to_string(),
1215 feature_ids,
1216 start_times,
1217 end_times,
1218 geometry: GeometryColumn::LineString(paths),
1219 vertex_times: Some(vtimes),
1220 vertex_values: None,
1221 triangles: None,
1222 vertex_value_matrix: None,
1223 properties: vec![("kind".to_string(), PropertyColumn::Categorical(kind))],
1224 }])
1225 .unwrap()
1226 };
1227
1228 let dir = tempfile::tempdir().unwrap();
1229 let opt_path = dir.path().join("opt.stt");
1230 let plain_path = dir.path().join("plain.stt");
1231
1232 let mut w = ArchiveWriter::create_optimized(&opt_path).unwrap();
1233 for s in 0..512u64 {
1234 w.add_tile(&TileId::new(10, (s % 32) as u32, (s / 32) as u32, 0), 0, 8000, 8, &make_payload(s))
1235 .unwrap();
1236 }
1237 w.finalize(&crate::metadata::Metadata::new("opt")).unwrap();
1238
1239 let mut w2 = ArchiveWriter::create(&plain_path, Compression::Zstd).unwrap();
1240 for s in 0..512u64 {
1241 w2.add_tile(&TileId::new(10, (s % 32) as u32, (s / 32) as u32, 0), 0, 8000, 8, &make_payload(s))
1242 .unwrap();
1243 }
1244 w2.finalize(&crate::metadata::Metadata::new("plain")).unwrap();
1245
1246 let opt_size = std::fs::metadata(&opt_path).unwrap().len();
1247 let plain_size = std::fs::metadata(&plain_path).unwrap().len();
1248 eprintln!("optimized: {opt_size} bytes, eager-zstd: {plain_size} bytes");
1249
1250 let reader = ArchiveReader::open(&opt_path).unwrap();
1251 assert!(reader.dictionary().is_some(), "expected a trained dictionary");
1252 let entry = reader.entries()[0].clone();
1253 assert_eq!(reader.read_layers(&entry).unwrap()[0].batch.num_rows(), 8);
1254 assert!(opt_size < plain_size, "dictionary should shrink: opt={opt_size}, plain={plain_size}");
1255 }
1256}