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