use std::time::{Duration, Instant};
use async_trait::async_trait;
use sqlx::SqlitePool;
use super::{CanonicalStore, DurabilityToken};
pub struct SqliteCanonicalStore {
pub(super) pool: SqlitePool,
instance_name: String,
outbox_table: String,
}
impl SqliteCanonicalStore {
pub fn new(
pool: SqlitePool,
instance_name: impl Into<String>,
outbox_table: impl Into<String>,
) -> Self {
Self {
pool,
instance_name: instance_name.into(),
outbox_table: outbox_table.into(),
}
}
fn safe_table(&self) -> Result<&str, String> {
let t = self.outbox_table.as_str();
if t.is_empty() || !t.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!(
"unsafe outbox_table '{t}' (SQLite identifiers must be [A-Za-z0-9_]+)"
));
}
Ok(t)
}
}
#[async_trait]
impl CanonicalStore for SqliteCanonicalStore {
fn backend_label(&self) -> &'static str {
"sqlite"
}
fn instance_name(&self) -> &str {
&self.instance_name
}
async fn current_durability_token(&self) -> Result<DurabilityToken, String> {
let v: (i64,) = sqlx::query_as("PRAGMA data_version")
.fetch_one(&self.pool)
.await
.map_err(|e| format!("PRAGMA data_version failed: {e}"))?;
Ok(DurabilityToken::new("sqlite", v.0.to_string()))
}
async fn wait_for_token(
&self,
token: &DurabilityToken,
timeout: Duration,
) -> Result<bool, String> {
if !token.is_for("sqlite") {
return Err(format!(
"SqliteCanonicalStore cannot wait on a '{}' token",
token.backend_label
));
}
let target: i64 = token
.value
.parse()
.map_err(|e| format!("invalid sqlite data_version '{}': {e}", token.value))?;
let started = Instant::now();
let poll = crate::runtime::canonical_store::durability_poll_interval(
timeout,
crate::runtime::canonical_store::SQLITE_DURABILITY_POLL_MS,
);
loop {
let (current,): (i64,) = sqlx::query_as("PRAGMA data_version")
.fetch_one(&self.pool)
.await
.map_err(|e| format!("PRAGMA data_version poll failed: {e}"))?;
if current >= target {
return Ok(true);
}
if started.elapsed() >= timeout {
return Ok(false);
}
tokio::time::sleep(poll).await;
}
}
async fn enqueue_outbox_event(
&self,
event_id: &str,
topic: &str,
partition_key: &str,
payload: &serde_json::Value,
) -> Result<i64, String> {
let table = self.safe_table()?;
let sql = format!(
"INSERT INTO {table} (event_id, topic, partition_key, payload, created_at) \
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
);
let payload_text = serde_json::to_string(payload)
.map_err(|e| format!("payload JSON serialise failed: {e}"))?;
let result = sqlx::query(&sql)
.bind(event_id)
.bind(topic)
.bind(partition_key)
.bind(payload_text)
.execute(&self.pool)
.await
.map_err(|e| format!("outbox insert (sqlite) failed: {e}"))?;
Ok(result.last_insert_rowid())
}
async fn outbox_max_seq(&self) -> Result<i64, String> {
let table = self.safe_table()?;
let sql = format!("SELECT COALESCE(MAX(event_seq), 0) FROM {table}");
let (max,): (i64,) = sqlx::query_as(&sql)
.fetch_one(&self.pool)
.await
.map_err(|e| format!("outbox max seq (sqlite) failed: {e}"))?;
Ok(max)
}
async fn ensure_system_tables(&self) -> Result<(), String> {
let table = self.safe_table()?;
let sql = super::sql_schema::sqlite_outbox_ddl(table);
sqlx::query(&sql)
.execute(&self.pool)
.await
.map_err(|e| format!("ensure_system_tables (sqlite) failed: {e}"))?;
Ok(())
}
async fn ensure_advisory_lease_table(&self) -> Result<(), String> {
let sql = "
CREATE TABLE IF NOT EXISTS udb_advisory_leases (
lease_name VARCHAR(255) PRIMARY KEY,
owner_id VARCHAR(255) NOT NULL,
expires_at TEXT NOT NULL
)
";
sqlx::query(sql)
.execute(&self.pool)
.await
.map_err(|e| format!("ensure_advisory_lease_table (sqlite) failed: {e}"))?;
Ok(())
}
async fn try_acquire_advisory_lease(
&self,
lease_name: &str,
owner_id: &str,
ttl: std::time::Duration,
) -> Result<bool, String> {
let now = chrono::Utc::now();
let now_iso = now.to_rfc3339();
let expires = now + chrono::Duration::seconds(ttl.as_secs() as i64);
let expires_iso = expires.to_rfc3339();
let sql = "
INSERT INTO udb_advisory_leases (lease_name, owner_id, expires_at)
VALUES (?, ?, ?)
ON CONFLICT(lease_name) DO UPDATE
SET owner_id = excluded.owner_id,
expires_at = excluded.expires_at
WHERE udb_advisory_leases.expires_at < ?
OR udb_advisory_leases.owner_id = excluded.owner_id
RETURNING owner_id
";
let resulting_owner: Option<String> = sqlx::query_scalar(sql)
.bind(lease_name)
.bind(owner_id)
.bind(&expires_iso)
.bind(&now_iso)
.fetch_optional(&self.pool)
.await
.map_err(|e| format!("try_acquire_advisory_lease (sqlite) failed: {e}"))?;
Ok(matches!(resulting_owner, Some(o) if o == owner_id))
}
async fn release_advisory_lease(&self, lease_name: &str, owner_id: &str) -> Result<(), String> {
let sql = "DELETE FROM udb_advisory_leases WHERE lease_name = ? AND owner_id = ?";
sqlx::query(sql)
.bind(lease_name)
.bind(owner_id)
.execute(&self.pool)
.await
.map_err(|e| format!("release_advisory_lease (sqlite) failed: {e}"))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use std::path::PathBuf;
use uuid::Uuid;
struct TempSqliteDb {
path: PathBuf,
}
impl TempSqliteDb {
fn new(prefix: &str) -> Self {
let path = std::env::temp_dir().join(format!("{prefix}-{}.db", Uuid::new_v4()));
let db = Self { path };
db.cleanup();
db
}
fn dsn(&self) -> String {
let path = self.path.to_string_lossy().replace('\\', "/");
format!("sqlite://{path}?mode=rwc")
}
fn cleanup(&self) {
let base = self.path.to_string_lossy();
for path in [
self.path.clone(),
PathBuf::from(format!("{base}-wal")),
PathBuf::from(format!("{base}-shm")),
] {
let _ = std::fs::remove_file(path);
}
}
}
impl Drop for TempSqliteDb {
fn drop(&mut self) {
self.cleanup();
}
}
async fn in_memory_pool() -> SqlitePool {
SqlitePoolOptions::new()
.max_connections(1) .connect("sqlite::memory:")
.await
.expect("in-memory sqlite connect")
}
#[tokio::test]
async fn backend_label_is_pinned() {
let pool = in_memory_pool().await;
let store = SqliteCanonicalStore::new(pool, "test", "udb_outbox_events");
assert_eq!(store.backend_label(), "sqlite");
assert_eq!(store.instance_name(), "test");
}
#[tokio::test]
async fn in_memory_round_trip_proves_p2p_works_without_postgres() {
let pool = in_memory_pool().await;
let store = SqliteCanonicalStore::new(pool, "test", "udb_outbox_events");
store.ensure_system_tables().await.expect("DDL");
assert_eq!(store.outbox_max_seq().await.expect("max seq"), 0);
let token_a = store.current_durability_token().await.expect("token a");
assert_eq!(token_a.backend_label, "sqlite");
let seq = store
.enqueue_outbox_event(
"11111111-1111-1111-1111-111111111111",
"test.topic",
"pk-1",
&serde_json::json!({"hello": "world"}),
)
.await
.expect("enqueue");
assert_eq!(seq, 1, "first auto-increment");
assert_eq!(store.outbox_max_seq().await.expect("max seq"), 1);
let token_b = store.current_durability_token().await.expect("token b");
let a: i64 = token_a.value.parse().unwrap();
let b: i64 = token_b.value.parse().unwrap();
assert!(b >= a, "data_version is monotone");
let cleared = store
.wait_for_token(&token_b, Duration::from_millis(10))
.await
.expect("wait");
assert!(cleared);
}
#[tokio::test]
async fn rejects_non_sqlite_token() {
let pool = in_memory_pool().await;
let store = SqliteCanonicalStore::new(pool, "test", "udb_outbox_events");
let mysql_token = DurabilityToken::new("mysql", "gtid:xyz");
let err = store
.wait_for_token(&mysql_token, Duration::from_millis(1))
.await
.expect_err("must reject cross-backend token");
assert!(err.contains("cannot wait on a 'mysql'"), "got: {err}");
}
#[tokio::test]
async fn advisory_lease_acquire_release_contention() {
let pool = in_memory_pool().await;
let store = SqliteCanonicalStore::new(pool, "test", "udb_outbox_events");
store.ensure_advisory_lease_table().await.unwrap();
let lease = "saga-worker";
let alice = store
.try_acquire_advisory_lease(lease, "alice", Duration::from_secs(60))
.await
.unwrap();
assert!(alice, "first acquire wins");
let bob = store
.try_acquire_advisory_lease(lease, "bob", Duration::from_secs(60))
.await
.unwrap();
assert!(
!bob,
"second acquirer must lose while Alice holds the lease"
);
let alice_again = store
.try_acquire_advisory_lease(lease, "alice", Duration::from_secs(60))
.await
.unwrap();
assert!(alice_again, "owner can refresh own lease");
store.release_advisory_lease(lease, "alice").await.unwrap();
let bob_now = store
.try_acquire_advisory_lease(lease, "bob", Duration::from_secs(60))
.await
.unwrap();
assert!(bob_now, "after release, next acquirer wins");
store.release_advisory_lease(lease, "alice").await.unwrap();
let bob_still = store
.try_acquire_advisory_lease(lease, "bob", Duration::from_secs(60))
.await
.unwrap();
assert!(
bob_still,
"Bob still holds after wrong-owner release attempt"
);
}
#[tokio::test]
async fn expired_lease_is_re_acquirable() {
let pool = in_memory_pool().await;
let store = SqliteCanonicalStore::new(pool, "test", "udb_outbox_events");
store.ensure_advisory_lease_table().await.unwrap();
let lease = "ephemeral";
store
.try_acquire_advisory_lease(lease, "first", Duration::from_secs(0))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let second = store
.try_acquire_advisory_lease(lease, "second", Duration::from_secs(60))
.await
.unwrap();
assert!(second, "expired lease must be re-acquirable");
}
#[tokio::test]
async fn two_store_handles_allocate_distinct_outbox_sequences() {
let db = TempSqliteDb::new("udb-outbox-ha");
let dsn = db.dsn();
let pool_a = SqlitePoolOptions::new()
.max_connections(2)
.connect(&dsn)
.await
.expect("sqlite file pool a");
let pool_b = SqlitePoolOptions::new()
.max_connections(2)
.connect(&dsn)
.await
.expect("sqlite file pool b");
let store_a = SqliteCanonicalStore::new(pool_a, "broker-a", "udb_outbox_events");
let store_b = SqliteCanonicalStore::new(pool_b, "broker-b", "udb_outbox_events");
store_a.ensure_system_tables().await.expect("DDL from A");
store_b.ensure_system_tables().await.expect("DDL from B");
let payload_a = serde_json::json!({"broker": "a"});
let payload_b = serde_json::json!({"broker": "b"});
let (seq_a, seq_b) = tokio::join!(
store_a.enqueue_outbox_event(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"test.outbox.ha",
"pk-a",
&payload_a,
),
store_b.enqueue_outbox_event(
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"test.outbox.ha",
"pk-b",
&payload_b,
)
);
let mut seqs = vec![seq_a.expect("seq a"), seq_b.expect("seq b")];
seqs.sort_unstable();
assert_eq!(seqs, vec![1, 2]);
assert_eq!(store_a.outbox_max_seq().await.expect("max seq"), 2);
drop(store_a);
drop(store_b);
}
#[tokio::test]
async fn unsafe_table_name_is_rejected() {
let pool = in_memory_pool().await;
let store = SqliteCanonicalStore::new(pool, "test", "evil; DROP");
assert!(store.safe_table().is_err());
let store2 = SqliteCanonicalStore::new(
SqlitePool::connect_lazy("sqlite::memory:").unwrap(),
"test",
"udb_system.events",
);
assert!(store2.safe_table().is_err());
}
}