SQLite persistence backend for AimDB. Stores long-term record history in a local SQLite database using WAL mode and a dedicated writer thread so the async executor is never blocked.
Runtime requirement: Requires a Tokio runtime. The writer thread communicates with async callers via
tokio::sync::oneshot. Do not use this crate with the Embassy adapter — it will not link.
[dependencies]
aimdb-persistence = "0.1"
aimdb-persistence-sqlite = "0.1"SQLite is bundled (via rusqlite's bundled feature) — no system SQLite
is needed.
Optional features:
aimdb-persistence-sqlite = { version = "0.1", features = ["tracing"] }use std::sync::Arc;
use std::time::Duration;
use aimdb_core::AimDbBuilder;
use aimdb_persistence::{AimDbBuilderPersistExt, AimDbQueryExt, RecordRegistrarPersistExt};
use aimdb_persistence_sqlite::SqliteBackend;
use aimdb_tokio_adapter::TokioAdapter;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Accuracy { value: f64, city: String }
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Arc::new(TokioAdapter::new()?);
// Open (or create) the database file.
let backend = Arc::new(SqliteBackend::new("./data/history.db")?);
let mut builder = AimDbBuilder::new()
.runtime(runtime)
.with_persistence(backend, Duration::from_secs(7 * 24 * 3600));
builder.configure::<Accuracy>("accuracy::vienna", |reg| {
reg.persist("accuracy::vienna");
});
let db = builder.build().await?;
let latest: Vec<Accuracy> = db.query_latest("accuracy::*", 5).await?;
println!("{} rows returned", latest.len());
Ok(())
} async caller (AimDB task)
│
│ mpsc::SyncSender<DbCommand> (bound = 64)
▼
┌─────────────────────────────────────┐
│ aimdb-sqlite OS thread │
│ │
│ rusqlite::Connection (WAL mode) │
│ ┌──────────────────────────────┐ │
│ │ record_history table │ │
│ │ id, record_name, value_json │ │
│ │ stored_at (Unix ms, i64) │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
│
│ tokio::sync::oneshot (reply)
▼
async caller receives Result<_, PersistenceError>
Key design properties:
- Non-blocking writes — each
store()call enqueues aDbCommandandawaits a oneshot reply; the writer thread does all SQLite I/O synchronously on its own OS thread. - Single writer, concurrent readers — WAL mode allows readers (queries) to proceed concurrently with the single writer.
- Prepared-statement cache —
prepare_cached()is used for all hot paths (INSERT, DELETE, SELECT) to avoid repeated SQL compilation. - Graceful shutdown — the writer thread exits cleanly when all
SqliteBackendclones (i.e. allSyncSenderhandles) are dropped.
CREATE TABLE IF NOT EXISTS record_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_name TEXT NOT NULL,
value_json TEXT NOT NULL,
stored_at INTEGER NOT NULL -- Unix timestamp in milliseconds (i64)
);
CREATE INDEX IF NOT EXISTS idx_record_time
ON record_history(record_name, stored_at DESC);The WITH … ROW_NUMBER() window function is used for efficient "top-N per
group" queries without a full table scan.
Opens (or creates) a database at path. Schema and WAL mode are initialised
synchronously before the writer thread is spawned — no block_on needed, no
Tokio runtime required at construction time.
let backend = SqliteBackend::new("/var/db/aimdb.sqlite")?;
let backend = SqliteBackend::new(":memory:")?; // in-memory (tests)Returns Err(PersistenceError::Backend(_)) if the file cannot be opened or
the schema cannot be applied.
| Method | Description |
|---|---|
store(name, value, timestamp) |
Insert one row |
query(pattern, params) |
Window-function SELECT with optional * wildcard, time range, and per-record limit |
cleanup(older_than) |
DELETE rows where stored_at < older_than; returns row count |
QueryParams {
limit_per_record: Some(10), // None → return all rows in window
start_time: Some(ms), // None → no lower bound
end_time: Some(ms), // None → no upper bound
}record_name LIKE ?1 ESCAPE '\\' is used for pattern queries. The AimDB
wildcard * is mapped to SQL %; literal % and _ in record names are
escaped automatically. Only * is recognised as a wildcard — ? is treated
as a literal character.
Timestamps are stored as SQLite INTEGER (signed 64-bit). u64 values from
the persistence layer are checked with i64::try_from before insertion;
out-of-range values (> i64::MAX) are rejected with
PersistenceError::Backend rather than silently truncated.
| Feature | Default | Description |
|---|---|---|
tracing |
no | Emit structured log events (corrupted rows, writer thread lifecycle) |
# Unit tests (uses in-memory / tempfile databases, no external dependencies)
cargo test -p aimdb-persistence-sqliteThe test suite covers:
- Store and wildcard query
- Time-range query with boundary inclusion
- Retention cleanup (row count verification)
- SQL LIKE pattern escaping (
_must not match arbitrary characters)
| Crate | Role |
|---|---|
aimdb-persistence |
Trait definitions and builder/query extension traits |
aimdb-core |
Core AimDB builder and Extensions TypeMap |
aimdb-tokio-adapter |
Required Tokio runtime adapter |
See LICENSE.