|
2 | 2 |
|
3 | 3 | //! Snapshot export/import, history compaction, memory estimation. |
4 | 4 |
|
5 | | -use loro::LoroDoc; |
6 | | - |
7 | 5 | use crate::error::{CrdtError, Result}; |
8 | 6 |
|
9 | 7 | use super::core::CrdtState; |
@@ -106,54 +104,55 @@ impl CrdtState { |
106 | 104 | /// Call this periodically (e.g., every 30 minutes or when memory |
107 | 105 | /// pressure exceeds threshold) to prevent unbounded history growth. |
108 | 106 | pub fn compact_history(&mut self) -> Result<()> { |
109 | | - // Export a shallow snapshot at the current frontiers. |
110 | | - let frontiers = self.doc.oplog_frontiers(); |
| 107 | + self.compact_to_frontiers(&self.doc.oplog_frontiers()) |
| 108 | + } |
| 109 | + |
| 110 | + /// Replace this document with a shallow snapshot taken at `frontiers`. |
| 111 | + /// |
| 112 | + /// The whole of compaction, shared by `compact_history` (current |
| 113 | + /// frontiers) and `compact_at_version` (a chosen version), so the two |
| 114 | + /// cannot drift apart on how the snapshot is admitted or how the document |
| 115 | + /// is swapped in. |
| 116 | + /// |
| 117 | + /// The snapshot is admitted through [`admit_local_import`]: this process |
| 118 | + /// exported it one line earlier. Under the peer ceilings, compaction would |
| 119 | + /// be refused for precisely the documents large enough to need it — the |
| 120 | + /// operation that would bring a document back under the limit gated by |
| 121 | + /// that limit. |
| 122 | + pub(in crate::state) fn compact_to_frontiers( |
| 123 | + &mut self, |
| 124 | + frontiers: &loro::Frontiers, |
| 125 | + ) -> Result<()> { |
111 | 126 | let snapshot = self |
112 | 127 | .doc |
113 | | - .export(loro::ExportMode::shallow_snapshot(&frontiers)) |
| 128 | + .export(loro::ExportMode::shallow_snapshot(frontiers)) |
114 | 129 | .map_err(|e| CrdtError::Loro(format!("shallow snapshot export: {e}")))?; |
115 | 130 |
|
116 | | - // Replace the doc with a fresh one loaded from the snapshot. |
117 | | - let new_doc = LoroDoc::new(); |
118 | | - new_doc |
119 | | - .set_peer_id(self.peer_id) |
120 | | - .map_err(|e| CrdtError::Loro(format!("failed to set peer_id on compacted doc: {e}")))?; |
121 | | - // Locally generated: it keeps the metadata and range checks but not |
122 | | - // the peer size ceilings, which would otherwise make compaction |
123 | | - // impossible for exactly the documents that need it most. |
124 | | - admit_local_import(&snapshot, &new_doc.oplog_vv())?; |
125 | | - new_doc |
| 131 | + let compacted = Self::new_doc(self.peer_id)?; |
| 132 | + admit_local_import(&snapshot, &compacted.oplog_vv())?; |
| 133 | + compacted |
126 | 134 | .import(&snapshot) |
127 | 135 | .map_err(|e| CrdtError::Loro(format!("shallow snapshot import: {e}")))?; |
128 | 136 |
|
129 | | - self.doc = new_doc; |
| 137 | + // `replace` also drops everything cached from the outgoing document. A |
| 138 | + // shallow snapshot keeps the version vector, so a derived value keyed |
| 139 | + // on the version alone would look current while describing bytes that |
| 140 | + // no longer exist. |
| 141 | + self.doc.replace(compacted); |
130 | 142 | Ok(()) |
131 | 143 | } |
132 | 144 |
|
133 | 145 | /// Estimated memory usage of the CRDT state (bytes). |
134 | 146 | /// |
135 | 147 | /// Includes operation history, current state, and internal caches. |
136 | 148 | /// Use this to decide when to trigger `compact_history()`. |
137 | | - /// Cached per frontier: the proxy is a full snapshot export, so polling it |
138 | | - /// on an unchanged document would re-serialize the whole document to learn |
139 | | - /// a number that cannot have moved. |
| 149 | + /// |
| 150 | + /// The proxy is a full snapshot export, so it is measured once per version |
| 151 | + /// rather than once per call: a document nobody is writing cannot have |
| 152 | + /// changed size. Compaction discards the measurement along with the |
| 153 | + /// document it described. |
140 | 154 | pub fn estimated_memory_bytes(&self) -> usize { |
141 | | - let version = self.doc.oplog_vv(); |
142 | | - if let Some((cached_at, bytes)) = self.memory_estimate.borrow().as_ref() |
143 | | - && *cached_at == version |
144 | | - { |
145 | | - return *bytes; |
146 | | - } |
147 | | - // Loro doesn't expose a direct memory metric. |
148 | | - // Use snapshot size as a proxy — it's proportional to state size. |
149 | | - // This is not precise but good enough for pressure monitoring. |
150 | | - let bytes = self |
151 | | - .doc |
152 | | - .export(loro::ExportMode::Snapshot) |
153 | | - .map(|s| s.len()) |
154 | | - .unwrap_or(0); |
155 | | - *self.memory_estimate.borrow_mut() = Some((version, bytes)); |
156 | | - bytes |
| 155 | + self.doc.estimated_bytes() |
157 | 156 | } |
158 | 157 | } |
159 | 158 |
|
@@ -218,6 +217,32 @@ mod tests { |
218 | 217 | ); |
219 | 218 | } |
220 | 219 |
|
| 220 | + #[test] |
| 221 | + fn from_local_snapshot_loads_without_the_peer_ceilings() { |
| 222 | + let source = CrdtState::new(3).expect("source state"); |
| 223 | + source |
| 224 | + .upsert("docs", "row", &[("title", LoroValue::String("v".into()))]) |
| 225 | + .expect("source write"); |
| 226 | + let snapshot = source.export_snapshot().expect("snapshot"); |
| 227 | + |
| 228 | + let reloaded = CrdtState::from_local_snapshot(3, &snapshot).expect("reload"); |
| 229 | + assert!( |
| 230 | + reloaded.row_exists("docs", "row"), |
| 231 | + "a state loaded from our own snapshot must carry its rows" |
| 232 | + ); |
| 233 | + } |
| 234 | + |
| 235 | + #[test] |
| 236 | + fn from_local_snapshot_rejects_malformed_bytes() { |
| 237 | + assert!( |
| 238 | + matches!( |
| 239 | + CrdtState::from_local_snapshot(1, &[0xff]), |
| 240 | + Err(CrdtError::ImportMalformed { .. }) |
| 241 | + ), |
| 242 | + "the convenience constructor keeps every structural check" |
| 243 | + ); |
| 244 | + } |
| 245 | + |
221 | 246 | #[test] |
222 | 247 | fn local_import_still_rejects_malformed_metadata() { |
223 | 248 | let state = CrdtState::new(1).expect("state"); |
|
0 commit comments