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            after_block_num: None,
146        };
147
148        db.store_note(&note).await.unwrap();
149
150        // Cursor is now seq-based; 0 fetches everything.
151        let fetched_notes = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
152        assert_eq!(fetched_notes.len(), 1);
153        assert_eq!(fetched_notes[0].header.id(), note.header.id());
154        assert!(fetched_notes[0].seq > 0);
155
156        // Test note exists
157        assert!(db.note_exists(note.header.id()).await.unwrap());
158
159        // Test stats
160        let (total_notes, total_tags) = db.get_stats().await.unwrap();
161        assert_eq!(total_notes, 1);
162        assert_eq!(total_tags, 1);
163    }
164
165    #[tokio::test]
166    async fn test_seq_assigned_monotonically_in_insert_order() {
167        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
168            .await
169            .unwrap();
170
171        let first = StoredNote {
172            header: test_note_header(),
173            details: vec![1],
174            created_at: Utc::now(),
175            seq: 0,
176            after_block_num: None,
177        };
178        db.store_note(&first).await.unwrap();
179
180        let second = StoredNote {
181            header: test_note_header(),
182            details: vec![2],
183            created_at: Utc::now(),
184            seq: 0,
185            after_block_num: None,
186        };
187        db.store_note(&second).await.unwrap();
188
189        // Fetch everything and assert INSERT order = read order = seq ascending.
190        let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
191        assert_eq!(fetched.len(), 2);
192        assert!(
193            fetched[0].seq < fetched[1].seq,
194            "expected monotonic seq; got {} then {}",
195            fetched[0].seq,
196            fetched[1].seq
197        );
198        assert_eq!(fetched[0].details, vec![1]);
199        assert_eq!(fetched[1].details, vec![2]);
200
201        // Cursor between the two seqs returns only the second.
202        let mid_cursor = u64::try_from(fetched[0].seq).expect("seq is non-negative");
203        let after_first = db.fetch_notes(TAG_LOCAL_ANY.into(), mid_cursor).await.unwrap();
204        assert_eq!(after_first.len(), 1);
205        assert_eq!(after_first[0].details, vec![2]);
206    }
207
208    #[tokio::test]
209    async fn test_concurrent_store_fetch_sees_all_rows() {
210        // Regression test for the `:memory:` pool-isolation bug: when the pool
211        // had max_size>1 and the URL was `:memory:`, writes and reads could
212        // land on different connections and each connection had its own
213        // isolated in-memory DB. Result: writes silently split across pool
214        // connections, fetches only saw a fraction of the actual data.
215        //
216        // With the pool clamped to size=1 for `:memory:`, all ops go to the
217        // same connection and see the same DB.
218        use std::sync::Arc;
219
220        use tokio::task::JoinSet;
221
222        const TAG_A: u32 = 0x3d9c_0000;
223        const TAG_B: u32 = 0x47ac_0000;
224
225        let db = Arc::new(
226            Database::connect(DatabaseConfig::default(), Metrics::default().db)
227                .await
228                .unwrap(),
229        );
230
231        // Spawn many concurrent writers — more than the old max_size=16 — so
232        // that the bug would have fragmented writes across connections.
233        let mut writers = JoinSet::new();
234        for i in 0..40u32 {
235            let db = db.clone();
236            writers.spawn(async move {
237                let tag = if i % 2 == 0 { TAG_A } else { TAG_B };
238                db.store_note(&StoredNote {
239                    header: test_note_header_with_tag(tag),
240                    details: vec![i as u8],
241                    created_at: Utc::now(),
242                    seq: 0,
243                    after_block_num: None,
244                })
245                .await
246                .unwrap();
247            });
248        }
249        while writers.join_next().await.is_some() {}
250
251        let fetched_a = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
252        let fetched_b = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
253        assert_eq!(
254            fetched_a.len() + fetched_b.len(),
255            40,
256            "all 40 concurrent writes should be visible"
257        );
258
259        let (total, _) = db.get_stats().await.unwrap();
260        assert_eq!(total, 40, "stats should reflect all 40 rows");
261    }
262
263    #[tokio::test]
264    async fn test_fetch_notes_seq_cursor_filtering() {
265        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
266            .await
267            .unwrap();
268
269        let note = StoredNote {
270            header: test_note_header(),
271            details: vec![1, 2, 3, 4],
272            created_at: Utc::now(),
273            seq: 0, // ignored on INSERT
274            after_block_num: None,
275        };
276
277        db.store_note(&note).await.unwrap();
278
279        // cursor=0 is strictly before any assigned seq → should return the note
280        let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
281        assert_eq!(fetched.len(), 1);
282        let stored_seq = fetched[0].seq;
283        assert!(stored_seq > 0, "expected seq > 0, got {stored_seq}");
284
285        // cursor = the note's own seq → strictly-greater filter excludes it
286        let cursor = u64::try_from(stored_seq).expect("seq is non-negative");
287        let after = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
288        assert_eq!(after.len(), 0);
289    }
290
291    /// Deterministic regression test for the seq-cursor fix.
292    ///
293    /// Two notes with IDENTICAL `created_at` microseconds (possible on macOS
294    /// under concurrent writes; more broadly, any system where wall-clock is
295    /// not injective under load). With the old `created_at` cursor, the
296    /// client's `rcursor = max(ts)` strict-greater filter rendered the 2nd
297    /// note permanently invisible. With the `seq` cursor, each note gets a
298    /// distinct monotonic id and both are reachable.
299    #[tokio::test]
300    async fn test_seq_cursor_survives_identical_created_at() {
301        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
302            .await
303            .unwrap();
304
305        let t = Utc::now();
306        let note1 = StoredNote {
307            header: test_note_header(),
308            details: vec![1],
309            created_at: t,
310            seq: 0,
311            after_block_num: None,
312        };
313        db.store_note(&note1).await.unwrap();
314
315        // Client's first fetch sees note1, advances cursor using the returned seq.
316        let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
317        assert_eq!(first.len(), 1);
318        let cursor = u64::try_from(first[0].seq).expect("seq is non-negative");
319
320        // A concurrent writer commits note2 with the SAME `created_at`.
321        let note2 = StoredNote {
322            header: test_note_header(),
323            details: vec![2],
324            created_at: t,
325            seq: 0,
326            after_block_num: None,
327        };
328        db.store_note(&note2).await.unwrap();
329
330        // With seq cursor: note2 has seq > cursor → returned.
331        // With old timestamp cursor: note2.ts == cursor → filtered by strict `>` → LOST.
332        let second = db.fetch_notes(TAG_LOCAL_ANY.into(), cursor).await.unwrap();
333        assert_eq!(
334            second.len(),
335            1,
336            "seq cursor must return note2 despite identical created_at; got {} notes",
337            second.len()
338        );
339        assert_eq!(second[0].details, vec![2]);
340    }
341
342    /// Deterministic regression test for the multi-tag single-snapshot fix.
343    ///
344    /// Simulates the old gRPC handler's per-tag loop: fetch tag A, then fetch
345    /// tag B, then advance client cursor to `max(seq)` across both results.
346    /// A concurrent tag-A insert landing between the per-tag queries gets a
347    /// seq ABOVE A's max but BELOW B's max — so when the client advances to
348    /// B's max seq, the A insert becomes permanently unreachable (its seq is
349    /// below the advanced cursor).
350    ///
351    /// `fetch_notes_by_tags` collapses the loop into a single `tag IN (…)`
352    /// query under one snapshot, so no interleave is possible.
353    #[tokio::test]
354    async fn test_multi_tag_single_snapshot_vs_per_tag_loop() {
355        use std::convert::TryFrom;
356
357        const TAG_A: u32 = 0x3d9c_0000;
358        const TAG_B: u32 = 0x47ac_0000;
359
360        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
361            .await
362            .unwrap();
363
364        // Seed: one pre-existing tag A note.
365        db.store_note(&StoredNote {
366            header: test_note_header_with_tag(TAG_A),
367            details: vec![1],
368            created_at: Utc::now(),
369            seq: 0,
370            after_block_num: None,
371        })
372        .await
373        .unwrap();
374
375        // === Simulate the old per-tag loop ===
376        // Step 1: fetch tag A.
377        let a_result = db.fetch_notes(TAG_A.into(), 0).await.unwrap();
378        assert_eq!(a_result.len(), 1);
379
380        // Between the per-tag queries, two concurrent writes commit in order:
381        //   (i) a tag A note — this is the one the loop will lose.
382        //  (ii) a tag B note — its higher seq will bump the client's cursor
383        //       past (i), rendering (i) unreachable on retry.
384        db.store_note(&StoredNote {
385            header: test_note_header_with_tag(TAG_A),
386            details: vec![2],
387            created_at: Utc::now(),
388            seq: 0,
389            after_block_num: None,
390        })
391        .await
392        .unwrap();
393        db.store_note(&StoredNote {
394            header: test_note_header_with_tag(TAG_B),
395            details: vec![3],
396            created_at: Utc::now(),
397            seq: 0,
398            after_block_num: None,
399        })
400        .await
401        .unwrap();
402
403        // Step 2: fetch tag B.
404        let b_result = db.fetch_notes(TAG_B.into(), 0).await.unwrap();
405        assert_eq!(b_result.len(), 1);
406
407        // Client advances cursor to max(seq) across both results, mirroring the
408        // gRPC handler's `rcursor` computation.
409        let rcursor: u64 = a_result
410            .iter()
411            .chain(b_result.iter())
412            .map(|n| u64::try_from(n.seq).unwrap())
413            .max()
414            .unwrap_or(0);
415
416        // Client's next per-tag fetches with the advanced cursor.
417        let retry_a = db.fetch_notes(TAG_A.into(), rcursor).await.unwrap();
418        let retry_b = db.fetch_notes(TAG_B.into(), rcursor).await.unwrap();
419
420        // Per-tag loop sees only 2 of the 3 notes — the interleaved tag A
421        // insert with the details=[2] payload is missing.
422        let per_tag_visible = a_result.len() + b_result.len() + retry_a.len() + retry_b.len();
423        assert_eq!(
424            per_tag_visible, 2,
425            "per-tag loop must lose the interleaved tag-A insert; visible = {per_tag_visible}"
426        );
427        assert!(
428            !retry_a.iter().any(|n| n.details == vec![2]),
429            "the interleaved tag-A insert with details=[2] must be unreachable to the per-tag loop"
430        );
431
432        // === The fix: single-snapshot multi-tag query ===
433        let snapshot = db.fetch_notes_by_tags(&[TAG_A.into(), TAG_B.into()], 0).await.unwrap();
434        assert_eq!(
435            snapshot.len(),
436            3,
437            "fetch_notes_by_tags must return all 3 notes in a single consistent snapshot; got {}",
438            snapshot.len()
439        );
440        assert!(
441            snapshot.iter().any(|n| n.details == vec![2]),
442            "the interleaved tag-A insert MUST be visible via the single-snapshot query"
443        );
444    }
445
446    /// Legacy cursor reset: a client carrying a pre-migration microsecond-
447    /// timestamp cursor (e.g. 1.7×10^15) would otherwise see 0 notes until
448    /// `seq` reached that magnitude, which at realistic rates is decades.
449    /// Cursors above `LEGACY_CURSOR_THRESHOLD` are treated as 0.
450    #[tokio::test]
451    async fn test_fetch_notes_resets_legacy_cursor() {
452        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
453            .await
454            .unwrap();
455
456        let note = StoredNote {
457            header: test_note_header(),
458            details: vec![1, 2, 3, 4],
459            created_at: Utc::now(),
460            seq: 0,
461            after_block_num: None,
462        };
463        db.store_note(&note).await.unwrap();
464
465        // A realistic "legacy" cursor — microseconds since the epoch, currently
466        // ~1.76×10^15. Well above the 10^12 threshold.
467        let legacy_cursor: u64 = 1_760_000_000_000_000;
468        let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), legacy_cursor).await.unwrap();
469        assert_eq!(
470            fetched.len(),
471            1,
472            "legacy microsecond cursor should be reset to 0, returning the note"
473        );
474
475        // Sanity check: a non-legacy cursor above the note's seq should NOT trigger the reset.
476        let normal_cursor: u64 = 1_000;
477        let empty = db.fetch_notes(TAG_LOCAL_ANY.into(), normal_cursor).await.unwrap();
478        assert_eq!(empty.len(), 0, "normal cursor > seq should filter correctly");
479    }
480
481    /// Pagination: a response is capped at `FETCH_NOTES_BATCH_SIZE` rows. A
482    /// backlogged client sees a bounded batch on each call and advances the
483    /// cursor to pick up the rest on the next call.
484    #[tokio::test]
485    async fn test_fetch_notes_paginates_at_batch_limit() {
486        use crate::database::sqlite::FETCH_NOTES_BATCH_SIZE;
487
488        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
489            .await
490            .unwrap();
491
492        // Insert BATCH_SIZE + extra notes for the same tag.
493        let extra: usize = 7;
494        let total = usize::try_from(FETCH_NOTES_BATCH_SIZE).unwrap() + extra;
495        for i in 0..total {
496            db.store_note(&StoredNote {
497                header: test_note_header(),
498                details: vec![(i % 256) as u8],
499                created_at: Utc::now(),
500                seq: 0,
501                after_block_num: None,
502            })
503            .await
504            .unwrap();
505        }
506
507        // First fetch from cursor=0 returns exactly BATCH_SIZE rows.
508        let first = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
509        assert_eq!(
510            i64::try_from(first.len()).unwrap(),
511            FETCH_NOTES_BATCH_SIZE,
512            "first batch must be capped at FETCH_NOTES_BATCH_SIZE"
513        );
514
515        // Advance cursor to max(seq) and refetch — remaining rows come back.
516        let advanced: u64 = first.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap();
517        let second = db.fetch_notes(TAG_LOCAL_ANY.into(), advanced).await.unwrap();
518        assert_eq!(second.len(), extra, "second batch must contain the remaining {extra} rows");
519
520        // Third fetch drains nothing (nothing left).
521        let third_cursor: u64 =
522            second.iter().map(|n| u64::try_from(n.seq).unwrap()).max().unwrap_or(advanced);
523        let third = db.fetch_notes(TAG_LOCAL_ANY.into(), third_cursor).await.unwrap();
524        assert_eq!(third.len(), 0, "drained");
525
526        // Stats reflect every row written.
527        let (total_stats, _) = db.get_stats().await.unwrap();
528        assert_eq!(usize::try_from(total_stats).unwrap(), total);
529    }
530
531    /// `after_block_num` survives the full round-trip: `StoredNote` →
532    /// `NewNote` → INSERT → SELECT → `Note` → `StoredNote` → `TransportNote`.
533    ///
534    /// Also verifies `u32::MAX` (the upper bound of the proto `uint32` field)
535    /// round-trips correctly through the `i64` `SQLite` column, confirming no
536    /// truncation at the `i32::MAX` boundary.
537    #[tokio::test]
538    async fn test_block_context_round_trips_through_store_and_fetch() {
539        use miden_note_transport_proto::miden_note_transport::TransportNote;
540
541        let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
542            .await
543            .unwrap();
544
545        // Store a note with a typical after_block_num value.
546        let note = StoredNote {
547            header: test_note_header(),
548            details: vec![10, 20, 30],
549            created_at: Utc::now(),
550            seq: 0,
551            after_block_num: Some(12345),
552        };
553        db.store_note(&note).await.unwrap();
554
555        let fetched = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
556        assert_eq!(fetched.len(), 1);
557        assert_eq!(fetched[0].after_block_num, Some(12345));
558
559        // Proto conversion must preserve the field.
560        let proto: TransportNote = fetched.into_iter().next().unwrap().into();
561        assert_eq!(proto.after_block_num, Some(12345));
562
563        // Store a second note with u32::MAX to confirm the i64 column handles
564        // the full u32 range without truncation at i32::MAX (2,147,483,647).
565        let note_max = StoredNote {
566            header: test_note_header(),
567            details: vec![99],
568            created_at: Utc::now(),
569            seq: 0,
570            after_block_num: Some(u32::MAX),
571        };
572        db.store_note(&note_max).await.unwrap();
573
574        // Fetch all notes (cursor 0) and find the u32::MAX one by details.
575        let all = db.fetch_notes(TAG_LOCAL_ANY.into(), 0).await.unwrap();
576        let max_note = all.iter().find(|n| n.details == vec![99]).expect("u32::MAX note not found");
577        assert_eq!(
578            max_note.after_block_num,
579            Some(u32::MAX),
580            "u32::MAX must survive the u32 -> i64 -> u32 round-trip"
581        );
582    }
583}