Skip to main content

miden_note_transport_node/database/
mod.rs

1mod error;
2mod maintenance;
3mod sqlite;
4
5pub use self::error::DatabaseError;
6pub use self::maintenance::DatabaseMaintenance;
7use self::sqlite::SqliteDatabase;
8use crate::metrics::MetricsDatabase;
9use crate::types::{NoteId, NoteTag, StoredNote};
10
11/// Database operations
12#[async_trait::async_trait]
13pub trait DatabaseBackend: Send + Sync {
14    /// Connect to the database
15    async fn connect(
16        config: DatabaseConfig,
17        metrics: MetricsDatabase,
18    ) -> Result<Self, DatabaseError>
19    where
20        Self: Sized;
21
22    /// Store a new note
23    async fn store_note(&self, note: &StoredNote) -> Result<(), DatabaseError>;
24
25    /// Fetch notes by tag
26    async fn fetch_notes(
27        &self,
28        tag: NoteTag,
29        cursor: u64,
30    ) -> Result<Vec<StoredNote>, DatabaseError>;
31
32    /// Fetch notes matching ANY of a set of tags, in a single DB snapshot.
33    ///
34    /// This is the preferred multi-tag query — running per-tag queries back
35    /// to back reopens a race where a concurrent INSERT can land between two
36    /// per-tag queries and get leapfrogged by the cursor advance.
37    async fn fetch_notes_by_tags(
38        &self,
39        tags: &[NoteTag],
40        cursor: u64,
41    ) -> Result<Vec<StoredNote>, DatabaseError>;
42
43    /// Get statistics about the database
44    async fn get_stats(&self) -> Result<(u64, u64), DatabaseError>;
45
46    /// Clean up old notes based on retention policy
47    async fn cleanup_old_notes(&self, retention_days: u32) -> Result<u64, DatabaseError>;
48
49    /// Check if a note exists
50    async fn note_exists(&self, note_id: NoteId) -> Result<bool, DatabaseError>;
51}
52
53/// Database manager for the transport layer
54pub struct Database {
55    backend: Box<dyn DatabaseBackend>,
56}
57
58/// [`Database`] configuration
59#[derive(Debug, Clone)]
60pub struct DatabaseConfig {
61    /// Database URL
62    pub url: String,
63    /// Retention period in days
64    pub retention_days: u32,
65}
66
67impl Default for DatabaseConfig {
68    fn default() -> Self {
69        Self {
70            url: ":memory:".to_string(),
71            retention_days: 30,
72        }
73    }
74}
75
76impl Database {
77    /// Connect to a database (with `SQLite` backend)
78    pub async fn connect(
79        config: DatabaseConfig,
80        metrics: MetricsDatabase,
81    ) -> Result<Self, DatabaseError> {
82        let backend = SqliteDatabase::connect(config, metrics).await?;
83        Ok(Self { backend: Box::new(backend) })
84    }
85
86    /// Store a new note
87    pub async fn store_note(&self, note: &StoredNote) -> Result<(), DatabaseError> {
88        self.backend.store_note(note).await?;
89        Ok(())
90    }
91
92    /// Fetch notes by tag with cursor-based pagination
93    pub async fn fetch_notes(
94        &self,
95        tag: NoteTag,
96        cursor: u64,
97    ) -> Result<Vec<StoredNote>, DatabaseError> {
98        self.backend.fetch_notes(tag, cursor).await
99    }
100
101    /// Fetch notes matching ANY of a set of tags, in a single DB snapshot.
102    pub async fn fetch_notes_by_tags(
103        &self,
104        tags: &[NoteTag],
105        cursor: u64,
106    ) -> Result<Vec<StoredNote>, DatabaseError> {
107        self.backend.fetch_notes_by_tags(tags, cursor).await
108    }
109
110    /// Get statistics about the database
111    pub async fn get_stats(&self) -> Result<(u64, u64), DatabaseError> {
112        self.backend.get_stats().await
113    }
114
115    /// Clean up old notes based on retention policy
116    pub async fn cleanup_old_notes(&self, retention_days: u32) -> Result<u64, DatabaseError> {
117        self.backend.cleanup_old_notes(retention_days).await
118    }
119
120    /// Check if a note exists
121    pub async fn note_exists(&self, note_id: NoteId) -> Result<bool, DatabaseError> {
122        self.backend.note_exists(note_id).await
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use chrono::Utc;
129
130    use super::*;
131    use crate::metrics::Metrics;
132    use crate::test_utils::{TAG_LOCAL_ANY, test_note_header, test_note_header_with_tag};
133
134    #[tokio::test]
135    async fn test_sqlite_database() {
136        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
137            .await
138            .unwrap();
139
140        let note = StoredNote {
141            header: test_note_header(),
142            details: vec![1, 2, 3, 4],
143            created_at: Utc::now(),
144            seq: 0, // ignored on INSERT
145        };
146
147        db.store_note(&note).await.unwrap();
148
149        // Cursor is now seq-based; 0 fetches everything.
150        let fetched_notes = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
151        assert_eq!(fetched_notes.len(), 1);
152        assert_eq!(fetched_notes[0].header.id(), note.header.id());
153        assert!(fetched_notes[0].seq > 0);
154
155        // Test note exists
156        assert!(db.note_exists(note.header.id()).await.unwrap());
157
158        // Test stats
159        let (total_notes, total_tags) = db.get_stats().await.unwrap();
160        assert_eq!(total_notes, 1);
161        assert_eq!(total_tags, 1);
162    }
163
164    #[tokio::test]
165    async fn test_seq_assigned_monotonically_in_insert_order() {
166        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
167            .await
168            .unwrap();
169
170        let first = StoredNote {
171            header: test_note_header(),
172            details: vec![1],
173            created_at: Utc::now(),
174            seq: 0,
175        };
176        db.store_note(&first).await.unwrap();
177
178        let second = StoredNote {
179            header: test_note_header(),
180            details: vec![2],
181            created_at: Utc::now(),
182            seq: 0,
183        };
184        db.store_note(&second).await.unwrap();
185
186        // Fetch everything and assert INSERT order = read order = seq ascending.
187        let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
188        assert_eq!(fetched.len(), 2);
189        assert!(
190            fetched[0].seq < fetched[1].seq,
191            "expected monotonic seq; got {} then {}",
192            fetched[0].seq,
193            fetched[1].seq
194        );
195        assert_eq!(fetched[0].details, vec![1]);
196        assert_eq!(fetched[1].details, vec![2]);
197
198        // Cursor between the two seqs returns only the second.
199        let mid_cursor = u64::try_from(fetched[0].seq).expect("seq is non-negative");
200        let after_first = db.fetch_notes(TAG_LOCAL_ANY.into(), mid_cursor).await.unwrap();
201        assert_eq!(after_first.len(), 1);
202        assert_eq!(after_first[0].details, vec![2]);
203    }
204
205    #[tokio::test]
206    async fn test_concurrent_store_fetch_sees_all_rows() {
207        // Regression test for the `:memory:` pool-isolation bug: when the pool
208        // had max_size>1 and the URL was `:memory:`, writes and reads could
209        // land on different connections and each connection had its own
210        // isolated in-memory DB. Result: writes silently split across pool
211        // connections, fetches only saw a fraction of the actual data.
212        //
213        // With the pool clamped to size=1 for `:memory:`, all ops go to the
214        // same connection and see the same DB.
215        use std::sync::Arc;
216
217        use tokio::task::JoinSet;
218
219        const TAG_A: u32 = 0x3d9c_0000;
220        const TAG_B: u32 = 0x47ac_0000;
221
222        let db = Arc::new(
223            Database::connect(DatabaseConfig::default(), Metrics::default().db)
224                .await
225                .unwrap(),
226        );
227
228        // Spawn many concurrent writers — more than the old max_size=16 — so
229        // that the bug would have fragmented writes across connections.
230        let mut writers = JoinSet::new();
231        for i in 0..40u32 {
232            let db = db.clone();
233            writers.spawn(async move {
234                let tag = if i % 2 == 0 { TAG_A } else { TAG_B };
235                db.store_note(&StoredNote {
236                    header: test_note_header_with_tag(tag),
237                    details: vec![i as u8],
238                    created_at: Utc::now(),
239                    seq: 0,
240                })
241                .await
242                .unwrap();
243            });
244        }
245        while writers.join_next().await.is_some() {}
246
247        let fetched_a = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
248        let fetched_b = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
249        assert_eq!(
250            fetched_a.len() + fetched_b.len(),
251            40,
252            "all 40 concurrent writes should be visible"
253        );
254
255        let (total, _) = db.get_stats().await.unwrap();
256        assert_eq!(total, 40, "stats should reflect all 40 rows");
257    }
258
259    #[tokio::test]
260    async fn test_fetch_notes_seq_cursor_filtering() {
261        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
262            .await
263            .unwrap();
264
265        let note = StoredNote {
266            header: test_note_header(),
267            details: vec![1, 2, 3, 4],
268            created_at: Utc::now(),
269            seq: 0, // ignored on INSERT
270        };
271
272        db.store_note(&note).await.unwrap();
273
274        // cursor=0 is strictly before any assigned seq → should return the note
275        let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
276        assert_eq!(fetched.len(), 1);
277        let stored_seq = fetched[0].seq;
278        assert!(stored_seq > 0, "expected seq > 0, got {stored_seq}");
279
280        // cursor = the note's own seq → strictly-greater filter excludes it
281        let cursor = u64::try_from(stored_seq).expect("seq is non-negative");
282        let after = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
283        assert_eq!(after.len(), 0);
284    }
285
286    /// Deterministic regression test for the seq-cursor fix.
287    ///
288    /// Two notes with IDENTICAL `created_at` microseconds (possible on macOS
289    /// under concurrent writes; more broadly, any system where wall-clock is
290    /// not injective under load). With the old `created_at` cursor, the
291    /// client's `rcursor = max(ts)` strict-greater filter rendered the 2nd
292    /// note permanently invisible. With the `seq` cursor, each note gets a
293    /// distinct monotonic id and both are reachable.
294    #[tokio::test]
295    async fn test_seq_cursor_survives_identical_created_at() {
296        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
297            .await
298            .unwrap();
299
300        let t = Utc::now();
301        let note1 = StoredNote {
302            header: test_note_header(),
303            details: vec![1],
304            created_at: t,
305            seq: 0,
306        };
307        db.store_note(&note1).await.unwrap();
308
309        // Client's first fetch sees note1, advances cursor using the returned seq.
310        let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
311        assert_eq!(first.len(), 1);
312        let cursor = u64::try_from(first[0].seq).expect("seq is non-negative");
313
314        // A concurrent writer commits note2 with the SAME `created_at`.
315        let note2 = StoredNote {
316            header: test_note_header(),
317            details: vec![2],
318            created_at: t,
319            seq: 0,
320        };
321        db.store_note(&note2).await.unwrap();
322
323        // With seq cursor: note2 has seq > cursor → returned.
324        // With old timestamp cursor: note2.ts == cursor → filtered by strict `>` → LOST.
325        let second = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
326        assert_eq!(
327            second.len(),
328            1,
329            "seq cursor must return note2 despite identical created_at; got {} notes",
330            second.len()
331        );
332        assert_eq!(second[0].details, vec![2]);
333    }
334
335    /// Deterministic regression test for the multi-tag single-snapshot fix.
336    ///
337    /// Simulates the old gRPC handler's per-tag loop: fetch tag A, then fetch
338    /// tag B, then advance client cursor to `max(seq)` across both results.
339    /// A concurrent tag-A insert landing between the per-tag queries gets a
340    /// seq ABOVE A's max but BELOW B's max — so when the client advances to
341    /// B's max seq, the A insert becomes permanently unreachable (its seq is
342    /// below the advanced cursor).
343    ///
344    /// `fetch_notes_by_tags` collapses the loop into a single `tag IN (…)`
345    /// query under one snapshot, so no interleave is possible.
346    #[tokio::test]
347    async fn test_multi_tag_single_snapshot_vs_per_tag_loop() {
348        use std::convert::TryFrom;
349
350        const TAG_A: u32 = 0x3d9c_0000;
351        const TAG_B: u32 = 0x47ac_0000;
352
353        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
354            .await
355            .unwrap();
356
357        // Seed: one pre-existing tag A note.
358        db.store_note(&StoredNote {
359            header: test_note_header_with_tag(TAG_A),
360            details: vec![1],
361            created_at: Utc::now(),
362            seq: 0,
363        })
364        .await
365        .unwrap();
366
367        // === Simulate the old per-tag loop ===
368        // Step 1: fetch tag A.
369        let a_result = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
370        assert_eq!(a_result.len(), 1);
371
372        // Between the per-tag queries, two concurrent writes commit in order:
373        //   (i) a tag A note — this is the one the loop will lose.
374        //  (ii) a tag B note — its higher seq will bump the client's cursor
375        //       past (i), rendering (i) unreachable on retry.
376        db.store_note(&StoredNote {
377            header: test_note_header_with_tag(TAG_A),
378            details: vec![2],
379            created_at: Utc::now(),
380            seq: 0,
381        })
382        .await
383        .unwrap();
384        db.store_note(&StoredNote {
385            header: test_note_header_with_tag(TAG_B),
386            details: vec![3],
387            created_at: Utc::now(),
388            seq: 0,
389        })
390        .await
391        .unwrap();
392
393        // Step 2: fetch tag B.
394        let b_result = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
395        assert_eq!(b_result.len(), 1);
396
397        // Client advances cursor to max(seq) across both results, mirroring the
398        // gRPC handler's `rcursor` computation.
399        let rcursor: u64 = a_result
400            .iter()
401            .chain(b_result.iter())
402            .map(|n| u64::try_from(n.seq).unwrap())
403            .max()
404            .unwrap_or(0);
405
406        // Client's next per-tag fetches with the advanced cursor.
407        let retry_a = db.fetch_notes(TAG_A.into(), rcursor).await.unwrap();
408        let retry_b = db.fetch_notes(TAG_B.into(), rcursor).await.unwrap();
409
410        // Per-tag loop sees only 2 of the 3 notes — the interleaved tag A
411        // insert with the details=[2] payload is missing.
412        let per_tag_visible = a_result.len() + b_result.len() + retry_a.len() + retry_b.len();
413        assert_eq!(
414            per_tag_visible, 2,
415            "per-tag loop must lose the interleaved tag-A insert; visible = {per_tag_visible}"
416        );
417        assert!(
418            !retry_a.iter().any(|n| n.details == vec![2]),
419            "the interleaved tag-A insert with details=[2] must be unreachable to the per-tag loop"
420        );
421
422        // === The fix: single-snapshot multi-tag query ===
423        let snapshot = db.fetch_notes_by_tags(&[TAG_A.into(), TAG_B.into()], 0).await.unwrap();
424        assert_eq!(
425            snapshot.len(),
426            3,
427            "fetch_notes_by_tags must return all 3 notes in a single consistent snapshot; got {}",
428            snapshot.len()
429        );
430        assert!(
431            snapshot.iter().any(|n| n.details == vec![2]),
432            "the interleaved tag-A insert MUST be visible via the single-snapshot query"
433        );
434    }
435
436    /// Legacy cursor reset: a client carrying a pre-migration microsecond-
437    /// timestamp cursor (e.g. 1.7×10^15) would otherwise see 0 notes until
438    /// `seq` reached that magnitude, which at realistic rates is decades.
439    /// Cursors above `LEGACY_CURSOR_THRESHOLD` are treated as 0.
440    #[tokio::test]
441    async fn test_fetch_notes_resets_legacy_cursor() {
442        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
443            .await
444            .unwrap();
445
446        let note = StoredNote {
447            header: test_note_header(),
448            details: vec![1, 2, 3, 4],
449            created_at: Utc::now(),
450            seq: 0,
451        };
452        db.store_note(&note).await.unwrap();
453
454        // A realistic "legacy" cursor — microseconds since the epoch, currently
455        // ~1.76×10^15. Well above the 10^12 threshold.
456        let legacy_cursor: u64 = 1_760_000_000_000_000;
457        let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), legacy_cursor).await.unwrap();
458        assert_eq!(
459            fetched.len(),
460            1,
461            "legacy microsecond cursor should be reset to 0, returning the note"
462        );
463
464        // Sanity check: a non-legacy cursor above the note's seq should NOT trigger the reset.
465        let normal_cursor: u64 = 1_000;
466        let empty = db.fetch_notes(TAG_LOCAL_ANY.into(), normal_cursor).await.unwrap();
467        assert_eq!(empty.len(), 0, "normal cursor > seq should filter correctly");
468    }
469
470    /// Pagination: a response is capped at `FETCH_NOTES_BATCH_SIZE` rows. A
471    /// backlogged client sees a bounded batch on each call and advances the
472    /// cursor to pick up the rest on the next call.
473    #[tokio::test]
474    async fn test_fetch_notes_paginates_at_batch_limit() {
475        use crate::database::sqlite::FETCH_NOTES_BATCH_SIZE;
476
477        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
478            .await
479            .unwrap();
480
481        // Insert BATCH_SIZE + extra notes for the same tag.
482        let extra: usize = 7;
483        let total = usize::try_from(FETCH_NOTES_BATCH_SIZE).unwrap() + extra;
484        for i in 0..total {
485            db.store_note(&StoredNote {
486                header: test_note_header(),
487                details: vec![(i % 256) as u8],
488                created_at: Utc::now(),
489                seq: 0,
490            })
491            .await
492            .unwrap();
493        }
494
495        // First fetch from cursor=0 returns exactly BATCH_SIZE rows.
496        let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
497        assert_eq!(
498            i64::try_from(first.len()).unwrap(),
499            FETCH_NOTES_BATCH_SIZE,
500            "first batch must be capped at FETCH_NOTES_BATCH_SIZE"
501        );
502
503        // Advance cursor to max(seq) and refetch — remaining rows come back.
504        let advanced: u64 = first.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap();
505        let second = db.fetch_notes(TAG_LOCAL_ANY.into(), advanced).await.unwrap();
506        assert_eq!(second.len(), extra, "second batch must contain the remaining {extra} rows");
507
508        // Third fetch drains nothing (nothing left).
509        let third_cursor: u64 =
510            second.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap_or(advanced);
511        let third = db.fetch_notes(TAG_LOCAL_ANY.into(), third_cursor).await.unwrap();
512        assert_eq!(third.len(), 0, "drained");
513
514        // Stats reflect every row written.
515        let (total_stats, _) = db.get_stats().await.unwrap();
516        assert_eq!(usize::try_from(total_stats).unwrap(), total);
517    }
518}