nodedb_lite/nodedb/core/flush/execute.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! `NodeDbLite::flush` — persist all in-memory state to storage.
4
5use crate::storage::engine::{StorageEngine, WriteOp};
6use nodedb_types::Namespace;
7use nodedb_types::error::{NodeDbError, NodeDbResult};
8
9use crate::engine::crdt::{CrdtEngine, CrdtWriteKind};
10use crate::engine::fts::coordinator::TextMutationPermit;
11use crate::nodedb::lock_ext::LockExt;
12
13use super::super::types::{META_CRDT_DELTAS, META_LAST_FLUSHED_MID, NodeDbLite};
14
15impl<S: StorageEngine> NodeDbLite<S> {
16 /// Number of full CRDT snapshot exports performed since this handle was
17 /// opened.
18 ///
19 /// A snapshot export costs O(document), so this is the term that decides
20 /// both flush latency and how much the file grows per tick. It is a
21 /// counter, not a timing, so a caller can assert on it directly: an idle
22 /// store must not advance it.
23 pub fn crdt_snapshot_export_count(&self) -> u64 {
24 self.crdt.lock_or_recover().snapshot_export_count()
25 }
26
27 /// Number of unsent-delta queue entries written since this handle was
28 /// opened.
29 ///
30 /// The queue is append-only, so this advances by what was added, not by
31 /// the queue's length. An idle store must not advance it at all.
32 pub fn crdt_delta_write_count(&self) -> u64 {
33 self.crdt.lock_or_recover().pending_delta_write_count()
34 }
35
36 /// Persist all in-memory state to storage (call before shutdown).
37 pub async fn flush(&self) -> NodeDbResult<()> {
38 let permit = self.fts_state.admit_exclusive().await;
39 self.flush_admitted(&permit).await
40 }
41
42 pub(crate) async fn flush_admitted(&self, _permit: &TextMutationPermit) -> NodeDbResult<()> {
43 // One flush at a time: the CRDT update sequence is allocated under the
44 // `crdt` guard but committed after it is released, so concurrent
45 // flushes would hand out the same numbers. See `flush_lock`.
46 let _flush_guard = self.flush_lock.lock().await;
47 crate::engine::fts::checkpoint::persist_checkpoint_incomplete(self.storage.as_ref())
48 .await
49 .map_err(NodeDbError::from)?;
50
51 // Drain the buffered KV writes first — they have their own batch-commit
52 // path. Without this, `flush()` (and the auto-flush timer) would not
53 // persist KV `put`s, contradicting "persist all in-memory state".
54 self.kv_flush_inner().await?;
55
56 let mut ops = Vec::new();
57
58 // Delta entries already on disk. Restore prefers these over the bulk
59 // blob, so any one that is no longer pending — acknowledged by Origin,
60 // or replaced by a peer-id rotation that re-authored the row — would
61 // come back on the next open and be pushed again. They are deleted in
62 // the same batch that writes the current set.
63 let persisted_delta_keys: Vec<Vec<u8>> = self
64 .storage
65 .scan_prefix(Namespace::Crdt, b"delta:")
66 .await?
67 .into_iter()
68 .map(|(key, _)| key)
69 .collect();
70
71 // ── Persist one CRDT snapshot per collection (CRC32C wrapped) ──
72 // Each collection owns its own Loro document, so each gets its own
73 // storage entry under `loro_snapshot:<collection>`.
74 //
75 // A collection whose frontier has not moved is not written at all; one
76 // that has moved is written as an update since its last persisted
77 // frontier, and only periodically as a fresh snapshot. Exporting a full
78 // snapshot per collection per tick cost O(document) regardless of the
79 // write rate — unbounded file growth on an otherwise idle store, and an
80 // export duty cycle that starved readers once the document outgrew the
81 // flush interval.
82 let (persisted, written_deltas, index_flush) = {
83 let crdt = self.crdt.lock_or_recover();
84 let plan = crdt.plan_persistence().map_err(NodeDbError::storage)?;
85 let mut persisted = Vec::with_capacity(plan.len());
86 for write in plan {
87 persisted.push(write.persisted());
88 match write.kind {
89 CrdtWriteKind::Checkpoint { superseded_deltas } => {
90 // In the same batch as the new base, so no restore ever
91 // sees a base with updates in front of it that it
92 // already contains.
93 for seq in 0..superseded_deltas {
94 ops.push(WriteOp::Delete {
95 ns: Namespace::LoroState,
96 key: CrdtEngine::state_delta_key_for(&write.collection, seq),
97 });
98 }
99 ops.push(WriteOp::Put {
100 ns: Namespace::LoroState,
101 key: CrdtEngine::snapshot_key_for(&write.collection),
102 value: crate::storage::checksum::wrap(&write.bytes),
103 });
104 }
105 CrdtWriteKind::Delta { seq } => {
106 ops.push(WriteOp::Put {
107 ns: Namespace::LoroState,
108 key: CrdtEngine::state_delta_key_for(&write.collection, seq),
109 value: crate::storage::checksum::wrap(&write.bytes),
110 });
111 }
112 }
113 }
114
115 // Write pending deltas individually (append-only persistence).
116 // Each delta is stored under `crdt:delta:{mutation_id:016x}`.
117 //
118 // Only the entries added or edited since the last flush are
119 // written. The queue is append-only and each entry owns its key,
120 // so rewriting an unchanged one stores bytes identical to the ones
121 // already there — and a replica with no Origin to acknowledge its
122 // deltas accumulates them without bound, which made that rewrite
123 // the whole outbox, once per `auto_flush_ms`.
124 let pending = crdt.pending_deltas();
125 let max_mid = pending.iter().map(|d| d.mutation_id).max().unwrap_or(0);
126
127 let live_keys: std::collections::HashSet<Vec<u8>> = pending
128 .iter()
129 .map(|d| CrdtEngine::delta_storage_key(d.mutation_id))
130 .collect();
131 let mut retired_any = false;
132 for key in persisted_delta_keys {
133 if !live_keys.contains(&key) {
134 retired_any = true;
135 ops.push(WriteOp::Delete {
136 ns: Namespace::Crdt,
137 key,
138 });
139 }
140 }
141
142 // The revision each entry was written at travels with it: the
143 // acknowledgement below happens after an await, and an entry queued
144 // or re-sequenced in that window was never in this batch.
145 let mut written_deltas: Vec<(u64, u64)> = Vec::new();
146 for (delta, revision) in crdt.pending_deltas_needing_write() {
147 let key = CrdtEngine::delta_storage_key(delta.mutation_id);
148 let value = CrdtEngine::serialize_delta(delta).map_err(NodeDbError::storage)?;
149 written_deltas.push((delta.mutation_id, revision));
150 ops.push(WriteOp::Put {
151 ns: Namespace::Crdt,
152 key,
153 value,
154 });
155 }
156
157 // Legacy bulk blob (for clients that haven't upgraded to incremental
158 // restore). It duplicates every entry above, so it is rewritten only
159 // when the queue actually changed rather than on every tick.
160 let queue_changed = retired_any || crdt.has_unpersisted_deltas();
161 if queue_changed {
162 let deltas_bulk = crdt
163 .serialize_pending_deltas()
164 .map_err(NodeDbError::storage)?;
165 ops.push(WriteOp::Put {
166 ns: Namespace::Crdt,
167 key: META_CRDT_DELTAS.to_vec(),
168 value: deltas_bulk,
169 });
170 }
171
172 // Index entries in the same batch and lock hold as the rows.
173 let index_flush = self.query_engine.indexes.stage_flush(&mut ops);
174
175 // Write the last-flushed mutation_id for partial flush safety.
176 ops.push(WriteOp::Put {
177 ns: Namespace::Meta,
178 key: META_LAST_FLUSHED_MID.to_vec(),
179 value: max_mid.to_le_bytes().to_vec(),
180 });
181
182 (persisted, written_deltas, index_flush)
183 };
184
185 let jobs = self.stage_indexes(&mut ops)?;
186 self.storage
187 .batch_write(&ops)
188 .await
189 .map_err(NodeDbError::storage)?;
190
191 // The CRDT writes are durable now, so advance the frontiers and the
192 // checkpoint accounting. Doing this only after the write means a failed
193 // batch leaves every collection outstanding and the next flush retries
194 // it.
195 {
196 let mut crdt = self.crdt.lock_or_recover();
197 crdt.mark_persisted(persisted);
198 crdt.mark_pending_deltas_persisted(written_deltas);
199 }
200 self.query_engine.indexes.mark_flushed(index_flush);
201
202 #[cfg(not(target_arch = "wasm32"))]
203 super::segments::write_segments(self.storage.as_ref(), jobs).await?;
204 #[cfg(target_arch = "wasm32")]
205 let _ = jobs;
206
207 // ── Persist spatial indices (separate batch — includes docmap) ────────
208 let (spatial_checkpoints, spatial_doc_to_entry, spatial_next_id) =
209 self.spatial.lock_or_recover().checkpoint_data();
210 crate::engine::spatial::checkpoint::flush_spatial(
211 self.storage.as_ref(),
212 &spatial_checkpoints,
213 &spatial_doc_to_entry,
214 spatial_next_id,
215 )
216 .await?;
217
218 // ── Persist FTS indices (separate batch — potentially large) ──
219 // Serialize is synchronous (no I/O); do it inside the lock so we don't
220 // need to clone FtsIndex. The resulting ops + segment blobs are written
221 // to storage after the lock is released.
222 let (fts_ops, fts_segment_writes) = {
223 let fts = self.fts_state.manager.lock_or_recover();
224 let (indices, id_to_surrogate, next_surrogate) = fts.checkpoint_data();
225 crate::engine::fts::checkpoint::serialize_fts(indices, id_to_surrogate, next_surrogate)
226 .map_err(|e| NodeDbError::storage(format!("fts serialize: {e}")))?
227 };
228 crate::engine::fts::checkpoint::write_serialized_fts(
229 self.storage.as_ref(),
230 fts_ops,
231 fts_segment_writes,
232 )
233 .await
234 .map_err(|e| NodeDbError::storage(format!("fts flush: {e}")))?;
235
236 // ── Persist sparse-vector inverted indices ────────────────────────────
237 // Same shape as the FTS block: serialize synchronously under the lock,
238 // then perform the storage write after releasing it.
239 let sparse_ops = {
240 let sparse = self.sparse_state.manager.lock_or_recover();
241 crate::engine::sparse_vector::checkpoint::serialize_sparse(sparse.checkpoint_data())
242 .map_err(|e| NodeDbError::storage(format!("sparse serialize: {e}")))?
243 };
244 crate::engine::sparse_vector::checkpoint::write_serialized_sparse(
245 self.storage.as_ref(),
246 sparse_ops,
247 )
248 .await
249 .map_err(|e| NodeDbError::storage(format!("sparse flush: {e}")))?;
250
251 // ── Spill FTS + spatial staging buffers to durable queues ────────────
252 // These queues accumulate sync entries written synchronously by
253 // `index_document_text`, `remove_document_text`, `spatial_insert`, and
254 // `spatial_delete`. Spilling here (async, ~every second) keeps the
255 // staging buffers bounded and ensures entries are durable before the
256 // next sync transport drain.
257 #[cfg(not(target_arch = "wasm32"))]
258 if let Some(q) = &self.fts_outbound {
259 q.flush_staging().await.map_err(NodeDbError::from)?;
260 }
261 #[cfg(not(target_arch = "wasm32"))]
262 if let Some(q) = &self.spatial_outbound {
263 q.flush_staging().await.map_err(NodeDbError::from)?;
264 }
265
266 if self.fts_state.checkpoint_trusted() {
267 let revisions = self
268 .fts_state
269 .manager
270 .lock_or_recover()
271 .declaration_revisions();
272 crate::engine::fts::checkpoint::persist_checkpoint_complete(
273 self.storage.as_ref(),
274 &revisions,
275 )
276 .await
277 .map_err(NodeDbError::from)?;
278 }
279 Ok(())
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use crate::nodedb::LockExt;
286 use crate::{LiteConfig, NodeDbLite, PagedbStorageMem};
287 use nodedb_client::NodeDb;
288 use nodedb_types::Value;
289 use nodedb_types::document::Document;
290
291 #[tokio::test]
292 async fn successful_flush_preserves_interrupted_mutation_distrust() {
293 let storage = PagedbStorageMem::open_in_memory().await.unwrap();
294 let db = NodeDbLite::open_with_config(
295 storage,
296 LiteConfig {
297 auto_flush_ms: 0,
298 sync_enabled: false,
299 ..LiteConfig::default()
300 },
301 )
302 .await
303 .unwrap();
304 drop(db.fts_state.admit_mutation().await);
305 let mut document = Document::new("one");
306 document.set("body", Value::String("laterwrite".into()));
307 db.document_put("articles", document).await.unwrap();
308 db.flush().await.unwrap();
309 let revisions = db
310 .fts_state
311 .manager
312 .lock_or_recover()
313 .declaration_revisions();
314 assert!(!crate::engine::fts::checkpoint::checkpoint_compatible(
315 db.storage.as_ref(), &revisions,
316 ).await.unwrap());
317 assert!(!db.fts_state.checkpoint_trusted());
318 }
319}