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        self.resident.next_entity_id()
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        self.resident.reserve_entity_ids(n)
26    }
27
28    pub(crate) fn register_entity_id(&self, id: EntityId) {
29        self.resident.register_entity_id(id)
30    }
31
32    /// Load store from binary file
33    ///
34    /// Binary dump framing is defined by `reddb_file::native_store`.
35    ///
36    /// Store payload shape:
37    /// ```text
38    /// [collection_count: varu32]
39    /// [collections...]
40    /// [cross_ref_count: varu32]
41    /// [cross_refs...]
42    /// ```
43    pub fn load_from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
44        let file = File::open(path)?;
45        let mut reader = BufReader::new(file);
46        let mut buf = Vec::new();
47        reader.read_to_end(&mut buf)?;
48        Self::load_from_bytes(&buf)
49    }
50
51    pub(crate) fn load_from_bytes(buf: &[u8]) -> Result<Self, Box<dyn std::error::Error>> {
52        Self::load_from_bytes_with_config(buf, UnifiedStoreConfig::default())
53    }
54
55    pub(crate) fn load_from_bytes_with_config(
56        buf: &[u8],
57        config: UnifiedStoreConfig,
58    ) -> Result<Self, Box<dyn std::error::Error>> {
59        let (resident, version) =
60            reddb_engine::storage::resident_store::ResidentStore::from_snapshot(
61                buf,
62                &config.manager_config,
63                config.auto_index_refs,
64                config.max_cross_refs,
65            )?;
66        let mut store = Self::with_config(config);
67        store.resident = resident;
68        store.note_result_cache_snapshot_version(version);
69        store.set_format_version(version.max(STORE_VERSION_CURRENT));
70        store.load_result_cache_epoch()?;
71
72        Ok(store)
73    }
74
75    /// Save store to binary file
76    ///
77    /// Uses compact binary encoding with varint for efficient storage.
78    /// No JSON - pure binary with pages and indices.
79    pub fn save_to_file(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
80        let buf = self.to_binary_dump_bytes();
81        reddb_file::write_native_store_bytes_atomically(path, &buf)?;
82        Ok(())
83    }
84
85    pub(crate) fn to_binary_dump_bytes(&self) -> Vec<u8> {
86        let _vault_guard = self.vault_publication_lock.lock();
87        let pending_vault_versions = self.pending_vault_versions.read();
88        let (_, excluded) = self.snapshot_manager.get().map_or_else(
89            || (0, Default::default()),
90            |manager| manager.checkpoint_excluded_state(),
91        );
92        self.resident
93            .snapshot_bytes(&pending_vault_versions, &excluded, || {
94                self.set_format_version(STORE_VERSION_CURRENT);
95            })
96    }
97
98    /// Read entity using the canonical native/browser logical codec.
99    pub(crate) fn read_entity_binary(
100        buf: &[u8],
101        pos: &mut usize,
102        format_version: u32,
103    ) -> Result<UnifiedEntity, Box<dyn std::error::Error>> {
104        Ok(reddb_engine::storage::entity_codec::read_entity_binary(
105            buf,
106            pos,
107            format_version,
108        )?)
109    }
110
111    /// Write entity using the canonical native/browser logical codec.
112    pub(crate) fn write_entity_binary(
113        buf: &mut Vec<u8>,
114        entity: &UnifiedEntity,
115        format_version: u32,
116    ) {
117        reddb_engine::storage::entity_codec::write_entity_binary(buf, entity, format_version);
118    }
119}
120
121#[cfg(test)]
122mod aux_metadata_dump_tests {
123    use super::*;
124    use std::collections::HashMap;
125
126    #[test]
127    fn snapshot_load_preserves_existing_wal_and_restores_future_appends() {
128        let directory = tempfile::tempdir().expect("directory");
129        let path = directory.path().join("snapshot.rdb");
130        let store = UnifiedStore::with_config(UnifiedStoreConfig::default());
131        let old =
132            UnifiedEntity::table_row(EntityId::new(1), "rows", 1, vec![Value::text("snapshot")]);
133        store.insert_auto("rows", old).expect("snapshot row");
134        let snapshot = store.to_binary_dump_bytes();
135        crate::storage::EmbeddedRdbArtifact::create_with_snapshot(&path, &snapshot)
136            .expect("artifact");
137        let newer = UnifiedEntity::table_row(
138            EntityId::new(1),
139            "rows",
140            1,
141            vec![Value::text("durable WAL")],
142        );
143        let action = StoreWalAction::upsert_entity("rows", &newer, None, STORE_VERSION_CURRENT);
144        UnifiedStore::with_config(UnifiedStoreConfig::default().with_embedded_wal_path(&path))
145            .finish_paged_write([action])
146            .expect("durable update after snapshot");
147        let before = std::fs::read(&path).expect("original artifact bytes");
148        let loaded = UnifiedStore::load_from_bytes_with_config(
149            &snapshot,
150            UnifiedStoreConfig::default().with_embedded_wal_path(&path),
151        )
152        .expect("reconstruct snapshot");
153        assert_eq!(std::fs::read(&path).expect("artifact after load"), before);
154        assert_eq!(
155            loaded.config().embedded_wal_path.as_deref(),
156            Some(path.as_path())
157        );
158
159        let generation = crate::storage::EmbeddedRdbArtifact::open(&path)
160            .expect("boundary before future write")
161            .selected_superblock
162            .generation;
163        loaded
164            .insert_auto(
165                "rows",
166                UnifiedEntity::table_row(
167                    EntityId::new(2),
168                    "rows",
169                    2,
170                    vec![Value::text("future write")],
171                ),
172            )
173            .expect("future writes still append WAL");
174        assert_eq!(
175            crate::storage::EmbeddedRdbArtifact::open(&path)
176                .expect("boundary after future write")
177                .selected_superblock
178                .generation,
179            generation + 1
180        );
181    }
182
183    #[test]
184    fn aux_metadata_round_trips_through_binary_dump() {
185        let store = UnifiedStore::with_config(UnifiedStoreConfig::default());
186        store.create_collection("k").expect("create collection");
187        store.set_aux_metadata(b"contract-blob".to_vec());
188
189        let bytes = store.to_binary_dump_bytes();
190        let reloaded = UnifiedStore::load_from_bytes(&bytes).expect("reload dump");
191
192        assert_eq!(reloaded.aux_metadata(), b"contract-blob".to_vec());
193    }
194
195    #[test]
196    fn empty_aux_metadata_round_trips_as_empty() {
197        let store = UnifiedStore::with_config(UnifiedStoreConfig::default());
198        let bytes = store.to_binary_dump_bytes();
199        let reloaded = UnifiedStore::load_from_bytes(&bytes).expect("reload dump");
200
201        assert!(reloaded.aux_metadata().is_empty());
202    }
203
204    #[test]
205    fn legacy_system_collection_snapshot_loads_and_republishes_canonical_name() {
206        let store = UnifiedStore::with_config(UnifiedStoreConfig::default());
207        store
208            .insert_auto(
209                "red_config",
210                UnifiedEntity::table_row(
211                    EntityId::new(1),
212                    "red_config",
213                    1,
214                    vec![Value::text("preserved")],
215                ),
216            )
217            .expect("config row");
218        let mut legacy = store.to_binary_dump_bytes();
219        let canonical = b"red.config";
220        let old = b"red_config";
221        let mut replacements = 0;
222        for offset in 0..=legacy.len() - canonical.len() {
223            if &legacy[offset..offset + canonical.len()] == canonical {
224                legacy[offset..offset + old.len()].copy_from_slice(old);
225                replacements += 1;
226            }
227        }
228        assert!(
229            replacements >= 2,
230            "snapshot carries catalog and entity names"
231        );
232        legacy.truncate(legacy.len() - 4);
233        reddb_file::append_native_store_crc32_footer(&mut legacy);
234
235        let migrated = UnifiedStore::load_from_bytes(&legacy).expect("legacy snapshot");
236        assert_eq!(migrated.list_collections(), vec!["red.config".to_string()]);
237        assert_eq!(
238            migrated
239                .get("red.config", EntityId::new(1))
240                .expect("preserved row")
241                .kind
242                .collection(),
243            "red.config"
244        );
245        let republished = migrated.to_binary_dump_bytes();
246        assert!(
247            republished.windows(old.len()).all(|window| window != old),
248            "new snapshots must not republish legacy identities"
249        );
250        assert!(republished
251            .windows(canonical.len())
252            .any(|window| window == canonical));
253    }
254
255    #[test]
256    fn interned_timeseries_series_reduces_repeated_tag_storage() {
257        let tags = HashMap::from([
258            (
259                "host".to_string(),
260                "srv-very-long-hostname-0001".to_string(),
261            ),
262            ("region".to_string(), "us-east-1".to_string()),
263            ("service".to_string(), "checkout-api".to_string()),
264        ]);
265        let inline_bytes: usize = (0..100)
266            .map(|timestamp_ns| {
267                let entity = UnifiedEntity::new(
268                    EntityId::new(0),
269                    EntityKind::TimeSeriesPoint(Box::new(TimeSeriesPointKind {
270                        series: "cpu_metrics".to_string(),
271                        metric: "cpu.idle".to_string(),
272                    })),
273                    EntityData::TimeSeries(crate::storage::TimeSeriesData {
274                        metric: "cpu.idle".to_string(),
275                        series_id: None,
276                        timestamp_ns,
277                        value: 94.8,
278                        tags: tags.clone(),
279                        fields: HashMap::new(),
280                    }),
281                );
282                UnifiedStore::serialize_entity_record(&entity, None, reddb_file::STORE_VERSION_V10)
283                    .len()
284            })
285            .sum();
286
287        let mut dictionary_fields = HashMap::new();
288        dictionary_fields.insert("collection".to_string(), Value::text("cpu_metrics"));
289        dictionary_fields.insert("series_id".to_string(), Value::UnsignedInteger(0));
290        dictionary_fields.insert("metric".to_string(), Value::text("cpu.idle"));
291        dictionary_fields.insert(
292            "canonical_tags".to_string(),
293            Value::text(
294                r#"{"host":"srv-very-long-hostname-0001","region":"us-east-1","service":"checkout-api"}"#,
295            ),
296        );
297        dictionary_fields.insert(
298            "tags".to_string(),
299            Value::Json(
300                br#"{"host":"srv-very-long-hostname-0001","region":"us-east-1","service":"checkout-api"}"#
301                    .to_vec(),
302            ),
303        );
304        let dictionary = UnifiedEntity::new(
305            EntityId::new(0),
306            EntityKind::TableRow {
307                table: Arc::from("red_timeseries_series"),
308                row_id: 0,
309            },
310            EntityData::Row(RowData {
311                columns: Vec::new(),
312                named: Some(dictionary_fields),
313                schema: None,
314            }),
315        );
316        let dictionary_bytes =
317            UnifiedStore::serialize_entity_record(&dictionary, None, STORE_VERSION_V11).len();
318        let interned_bytes = dictionary_bytes
319            + (0..100)
320                .map(|timestamp_ns| {
321                    let entity = UnifiedEntity::new(
322                        EntityId::new(0),
323                        EntityKind::TimeSeriesPoint(Box::new(TimeSeriesPointKind {
324                            series: "cpu_metrics".to_string(),
325                            metric: "cpu.idle".to_string(),
326                        })),
327                        EntityData::TimeSeries(crate::storage::TimeSeriesData {
328                            metric: "cpu.idle".to_string(),
329                            series_id: Some(0),
330                            timestamp_ns,
331                            value: 94.8,
332                            tags: HashMap::new(),
333                            fields: HashMap::new(),
334                        }),
335                    );
336                    UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11).len()
337                })
338                .sum::<usize>();
339
340        assert!(
341            interned_bytes < inline_bytes,
342            "interned series storage should shrink repeated tags: inline={inline_bytes}, interned={interned_bytes}"
343        );
344    }
345}