feat(lsql): capture nondeterministic function results (#3361)

This commit is contained in:
Andrus Salumets
2026-08-27 08:20:28 +00:00
committed by GitHub
parent c1f68d2fa4
commit c33f4d2b35
9 changed files with 890 additions and 116 deletions
+27 -19
View File
@@ -221,7 +221,10 @@ mod tests {
use super::on_event;
use crate::{
db::Databases,
protocol::{ChannelInscription, EncodedWrite, PAYLOAD_MARKER, Statement, Transaction},
protocol::{
CapturedFunctionCalls, ChannelInscription, EncodedWrite, PAYLOAD_MARKER, Statement,
Transaction,
},
};
const CHANNEL_ID: [u8; 32] = [9; 32];
@@ -278,7 +281,8 @@ mod tests {
}
fn encoded_write(transaction: &Transaction) -> EncodedWrite {
EncodedWrite::new(transaction).expect("payload should encode")
EncodedWrite::new(transaction, CapturedFunctionCalls::empty())
.expect("payload should encode")
}
fn item_count(path: &std::path::Path) -> i64 {
@@ -431,26 +435,28 @@ mod tests {
let live_path = db.live_path().to_owned();
let setup = transaction("CREATE TABLE items(value INTEGER NOT NULL)", Vec::new());
let setup_encoded = EncodedWrite::new(&setup).expect("setup write should encode");
db.commit_local_write(&setup, &setup_encoded)
let setup_tx_id = db
.commit_local_write(&setup)
.expect("setup write should commit");
db.mark_publish_complete(setup_encoded.tx_id)
db.mark_publish_complete(setup_tx_id)
.expect("setup publish should be complete");
let insert = transaction(
"INSERT INTO items(value) VALUES (?1)",
vec![Value::Integer(1)],
);
let insert_encoded = EncodedWrite::new(&insert).expect("insert write should encode");
db.commit_local_write(&insert, &insert_encoded)
db.commit_local_write(&insert)
.expect("insert write should commit");
let insert_payload = db
.pending_publish()
.expect("pending write should load")
.expect("pending write should exist")
.payload;
let event = blocks_processed(
checkpoint(2, 2),
vec![ChannelUpdateTx::Inscription(inscription(
&insert_encoded.payload,
&insert_payload,
2,
))],
Vec::new(),
@@ -567,6 +573,7 @@ mod tests {
let conflicting = ChannelInscription {
tx_id: first.tx_id,
transaction: transaction("CREATE TABLE conflicting_write(value INTEGER)", Vec::new()),
captured_function_calls: CapturedFunctionCalls::empty(),
}
.encode()
.expect("conflicting write should encode");
@@ -618,7 +625,7 @@ mod tests {
.payload;
let version_offset = PAYLOAD_MARKER.len();
unsupported[version_offset..version_offset + size_of::<u16>()]
.copy_from_slice(&2u16.to_le_bytes());
.copy_from_slice(&3u16.to_le_bytes());
let following = encoded_write(&transaction(
"CREATE TABLE following_write(value INTEGER)",
@@ -659,11 +666,15 @@ mod tests {
let live_path = db.live_path().to_owned();
let local = transaction("CREATE TABLE local_write(value INTEGER)", Vec::new());
let local_encoded = EncodedWrite::new(&local).expect("local write should encode");
db.commit_local_write(&local, &local_encoded)
let local_tx_id = db
.commit_local_write(&local)
.expect("local write should commit");
db.mark_publish_complete(local_encoded.tx_id)
let local_payload = db
.pending_publish()
.expect("pending write should load")
.expect("pending write should exist")
.payload;
db.mark_publish_complete(local_tx_id)
.expect("local publish should be complete");
let adopted = encoded_write(&transaction(
@@ -676,10 +687,7 @@ mod tests {
&adopted.payload,
2,
))],
vec![ChannelUpdateTx::Inscription(inscription(
&local_encoded.payload,
1,
))],
vec![ChannelUpdateTx::Inscription(inscription(&local_payload, 1))],
Vec::new(),
);
+335 -66
View File
@@ -2,6 +2,7 @@
use std::{
fs,
ops::Deref,
path::{Path, PathBuf},
time::Duration,
};
@@ -16,6 +17,7 @@ use rusqlite::{
use crate::{
error::Error,
functions::FunctionOverrides,
protocol::{ChannelInscription, EncodedWrite, Transaction, TxId},
};
@@ -40,7 +42,7 @@ const PENDING_WRITE_SCHEMA: &str = "
const APPLIED_WRITE_SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS __logos_sql_applied_writes (
tx_id BLOB PRIMARY KEY CHECK (length(tx_id) = 32),
transaction_digest BLOB NOT NULL CHECK (length(transaction_digest) = 32)
content_digest BLOB NOT NULL CHECK (length(content_digest) = 32)
) STRICT;
";
@@ -81,6 +83,7 @@ const INSERT_REJECTED_WRITE: &str = "
VALUES (?1, ?2, ?3)
ON CONFLICT (this_msg) DO NOTHING
";
const INSERT_PENDING_WRITE: &str = "
INSERT INTO __logos_sql_pending_write (singleton, tx_id, payload)
VALUES (1, ?1, ?2)
@@ -98,13 +101,13 @@ const MARK_PUBLISH_COMPLETE: &str = "
";
const SELECT_APPLIED_WRITE: &str = "
SELECT transaction_digest
SELECT content_digest
FROM __logos_sql_applied_writes
WHERE tx_id = ?1
";
const INSERT_APPLIED_WRITE: &str = "
INSERT INTO __logos_sql_applied_writes (tx_id, transaction_digest)
INSERT INTO __logos_sql_applied_writes (tx_id, content_digest)
VALUES (?1, ?2)
";
@@ -115,6 +118,19 @@ const WRITER_PRAGMAS: &str = "
const FOREIGN_KEYS_PRAGMA: &str = "PRAGMA foreign_keys = ON;";
// These functions depend on one connection, database file, or SQLite build.
// Their results cannot be reproduced from the ordered channel history.
const UNSUPPORTED_FUNCTIONS: [&str; 9] = [
"changes",
"last_insert_rowid",
"load_extension",
"sqlite_compileoption_get",
"sqlite_compileoption_used",
"sqlite_offset",
"sqlite_source_id",
"sqlite_version",
"total_changes",
];
/// Raw database representation of a write waiting for publication.
struct StoredPendingPublish {
tx_id: Vec<u8>,
@@ -137,10 +153,24 @@ pub struct PendingPublish {
pub payload: Vec<u8>,
}
/// A replicated database connection and the function state attached to it.
struct ReplicatedDatabase {
connection: Connection,
functions: FunctionOverrides,
}
impl Deref for ReplicatedDatabase {
type Target = Connection;
fn deref(&self) -> &Self::Target {
&self.connection
}
}
/// Owns the participant-local database connections.
pub struct Databases {
lib: Connection,
live: Connection,
lib: ReplicatedDatabase,
live: ReplicatedDatabase,
control: Connection,
lib_path: PathBuf,
live_path: PathBuf,
@@ -157,7 +187,7 @@ impl Databases {
let lib = open_writer(&lib_path)?;
let live = open_writer(&live_path)?;
let control = open_writer(&control_path)?;
let control = open_connection(&control_path)?;
for connection in [&lib, &live] {
connection.execute_batch(PENDING_WRITE_SCHEMA)?;
@@ -236,26 +266,21 @@ impl Databases {
}
/// Commits application effects and their pending publish record together in
/// `LIVE.db`.
pub(crate) fn commit_local_write(
&mut self,
transaction: &Transaction,
encoded: &EncodedWrite,
) -> Result<TxId, Error> {
pub(crate) fn commit_local_write(&mut self, transaction: &Transaction) -> Result<TxId, Error> {
if self.pending_publish()?.is_some() {
return Err(Error::PublishPending);
}
let db_transaction = self.live.transaction()?;
let capture = self.live.functions.capture();
let db_transaction = self.live.connection.transaction()?;
// TODO: Capture nondeterministic function results and include them in
// the transaction published to other participants.
apply_statements(&db_transaction, transaction)?;
let transaction_digest = transaction.digest();
let captured_function_calls = capture.finish()?;
let encoded = EncodedWrite::new(transaction, captured_function_calls)?;
db_transaction.execute(
INSERT_APPLIED_WRITE,
params![encoded.tx_id.as_ref(), transaction_digest],
params![encoded.tx_id.as_ref(), encoded.content_digest],
)?;
db_transaction.execute(
@@ -281,10 +306,10 @@ impl Databases {
&mut self,
write: &ChannelInscription,
) -> Result<(), Error> {
let transaction_digest = write.transaction.digest();
let content_digest = write.content_digest();
is_write_applied(&self.lib, write.tx_id, &transaction_digest)?;
is_write_applied(&self.live, write.tx_id, &transaction_digest)?;
is_write_applied(&self.lib.connection, write.tx_id, &content_digest)?;
is_write_applied(&self.live.connection, write.tx_id, &content_digest)?;
// LIB and LIVE are separate SQLite files. If LIVE fails after LIB
// commits, the checkpoint remains behind. Redelivery then skips LIB
@@ -307,6 +332,7 @@ impl Databases {
pub(crate) fn pending_publish(&self) -> Result<Option<PendingPublish>, Error> {
let record = self
.live
.connection
.query_row(SELECT_PENDING_PUBLISH, [], StoredPendingPublish::from_row)
.optional()?;
@@ -323,7 +349,10 @@ impl Databases {
}
pub(crate) fn mark_publish_complete(&self, tx_id: TxId) -> Result<(), Error> {
let changed = self.live.execute(MARK_PUBLISH_COMPLETE, [tx_id.as_ref()])?;
let changed = self
.live
.connection
.execute(MARK_PUBLISH_COMPLETE, [tx_id.as_ref()])?;
if changed != 1 {
return Err(Error::InvalidLocalState(
@@ -343,7 +372,17 @@ impl Databases {
}
}
fn open_writer(path: &Path) -> Result<Connection, Error> {
fn open_writer(path: &Path) -> Result<ReplicatedDatabase, Error> {
let connection = open_connection(path)?;
let functions = FunctionOverrides::install(&connection)?;
Ok(ReplicatedDatabase {
connection,
functions,
})
}
fn open_connection(path: &Path) -> Result<Connection, Error> {
let conn = Connection::open(path)?;
configure_connection(&conn)?;
@@ -360,17 +399,24 @@ fn configure_connection(conn: &Connection) -> Result<(), Error> {
}
fn apply_channel_write(
connection: &mut Connection,
database: &mut ReplicatedDatabase,
write: &ChannelInscription,
) -> Result<(), Error> {
let transaction_digest = write.transaction.digest();
let db_transaction = connection.transaction()?;
let content_digest = write.content_digest();
let replay = database.functions.replay(&write.captured_function_calls);
let db_transaction = database.connection.transaction()?;
if is_write_applied(&db_transaction, write.tx_id, &transaction_digest)? {
if is_write_applied(&db_transaction, write.tx_id, &content_digest)? {
return Ok(());
}
if let Err(error) = apply_statements(&db_transaction, &write.transaction) {
if replay.failed() {
return Err(Error::InvalidPayload(
"captured SQLite function call does not match replay",
));
}
return match error {
Error::Database(error) if is_deterministic_sql_error(&error) => {
Err(Error::RejectedSql(error))
@@ -379,9 +425,11 @@ fn apply_channel_write(
};
}
replay.finish()?;
db_transaction.execute(
INSERT_APPLIED_WRITE,
params![write.tx_id.as_ref(), transaction_digest],
params![write.tx_id.as_ref(), content_digest],
)?;
db_transaction.commit()?;
@@ -391,7 +439,7 @@ fn apply_channel_write(
fn is_write_applied(
connection: &Connection,
tx_id: TxId,
transaction_digest: &[u8; 32],
content_digest: &[u8; 32],
) -> Result<bool, Error> {
let stored_digest = connection
.query_row(SELECT_APPLIED_WRITE, [tx_id.as_ref()], |row| {
@@ -400,7 +448,7 @@ fn is_write_applied(
.optional()?;
if let Some(stored_digest) = stored_digest {
if stored_digest.as_slice() != transaction_digest {
if stored_digest.as_slice() != content_digest {
return Err(Error::InvalidPayload(
"transaction id was reused for different content",
));
@@ -482,7 +530,14 @@ fn authorize_application_sql(context: AuthContext<'_>) -> Authorization {
| AuthAction::Detach { .. }
| AuthAction::Pragma { .. }
) || is_temporary_object_action(context.action)
|| action_uses_reserved_name(context.action);
|| action_uses_reserved_name(context.action)
|| matches!(
context.action,
AuthAction::Function { function_name }
if UNSUPPORTED_FUNCTIONS
.iter()
.any(|name| function_name.eq_ignore_ascii_case(name))
);
if denied {
Authorization::Deny
@@ -567,14 +622,16 @@ mod tests {
node_types::{HeaderId, MsgId, Slot},
sequencer::SequencerCheckpoint,
};
use rusqlite::types::Value;
use rusqlite::{Connection, types::Value};
use tempfile::TempDir;
use super::Databases;
use crate::{
error::Error,
local_write,
protocol::{ChannelInscription, EncodedWrite, Statement, Transaction, TxId},
protocol::{
CapturedFunction, CapturedFunctionCall, CapturedFunctionCalls, ChannelInscription,
Statement, Transaction, TxId,
},
};
fn checkpoint(byte: u8, slot: u64) -> SequencerCheckpoint {
@@ -606,11 +663,17 @@ mod tests {
.expect("transaction should be valid")
}
fn encoded_write(transaction: &Transaction) -> EncodedWrite {
EncodedWrite::new(transaction).expect("write should encode")
fn row_values(connection: &Connection, table: &str) -> Vec<Value> {
connection
.query_row(&format!("SELECT * FROM {table}"), [], |row| {
(0..row.as_ref().column_count())
.map(|column| row.get(column))
.collect()
})
.expect("captured row should be readable")
}
fn assert_application_sql_rejected(sql: &str) {
fn rejected_application_sql(sql: &str) -> Error {
let dir = TempDir::new().expect("temporary directory should be created");
let mut db = Databases::open(dir.path()).expect("databases should open");
@@ -619,28 +682,35 @@ mod tests {
.expect("application table should be created");
let transaction = transaction(sql);
let encoded = encoded_write(&transaction);
let error = db
.commit_local_write(&transaction, &encoded)
.commit_local_write(&transaction)
.expect_err("application SQL should be rejected");
assert!(
db.pending_publish()
.expect("pending publication should load")
.is_none()
);
error
}
fn assert_application_sql_rejected(sql: &str) {
assert!(matches!(rejected_application_sql(sql), Error::Database(_)));
}
fn assert_application_sql_denied(sql: &str) {
let error = rejected_application_sql(sql);
assert!(matches!(
error,
Error::Database(ref error)
if error.sqlite_error_code()
== Some(rusqlite::ErrorCode::AuthorizationForStatementDenied)
));
assert!(
db.pending_publish()
.expect("pending publication should load")
.is_none()
);
}
fn internal_schema(
connection: &rusqlite::Connection,
) -> Vec<(String, String, String, Option<String>)> {
fn internal_schema(connection: &Connection) -> Vec<(String, String, String, Option<String>)> {
let mut statement = connection
.prepare(
"SELECT type, name, tbl_name, sql
@@ -690,9 +760,8 @@ mod tests {
.expect("application table should be created");
let transaction = insert("hello");
let encoded = encoded_write(&transaction);
db.commit_local_write(&transaction, &encoded)
let tx_id = db
.commit_local_write(&transaction)
.expect("write should commit");
let count: i64 = db
@@ -706,7 +775,7 @@ mod tests {
.expect("pending publish should load")
.expect("pending publish should exist")
.tx_id,
encoded.tx_id
tx_id
);
}
@@ -728,13 +797,15 @@ mod tests {
.expect("application table should be created");
let transaction = insert("hello");
let first_tx_id =
local_write::commit(&mut db, &transaction).expect("first write should commit");
let first_tx_id = db
.commit_local_write(&transaction)
.expect("first write should commit");
db.mark_publish_complete(first_tx_id)
.expect("first publication should complete");
let second_tx_id =
local_write::commit(&mut db, &transaction).expect("second write should commit");
let second_tx_id = db
.commit_local_write(&transaction)
.expect("second write should commit");
assert_ne!(second_tx_id, first_tx_id);
@@ -746,6 +817,193 @@ mod tests {
assert_eq!(count, 2);
}
#[test]
fn function_results_are_replayed_exactly() {
let dir = TempDir::new().expect("temporary directory should be created");
let mut db = Databases::open(dir.path()).expect("databases should open");
let schema = "CREATE TABLE captured(
random_value,
random_blob_value,
date_value,
time_value,
datetime_value,
julian_day_value,
unix_epoch_value,
strftime_value,
time_diff_value,
current_date_value,
current_time_value,
current_timestamp_value
)";
for connection in [&db.lib, &db.live] {
connection
.execute(schema, [])
.expect("application table should be created");
}
let write = transaction(
"INSERT INTO captured VALUES (
random(),
randomblob(16),
date('now'),
time('now'),
datetime('now'),
julianday('now'),
unixepoch('now'),
strftime('%s', 'now'),
timediff('now', 'now'),
CURRENT_DATE,
CURRENT_TIME,
CURRENT_TIMESTAMP
)",
);
db.commit_local_write(&write)
.expect("local write should commit");
let pending = db
.pending_publish()
.expect("pending publish should load")
.expect("pending publish should exist");
let channel_inscription =
ChannelInscription::decode(&pending.payload).expect("payload should decode");
let functions = channel_inscription
.captured_function_calls
.as_slice()
.iter()
.map(|call| call.function)
.collect::<Vec<_>>();
assert_eq!(
functions,
vec![
CapturedFunction::Random,
CapturedFunction::RandomBlob,
CapturedFunction::Date,
CapturedFunction::Time,
CapturedFunction::DateTime,
CapturedFunction::JulianDay,
CapturedFunction::UnixEpoch,
CapturedFunction::Strftime,
CapturedFunction::TimeDiff,
CapturedFunction::CurrentDate,
CapturedFunction::CurrentTime,
CapturedFunction::CurrentTimestamp,
]
);
db.apply_finalized_write(&channel_inscription)
.expect("captured write should replay");
assert_eq!(
row_values(&db.live, "captured"),
row_values(&db.lib, "captured")
);
}
#[test]
fn function_calls_inside_defaults_and_triggers_are_captured() {
let dir = TempDir::new().expect("temporary directory should be created");
let mut db = Databases::open(dir.path()).expect("databases should open");
let schema = "
CREATE TABLE items(
value TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE audit(random_value INTEGER);
CREATE TRIGGER audit_insert AFTER INSERT ON items BEGIN
INSERT INTO audit VALUES (random());
END;
";
for connection in [&db.lib, &db.live] {
connection
.execute_batch(schema)
.expect("application schema should be created");
}
let write = transaction("INSERT INTO items(value) VALUES ('hello')");
db.commit_local_write(&write)
.expect("local write should commit");
let pending = db
.pending_publish()
.expect("pending publish should load")
.expect("pending publish should exist");
let channel_inscription =
ChannelInscription::decode(&pending.payload).expect("payload should decode");
assert_eq!(
channel_inscription.captured_function_calls.as_slice().len(),
2
);
db.apply_finalized_write(&channel_inscription)
.expect("trigger write should replay");
assert_eq!(row_values(&db.live, "items"), row_values(&db.lib, "items"));
assert_eq!(row_values(&db.live, "audit"), row_values(&db.lib, "audit"));
}
#[test]
fn missing_function_result_rejects_channel_inscription() {
let dir = TempDir::new().expect("temporary directory should be created");
let mut db = Databases::open(dir.path()).expect("databases should open");
db.live
.execute("CREATE TABLE items(value INTEGER)", [])
.expect("application table should be created");
let write = ChannelInscription {
tx_id: TxId::from([7; 32]),
transaction: transaction("INSERT INTO items VALUES (random())"),
captured_function_calls: CapturedFunctionCalls::empty(),
};
let error = db
.apply_adopted_write(&write)
.expect_err("missing result should reject the write");
assert!(matches!(error, Error::InvalidPayload(_)));
assert_eq!(
db.live
.query_row("SELECT count(*) FROM items", [], |row| row.get::<_, i64>(0))
.expect("row count should be readable"),
0
);
}
#[test]
fn unused_function_result_rejects_channel_inscription() {
let dir = TempDir::new().expect("temporary directory should be created");
let mut db = Databases::open(dir.path()).expect("databases should open");
db.live
.execute("CREATE TABLE items(value INTEGER)", [])
.expect("application table should be created");
let captured = CapturedFunctionCall::new(CapturedFunction::Random, Value::Integer(7))
.expect("captured result should be valid");
let write = ChannelInscription {
tx_id: TxId::from([7; 32]),
transaction: transaction("INSERT INTO items VALUES (1)"),
captured_function_calls: CapturedFunctionCalls::new(vec![captured])
.expect("captured calls should be valid"),
};
let error = db
.apply_adopted_write(&write)
.expect_err("unused result should reject the write");
assert!(matches!(error, Error::InvalidPayload(_)));
assert_eq!(
db.live
.query_row("SELECT count(*) FROM items", [], |row| row.get::<_, i64>(0))
.expect("row count should be readable"),
0
);
}
#[test]
fn reused_transaction_id_with_different_content_is_rejected() {
let dir = TempDir::new().expect("temporary directory should be created");
@@ -759,10 +1017,12 @@ mod tests {
let first = ChannelInscription {
tx_id,
transaction: insert("first"),
captured_function_calls: CapturedFunctionCalls::empty(),
};
let conflicting = ChannelInscription {
tx_id,
transaction: insert("conflicting"),
captured_function_calls: CapturedFunctionCalls::empty(),
};
db.apply_adopted_write(&first)
@@ -797,10 +1057,12 @@ mod tests {
let first = ChannelInscription {
tx_id,
transaction: insert("first"),
captured_function_calls: CapturedFunctionCalls::empty(),
};
let conflicting = ChannelInscription {
tx_id,
transaction: insert("conflicting"),
captured_function_calls: CapturedFunctionCalls::empty(),
};
db.apply_adopted_write(&first)
@@ -825,9 +1087,7 @@ mod tests {
let dir = TempDir::new().expect("temporary directory should be created");
let mut db = Databases::open(dir.path()).expect("databases should open");
let transaction = transaction("CREATE TABLE items(value TEXT NOT NULL)");
let encoded = encoded_write(&transaction);
db.commit_local_write(&transaction, &encoded)
db.commit_local_write(&transaction)
.expect("schema write should commit");
db.live
@@ -854,9 +1114,7 @@ mod tests {
Statement::new(control.to_owned(), Vec::new()).expect("statement should be valid"),
])
.expect("transaction should be valid");
let encoded = encoded_write(&transaction);
db.commit_local_write(&transaction, &encoded)
db.commit_local_write(&transaction)
.expect_err("transaction control should be rejected");
let count: i64 = db
@@ -879,7 +1137,7 @@ mod tests {
"PRAGMA synchronous = OFF",
"ATTACH DATABASE ':memory:' AS other",
] {
assert_application_sql_rejected(sql);
assert_application_sql_denied(sql);
}
}
@@ -894,7 +1152,19 @@ mod tests {
"CREATE TRIGGER __logos_sql_trigger AFTER INSERT ON items BEGIN SELECT 1; END",
"CREATE TABLE __LOGOS_SQL_mixed_case(value INTEGER)",
] {
assert_application_sql_rejected(sql);
assert_application_sql_denied(sql);
}
}
#[test]
fn connection_dependent_functions_are_rejected() {
for function in [
"changes()",
"last_insert_rowid()",
"sqlite_version()",
"total_changes()",
] {
assert_application_sql_rejected(&format!("SELECT {function}"));
}
}
@@ -904,7 +1174,7 @@ mod tests {
"CREATE TEMP TABLE temporary_items(value INTEGER)",
"CREATE TEMP VIEW temporary_items AS SELECT 1",
] {
assert_application_sql_rejected(sql);
assert_application_sql_denied(sql);
}
}
@@ -913,9 +1183,8 @@ mod tests {
let dir = TempDir::new().expect("temporary directory should be created");
let mut db = Databases::open(dir.path()).expect("databases should open");
let transaction = transaction("CREATE TABLE aaaaaaaaaa\u{65e5}(value INTEGER)");
let encoded = encoded_write(&transaction);
db.commit_local_write(&transaction, &encoded)
db.commit_local_write(&transaction)
.expect("non-reserved Unicode name should be accepted");
}
+298
View File
@@ -0,0 +1,298 @@
//! Capture and replay of `SQLite` functions whose results vary between runs.
//!
//! Selected built-ins are overridden on each replicated writer connection.
//! During local execution, the override calls the original built-in on a
//! separate in-memory connection and records its result. During replay, it
//! returns the recorded result without evaluating the function again.
use std::{
collections::VecDeque,
sync::{Arc, Mutex, MutexGuard},
};
use rusqlite::{
Connection,
functions::{Context, FunctionFlags},
params_from_iter,
types::Value,
};
use crate::{
error::Error,
protocol::{CapturedFunction, CapturedFunctionCall, CapturedFunctionCalls},
};
/// Describes one `SQLite` built-in intercepted by the replicated connection.
#[derive(Clone, Copy)]
struct FunctionDefinition {
function: CapturedFunction,
name: &'static str,
argument_count: i32,
keyword: bool,
}
// SQLite functions whose results are captured during local execution and
// supplied in the same order during replay on other replicas.
const FUNCTIONS: [FunctionDefinition; 12] = [
FunctionDefinition::new(CapturedFunction::Random, "random", 0),
FunctionDefinition::new(CapturedFunction::RandomBlob, "randomblob", 1),
FunctionDefinition::variadic(CapturedFunction::Date, "date"),
FunctionDefinition::variadic(CapturedFunction::Time, "time"),
FunctionDefinition::variadic(CapturedFunction::DateTime, "datetime"),
FunctionDefinition::variadic(CapturedFunction::JulianDay, "julianday"),
FunctionDefinition::variadic(CapturedFunction::UnixEpoch, "unixepoch"),
FunctionDefinition::variadic(CapturedFunction::Strftime, "strftime"),
FunctionDefinition::new(CapturedFunction::TimeDiff, "timediff", 2),
FunctionDefinition::keyword(CapturedFunction::CurrentDate, "current_date"),
FunctionDefinition::keyword(CapturedFunction::CurrentTime, "current_time"),
FunctionDefinition::keyword(CapturedFunction::CurrentTimestamp, "current_timestamp"),
];
impl FunctionDefinition {
const fn new(function: CapturedFunction, name: &'static str, argument_count: i32) -> Self {
Self {
function,
name,
argument_count,
keyword: false,
}
}
const fn variadic(function: CapturedFunction, name: &'static str) -> Self {
Self::new(function, name, -1)
}
const fn keyword(function: CapturedFunction, name: &'static str) -> Self {
Self {
function,
name,
argument_count: 0,
keyword: true,
}
}
fn query(self, argument_count: usize) -> String {
if self.keyword {
return format!("SELECT {}", self.name);
}
let parameters = std::iter::repeat_n("?", argument_count)
.collect::<Vec<_>>()
.join(", ");
format!("SELECT {}({parameters})", self.name)
}
}
/// Behavior of the installed callbacks for the current writer operation.
/// Only one capture or replay session can be active on a connection.
enum Mode {
Passthrough,
Capture(Vec<CapturedFunctionCall>),
Replay {
calls: VecDeque<CapturedFunctionCall>,
failed: bool,
},
}
/// Controls the function implementations installed on one replicated writer.
pub struct FunctionOverrides {
state: Arc<Mutex<Mode>>,
}
impl FunctionOverrides {
/// Installs the overrides and creates the untouched `SQLite` connection
/// used to evaluate the original built-ins during capture.
pub fn install(connection: &Connection) -> Result<Self, Error> {
let state = Arc::new(Mutex::new(Mode::Passthrough));
let evaluator = Arc::new(Mutex::new(Connection::open_in_memory()?));
for definition in FUNCTIONS {
let state = Arc::clone(&state);
let evaluator = Arc::clone(&evaluator);
connection.create_scalar_function(
definition.name,
definition.argument_count,
FunctionFlags::SQLITE_UTF8,
move |context| invoke(definition, context, &state, &evaluator),
)?;
}
Ok(Self { state })
}
/// Starts recording function calls made by one local transaction.
pub fn capture(&mut self) -> CaptureSession<'_> {
*lock(&self.state) = Mode::Capture(Vec::new());
CaptureSession::new(self)
}
/// Starts replaying the recorded calls for one received transaction.
pub fn replay(&mut self, calls: &CapturedFunctionCalls) -> ReplaySession<'_> {
*lock(&self.state) = Mode::Replay {
calls: calls.as_slice().iter().cloned().collect(),
failed: false,
};
ReplaySession::new(self)
}
}
/// Restores passthrough mode if capture exits before [`Self::finish`].
pub struct CaptureSession<'a> {
overrides: &'a mut FunctionOverrides,
active: bool,
}
impl<'a> CaptureSession<'a> {
const fn new(overrides: &'a mut FunctionOverrides) -> Self {
Self {
overrides,
active: true,
}
}
pub fn finish(mut self) -> Result<CapturedFunctionCalls, Error> {
let mode = take_mode(&self.overrides.state);
self.active = false;
let Mode::Capture(calls) = mode else {
unreachable!("capture guard must own an active capture session");
};
CapturedFunctionCalls::new(calls)
}
}
impl Drop for CaptureSession<'_> {
fn drop(&mut self) {
if self.active {
reset(&self.overrides.state);
}
}
}
/// Restores passthrough mode if replay exits before [`Self::finish`].
pub struct ReplaySession<'a> {
overrides: &'a mut FunctionOverrides,
active: bool,
}
impl<'a> ReplaySession<'a> {
const fn new(overrides: &'a mut FunctionOverrides) -> Self {
Self {
overrides,
active: true,
}
}
pub fn failed(&self) -> bool {
matches!(
&*lock(&self.overrides.state),
Mode::Replay { failed: true, .. }
)
}
pub fn finish(mut self) -> Result<(), Error> {
let mode = take_mode(&self.overrides.state);
self.active = false;
let Mode::Replay { calls, failed } = mode else {
unreachable!("replay guard must own an active replay session");
};
if failed {
return Err(Error::InvalidPayload(
"captured SQLite function call does not match replay",
));
}
if !calls.is_empty() {
return Err(Error::InvalidPayload(
"captured SQLite function results were not consumed",
));
}
Ok(())
}
}
impl Drop for ReplaySession<'_> {
fn drop(&mut self) {
if self.active {
reset(&self.overrides.state);
}
}
}
fn invoke(
definition: FunctionDefinition,
context: &Context<'_>,
state: &Mutex<Mode>,
evaluator: &Mutex<Connection>,
) -> rusqlite::Result<Value> {
let mut mode = lock(state);
match &mut *mode {
Mode::Passthrough => evaluate(definition, context, evaluator),
Mode::Capture(calls) => {
let result = evaluate(definition, context, evaluator)?;
let call = CapturedFunctionCall::new(definition.function, result.clone())
.map_err(|error| rusqlite::Error::UserFunctionError(Box::new(error)))?;
calls.push(call);
Ok(result)
}
Mode::Replay { calls, failed } => {
let Some(call) = calls.pop_front() else {
*failed = true;
return Err(replay_error("captured SQLite function result is missing"));
};
if call.function != definition.function {
*failed = true;
return Err(replay_error(
"captured SQLite function order does not match",
));
}
Ok(call.result.into_value())
}
}
}
fn evaluate(
definition: FunctionDefinition,
context: &Context<'_>,
evaluator: &Mutex<Connection>,
) -> rusqlite::Result<Value> {
// Calling the function on the replicated connection would recurse into
// this override. The evaluator connection still has SQLite's built-in.
let arguments = (0..context.len())
.map(|index| context.get_raw(index).into())
.collect::<Vec<Value>>();
let query = definition.query(arguments.len());
lock(evaluator).query_row(&query, params_from_iter(arguments), |row| row.get(0))
}
fn replay_error(message: &'static str) -> rusqlite::Error {
rusqlite::Error::UserFunctionError(message.into())
}
fn take_mode(state: &Mutex<Mode>) -> Mode {
std::mem::replace(&mut *lock(state), Mode::Passthrough)
}
fn reset(state: &Mutex<Mode>) {
*lock(state) = Mode::Passthrough;
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
+1 -1
View File
@@ -8,7 +8,7 @@
mod applier;
mod db;
mod error;
mod local_write;
mod functions;
mod logos_sql;
mod protocol;
mod runtime;
-15
View File
@@ -1,15 +0,0 @@
//! Outbound write path for transactions initiated by the local application.
//!
//! Transactions arriving through channel history are handled by the applier.
use crate::{
db::Databases,
error::Error,
protocol::{EncodedWrite, Transaction, TxId},
};
pub fn commit(db: &mut Databases, transaction: &Transaction) -> Result<TxId, Error> {
let encoded = EncodedWrite::new(transaction)?;
db.commit_local_write(transaction, &encoded)
}
+76 -1
View File
@@ -4,14 +4,31 @@ use lb_codec::{BinaryDecode, BinaryEncode, DecodeError};
use lb_utils::bounded::UpperBoundedVec;
use rusqlite::types::Value;
use super::{MAX_PAYLOAD_BYTES, SqlParameter, SqlText};
use super::{CapturedFunction, MAX_PAYLOAD_BYTES, SqlParameter, SqlText};
// Stable wire tags for SQLite value variants. Existing values must never be
// renumbered because inscriptions remain part of the channel history.
const NULL: u8 = 0;
const INTEGER: u8 = 1;
const REAL: u8 = 2;
const TEXT: u8 = 3;
const BLOB: u8 = 4;
// Stable wire tags for captured SQLite functions. New functions may be
// appended, but existing values must keep their encoding.
const RANDOM: u8 = 0;
const RANDOM_BLOB: u8 = 1;
const DATE: u8 = 2;
const TIME: u8 = 3;
const DATE_TIME: u8 = 4;
const JULIAN_DAY: u8 = 5;
const UNIX_EPOCH: u8 = 6;
const STRFTIME: u8 = 7;
const TIME_DIFF: u8 = 8;
const CURRENT_DATE: u8 = 9;
const CURRENT_TIME: u8 = 10;
const CURRENT_TIMESTAMP: u8 = 11;
type BoundedBytes = UpperBoundedVec<u8, MAX_PAYLOAD_BYTES>;
// Every variable-length field uses the same fixed-width prefix. Keep the two
@@ -19,6 +36,64 @@ type BoundedBytes = UpperBoundedVec<u8, MAX_PAYLOAD_BYTES>;
const _: () = assert!(MAX_PAYLOAD_BYTES > u16::MAX as usize);
const _: () = assert!(MAX_PAYLOAD_BYTES <= u32::MAX as usize);
impl BinaryEncode for CapturedFunction {
fn encoded_length(&self) -> usize {
size_of::<u8>()
}
fn encode_into(&self, out: &mut Vec<u8>) {
let discriminant = match self {
Self::Random => RANDOM,
Self::RandomBlob => RANDOM_BLOB,
Self::Date => DATE,
Self::Time => TIME,
Self::DateTime => DATE_TIME,
Self::JulianDay => JULIAN_DAY,
Self::UnixEpoch => UNIX_EPOCH,
Self::Strftime => STRFTIME,
Self::TimeDiff => TIME_DIFF,
Self::CurrentDate => CURRENT_DATE,
Self::CurrentTime => CURRENT_TIME,
Self::CurrentTimestamp => CURRENT_TIMESTAMP,
};
discriminant.encode_into(out);
}
}
impl BinaryDecode for CapturedFunction {
type Context = ();
fn decode<'input>(
input: &'input [u8],
(): &Self::Context,
) -> Result<(&'input [u8], Self), DecodeError> {
let (input, discriminant) = <u8 as BinaryDecode>::decode(input, &())?;
let function = match discriminant {
RANDOM => Self::Random,
RANDOM_BLOB => Self::RandomBlob,
DATE => Self::Date,
TIME => Self::Time,
DATE_TIME => Self::DateTime,
JULIAN_DAY => Self::JulianDay,
UNIX_EPOCH => Self::UnixEpoch,
STRFTIME => Self::Strftime,
TIME_DIFF => Self::TimeDiff,
CURRENT_DATE => Self::CurrentDate,
CURRENT_TIME => Self::CurrentTime,
CURRENT_TIMESTAMP => Self::CurrentTimestamp,
_ => {
return Err(DecodeError::unknown_discriminant::<Self>(u64::from(
discriminant,
)));
}
};
Ok((input, function))
}
}
impl BinaryEncode for SqlText {
fn encoded_length(&self) -> usize {
size_of::<u32>() + self.as_str().len()
+43 -2
View File
@@ -3,7 +3,26 @@
use lb_codec::codec_fixtures;
use rusqlite::types::Value;
use super::{ChannelInscription, SqlParameter, SqlText, Statement, Transaction, TxId};
use super::{
CapturedFunction, CapturedFunctionCall, CapturedFunctionCalls, ChannelInscription,
SqlParameter, SqlText, Statement, Transaction, TxId,
};
codec_fixtures!(
CapturedFunction,
CapturedFunction::Random => "00",
CapturedFunction::RandomBlob => "01",
CapturedFunction::Date => "02",
CapturedFunction::Time => "03",
CapturedFunction::DateTime => "04",
CapturedFunction::JulianDay => "05",
CapturedFunction::UnixEpoch => "06",
CapturedFunction::Strftime => "07",
CapturedFunction::TimeDiff => "08",
CapturedFunction::CurrentDate => "09",
CapturedFunction::CurrentTime => "0a",
CapturedFunction::CurrentTimestamp => "0b"
);
codec_fixtures!(
TxId,
@@ -30,6 +49,26 @@ codec_fixtures!(
"040200000000ff"
);
fn captured_function_call_fixture() -> CapturedFunctionCall {
CapturedFunctionCall::new(CapturedFunction::Random, Value::Integer(42))
.expect("fixture should be valid")
}
codec_fixtures!(
CapturedFunctionCall,
captured_function_call_fixture() => "00012a00000000000000"
);
fn captured_function_calls_fixture() -> CapturedFunctionCalls {
CapturedFunctionCalls::new(vec![captured_function_call_fixture()])
.expect("fixture should be valid")
}
codec_fixtures!(
CapturedFunctionCalls,
captured_function_calls_fixture() => "0100000000012a00000000000000"
);
fn statement_fixture() -> Statement {
Statement::new("SELECT 1".to_owned(), Vec::new()).expect("fixture should be valid")
}
@@ -74,6 +113,7 @@ fn channel_inscription_fixture() -> ChannelInscription {
ChannelInscription {
tx_id: TxId::from([3; 32]),
transaction: transaction_fixture(),
captured_function_calls: CapturedFunctionCalls::empty(),
}
}
@@ -81,6 +121,7 @@ codec_fixtures!(
ChannelInscription,
channel_inscription_fixture() => concat!(
"0303030303030303030303030303030303030303030303030303030303030303",
"010000000800000053454c454354203100000000"
"010000000800000053454c454354203100000000",
"00000000"
)
);
+109 -10
View File
@@ -14,9 +14,12 @@ use crate::error::Error;
mod codec;
mod fixtures;
// Every payload starts with this marker and version before the encoded body.
pub const PAYLOAD_MARKER: [u8; 9] = *b"LOGOS_SQL";
const PAYLOAD_VERSION: u16 = 1;
const PAYLOAD_VERSION: u16 = 2;
const PAYLOAD_HEADER_LEN: usize = PAYLOAD_MARKER.len() + size_of::<u16>();
// A complete λSQL transaction must fit into one channel inscription.
const MAX_PAYLOAD_BYTES: usize = Inscription::MAX;
/// Stable identity of one application write.
@@ -113,6 +116,12 @@ impl ToSql for SqlParameter {
}
}
impl SqlParameter {
pub fn into_value(self) -> Value {
self.0
}
}
/// One parameterized SQL statement.
#[derive(Clone, Debug, PartialEq, BinaryCodec)]
pub struct Statement {
@@ -177,9 +186,62 @@ impl Transaction {
pub fn statements(&self) -> &[Statement] {
self.statements.as_slice()
}
}
pub(crate) fn digest(&self) -> [u8; 32] {
Blake2b::<U32>::digest(self.encode_to_vec()).into()
/// `SQLite` function whose result must be reproduced during channel replay.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CapturedFunction {
Random,
RandomBlob,
Date,
Time,
DateTime,
JulianDay,
UnixEpoch,
Strftime,
TimeDiff,
CurrentDate,
CurrentTime,
CurrentTimestamp,
}
/// One captured `SQLite` function call and the value returned locally.
#[derive(Clone, Debug, PartialEq, BinaryCodec)]
pub struct CapturedFunctionCall {
pub function: CapturedFunction,
pub result: SqlParameter,
}
impl CapturedFunctionCall {
pub fn new(function: CapturedFunction, result: Value) -> Result<Self, Error> {
Ok(Self {
function,
result: SqlParameter::try_from(result)?,
})
}
}
/// Function results captured while executing one replicated transaction.
#[derive(Clone, Debug, PartialEq, BinaryCodec)]
pub struct CapturedFunctionCalls {
calls: UpperBoundedVec<CapturedFunctionCall, MAX_PAYLOAD_BYTES>,
}
impl CapturedFunctionCalls {
pub fn new(calls: Vec<CapturedFunctionCall>) -> Result<Self, Error> {
let calls = UpperBoundedVec::try_from(calls).map_err(|_| Error::InscriptionTooLarge)?;
Ok(Self { calls })
}
pub const fn empty() -> Self {
Self {
calls: UpperBoundedVec::new_unchecked(Vec::new()),
}
}
pub fn as_slice(&self) -> &[CapturedFunctionCall] {
self.calls.as_slice()
}
}
@@ -188,6 +250,7 @@ impl Transaction {
pub struct ChannelInscription {
pub tx_id: TxId,
pub transaction: Transaction,
pub captured_function_calls: CapturedFunctionCalls,
}
impl ChannelInscription {
@@ -225,26 +288,40 @@ impl ChannelInscription {
<Self as BinaryDecode>::decode_all(body, &())
.map_err(|_| Error::InvalidPayload("body cannot be decoded"))
}
pub fn content_digest(&self) -> [u8; 32] {
Blake2b::<U32>::digest(self.encode_to_vec()).into()
}
}
/// A local write after its identity and channel payload have been encoded.
pub struct EncodedWrite {
pub tx_id: TxId,
pub content_digest: [u8; 32],
pub payload: Vec<u8>,
}
impl EncodedWrite {
pub fn new(transaction: &Transaction) -> Result<Self, Error> {
pub fn new(
transaction: &Transaction,
captured_function_calls: CapturedFunctionCalls,
) -> Result<Self, Error> {
let tx_id = TxId::generate();
let channel_inscription = ChannelInscription {
tx_id,
transaction: transaction.clone(),
captured_function_calls,
};
let content_digest = channel_inscription.content_digest();
let payload = channel_inscription.encode()?;
Ok(Self { tx_id, payload })
Ok(Self {
tx_id,
content_digest,
payload,
})
}
}
@@ -271,7 +348,8 @@ mod tests {
use rusqlite::types::Value;
use super::{
ChannelInscription, EncodedWrite, MAX_PAYLOAD_BYTES, Statement, Transaction, TxId,
CapturedFunctionCalls, ChannelInscription, EncodedWrite, MAX_PAYLOAD_BYTES, Statement,
Transaction, TxId,
};
#[test]
@@ -292,7 +370,8 @@ mod tests {
])
.expect("transaction should be valid");
let encoded = EncodedWrite::new(&transaction).expect("submission should encode");
let encoded = EncodedWrite::new(&transaction, CapturedFunctionCalls::empty())
.expect("payload should encode");
let decoded = ChannelInscription::decode(&encoded.payload)
.expect("channel inscription should decode");
@@ -309,6 +388,7 @@ mod tests {
let write = ChannelInscription {
tx_id: TxId::from([3; 32]),
transaction,
captured_function_calls: CapturedFunctionCalls::empty(),
};
let mut payload = write.encode().expect("payload should encode");
payload.push(0);
@@ -325,18 +405,37 @@ mod tests {
let write = ChannelInscription {
tx_id: TxId::from([3; 32]),
transaction,
captured_function_calls: CapturedFunctionCalls::empty(),
};
let expected = hex::decode(concat!(
"4c4f474f535f53514c0100",
"4c4f474f535f53514c0200",
"0303030303030303030303030303030303030303030303030303030303030303",
"010000000800000053454c454354203100000000"
"010000000800000053454c45435420310000000000000000"
))
.expect("fixture should be valid hex");
assert_eq!(write.encode().expect("payload should encode"), expected);
}
#[test]
fn content_digest_is_pinned() {
let transaction = Transaction::new(vec![
Statement::new("SELECT 1".to_owned(), Vec::new()).expect("statement should be valid"),
])
.expect("transaction should be valid");
let write = ChannelInscription {
tx_id: TxId::from([3; 32]),
transaction,
captured_function_calls: CapturedFunctionCalls::empty(),
};
assert_eq!(
hex::encode(write.content_digest()),
"b119823633ba6fbe90618b226b0d68eae1805876a86c1057237aca6205874b30"
);
}
#[test]
fn complete_payload_must_fit_one_inscription() {
let transaction = Transaction::new(vec![
@@ -347,7 +446,7 @@ mod tests {
.expect("statement should be valid"),
])
.expect("transaction should be valid");
let result = EncodedWrite::new(&transaction);
let result = EncodedWrite::new(&transaction, CapturedFunctionCalls::empty());
assert!(matches!(result, Err(crate::Error::InscriptionTooLarge)));
}
+1 -2
View File
@@ -16,7 +16,6 @@ use crate::{
applier,
db::Databases,
error::Error,
local_write,
protocol::{Transaction, TxId},
};
@@ -176,7 +175,7 @@ impl Runtime {
} else if !self.sequencer_ready {
Err(Error::SequencerNotReady)
} else {
let committed = local_write::commit(&mut self.db, &transaction);
let committed = self.db.commit_local_write(&transaction);
if let Ok(tx_id) = committed {
tracing::trace!(