Skip to main content

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}