Skip to content

chore: sqlite framework#2265

Open
SantiagoPittella wants to merge 2 commits into
nextfrom
santiagopittella-sqlite-framework-phase1
Open

chore: sqlite framework#2265
SantiagoPittella wants to merge 2 commits into
nextfrom
santiagopittella-sqlite-framework-phase1

Conversation

@SantiagoPittella

@SantiagoPittella SantiagoPittella commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

closes #2248 and #2249

Summary

Introduces a light SQLite framework in crates/db (miden-node-db) and migrates the validator onto it, removing Diesel. It handles a connection pool, type-enforced read/write transactions, always-cached queries, a type codec, and cacheable IN-list helpers.

  • The framework keeps a small pool of connections alive and hands them out. Because SQLite is blocking, the actual query work runs on a background thread.
  • We dont handle raw connections. The struct provides either a read or a write transaction and get a small
    handle:
    • read(...) gives you a ReadTx. It begins a read-only transaction that is never committed, so
      nothing it does can persist.
    • write(...) gives you a WriteTx. It begins a write transaction.
  • Added verbs like query_rows, query_opt, query_one, exists, count and execute (write only). Every one of them prepares its statement through SQLite's cache.
  • Added a codec to convert between native data types and sql's.

The usage is like this:

// a read
db.read("chain_tip", |tx| {
    tx.query_opt(
        "SELECT block_header FROM block_headers ORDER BY block_num DESC LIMIT 1",
        &[],
        |row| row.get::<BlockHeader>(0),
    )
}).await?;

// a write (several statements in one atomic transaction)
db.write("apply", move |tx| {
    tx.execute("INSERT INTO ... VALUES (?1, ?2)", &[&a, &b])?;
    tx.execute("UPDATE ... SET ... WHERE id = ?1", &[&id])?;
    Ok::<(), DatabaseError>(())
}).await?;

Changelog

changelog = "none"
reason    = "Internal change only."

@SantiagoPittella SantiagoPittella force-pushed the santiagopittella-sqlite-framework-phase1 branch from 31dc8cc to a954f57 Compare June 19, 2026 15:36
//! parameter and expand it with `json_each`, keeping the SQL text constant:
//!
//! - integer keys: `... WHERE col IN (SELECT value FROM json_each(?1))`
//! - BLOB keys: `... WHERE hex(col) IN (SELECT value FROM json_each(?1))`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what the cost of this hex() conversion is for BLOB values... I mean this would require one conversion per each list element in our query, but also in the SQLite query when it's comparing values, which might make this fairly expensive.

Do you have benchmarks for this?


/// Per-connection prepared-statement cache capacity. Raised above rusqlite's default of 16 because
/// the store keeps a larger set of distinct statements.
const STATEMENT_CACHE_CAPACITY: usize = 64;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, Diesel uses an "unlimited" cache -- we might as well just use something significantly larger here, like ~512 queries? We have a limited number of concurrent connections in the pool, so I think it's safe to do so.

impl PinnedConnection {
/// Runs `query` inside a read-only (`DEFERRED`, never committed) transaction on the pinned
/// connection.
pub async fn read<R, E, F>(&self, msg: impl ToString + Send, query: F) -> Result<R, E>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand this (and .write()) are the only ways to execute queries on a connections. These take a sync FnOnce that gets passed the actual ReadTx / WriteTx transaction.

This is very similar to what we had with Diesel.

My problem with this approach is that this makes it pretty much impossible to implement a "one transaction per request" model where the request handle could mix sync queries with async code. For that to work we'd need to have an API that actually returns a transaction instance the RPC handler can use while handling the same request.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I concur with this; I think a rough concept is:

let (write_conn, read_only_conn_pool) = database.load(<path>).unwrap();

This would also enforce that there is a single write connection only.

We're also largely "lying" about async in our current usage. The only actual async work we do is network IO over gRPC. The rest are all BS wrappers hiding the sync work - file IO, Sqlite and rocksdb are all sync operations in reality.

Comment on lines +37 to +42
/// A read-only transaction. Opened `DEFERRED` and never committed (changes roll back on drop).
pub struct ReadTx<'t>(&'t Transaction<'t>);

/// A read-write transaction. Opened `IMMEDIATE` and committed by the pool when the closure returns
/// `Ok`.
pub struct WriteTx<'t>(&'t Transaction<'t>);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we reframe this as

a write transaction is a read transaction with additional permissions

then you can reduce the duplication by:

Suggested change
/// A read-only transaction. Opened `DEFERRED` and never committed (changes roll back on drop).
pub struct ReadTx<'t>(&'t Transaction<'t>);
/// A read-write transaction. Opened `IMMEDIATE` and committed by the pool when the closure returns
/// `Ok`.
pub struct WriteTx<'t>(&'t Transaction<'t>);
/// A read-only transaction. Opened `DEFERRED` and never committed (changes roll back on drop).
pub struct ReadTx<'t>(&'t Transaction<'t>);
/// A read-write transaction. Opened `IMMEDIATE` and committed by the pool when the closure returns
/// `Ok`.
pub struct WriteTx<'t>(ReadTx<'t>);
// This lets you elide the entire read queries.
impl Deref<ReadTx> for WriteTx {
..
}

Comment on lines +164 to +166
// SHARED QUERY HELPERS
// =================================================================================================

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, I think we can collapse all of this by using Deref.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, I'm not sure that we need these helpers? Maybe we only need to expose a query method and see how that goes?

Comment thread crates/db/src/lib.rs
mod errors;
mod manager;
pub mod migration;
pub mod sqlite;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the end goal is for this to only be sqlite, I don't think we need to hide it in a submodule? i.e. we could just have transaction, pool, connection etc living in the root.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQLite: Design framework

3 participants