Skip to main content

reddb_server/storage/unified/store/
impl_file.rs

1use super::*;
2
3impl UnifiedStore {
4    pub fn new() -> Self {
5        Self::with_config(UnifiedStoreConfig::default())
6    }
7
8    /// Get the current storage format version
9    pub fn format_version(&self) -> u32 {
10        self.format_version.load(Ordering::SeqCst)
11    }
12
13    pub(crate) fn set_format_version(&self, version: u32) {
14        self.format_version.store(version, Ordering::SeqCst);
15    }
16
17    /// Allocate a global entity ID
18    pub fn next_entity_id(&self) -> EntityId {
19        EntityId::new(self.next_entity_id.fetch_add(1, Ordering::SeqCst))
20    }
21
22    /// Reserve `n` contiguous global entity IDs with one fetch_add.
23    /// Caller assigns `id = EntityId::new(start + i)` per entity.
24    pub fn reserve_entity_ids(&self, n: u64) -> std::ops::Range<u64> {
25        let start = self.next_entity_id.fetch_add(n, Ordering::SeqCst);
26        start..start + n
27    }
28
29    pub(crate) fn register_entity_id(&self, id: EntityId) {
30        let candidate = id.raw().saturating_add(1);
31        let mut current = self.next_entity_id.load(Ordering::SeqCst);
32        while candidate > current {
33            match self.next_entity_id.compare_exchange(
34                current,
35                candidate,
36                Ordering::SeqCst,
37                Ordering::SeqCst,
38            ) {
39                Ok(_) => break,
40                Err(updated) => current = updated,
41            }
42        }
43    }
44
45    /// Load a persisted image and rebuild its derived indexes, retaining the
46    /// stored sequence instead of assigning one from snapshot iteration order.
47    fn restore_snapshot_entity(
48        &self,
49        collection: &str,
50        mut entity: UnifiedEntity,
51    ) -> Result<EntityId, StoreError> {
52        let manager = self.get_or_create_collection(collection);
53        if entity.id.raw() == 0 {
54            entity.id = self.next_entity_id();
55        } else {
56            self.register_entity_id(entity.id);
57        }
58        if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
59            if *row_id == 0 {
60                *row_id = manager.next_row_id();
61            } else {
62                manager.register_row_id(*row_id);
63            }
64        }
65        entity.ensure_table_logical_id();
66        self.context_index.index_entity(collection, &entity);
67        if self.config.auto_index_refs {
68            self.index_cross_refs(&entity, collection)?;
69        }
70        let graph_label = match &entity.kind {
71            EntityKind::GraphNode(node) => Some(node.label.clone()),
72            _ => None,
73        };
74        let id = manager.restore_snapshot_entity(entity)?;
75        if let Some(label) = graph_label {
76            self.update_graph_label_index(collection, &label, id);
77        }
78        Ok(id)
79    }
80
81    /// Load store from binary file
82    ///
83    /// Binary dump framing is defined by `reddb_file::native_store`.
84    ///
85    /// Store payload shape:
86    /// ```text
87    /// [collection_count: varu32]
88    /// [collections...]
89    /// [cross_ref_count: varu32]
90    /// [cross_refs...]
91    /// ```
92    pub fn load_from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
93        let file = File::open(path)?;
94        let mut reader = BufReader::new(file);
95        let mut buf = Vec::new();
96        reader.read_to_end(&mut buf)?;
97        Self::load_from_bytes(&buf)
98    }
99
100    pub(crate) fn load_from_bytes(buf: &[u8]) -> Result<Self, Box<dyn std::error::Error>> {
101        Self::load_from_bytes_with_config(buf, UnifiedStoreConfig::default())
102    }
103
104    pub(crate) fn load_from_bytes_with_config(
105        buf: &[u8],
106        config: UnifiedStoreConfig,
107    ) -> Result<Self, Box<dyn std::error::Error>> {
108        let mut buf = buf.to_vec();
109        let version =
110            reddb_file::decode_native_store_header(&buf).map_err(|err| err.to_string())?;
111        let mut pos = 8;
112
113        // V3+ has CRC32 footer — verify integrity before parsing
114        reddb_file::verify_native_store_crc32_footer(&mut buf, version)
115            .map_err(|err| err.to_string())?;
116
117        let mut store = Self::with_config(config);
118        // Reconstruct the snapshot without appending its old images to the
119        // live WAL. The caller replays that WAL after loading this snapshot.
120        let embedded_wal_path = store.config.embedded_wal_path.take();
121        store.set_format_version(version);
122
123        // Read collection count
124        let collection_count =
125            reddb_file::decode_native_dump_count(&buf, &mut pos).map_err(|e| e.to_string())?;
126
127        // Read each collection
128        for _ in 0..collection_count {
129            let collection_header =
130                reddb_file::decode_native_dump_collection_header(&buf, &mut pos)
131                    .map_err(|e| e.to_string())?;
132
133            // The registry is persisted even when the last entity was removed.
134            // Restore it independently of the entity loop so empty queues and
135            // other collections survive reopening without emitting new WAL.
136            store.get_or_create_collection(&collection_header.name);
137
138            // Read each entity — V7+ includes metadata alongside entity data.
139            for _ in 0..collection_header.entity_count {
140                if version >= STORE_VERSION_V7 {
141                    // Length-prefixed entity+metadata record (serialize_entity_record format)
142                    let record_bytes = reddb_file::decode_native_dump_entity_record(&buf, &mut pos)
143                        .map_err(|e| e.to_string())?;
144
145                    let (entity, metadata) = Self::deserialize_entity_record(record_bytes, version)
146                        .map_err(|e| format!("Entity record deserialization error: {e}"))?;
147
148                    store.restore_snapshot_entity(&collection_header.name, entity.clone())?;
149
150                    if let Some(metadata) = metadata {
151                        if let Some(manager) = store.get_collection(&collection_header.name) {
152                            let _ = manager.set_metadata(entity.id, metadata);
153                        }
154                    }
155                } else {
156                    // V1–V6: entity only, no metadata
157                    let entity = Self::read_entity_binary(&buf, &mut pos, version)?;
158                    store.restore_snapshot_entity(&collection_header.name, entity)?;
159                }
160            }
161        }
162
163        if pos < buf.len() {
164            // Read cross-references section
165            let cross_ref_count =
166                reddb_file::decode_native_dump_count(&buf, &mut pos).map_err(|e| e.to_string())?;
167
168            for _ in 0..cross_ref_count {
169                let cross_ref = reddb_file::decode_native_dump_cross_ref(&buf, &mut pos)
170                    .map_err(|e| e.to_string())?;
171                let ref_type = RefType::from_byte(cross_ref.ref_type);
172
173                let source_collection = store
174                    .get_any(EntityId::new(cross_ref.source_id))
175                    .map(|(name, _)| name)
176                    .unwrap_or_else(|| cross_ref.target_collection.clone());
177                let _ = store.add_cross_ref(
178                    &source_collection,
179                    EntityId::new(cross_ref.source_id),
180                    &cross_ref.target_collection,
181                    EntityId::new(cross_ref.target_id),
182                    ref_type,
183                    1.0,
184                );
185            }
186        }
187
188        // V10+: a trailing opaque auxiliary-metadata blob follows the
189        // cross-references. RedDB stores the collection contracts here so the
190        // single-file artifact carries each collection's declared model across
191        // a restart. Older dumps end at the cross-refs and have no blob.
192        if version >= reddb_file::STORE_VERSION_V10 && pos < buf.len() {
193            let aux = reddb_file::decode_native_dump_entity_record(&buf, &mut pos)
194                .map_err(|e| e.to_string())?;
195            if !aux.is_empty() {
196                store.set_aux_metadata(aux.to_vec());
197            }
198        }
199
200        if store.format_version() < STORE_VERSION_CURRENT {
201            store.set_format_version(STORE_VERSION_CURRENT);
202        }
203        store.config.embedded_wal_path = embedded_wal_path;
204
205        Ok(store)
206    }
207
208    /// Save store to binary file
209    ///
210    /// Uses compact binary encoding with varint for efficient storage.
211    /// No JSON - pure binary with pages and indices.
212    pub fn save_to_file(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
213        let buf = self.to_binary_dump_bytes();
214        reddb_file::write_native_store_bytes_atomically(path, &buf)?;
215        Ok(())
216    }
217
218    pub(crate) fn to_binary_dump_bytes(&self) -> Vec<u8> {
219        let _vault_guard = self.vault_publication_lock.lock();
220        let pending_vault_versions = self.pending_vault_versions.read();
221        let (_, excluded) = self.snapshot_manager.get().map_or_else(
222            || (0, Default::default()),
223            |manager| manager.checkpoint_excluded_state(),
224        );
225        let mut buf = Vec::new();
226
227        // Version 9 includes explicit table-row logical identity plus MVCC
228        // xmin/xmax alongside the V7 metadata envelope.
229        buf.extend_from_slice(&reddb_file::encode_native_store_header(
230            STORE_VERSION_CURRENT,
231        ));
232
233        // Get all collections
234        let collections = self.collections.read();
235        reddb_file::encode_native_dump_count(&mut buf, collections.len() as u32);
236
237        let fv = STORE_VERSION_CURRENT;
238        for (name, manager) in collections.iter() {
239            // Get all entities from this collection
240            let entities = manager.query_all(|entity| {
241                if pending_vault_versions.contains(&entity.id) {
242                    return false;
243                }
244                !matches!(entity.data, EntityData::Row(_) | EntityData::Vector(_))
245                    || !excluded.contains(&entity.xmin)
246            });
247            reddb_file::encode_native_dump_collection_header(&mut buf, name, entities.len() as u32);
248
249            // V7+: serialize entity+metadata as a length-prefixed record.
250            // Each record: [u32 len][serialize_entity_record bytes]
251            for mut entity in entities {
252                // Unfinished and rolled-back deletions cannot become committed
253                // tombstones when recovery reconstructs the xid floor.
254                if matches!(entity.data, EntityData::Row(_)) && excluded.contains(&entity.xmax) {
255                    entity.xmax = 0;
256                }
257                let metadata = manager.get_metadata(entity.id);
258                let record = Self::serialize_entity_record(&entity, metadata.as_ref(), fv);
259                reddb_file::encode_native_dump_entity_record(&mut buf, &record);
260            }
261        }
262
263        // Write cross-references
264        let cross_refs = self.cross_refs.read();
265        let total_refs: usize = cross_refs.values().map(|v| v.len()).sum();
266        reddb_file::encode_native_dump_count(&mut buf, total_refs as u32);
267
268        for (source_id, refs) in cross_refs.iter() {
269            for (target_id, ref_type, collection) in refs {
270                reddb_file::encode_native_dump_cross_ref(
271                    &mut buf,
272                    source_id.raw(),
273                    target_id.raw(),
274                    ref_type.to_byte(),
275                    collection,
276                );
277            }
278        }
279
280        // V10+: trailing opaque auxiliary-metadata blob (collection contracts
281        // for the single-file artifact). Always written — empty when unused —
282        // so the read side can unconditionally expect it for V10 dumps.
283        {
284            let aux = self.aux_metadata.read();
285            reddb_file::encode_native_dump_entity_record(&mut buf, &aux);
286        }
287
288        self.set_format_version(STORE_VERSION_CURRENT);
289
290        reddb_file::append_native_store_crc32_footer(&mut buf);
291        buf
292    }
293
294    /// Read entity using the canonical native/browser logical codec.
295    pub(crate) fn read_entity_binary(
296        buf: &[u8],
297        pos: &mut usize,
298        format_version: u32,
299    ) -> Result<UnifiedEntity, Box<dyn std::error::Error>> {
300        Ok(reddb_engine::storage::entity_codec::read_entity_binary(
301            buf,
302            pos,
303            format_version,
304        )?)
305    }
306
307    /// Write entity using the canonical native/browser logical codec.
308    pub(crate) fn write_entity_binary(
309        buf: &mut Vec<u8>,
310        entity: &UnifiedEntity,
311        format_version: u32,
312    ) {
313        reddb_engine::storage::entity_codec::write_entity_binary(buf, entity, format_version);
314    }
315}
316
317#[cfg(test)]
318mod aux_metadata_dump_tests {
319    use super::*;
320    use std::collections::HashMap;
321
322    #[test]
323    fn snapshot_load_preserves_existing_wal_and_restores_future_appends() {
324        let directory = tempfile::tempdir().expect("directory");
325        let path = directory.path().join("snapshot.rdb");
326        let store = UnifiedStore::with_config(UnifiedStoreConfig::default());
327        let old =
328            UnifiedEntity::table_row(EntityId::new(1), "rows", 1, vec![Value::text("snapshot")]);
329        store.insert_auto("rows", old).expect("snapshot row");
330        let snapshot = store.to_binary_dump_bytes();
331        crate::storage::EmbeddedRdbArtifact::create_with_snapshot(&path, &snapshot)
332            .expect("artifact");
333        let newer = UnifiedEntity::table_row(
334            EntityId::new(1),
335            "rows",
336            1,
337            vec![Value::text("durable WAL")],
338        );
339        let action = StoreWalAction::upsert_entity("rows", &newer, None, STORE_VERSION_CURRENT);
340        UnifiedStore::with_config(UnifiedStoreConfig::default().with_embedded_wal_path(&path))
341            .finish_paged_write([action])
342            .expect("durable update after snapshot");
343        let before = std::fs::read(&path).expect("original artifact bytes");
344        let loaded = UnifiedStore::load_from_bytes_with_config(
345            &snapshot,
346            UnifiedStoreConfig::default().with_embedded_wal_path(&path),
347        )
348        .expect("reconstruct snapshot");
349        assert_eq!(std::fs::read(&path).expect("artifact after load"), before);
350        assert_eq!(
351            loaded.config().embedded_wal_path.as_deref(),
352            Some(path.as_path())
353        );
354
355        let generation = crate::storage::EmbeddedRdbArtifact::open(&path)
356            .expect("boundary before future write")
357            .selected_superblock
358            .generation;
359        loaded
360            .insert_auto(
361                "rows",
362                UnifiedEntity::table_row(
363                    EntityId::new(2),
364                    "rows",
365                    2,
366                    vec![Value::text("future write")],
367                ),
368            )
369            .expect("future writes still append WAL");
370        assert_eq!(
371            crate::storage::EmbeddedRdbArtifact::open(&path)
372                .expect("boundary after future write")
373                .selected_superblock
374                .generation,
375            generation + 1
376        );
377    }
378
379    #[test]
380    fn aux_metadata_round_trips_through_binary_dump() {
381        let store = UnifiedStore::with_config(UnifiedStoreConfig::default());
382        store.create_collection("k").expect("create collection");
383        store.set_aux_metadata(b"contract-blob".to_vec());
384
385        let bytes = store.to_binary_dump_bytes();
386        let reloaded = UnifiedStore::load_from_bytes(&bytes).expect("reload dump");
387
388        assert_eq!(reloaded.aux_metadata(), b"contract-blob".to_vec());
389    }
390
391    #[test]
392    fn empty_aux_metadata_round_trips_as_empty() {
393        let store = UnifiedStore::with_config(UnifiedStoreConfig::default());
394        let bytes = store.to_binary_dump_bytes();
395        let reloaded = UnifiedStore::load_from_bytes(&bytes).expect("reload dump");
396
397        assert!(reloaded.aux_metadata().is_empty());
398    }
399
400    #[test]
401    fn interned_timeseries_series_reduces_repeated_tag_storage() {
402        let tags = HashMap::from([
403            (
404                "host".to_string(),
405                "srv-very-long-hostname-0001".to_string(),
406            ),
407            ("region".to_string(), "us-east-1".to_string()),
408            ("service".to_string(), "checkout-api".to_string()),
409        ]);
410        let inline_bytes: usize = (0..100)
411            .map(|timestamp_ns| {
412                let entity = UnifiedEntity::new(
413                    EntityId::new(0),
414                    EntityKind::TimeSeriesPoint(Box::new(TimeSeriesPointKind {
415                        series: "cpu_metrics".to_string(),
416                        metric: "cpu.idle".to_string(),
417                    })),
418                    EntityData::TimeSeries(crate::storage::TimeSeriesData {
419                        metric: "cpu.idle".to_string(),
420                        series_id: None,
421                        timestamp_ns,
422                        value: 94.8,
423                        tags: tags.clone(),
424                        fields: HashMap::new(),
425                    }),
426                );
427                UnifiedStore::serialize_entity_record(&entity, None, reddb_file::STORE_VERSION_V10)
428                    .len()
429            })
430            .sum();
431
432        let mut dictionary_fields = HashMap::new();
433        dictionary_fields.insert("collection".to_string(), Value::text("cpu_metrics"));
434        dictionary_fields.insert("series_id".to_string(), Value::UnsignedInteger(0));
435        dictionary_fields.insert("metric".to_string(), Value::text("cpu.idle"));
436        dictionary_fields.insert(
437            "canonical_tags".to_string(),
438            Value::text(
439                r#"{"host":"srv-very-long-hostname-0001","region":"us-east-1","service":"checkout-api"}"#,
440            ),
441        );
442        dictionary_fields.insert(
443            "tags".to_string(),
444            Value::Json(
445                br#"{"host":"srv-very-long-hostname-0001","region":"us-east-1","service":"checkout-api"}"#
446                    .to_vec(),
447            ),
448        );
449        let dictionary = UnifiedEntity::new(
450            EntityId::new(0),
451            EntityKind::TableRow {
452                table: Arc::from("red_timeseries_series"),
453                row_id: 0,
454            },
455            EntityData::Row(RowData {
456                columns: Vec::new(),
457                named: Some(dictionary_fields),
458                schema: None,
459            }),
460        );
461        let dictionary_bytes =
462            UnifiedStore::serialize_entity_record(&dictionary, None, STORE_VERSION_V11).len();
463        let interned_bytes = dictionary_bytes
464            + (0..100)
465                .map(|timestamp_ns| {
466                    let entity = UnifiedEntity::new(
467                        EntityId::new(0),
468                        EntityKind::TimeSeriesPoint(Box::new(TimeSeriesPointKind {
469                            series: "cpu_metrics".to_string(),
470                            metric: "cpu.idle".to_string(),
471                        })),
472                        EntityData::TimeSeries(crate::storage::TimeSeriesData {
473                            metric: "cpu.idle".to_string(),
474                            series_id: Some(0),
475                            timestamp_ns,
476                            value: 94.8,
477                            tags: HashMap::new(),
478                            fields: HashMap::new(),
479                        }),
480                    );
481                    UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11).len()
482                })
483                .sum::<usize>();
484
485        assert!(
486            interned_bytes < inline_bytes,
487            "interned series storage should shrink repeated tags: inline={inline_bytes}, interned={interned_bytes}"
488        );
489    }
490}