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 interned_timeseries_series_reduces_repeated_tag_storage() {
204        let tags = HashMap::from([
205            (
206                "host".to_string(),
207                "srv-very-long-hostname-0001".to_string(),
208            ),
209            ("region".to_string(), "us-east-1".to_string()),
210            ("service".to_string(), "checkout-api".to_string()),
211        ]);
212        let inline_bytes: usize = (0..100)
213            .map(|timestamp_ns| {
214                let entity = UnifiedEntity::new(
215                    EntityId::new(0),
216                    EntityKind::TimeSeriesPoint(Box::new(TimeSeriesPointKind {
217                        series: "cpu_metrics".to_string(),
218                        metric: "cpu.idle".to_string(),
219                    })),
220                    EntityData::TimeSeries(crate::storage::TimeSeriesData {
221                        metric: "cpu.idle".to_string(),
222                        series_id: None,
223                        timestamp_ns,
224                        value: 94.8,
225                        tags: tags.clone(),
226                        fields: HashMap::new(),
227                    }),
228                );
229                UnifiedStore::serialize_entity_record(&entity, None, reddb_file::STORE_VERSION_V10)
230                    .len()
231            })
232            .sum();
233
234        let mut dictionary_fields = HashMap::new();
235        dictionary_fields.insert("collection".to_string(), Value::text("cpu_metrics"));
236        dictionary_fields.insert("series_id".to_string(), Value::UnsignedInteger(0));
237        dictionary_fields.insert("metric".to_string(), Value::text("cpu.idle"));
238        dictionary_fields.insert(
239            "canonical_tags".to_string(),
240            Value::text(
241                r#"{"host":"srv-very-long-hostname-0001","region":"us-east-1","service":"checkout-api"}"#,
242            ),
243        );
244        dictionary_fields.insert(
245            "tags".to_string(),
246            Value::Json(
247                br#"{"host":"srv-very-long-hostname-0001","region":"us-east-1","service":"checkout-api"}"#
248                    .to_vec(),
249            ),
250        );
251        let dictionary = UnifiedEntity::new(
252            EntityId::new(0),
253            EntityKind::TableRow {
254                table: Arc::from("red_timeseries_series"),
255                row_id: 0,
256            },
257            EntityData::Row(RowData {
258                columns: Vec::new(),
259                named: Some(dictionary_fields),
260                schema: None,
261            }),
262        );
263        let dictionary_bytes =
264            UnifiedStore::serialize_entity_record(&dictionary, None, STORE_VERSION_V11).len();
265        let interned_bytes = dictionary_bytes
266            + (0..100)
267                .map(|timestamp_ns| {
268                    let entity = UnifiedEntity::new(
269                        EntityId::new(0),
270                        EntityKind::TimeSeriesPoint(Box::new(TimeSeriesPointKind {
271                            series: "cpu_metrics".to_string(),
272                            metric: "cpu.idle".to_string(),
273                        })),
274                        EntityData::TimeSeries(crate::storage::TimeSeriesData {
275                            metric: "cpu.idle".to_string(),
276                            series_id: Some(0),
277                            timestamp_ns,
278                            value: 94.8,
279                            tags: HashMap::new(),
280                            fields: HashMap::new(),
281                        }),
282                    );
283                    UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11).len()
284                })
285                .sum::<usize>();
286
287        assert!(
288            interned_bytes < inline_bytes,
289            "interned series storage should shrink repeated tags: inline={inline_bytes}, interned={interned_bytes}"
290        );
291    }
292}