Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ Skip the changelog only when the PR contains no runtime-affecting changes
(docs, CI, tooling, tests). In that case the hook will tell you to apply the
`no changelog` label instead.

## Unreleased

### Changes

- Removed support for in-memory databases. The --database-url CLI option is now required, the service won't start without explicitly specifying the database file path ([#113](https://github.com/0xMiden/note-transport-service/pull/113)).

## v0.4.1 (2026-06-17)

### Features
Expand All @@ -56,4 +62,4 @@ Released before this changelog was started. See [`git log v0.2..v0.3.0`](https:/

## v0.2 (2026-01-24)

First tagged release. Earlier history available via `git log v0.2`.
First tagged release. Earlier history available via `git log v0.2`.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ rand = { version = "0.9" }
serde = { features = ["derive"], version = "1.0" }
serde_json = { version = "1.0" }
serial_test = { version = "3.2" }
tempfile = { version = "3" }
thiserror = { default-features = false, version = "2.0" }
tokio = { features = ["macros", "net", "rt-multi-thread"], version = "1.48" }
tonic = { default-features = false, features = ["codegen", "transport"], version = "0.14" }
Expand Down
18 changes: 17 additions & 1 deletion bin/node/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct Args {
port: u16,

/// Database URL
#[arg(long, default_value = ":memory:")]
#[arg(long)]
database_url: String,

/// Retention period in days
Expand Down Expand Up @@ -82,3 +82,19 @@ async fn main() -> Result<()> {

Ok(())
}

#[cfg(test)]
mod tests {
use clap::error::ErrorKind;

use super::*;

#[test]
fn database_url_is_required() {
let Err(error) = Args::try_parse_from(["miden-note-transport-node"]) else {
panic!("database URL should be required");
};

assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument);
}
}
1 change: 1 addition & 0 deletions crates/node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ rand = { workspace = true }

[dev-dependencies]
serial_test = { workspace = true }
tempfile = { workspace = true }
11 changes: 7 additions & 4 deletions crates/node/src/database/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ mod tests {

use super::*;
use crate::metrics::Metrics;
use crate::test_utils::test_note_header;
use crate::test_utils::{temp_database_config, test_note_header};
use crate::types::StoredNote;

fn note_at(age: Duration) -> StoredNote {
Expand All @@ -86,7 +86,8 @@ mod tests {
#[tokio::test]
#[serial]
async fn test_cleanup_old_notes_no_retention() {
let config = DatabaseConfig { retention_days: 0, ..Default::default() };
let (mut config, _temp_dir) = temp_database_config();
config.retention_days = 0;

let db = Arc::new(Database::connect(config.clone(), Metrics::default().db).await.unwrap());
db.store_note(&note_at(Duration::from_secs(30))).await.unwrap();
Expand All @@ -102,7 +103,8 @@ mod tests {
#[tokio::test]
#[serial]
async fn test_cleanup_old_notes_retention() {
let config = DatabaseConfig { retention_days: 7, ..Default::default() };
let (mut config, _temp_dir) = temp_database_config();
config.retention_days = 7;

let db = Arc::new(Database::connect(config.clone(), Metrics::default().db).await.unwrap());
db.store_note(&note_at(Duration::from_secs(30))).await.unwrap();
Expand All @@ -118,7 +120,8 @@ mod tests {
#[tokio::test]
#[serial]
async fn test_cleanup_old_notes_mixed_ages() {
let config = DatabaseConfig { retention_days: 1, ..Default::default() };
let (mut config, _temp_dir) = temp_database_config();
config.retention_days = 1;

let db = Arc::new(Database::connect(config.clone(), Metrics::default().db).await.unwrap());
db.store_note(&note_at(Duration::from_secs(30))).await.unwrap();
Expand Down
90 changes: 41 additions & 49 deletions crates/node/src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,6 @@ pub struct DatabaseConfig {
pub retention_days: u32,
}

impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: ":memory:".to_string(),
retention_days: 30,
}
}
}

impl Database {
/// Connect to a database (with `SQLite` backend)
pub async fn connect(
Expand Down Expand Up @@ -126,16 +117,43 @@ impl Database {
#[cfg(test)]
mod tests {
use chrono::Utc;
use tempfile::TempDir;

use super::*;
use crate::metrics::Metrics;
use crate::test_utils::{TAG_LOCAL_ANY, test_note_header, test_note_header_with_tag};
use crate::test_utils::{
TAG_LOCAL_ANY,
temp_database_config,
test_note_header,
test_note_header_with_tag,
};

async fn test_database() -> (Database, TempDir) {
let (config, temp_dir) = temp_database_config();
let database = Database::connect(config, Metrics::default().db).await.unwrap();
(database, temp_dir)
}

#[tokio::test]
async fn test_in_memory_databases_are_rejected() {
for url in [":memory:", "file::memory:?cache=shared", "file:notes?mode=memory&cache=shared"]
{
let result = Database::connect(
DatabaseConfig { url: url.to_string(), retention_days: 30 },
Metrics::default().db,
)
.await;

assert!(
matches!(result, Err(DatabaseError::Configuration(message)) if message.contains("in-memory")),
"expected an in-memory database configuration error for {url}"
);
}
}

#[tokio::test]
async fn test_sqlite_database() {
let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

let note = StoredNote {
header: test_note_header(),
Expand Down Expand Up @@ -164,9 +182,7 @@ mod tests {

#[tokio::test]
async fn test_seq_assigned_monotonically_in_insert_order() {
let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

let first = StoredNote {
header: test_note_header(),
Expand Down Expand Up @@ -207,29 +223,17 @@ mod tests {

#[tokio::test]
async fn test_concurrent_store_fetch_sees_all_rows() {
// Regression test for the `:memory:` pool-isolation bug: when the pool
// had max_size>1 and the URL was `:memory:`, writes and reads could
// land on different connections and each connection had its own
// isolated in-memory DB. Result: writes silently split across pool
// connections, fetches only saw a fraction of the actual data.
//
// With the pool clamped to size=1 for `:memory:`, all ops go to the
// same connection and see the same DB.
use std::sync::Arc;

use tokio::task::JoinSet;

const TAG_A: u32 = 0x3d9c_0000;
const TAG_B: u32 = 0x47ac_0000;

let db = Arc::new(
Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap(),
);
let (db, _temp_dir) = test_database().await;
let db = Arc::new(db);

// Spawn many concurrent writers — more than the old max_size=16 — so
// that the bug would have fragmented writes across connections.
// Spawn more concurrent writers than the connection pool can service at once.
let mut writers = JoinSet::new();
for i in 0..40u32 {
let db = db.clone();
Expand Down Expand Up @@ -262,9 +266,7 @@ mod tests {

#[tokio::test]
async fn test_fetch_notes_seq_cursor_filtering() {
let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

let note = StoredNote {
header: test_note_header(),
Expand Down Expand Up @@ -298,9 +300,7 @@ mod tests {
/// distinct monotonic id and both are reachable.
#[tokio::test]
async fn test_seq_cursor_survives_identical_created_at() {
let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

let t = Utc::now();
let note1 = StoredNote {
Expand Down Expand Up @@ -357,9 +357,7 @@ mod tests {
const TAG_A: u32 = 0x3d9c_0000;
const TAG_B: u32 = 0x47ac_0000;

let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

// Seed: one pre-existing tag A note.
db.store_note(&StoredNote {
Expand Down Expand Up @@ -449,9 +447,7 @@ mod tests {
/// Cursors above `LEGACY_CURSOR_THRESHOLD` are treated as 0.
#[tokio::test]
async fn test_fetch_notes_resets_legacy_cursor() {
let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

let note = StoredNote {
header: test_note_header(),
Expand Down Expand Up @@ -485,9 +481,7 @@ mod tests {
async fn test_fetch_notes_paginates_at_batch_limit() {
use crate::database::sqlite::FETCH_NOTES_BATCH_SIZE;

let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

// Insert BATCH_SIZE + extra notes for the same tag.
let extra: usize = 7;
Expand Down Expand Up @@ -538,9 +532,7 @@ mod tests {
async fn test_block_context_round_trips_through_store_and_fetch() {
use miden_note_transport_proto::miden_note_transport::TransportNote;

let db = Database::connect(DatabaseConfig::default(), Metrics::default().db)
.await
.unwrap();
let (db, _temp_dir) = test_database().await;

// Store a note with a typical after_block_num value.
let note = StoredNote {
Expand Down
31 changes: 13 additions & 18 deletions crates/node/src/database/sqlite/connection_manager.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
//! A minimal connection manager wrapper
//!
//! Only required to setup connection parameters, specifically `WAL`.
//! A minimal connection manager wrapper for per-connection SQLite parameters.

use deadpool_sync::InteractError;
use diesel::prelude::*;

use crate::database::DatabaseError;
use crate::database::sqlite::migrations;

/// Connection manager error types
Expand Down Expand Up @@ -80,13 +79,6 @@ impl deadpool::managed::Manager for ConnectionManager {
pub fn configure_connection_on_creation(
conn: &mut SqliteConnection,
) -> Result<(), ConnectionManagerError> {
// Enable the WAL mode. This allows concurrent reads while the transaction is being written,
// this is required for proper synchronization of the servers in-memory and on-disk
// representations (see [State::apply_block])
diesel::sql_query("PRAGMA journal_mode=WAL")
.execute(conn)
.map_err(ConnectionManagerError::ConnectionParamSetup)?;

// Enable foreign key checks.
diesel::sql_query("PRAGMA foreign_keys=ON")
.execute(conn)
Expand All @@ -97,13 +89,16 @@ pub fn configure_connection_on_creation(
.execute(conn)
.map_err(ConnectionManagerError::ConnectionParamSetup)?;

// Apply migrations on each connection to ensure schema is up to date
migrations::apply_migrations(conn).map_err(|e| {
ConnectionManagerError::ConnectionParamSetup(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UnableToSendCommand,
Box::new(format!("Migration failed: {e}")),
))
})?;

Ok(())
}

/// Initializes settings and schema shared by all connections to a database file.
pub fn initialize_database(conn: &mut SqliteConnection) -> Result<(), DatabaseError> {
// WAL mode is persistent for the database file and must be enabled once, before the pool can
// create connections concurrently.
diesel::sql_query("PRAGMA journal_mode=WAL")
.execute(conn)
.map_err(|e| DatabaseError::Configuration(format!("Failed to enable WAL mode: {e}")))?;

migrations::apply_migrations(conn)
}
2 changes: 1 addition & 1 deletion crates/node/src/database/sqlite/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/database/sqlit

#[instrument(level = "debug", skip_all, err)]
pub fn apply_migrations(conn: &mut SqliteConnection) -> std::result::Result<(), DatabaseError> {
let migrations = conn.pending_migrations(MIGRATIONS).expect("In memory migrations never fail");
let migrations = conn.pending_migrations(MIGRATIONS).expect("embedded migrations are valid");
tracing::info!("Applying {} migration(s)", migrations.len());

if let Err(e) = conn.run_pending_migrations(MIGRATIONS) {
Expand Down
Loading
Loading