use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::ops::Range;
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::sync::atomic::Ordering;
use crate::binary_cas::BlobId;
use crate::branch::BranchRefReader;
use crate::common::{ExecuteStatementMetadata, ExpiredReadRetryState};
use crate::functions::{FunctionContext, FunctionProviderHandle};
use crate::sql_telemetry::{
SqlStatementTelemetry, finish_operation, finish_single_statement_batch, start_batch,
};
use crate::sql2;
use crate::sql2::{
ExactFilesystemRead, ExactLixFileReadColumn, ExactLixFileReadSelector,
exact_filesystem_read_interest_route, exact_filesystem_read_route,
};
use crate::sql2::SqlWriteExecutionContext;
use crate::sql2::{is_acknowledgeable_file_content_read, late_materialized_lix_file_content_read};
use crate::storage_adapter::Storage;
use crate::storage_adapter::{
SharedStorageAdapterRead, StorageAdapter, StorageAdapterRead, StorageAdapterReadScope,
StorageReadDurability, StorageReadOptions, StorageWriteOptions, StorageWriteSet,
};
use crate::telemetry::{ActiveTelemetrySpan, CHECKPOINT_CREATE, Status, TelemetryAttribute};
use crate::transaction::{begin_commit_boundary, commit_at_boundary};
use crate::{Blob, LixError, LixNotice, ResultColumnType, RowRef, SqlQueryResult, Value};
use datafusion::arrow::array::{ArrayRef, LargeStringBuilder, StringBuilder};
use datafusion::arrow::datatypes::{DataType, Field, Schema};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::sql::parser::Statement as DataFusionStatement;
#[cfg(feature = "storage-benches")]
use futures_util::TryStreamExt;
use serde_json::{Map as JsonMap, Value as JsonValue};
use tracing::Instrument as _;
use super::ExecuteIdempotency;
use super::context::{SessionContext, SessionSqlExecutionContext};
use super::idempotency::{ExecuteIdempotencyReceipt, load_receipt};
use super::transaction::{SessionTransaction, transaction_state_error};
const MAX_INITIAL_LITERAL_COLUMN_BYTES: usize = 64 * 1024 * 1024;
const MAX_AUTO_COMMIT_RETRIES: usize = 16;
enum LiteralParameterBuilder {
Utf8(StringBuilder),
LargeUtf8(LargeStringBuilder),
}
impl LiteralParameterBuilder {
fn with_capacity(large_offsets: bool, item_capacity: usize, data_capacity: usize) -> Self {
if large_offsets {
Self::LargeUtf8(LargeStringBuilder::with_capacity(
item_capacity,
data_capacity,
))
} else {
Self::Utf8(StringBuilder::with_capacity(item_capacity, data_capacity))
}
}
fn append_value(&mut self, value: &str) {
match self {
Self::Utf8(builder) => builder.append_value(value),
Self::LargeUtf8(builder) => builder.append_value(value),
}
}
fn finish(&mut self) -> ArrayRef {
match self {
Self::Utf8(builder) => Arc::new(builder.finish()),
Self::LargeUtf8(builder) => Arc::new(builder.finish()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CommitSpan {
before: String,
after: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CommitReceipt {
pub commit: Option<CommitSpan>,
}
#[derive(Debug, Clone)]
pub struct ExecuteBatchResult {
pub results: Vec<ExecuteResult>,
pub commit: Option<CommitSpan>,
}
impl ExecuteBatchResult {
pub(crate) fn from_results(mut results: Vec<ExecuteResult>) -> Self {
let commit = results.iter_mut().find_map(|result| result.commit.take());
for result in &mut results {
result.commit = None;
}
Self { results, commit }
}
}
impl CommitReceipt {
pub(crate) fn annotate_completion_error(&self, error: LixError) -> LixError {
let mut error = super::context::non_retryable_after_commit(error);
error
.details_mut()
.and_then(serde_json::Value::as_object_mut)
.expect("completion error has object details")
.insert("commit".into(), serde_json::json!(self.commit));
error
}
}
impl CommitSpan {
pub(crate) fn new(before: String, after: String) -> Self {
Self { before, after }
}
pub(crate) fn from_commit_ids(
(before, after): (crate::changelog::CommitId, crate::changelog::CommitId),
) -> Self {
Self::new(before.to_string(), after.to_string())
}
pub fn before(&self) -> &str {
&self.before
}
pub fn after(&self) -> &str {
&self.after
}
}
#[derive(Debug, Clone)]
pub struct ExecuteResult {
statement_index: Option<usize>,
statement_label: Option<String>,
backing: Option<Arc<ExecuteResultBacking>>,
rows_affected: u64,
commit: Option<CommitSpan>,
checkpoint_telemetry: Option<(String, String)>,
#[cfg(feature = "storage-benches")]
profile_provider_rows_examined: u64,
}
#[derive(Debug)]
struct ExecuteResultBacking {
columns: Arc<[String]>,
column_types: Arc<[ResultColumnType]>,
rows: OnceLock<Vec<Row>>,
columnar: StdMutex<Option<ColumnarResult>>,
notices: Vec<LixNotice>,
file_view_mutations: Vec<sql2::SessionFileViewMutation>,
}
#[derive(Debug)]
struct ColumnarResult {
fields: Vec<Field>,
batches: Arc<[RecordBatch]>,
}
impl PartialEq for ExecuteResult {
fn eq(&self, other: &Self) -> bool {
self.statement_index == other.statement_index
&& self.statement_label == other.statement_label
&& self.rows_affected == other.rows_affected
&& (matches!(
(&self.backing, &other.backing),
(Some(left), Some(right)) if Arc::ptr_eq(left, right)
) || (self.columns() == other.columns()
&& self.column_types() == other.column_types()
&& self.rows() == other.rows()
&& self.notices() == other.notices()))
}
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq)]
pub struct CoherentReadBatch {
pub active_branch_id: String,
pub active_branch_commit_id: String,
pub storage_mutation_revision: Option<Vec<u8>>,
pub results: Vec<ExecuteResult>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRead {
content: Blob,
total_size: u64,
range: Range<u64>,
content_identity: String,
}
impl FileRead {
pub fn content(&self) -> &Blob {
&self.content
}
pub fn into_content(self) -> Blob {
self.content
}
pub fn total_size(&self) -> u64 {
self.total_size
}
pub fn range(&self) -> Range<u64> {
self.range.clone()
}
pub fn content_identity(&self) -> &str {
&self.content_identity
}
}
impl ExecuteResult {
pub fn statement_index(&self) -> Option<usize> {
self.statement_index
}
pub fn label(&self) -> Option<&str> {
self.statement_label.as_deref()
}
pub fn commit(&self) -> Option<&CommitSpan> {
self.commit.as_ref()
}
pub(crate) fn with_commit(mut self, commit: Option<CommitSpan>) -> Self {
if commit.is_some() {
self.commit = commit;
}
self
}
fn with_batch_metadata(mut self, statement_index: usize, label: Option<String>) -> Self {
self.statement_index = Some(statement_index);
self.statement_label = label;
self
}
pub(crate) fn from_session_read_result(result: sql2::SessionReadSqlResult) -> Self {
match result.query {
sql2::SessionReadResult::Rows(result) => Self::from_sql_query_result(result),
sql2::SessionReadResult::Columnar {
fields,
batches,
notices,
} => Self::from_columnar_result(fields, batches, notices),
}
}
fn from_sql_query_result(result: SqlQueryResult) -> Self {
#[cfg(feature = "storage-benches")]
let started = crate::sql_profile::is_active().then(std::time::Instant::now);
let result = Self::from_query_parts(
result.columns,
result.column_types,
result.rows,
0,
result.notices,
);
#[cfg(feature = "storage-benches")]
if let Some(started) = started {
crate::sql_profile::record_phase(
crate::sql_profile::Phase::PublicResultMaterialization,
started.elapsed(),
);
}
result
}
fn from_sql_write_result(result: sql2::SqlWriteResult) -> Self {
let sql2::SqlWriteResult {
rows_affected,
returning,
checkpoint_telemetry,
} = result;
let mut result = match returning {
Some(result) => Self::from_query_parts(
result.columns,
result.column_types,
result.rows,
rows_affected,
result.notices,
),
None => Self::from_rows_affected(rows_affected),
};
result.checkpoint_telemetry = checkpoint_telemetry;
result
}
pub fn from_rows_affected(rows_affected: u64) -> Self {
Self {
statement_index: None,
statement_label: None,
backing: None,
rows_affected,
commit: None,
checkpoint_telemetry: None,
#[cfg(feature = "storage-benches")]
profile_provider_rows_examined: 0,
}
}
pub fn from_rows(columns: Vec<String>, rows: Vec<Vec<Value>>) -> Self {
Self::from_query_parts(columns, Vec::new(), rows, 0, Vec::new())
}
pub(crate) fn from_idempotency_parts(
columns: Vec<String>,
column_types: Vec<ResultColumnType>,
rows: Vec<Vec<Value>>,
rows_affected: u64,
notices: Vec<LixNotice>,
commit: Option<CommitSpan>,
) -> Self {
Self::from_query_parts(columns, column_types, rows, rows_affected, notices)
.with_commit(commit)
}
pub(crate) fn from_protocol_response(
statement_index: Option<usize>,
label: Option<String>,
columns: Vec<String>,
column_types: Vec<ResultColumnType>,
rows: Vec<Vec<Value>>,
rows_affected: u64,
notices: Vec<LixNotice>,
commit: Option<CommitSpan>,
) -> Self {
let mut result =
Self::from_query_parts(columns, column_types, rows, rows_affected, notices);
result.statement_index = statement_index;
result.statement_label = label;
result.with_commit(commit)
}
fn from_query_parts(
columns: Vec<String>,
mut column_types: Vec<ResultColumnType>,
rows: Vec<Vec<Value>>,
rows_affected: u64,
notices: Vec<LixNotice>,
) -> Self {
if column_types.len() != columns.len() {
column_types = infer_column_types(columns.len(), &rows);
}
let columns: Arc<[String]> = columns.into();
let column_types: Arc<[ResultColumnType]> = column_types.into();
let rows = Row::from_nested(Arc::clone(&columns), rows);
Self {
statement_index: None,
statement_label: None,
backing: Some(Arc::new(ExecuteResultBacking {
columns,
column_types,
rows: OnceLock::from(rows),
columnar: StdMutex::new(None),
notices,
file_view_mutations: Vec::new(),
})),
rows_affected,
commit: None,
checkpoint_telemetry: None,
#[cfg(feature = "storage-benches")]
profile_provider_rows_examined: 0,
}
}
fn from_columnar_result(
fields: Vec<Field>,
batches: Arc<[RecordBatch]>,
notices: Vec<LixNotice>,
) -> Self {
let columns = fields
.iter()
.map(|field| field.name().clone())
.collect::<Vec<_>>()
.into();
let column_types = fields
.iter()
.map(sql2::result_column_type)
.collect::<Result<Vec<_>, _>>()
.expect("columnar result fields were validated before public ownership transfer")
.into();
Self {
statement_index: None,
statement_label: None,
backing: Some(Arc::new(ExecuteResultBacking {
columns,
column_types,
rows: OnceLock::new(),
columnar: StdMutex::new(Some(ColumnarResult { fields, batches })),
notices,
file_view_mutations: Vec::new(),
})),
rows_affected: 0,
commit: None,
checkpoint_telemetry: None,
#[cfg(feature = "storage-benches")]
profile_provider_rows_examined: 0,
}
}
fn with_file_view_mutations(mut self, mutations: Vec<sql2::SessionFileViewMutation>) -> Self {
let backing = self.backing.get_or_insert_with(|| {
Arc::new(ExecuteResultBacking {
columns: Vec::new().into(),
column_types: Vec::new().into(),
rows: OnceLock::from(Vec::new()),
columnar: StdMutex::new(None),
notices: Vec::new(),
file_view_mutations: Vec::new(),
})
});
Arc::get_mut(backing)
.expect("fresh execute result backing must be uniquely owned")
.file_view_mutations = mutations;
self
}
pub(crate) fn file_view_mutations(&self) -> &[sql2::SessionFileViewMutation] {
self.backing
.as_deref()
.map_or(&[], |backing| backing.file_view_mutations.as_slice())
}
pub fn columns(&self) -> &[String] {
self.backing
.as_deref()
.map_or(&[], |backing| backing.columns.as_ref())
}
pub fn column_types(&self) -> &[ResultColumnType] {
self.backing
.as_deref()
.map_or(&[], |backing| backing.column_types.as_ref())
}
pub fn rows(&self) -> &[Row] {
self.backing.as_deref().map_or(&[], |backing| {
backing
.rows
.get_or_init(|| backing.materialize_rows())
.as_slice()
})
}
pub fn iter(&self) -> impl Iterator<Item = ResultRowRef<'_>> {
let columns = self.columns();
self.rows().iter().map(move |row| ResultRowRef {
columns,
values: row.values(),
})
}
pub fn len(&self) -> usize {
self.rows().len()
}
pub fn is_empty(&self) -> bool {
self.rows().is_empty()
}
pub fn rows_affected(&self) -> u64 {
self.rows_affected
}
pub fn notices(&self) -> &[LixNotice] {
self.backing
.as_deref()
.map_or(&[], |backing| backing.notices.as_slice())
}
pub(crate) fn with_authority_notice(self) -> Self {
let columns = self.columns().to_vec();
let column_types = self.column_types().to_vec();
let rows = self
.rows()
.iter()
.map(|row| row.values().to_vec())
.collect();
let mut notices = self.notices().to_vec();
notices.push(LixNotice {
code: "LIX_AUTHORITY_SQL".to_owned(),
message: "Executed against the authority; local pending edits are not included"
.to_owned(),
hint: None,
});
let mut result =
Self::from_query_parts(columns, column_types, rows, self.rows_affected, notices);
result.statement_index = self.statement_index;
result.statement_label = self.statement_label;
result.commit = self.commit;
result.checkpoint_telemetry = self.checkpoint_telemetry;
#[cfg(feature = "storage-benches")]
{
result.profile_provider_rows_examined = self.profile_provider_rows_examined;
}
result
}
pub fn get<'a>(&self, row: &'a Row, column_name: &str) -> Option<&'a Value> {
let index = self.column_index(column_name)?;
row.get_index(index)
}
pub fn column_index(&self, column_name: &str) -> Option<usize> {
self.columns()
.iter()
.position(|column| column == column_name)
}
}
fn infer_column_types(column_count: usize, rows: &[Vec<Value>]) -> Vec<ResultColumnType> {
(0..column_count)
.map(|column_index| {
rows.iter()
.filter_map(|row| row.get(column_index))
.find(|value| !matches!(value, Value::Null))
.map_or(ResultColumnType::Null, ResultColumnType::from_value)
})
.collect()
}
impl ExecuteResultBacking {
fn materialize_rows(&self) -> Vec<Row> {
let mut columnar = self.columnar.lock().expect("columnar result lock poisoned");
let Some(columnar_result) = columnar.as_ref() else {
return Vec::new();
};
#[cfg(feature = "storage-benches")]
let started = crate::sql_profile::is_active().then(std::time::Instant::now);
let (values, row_count) =
sql2::query_values_from_batches(&columnar_result.fields, &columnar_result.batches)
.expect("columnar result was validated before public ownership transfer");
#[cfg(feature = "storage-benches")]
if let Some(started) = started {
crate::sql_profile::record_phase(
crate::sql_profile::Phase::PublicResultMaterialization,
started.elapsed(),
);
}
let column_count = columnar_result.fields.len();
*columnar = None;
drop(columnar);
Row::from_flat(Arc::clone(&self.columns), values, row_count, column_count)
}
}
pub struct Row {
backing: Arc<RowBacking>,
values: Range<usize>,
}
struct RowBacking {
columns: Arc<[String]>,
values: Box<[Value]>,
}
impl Clone for Row {
fn clone(&self) -> Self {
let values = self.values().to_vec().into_boxed_slice();
let value_count = values.len();
Self {
backing: Arc::new(RowBacking {
columns: Arc::clone(&self.backing.columns),
values,
}),
values: 0..value_count,
}
}
}
impl Row {
fn from_nested(columns: Arc<[String]>, rows: Vec<Vec<Value>>) -> Vec<Self> {
let mut values = Vec::with_capacity(rows.iter().map(Vec::len).sum());
let mut ranges = Vec::with_capacity(rows.len());
for row in rows {
let start = values.len();
values.extend(row);
ranges.push(start..values.len());
}
let backing = Arc::new(RowBacking {
columns,
values: values.into_boxed_slice(),
});
ranges
.into_iter()
.map(|values| Self {
backing: Arc::clone(&backing),
values,
})
.collect()
}
fn from_flat(
columns: Arc<[String]>,
values: Vec<Value>,
row_count: usize,
column_count: usize,
) -> Vec<Self> {
debug_assert_eq!(values.len(), row_count.saturating_mul(column_count));
let backing = Arc::new(RowBacking {
columns,
values: values.into_boxed_slice(),
});
(0..row_count)
.map(|row_index| {
let start = row_index * column_count;
Self {
backing: Arc::clone(&backing),
values: start..start + column_count,
}
})
.collect()
}
pub fn values(&self) -> &[Value] {
&self.backing.values[self.values.clone()]
}
pub fn get_index(&self, index: usize) -> Option<&Value> {
self.values().get(index)
}
pub fn value(&self, column_name: &str) -> Result<&Value, LixError> {
let index = self.column_index(column_name)?;
self.values().get(index).ok_or_else(|| {
LixError::new(
LixError::CODE_COLUMN_NOT_FOUND,
format!(
"column '{}' points past row width {}; available columns: {}",
column_name,
self.values.len(),
self.available_columns()
),
)
})
}
pub fn get<T>(&self, column_name: &str) -> Result<T, LixError>
where
T: TryFromValue,
{
T::try_from_value(self.value(column_name)?)
}
fn column_index(&self, column_name: &str) -> Result<usize, LixError> {
self.backing
.columns
.iter()
.position(|column| column == column_name)
.ok_or_else(|| {
LixError::new(
LixError::CODE_COLUMN_NOT_FOUND,
format!(
"column '{}' does not exist; available columns: {}",
column_name,
self.available_columns()
),
)
})
}
fn available_columns(&self) -> String {
if self.backing.columns.is_empty() {
"<none>".to_string()
} else {
self.backing.columns.join(", ")
}
}
}
impl std::fmt::Debug for Row {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Row")
.field("columns", &self.backing.columns)
.field("values", &self.values())
.finish()
}
}
impl PartialEq for Row {
fn eq(&self, other: &Self) -> bool {
self.backing.columns == other.backing.columns && self.values() == other.values()
}
}
pub trait TryFromValue: Sized {
fn try_from_value(value: &Value) -> Result<Self, LixError>;
}
impl TryFromValue for Value {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
Ok(value.clone())
}
}
impl TryFromValue for String {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::Text(value) => Ok(value.clone()),
other => Err(value_type_error("text", other)),
}
}
}
impl TryFromValue for RowRef {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::RowRef(value) => Ok(value.clone()),
other => Err(value_type_error("row_ref", other)),
}
}
}
impl TryFromValue for bool {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::Boolean(value) => Ok(*value),
other => Err(value_type_error("boolean", other)),
}
}
}
impl TryFromValue for i64 {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::Integer(value) => Ok(*value),
other => Err(value_type_error("integer", other)),
}
}
}
impl TryFromValue for f64 {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::Real(value) => Ok(*value),
other => Err(value_type_error("real", other)),
}
}
}
impl TryFromValue for serde_json::Value {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::Jsonb(value) => Ok(value.to_value()),
other => Err(value_type_error("jsonb", other)),
}
}
}
impl TryFromValue for Vec<u8> {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::Blob(value) => Ok(value.to_vec()),
other => Err(value_type_error("blob", other)),
}
}
}
impl TryFromValue for Blob {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
match value {
Value::Blob(value) => Ok(value.clone()),
other => Err(value_type_error("blob", other)),
}
}
}
impl TryFromValue for bytes::Bytes {
fn try_from_value(value: &Value) -> Result<Self, LixError> {
Blob::try_from_value(value).map(Blob::into_bytes)
}
}
fn value_type_error(expected: &str, actual: &Value) -> LixError {
LixError::new(
"LIX_ERROR_VALUE_TYPE",
format!("expected {expected} value, got {actual:?}"),
)
}
#[derive(Debug, Clone, Copy)]
pub struct ResultRowRef<'a> {
columns: &'a [String],
values: &'a [Value],
}
impl ResultRowRef<'_> {
pub fn columns(&self) -> &[String] {
self.columns
}
pub fn values(&self) -> &[Value] {
self.values
}
pub fn get(&self, column_name: &str) -> Option<&Value> {
let index = self
.columns
.iter()
.position(|column| column == column_name)?;
self.values.get(index)
}
pub fn get_index(&self, index: usize) -> Option<&Value> {
self.values.get(index)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExecuteOptions {
pub origin_key: Option<String>,
pub max_auto_commit_retries: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionDisposition {
CancellableRead,
Durable,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExecuteBatchStatement {
pub sql: String,
pub params: Vec<Value>,
pub label: Option<String>,
}
fn staged_commit_span<StorageImpl>(
transaction: &crate::transaction::Transaction<StorageImpl>,
) -> Result<Option<CommitSpan>, LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
Ok(transaction
.staged_active_branch_commit_span()?
.map(CommitSpan::from_commit_ids))
}
fn with_staged_commit_span<StorageImpl>(
transaction: &crate::transaction::Transaction<StorageImpl>,
results: Vec<ExecuteResult>,
) -> Result<Vec<ExecuteResult>, LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
let commit = staged_commit_span(transaction)?;
Ok(results
.into_iter()
.map(|result| result.with_commit(commit.clone()))
.collect())
}
fn annotate_batch_results(
statements: &[ExecuteBatchStatement],
results: Vec<ExecuteResult>,
) -> Result<Vec<ExecuteResult>, LixError> {
if results.len() != statements.len() {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"execute batch produced a result count different from its statement count",
)
.with_details(serde_json::json!({
"statementCount": statements.len(),
"resultCount": results.len(),
})));
}
Ok(results
.into_iter()
.enumerate()
.map(|(statement_index, result)| {
result.with_batch_metadata(statement_index, statements[statement_index].label.clone())
})
.collect())
}
enum ExecuteBatchExecution {
ReadOnly(Vec<datafusion::sql::parser::Statement>),
Transaction(TransactionBatchStatements),
}
#[derive(Clone)]
enum TransactionBatchStatements {
Shared {
statement: datafusion::sql::parser::Statement,
len: usize,
},
AutoParameterizedUpdate {
sql: Arc<str>,
statement: datafusion::sql::parser::Statement,
parameter_batch: RecordBatch,
},
Distinct(Vec<datafusion::sql::parser::Statement>),
}
impl TransactionBatchStatements {
fn len(&self) -> usize {
match self {
Self::Shared { len, .. } => *len,
Self::AutoParameterizedUpdate {
parameter_batch, ..
} => parameter_batch.num_rows(),
Self::Distinct(statements) => statements.len(),
}
}
fn contains_write(&self) -> Result<bool, LixError> {
match self {
Self::Shared { statement, .. } => {
Ok(sql2::bind_statement_route(statement)? == sql2::BoundStatementRoute::Write)
}
Self::AutoParameterizedUpdate { .. } => Ok(true),
Self::Distinct(statements) => {
statements
.iter()
.try_fold(false, |contains_write, statement| {
Ok(contains_write
|| sql2::bind_statement_route(statement)?
== sql2::BoundStatementRoute::Write)
})
}
}
}
fn into_vec(self) -> Vec<datafusion::sql::parser::Statement> {
match self {
Self::Shared { statement, len } => vec![statement; len],
Self::AutoParameterizedUpdate {
statement,
parameter_batch,
..
} => vec![statement; parameter_batch.num_rows()],
Self::Distinct(statements) => statements,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ReadBatchKind {
Ordinary,
Coherent,
}
impl ReadBatchKind {
fn telemetry_name(self) -> &'static str {
match self {
Self::Ordinary => "batch",
Self::Coherent => "coherent_read_batch",
}
}
fn normalize_error(self, error: LixError, sql: &str, index: usize) -> LixError {
let error = normalize_sql_surface_error(error, sql);
match self {
Self::Ordinary => with_batch_statement_index(error, index),
Self::Coherent => error,
}
}
}
struct ReadBatchSnapshot {
active_branch_id: String,
active_branch_commit_id: String,
storage_mutation_revision: Option<Vec<u8>>,
}
struct ReadBatchResult {
results: Vec<ExecuteResult>,
snapshot: Option<ReadBatchSnapshot>,
}
enum IdempotencyReceiptResolution {
Absent,
Replay(ExecuteIdempotencyReceipt),
}
const NATIVE_FILE_UPSERT_SQL: &str = "INSERT INTO lix_file (path, content) VALUES ($1, $2) \
ON CONFLICT (path) DO UPDATE SET content = excluded.content";
fn validate_native_file_upsert_batch(writes: &[(String, Blob)]) -> Result<(), LixError> {
if writes.is_empty() {
return Err(LixError::new(
LixError::CODE_INVALID_PARAM,
"upsert_file_content_batch requires at least one file",
)
.with_details(serde_json::json!({
"operation": "upsertFileContentBatch",
"argument": "writes",
"expected": "non-empty array",
})));
}
let mut paths = BTreeSet::new();
for (path, _) in writes {
crate::common::LixPath::try_from_file_path(path)?;
if !paths.insert(path.as_str()) {
return Err(LixError::new(
LixError::CODE_INVALID_PARAM,
format!("upsert_file_content_batch contains duplicate path '{path}'"),
)
.with_details(serde_json::json!({
"operation": "upsertFileContentBatch",
"argument": "writes",
"path": path,
"expected": "unique file paths",
})));
}
}
Ok(())
}
impl<StorageImpl> SessionContext<StorageImpl>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
pub(crate) fn execution_disposition(
&self,
sql: &str,
) -> Result<ExecutionDisposition, LixError> {
let statement = self.sql_planning_cache.parse_statement(sql)?;
execution_disposition(&statement)
}
pub(crate) fn is_standalone_global_history_read(&self, sql: &str) -> Result<bool, LixError> {
let statement = self.sql_planning_cache.parse_statement(sql)?;
Ok(sql2::is_standalone_global_history_read(&statement))
}
pub(crate) fn execute_batch_disposition(
&self,
statements: &[ExecuteBatchStatement],
) -> Result<ExecutionDisposition, LixError> {
for (statement_index, statement) in statements.iter().enumerate() {
let parsed = self
.sql_planning_cache
.parse_statement(&statement.sql)
.map_err(|error| with_batch_statement_index(error, statement_index))?;
if execution_disposition(&parsed)
.map_err(|error| with_batch_statement_index(error, statement_index))?
== ExecutionDisposition::Durable
{
return Ok(ExecutionDisposition::Durable);
}
}
Ok(ExecutionDisposition::CancellableRead)
}
pub async fn execute(&self, sql: &str, params: &[Value]) -> Result<ExecuteResult, LixError> {
Box::pin(self.execute_with_options(sql, params, ExecuteOptions::default())).await
}
#[cfg(feature = "storage-benches")]
pub(crate) async fn execute_profiled(
&self,
sql: &str,
params: &[Value],
) -> Result<(ExecuteResult, crate::SqlReadProfile), LixError> {
let (result, mut profile) = crate::sql_profile::scope(self.execute(sql, params)).await;
if let Ok(result) = &result {
if result.profile_provider_rows_examined != 0 {
profile.scan_rows = profile.scan_rows.saturating_add(result.len() as u64);
}
profile.provider_rows_examined = profile
.provider_rows_examined
.saturating_add(result.profile_provider_rows_examined);
}
result.map(|result| (result, profile))
}
#[cfg(feature = "storage-benches")]
pub(crate) async fn execute_result_streaming_profiled(
&self,
sql: &str,
params: &[Value],
mode: &str,
row_limit: Option<usize>,
) -> Result<crate::SqlReadProfile, LixError> {
let (result, profile) = crate::sql_profile::scope(async {
if mode == "full" {
let result = self.execute(sql, params).await?;
let rows = result.rows();
let consumed = row_limit.map_or(rows.len(), |limit| limit.min(rows.len()));
let checksum = rows.iter().take(consumed).try_fold(0u64, |checksum, row| {
profile_result_checksum(checksum, row.values())
})?;
crate::sql_profile::record_result_rows(consumed, rows.len(), rows.len());
crate::sql_profile::record_result_checksum(checksum);
return Ok(());
}
if !matches!(mode, "stream" | "live" | "count_only") {
return Err(LixError::new(
LixError::CODE_INVALID_PARAM,
format!("unknown result streaming profile mode '{mode}'"),
));
}
self.ensure_open()?;
let statement = self.sql_planning_cache.parse_statement(sql)?;
if sql2::bind_statement_route(&statement)? != sql2::BoundStatementRoute::Read
|| sql2::statement_has_durable_runtime_function(&statement)
|| exact_filesystem_read_route(&statement, params).is_some()
|| late_materialized_lix_file_content_read(&statement, params).is_some()
{
return Err(LixError::new(
LixError::CODE_UNSUPPORTED_SQL,
"result streaming profiler accepts only ordinary cancellable reads",
));
}
let _operation_guard = self.begin_waitable_session_operation().await?;
let read_scope = self
.storage
.begin_read(StorageReadOptions::default())
.await?;
with_static_session_sql_read::<StorageImpl, _, _, _>(
read_scope,
|read_store: SharedStorageAdapterRead<StorageImpl::Read<'static>>| async move {
let active_branch_id = self.active_branch_id_from_reader(&read_store).await?;
let ctx = SessionSqlExecutionContext {
active_branch_id: &active_branch_id,
active_account_id: self.active_account_id(),
read_store,
hot_state: Arc::clone(&self.hot_state),
binary_cas: Arc::clone(&self.binary_cas),
branch_ctx: Arc::clone(&self.branch_ctx),
catalog_context: Arc::clone(&self.catalog_context),
sql_planning_cache: Arc::clone(&self.sql_planning_cache),
functions: FunctionProviderHandle::system(),
plugin_host: self.plugin_host.clone(),
file_views: None,
};
let read_session =
sql2::prepare_read_session(&ctx, std::slice::from_ref(&statement)).await?;
match mode {
"stream" => {
let result =
sql2::execute_read_statement_in_session_with_collected_batches(
&read_session,
sql,
statement,
params,
)
.await?;
let _notice_count = result.notices.len();
let mut cursor =
sql2::BatchRowCursor::collected(&result.fields, &result.batches);
consume_profile_cursor(&mut cursor, row_limit).await?;
}
"live" => {
let mut result =
sql2::execute_read_statement_in_session_with_batch_stream(
&read_session,
sql,
statement,
params,
)
.await?;
let _notice_count = result.notices.len();
let mut cursor = sql2::BatchRowCursor::live(&mut result);
consume_profile_cursor(&mut cursor, row_limit).await?;
drop(cursor);
drop(result);
}
"count_only" => {
let mut result =
sql2::execute_read_statement_in_session_with_batch_stream(
&read_session,
sql,
statement,
params,
)
.await?;
let _notice_count = result.notices.len();
let mut rows = 0usize;
let mut batches = 0usize;
while let Some(batch) = {
let started = std::time::Instant::now();
let batch = result
.stream
.try_next()
.await
.map_err(sql2::datafusion_error_to_lix_error);
crate::sql_profile::record_phase(
crate::sql_profile::Phase::ArrowExecution,
started.elapsed(),
);
batch?
} {
rows = rows.saturating_add(batch.num_rows());
batches = batches.saturating_add(1);
}
crate::sql_profile::record_result_count_only(rows, batches);
crate::sql_profile::record_result_rows(rows, 0, 0);
drop(result);
}
_ => unreachable!("profile mode validated before opening read"),
}
drop(read_session);
drop(ctx);
Ok(())
},
)
.await
})
.await;
result?;
Ok(profile)
}
pub(crate) async fn execute_with_options(
&self,
sql: &str,
params: &[Value],
options: ExecuteOptions,
) -> Result<ExecuteResult, LixError> {
Box::pin(self.execute_with_options_and_metadata(
sql,
params,
options,
ExecuteStatementMetadata::default(),
))
.await
}
pub(crate) async fn execute_with_options_and_metadata(
&self,
sql: &str,
params: &[Value],
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
) -> Result<ExecuteResult, LixError> {
validate_execute_statement_metadata(params.len(), &metadata, None)?;
Box::pin(self.execute_with_kind(sql, params, options, metadata, "execute", None, false))
.await
}
pub(crate) fn execute_with_idempotency_and_options_and_metadata(
self: Arc<Self>,
sql: String,
params: Vec<Value>,
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
idempotency: Option<ExecuteIdempotency>,
) -> impl Future<Output = Result<ExecuteResult, LixError>> + Send + 'static {
unsafe {
super::AssumeSendFuture::new(async move {
validate_execute_statement_metadata(params.len(), &metadata, None)?;
self.execute_with_kind(
&sql,
¶ms,
options,
metadata,
"execute",
idempotency,
true,
)
.await
})
}
}
pub(crate) async fn upsert_file_content(
&self,
path: String,
content: Blob,
) -> Result<u64, LixError> {
self.ensure_open()?;
crate::common::LixPath::try_from_file_path(&path)?;
self.refresh_active_branch_base_if_stale().await?;
let write_access = self.begin_session_write_access().await?;
let sql_planning_cache = Arc::clone(&self.sql_planning_cache);
self.with_write_transaction_reserved_lending(
write_access,
async move |transaction| {
let fast_path = sql2::execute_fast_lix_file_path_writes(
transaction,
vec![(path.clone(), content.clone(), None, None)],
sql2::FastLixFilePathWriteConflict::UpdateContent,
None,
)
.await?;
if let Some(count) = fast_path {
return Ok(count);
}
let statement = sql_planning_cache.parse_statement(NATIVE_FILE_UPSERT_SQL)?;
let plan = transaction
.prepare_sql_write_logical_plan(NATIVE_FILE_UPSERT_SQL, &statement)?;
sql2::execute_write_logical_plan_result_with_metadata(
transaction,
plan,
&[Value::Text(path), Value::Blob(content)],
&ExecuteStatementMetadata::default(),
)
.await
.map(|result| result.rows_affected)
},
|_| Ok(()),
)
.await
}
pub(crate) async fn upsert_file_content_batch(
&self,
writes: Vec<(String, Blob)>,
) -> Result<u64, LixError> {
self.ensure_open()?;
validate_native_file_upsert_batch(&writes)?;
let write_access = self.begin_session_write_access().await?;
self.with_write_transaction_reserved_lending(write_access, async move |transaction| {
sql2::execute_fast_lix_file_path_writes(
transaction,
writes
.into_iter()
.map(|(path, content)| (path, content, None, None))
.collect(),
sql2::FastLixFilePathWriteConflict::UpdateContent,
None,
)
.await?
.ok_or_else(|| {
LixError::new(
LixError::CODE_CONSTRAINT_VIOLATION,
"upsert_file_content_batch requires a filesystem layout that its direct path index can route unambiguously",
)
.with_details(serde_json::json!({
"operation": "upsertFileContentBatch",
"expected": "a filesystem layout that the direct path index can route unambiguously",
}))
})
}, |_| Ok(()))
.await
}
pub(crate) fn read_file_content(
&self,
path: String,
requested_range: Option<Range<u64>>,
) -> impl Future<Output = Result<Option<FileRead>, LixError>> + Send + '_ {
self.read_file_content_inner(path, requested_range)
}
async fn read_file_content_inner(
&self,
path: String,
requested_range: Option<Range<u64>>,
) -> Result<Option<FileRead>, LixError> {
self.ensure_open()?;
crate::common::LixPath::try_from_file_path(&path)?;
let paths = BTreeSet::from([path]);
let _operation_guard = self.begin_waitable_session_operation().await?;
let (content, file_view_mutations, captured_interests) =
execute_coherent_session_read::<StorageImpl, _, _, _>(
&self.storage,
true,
|read_store: SharedStorageAdapterRead<StorageImpl::Read<'static>>| {
let paths = paths.clone();
let requested_range = requested_range.clone();
async move {
let active_branch_id =
self.active_branch_id_from_reader(&read_store).await?;
let capture = self.hot_state.capture_foreground_read_interests();
if let Some((_, capture)) = &capture {
let path = paths
.iter()
.next()
.expect("structured file read always has one path")
.clone();
register_seeded_file_interest(
capture,
&active_branch_id,
None,
crate::hot_state::FilePathInterest::Comparison {
operation:
crate::hot_state::FilePathInterestComparison::Equal,
value: path,
},
true,
requested_range
.as_ref()
.map(|range| (range.start, range.end)),
)?;
}
let read_hot = capture.as_ref().map_or_else(
|| Arc::clone(&self.hot_state),
|(hot, _)| Arc::new(hot.clone()),
);
let plugin_cache_snapshot = read_store.snapshot_cache_key();
let hot_state: Arc<dyn crate::hot_state::HotStateReader> =
Arc::new(read_hot.reader(read_store.clone()));
let filesystem_path_index: Arc<
dyn crate::filesystem::FilesystemPathIndexReader,
> = Arc::new(read_hot.reader(read_store.clone()));
let branch_ref: Arc<dyn BranchRefReader> =
Arc::new(self.branch_ctx.ref_reader(read_store.clone()));
let blob_reader: Arc<dyn crate::binary_cas::BlobDataReader> =
Arc::new(self.binary_cas.reader(read_store.clone()));
let file_view_collector = self.file_views.fork_for_read();
let result = async {
let result = sql2::execute_exact_lix_file_batch_read(
&active_branch_id,
hot_state,
filesystem_path_index,
branch_ref,
blob_reader,
self.plugin_host.clone(),
Some(file_view_collector.clone()),
plugin_cache_snapshot,
&paths,
requested_range.clone(),
)
.await?;
let content =
native_file_read_from_exact_result(result, &paths, requested_range)?;
if let Some((_, capture)) = &capture {
let reader = self.hot_state.reader(read_store.clone());
let executable_rows = reader
.prepare_captured_read_interests(
&capture.snapshot()?,
self.active_account_id(),
)
.await?;
self.catalog_context
.prepare_returned_row_catalogs(
&reader,
&executable_rows,
crate::catalog::load_catalog_revision(&read_store)
.await?
.as_ref(),
)
.await?;
crate::plugin::runtime::prepare_returned_row_executables(
&reader,
&self.binary_cas.reader(read_store),
&executable_rows,
)
.await?;
}
Ok::<_, LixError>((
content,
file_view_collector.plugin_file_mutations(),
capture
.as_ref()
.map(|(_, capture)| Arc::clone(capture))
.into_iter()
.collect::<Vec<_>>(),
))
}
.await
.map_err(|error| {
crate::sync::annotate_read_fulfillment_capture(
error,
capture.as_ref().map(|(_, capture)| capture.as_ref()),
)
})?;
Ok(result)
}
},
)
.await?;
if let Some(content) = &content {
crate::common::ReadResultBudget::default().charge(content.content().len(), 1)?;
}
for capture in captured_interests {
capture.publish_capture()?;
}
self.file_views.apply_mutations(file_view_mutations);
self.flush_partial_read_interests().await?;
Ok(content)
}
pub(crate) async fn execute_for_observe(
&self,
sql: &str,
params: &[Value],
) -> Result<ExecuteResult, LixError> {
self.execute_with_kind(
sql,
params,
ExecuteOptions::default(),
ExecuteStatementMetadata::default(),
"observe",
None,
false,
)
.await
}
async fn execute_with_kind(
&self,
sql: &str,
params: &[Value],
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
execution_kind: &'static str,
idempotency: Option<ExecuteIdempotency>,
require_idempotency_for_writes: bool,
) -> Result<ExecuteResult, LixError> {
let telemetry =
SqlStatementTelemetry::start(self.telemetry.as_ref(), sql, execution_kind, None);
let checkpoint_statement = self
.sql_planning_cache
.parse_statement(sql)
.ok()
.and_then(|statement| sql2::checkpoint_function_plan(&statement).ok().flatten())
.is_some_and(|plan| !matches!(plan, sql2::CheckpointFunctionPlan::Recovery { .. } | sql2::CheckpointFunctionPlan::UndoRedo { .. }));
let result = if checkpoint_statement {
let operation = Box::pin(async {
let checkpoint_span =
ActiveTelemetrySpan::start_current(&CHECKPOINT_CREATE, Vec::new());
let execution = self.execute_with_options_inner(
sql,
params,
options,
metadata,
execution_kind == "observe",
idempotency,
require_idempotency_for_writes,
);
let result = match checkpoint_span.as_ref() {
Some(span) => span.instrument(execution).await,
None => execution.await,
};
match result {
Ok(result) => {
if let Some(span) = checkpoint_span {
let attributes = result
.checkpoint_telemetry
.as_ref()
.map(|(commit_id, parent_commit_id)| {
vec![
TelemetryAttribute::string(
"lix.commit_id",
commit_id.clone(),
),
TelemetryAttribute::string(
"lix.parent_commit_id",
parent_commit_id.clone(),
),
]
})
.unwrap_or_default();
span.finish(Status::Unset, attributes);
}
Ok(result)
}
Err(error) => {
if let Some(span) = checkpoint_span {
span.finish(
Status::error(error.code.clone()),
vec![TelemetryAttribute::string("error.type", error.code.clone())],
);
}
Err(error)
}
}
});
match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
}
} else {
let operation = self.execute_with_options_inner(
sql,
params,
options,
metadata,
execution_kind == "observe",
idempotency,
require_idempotency_for_writes,
);
match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
}
};
let result = match result {
Ok(value) => self
.flush_partial_read_interests()
.await
.map(|_| value)
.map_err(super::context::non_retryable_after_execution),
Err(error) => Err(error),
};
if let Some(telemetry) = telemetry {
telemetry.finish(&result);
}
result
}
async fn execute_with_options_inner(
&self,
sql: &str,
params: &[Value],
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
defer_file_view_acknowledgement: bool,
idempotency: Option<ExecuteIdempotency>,
require_idempotency_for_writes: bool,
) -> Result<ExecuteResult, LixError> {
self.ensure_open()?;
if let Some(operation) = &self.account_insertion {
operation.ensure_sql(sql, params)?;
}
let statement = self.sql_planning_cache.parse_statement(sql)?;
let route = sql2::bind_statement_route(&statement)?;
if route == sql2::BoundStatementRoute::Write {
if require_idempotency_for_writes && idempotency.is_none() {
return Err(LixError::new(
LixError::CODE_IDEMPOTENCY_KEY_REQUIRED,
"Idempotency-Key is required for SQL mutations",
));
}
if let Some(idempotency) = idempotency {
return self
.execute_idempotent_write(
sql,
statement,
params,
options,
metadata,
idempotency,
)
.await;
}
let sql_for_error = sql.to_string();
let params = params.to_vec();
let mut retries = AutoCommitRetries::new(options.max_auto_commit_retries);
loop {
let write_access = self.begin_session_write_access().await?;
let sql_for_planning = sql_for_error.clone();
let statement = statement.clone();
let params = params.clone();
let options = options.clone();
let metadata = metadata.clone();
let result = self
.with_write_transaction_reserved_lending_spanned(
write_access,
async move |transaction| {
let previous_origin_key =
transaction.replace_origin_key(options.origin_key);
let result = async {
let tx_plan = transaction.prepare_sql_write_logical_plan(
&sql_for_planning,
&statement,
)?;
let result = execute_prepared_transaction_write(
transaction,
tx_plan,
¶ms,
&metadata,
)
.await?;
Ok(ExecuteResult::from_sql_write_result(result))
}
.await;
transaction.replace_origin_key(previous_origin_key);
result
},
|_| Ok(()),
)
.await
.map_err(|error| normalize_sql_surface_error(error, &sql_for_error));
match result {
Ok((result, commit)) => return Ok(result.with_commit(commit)),
Err(error) => {
if retries.retry(&error).await {
continue;
}
return Err(retries.annotate(error));
}
}
}
}
if !defer_file_view_acknowledgement {
self.refresh_active_branch_base_if_stale().await?;
}
let read_plan = sql2::plan_read_statement(&statement, params);
let acknowledge_file_views = read_plan.acknowledge_file_views;
let has_durable_runtime_function = sql2::statement_has_durable_runtime_function(&statement);
let runtime_write_access = if has_durable_runtime_function {
let write_access = self.begin_session_write_access().await?;
Some(write_access)
} else {
None
};
let _operation_guard = if runtime_write_access.is_some() {
None
} else {
Some(self.begin_waitable_session_operation().await?)
};
let _deterministic_runtime_guard = if has_durable_runtime_function {
Some(self.lock_deterministic_runtime().await)
} else {
None
};
let (mut read_result, file_view_mutations, _provider_rows_examined, captured_interests) =
execute_coherent_session_read::<StorageImpl, _, _, _>(
&self.storage,
!has_durable_runtime_function,
|read_store: SharedStorageAdapterRead<StorageImpl::Read<'static>>| {
let statement = statement.clone();
let read_plan = read_plan.clone();
async move {
self.execute_read_statement_with_store(
read_store,
sql,
statement,
params,
acknowledge_file_views,
read_plan,
has_durable_runtime_function,
)
.await
}
},
)
.await
.map_err(|error| normalize_sql_surface_error(error, sql))?;
if let Some(capture) = captured_interests {
capture.publish_capture()?;
}
let runtime_storage_stats = match read_result.runtime_functions.take() {
Some(runtime_functions) => {
self.persist_runtime_functions_if_needed(
runtime_functions,
runtime_write_access.is_some(),
)
.await?
}
None => None,
};
drop(runtime_write_access);
if let Some(stats) = runtime_storage_stats {
self.observe_invalidation.bump_if_storage_changed(&stats);
}
let result = ExecuteResult::from_session_read_result(read_result)
.with_file_view_mutations(file_view_mutations);
#[cfg(feature = "storage-benches")]
let result = {
let mut result = result;
result.profile_provider_rows_examined = _provider_rows_examined as u64;
result
};
if !defer_file_view_acknowledgement {
self.file_views
.apply_mutations(result.file_view_mutations().iter().cloned());
}
Ok(result)
}
pub(super) async fn refresh_active_branch_base_if_stale(&self) -> Result<(), LixError> {
let mut retry = ExpiredReadRetryState::default();
loop {
match self.refresh_active_branch_base_if_stale_inner().await {
Err(error) => {
let Some(delay) = retry.next_delay(&error) else {
return Err(error);
};
tokio::task::yield_now().await;
if !delay.is_zero() {
crate::sync::sleep(delay).await;
}
}
result => return result,
}
}
}
async fn refresh_active_branch_base_if_stale_inner(&self) -> Result<(), LixError> {
if self.sync_mode.role() == crate::sync::SyncRole::PartialReplica {
self.sync_mode.ensure_partial_admission_healthy()?;
return Ok(());
}
let invalidation_generation = self.observe_invalidation.generation();
if self.base_refresh_generation.load(Ordering::SeqCst) == invalidation_generation {
return Ok(());
}
let read = self
.storage
.begin_read(StorageReadOptions::default())
.await?;
let active_branch_id = self.active_branch_id_from_reader(&read).await?;
if active_branch_id == crate::GLOBAL_BRANCH_ID {
if let Some(global_head) = self
.branch_ctx
.ref_reader(&read)
.load_head_commit_id(crate::GLOBAL_BRANCH_ID)
.await?
{
*self.observed_global_head.write().map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"session global-head observation is poisoned",
)
})? = Some(global_head);
}
self.base_refresh_generation
.store(invalidation_generation, Ordering::SeqCst);
return Ok(());
}
let branch_reader = self.branch_ctx.ref_reader(&read);
let Some(active_head) = branch_reader.load_head_commit_id(&active_branch_id).await? else {
self.base_refresh_generation
.store(invalidation_generation, Ordering::SeqCst);
return Ok(());
};
let Some(global_head) = branch_reader
.load_head_commit_id(crate::GLOBAL_BRANCH_ID)
.await?
else {
self.base_refresh_generation
.store(invalidation_generation, Ordering::SeqCst);
return Ok(());
};
drop(branch_reader);
let node = crate::commit_graph::CommitGraphContext::new()
.reader(&read)
.load_node(&active_head)
.await?
.ok_or_else(|| {
LixError::new(
LixError::CODE_COMMIT_NOT_FOUND,
format!("active branch head '{active_head}' does not exist"),
)
})?;
let observed_global_head = {
let mut observed = self.observed_global_head.write().map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"session global-head observation is poisoned",
)
})?;
let previous = observed.unwrap_or_else(|| node.base_commit_id.unwrap_or(global_head));
*observed = Some(previous);
previous
};
if observed_global_head == global_head {
self.base_refresh_generation
.store(invalidation_generation, Ordering::SeqCst);
return Ok(());
}
drop(read);
let write_access = self.begin_session_write_access().await?;
let result = self
.with_write_transaction_reserved_lending(
write_access,
async move |transaction| {
transaction.stage_base_refresh_if_needed().await?;
Ok(())
},
|_| Ok(()),
)
.await;
if result.is_ok() {
*self.observed_global_head.write().map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"session global-head observation is poisoned",
)
})? = Some(global_head);
self.base_refresh_generation
.store(invalidation_generation, Ordering::SeqCst);
}
result
}
async fn execute_idempotent_write(
&self,
sql: &str,
statement: DataFusionStatement,
params: &[Value],
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
idempotency: ExecuteIdempotency,
) -> Result<ExecuteResult, LixError> {
self.execute_with_idempotency_recovery(
&idempotency,
ExecuteIdempotencyReceipt::into_single_result,
options.max_auto_commit_retries,
|| async {
let write_access = self.begin_session_write_access().await?;
let sql_for_planning = sql.to_owned();
let statement = statement.clone();
let params = params.to_vec();
let options = options.clone();
let metadata = metadata.clone();
let idempotency_for_commit = idempotency.clone();
self.with_write_transaction_reserved_lending_spanned(
write_access,
async move |transaction| {
let previous_origin_key =
transaction.replace_origin_key(options.origin_key);
let result = async {
let tx_plan = transaction
.prepare_sql_write_logical_plan(&sql_for_planning, &statement)?;
let result = execute_prepared_transaction_write(
transaction,
tx_plan,
¶ms,
&metadata,
)
.await?;
let result = ExecuteResult::from_sql_write_result(result)
.with_commit(staged_commit_span(transaction)?);
let receipt = ExecuteIdempotencyReceipt::single(
&idempotency_for_commit,
&result,
)?;
transaction.stage_execute_idempotency_receipt(
&idempotency_for_commit,
&receipt,
)?;
Ok(result)
}
.await;
transaction.replace_origin_key(previous_origin_key);
result
},
|_| Ok(()),
)
.await
.map(|(result, commit)| result.with_commit(commit))
.map_err(|error| normalize_sql_surface_error(error, sql))
},
)
.await
}
async fn execute_with_idempotency_recovery<T, F, Fut>(
&self,
idempotency: &ExecuteIdempotency,
replay: fn(ExecuteIdempotencyReceipt) -> Result<T, LixError>,
max_auto_commit_retries: Option<u32>,
mut execute: F,
) -> Result<T, LixError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, LixError>>,
{
let mut retries = AutoCommitRetries::new(max_auto_commit_retries);
if let IdempotencyReceiptResolution::Replay(receipt) = self
.resolve_idempotency_receipt_with_expired_read_retry(idempotency, &mut retries.expired)
.await?
{
return replay(receipt);
}
loop {
let result = execute().await;
match result {
Ok(result) => return Ok(result),
Err(error)
if error.code == LixError::CODE_STORAGE_READ_EXPIRED
&& retries.retry(&error).await =>
{
continue;
}
Err(error)
if matches!(
error.code.as_str(),
LixError::CODE_TRANSACTION_CONFLICT
| LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN
) =>
{
match self
.resolve_idempotency_receipt_with_expired_read_retry(
idempotency,
&mut retries.expired,
)
.await
{
Ok(IdempotencyReceiptResolution::Replay(receipt)) => {
self.observe_invalidation.bump();
self.file_views.clear();
return replay(receipt);
}
Ok(IdempotencyReceiptResolution::Absent) => {
if retries.retry(&error).await {
continue;
}
return Err(retries.annotate(error));
}
Err(recovery_error) => return Err(retries.annotate(recovery_error)),
};
}
Err(error) => return Err(retries.annotate(error)),
}
}
}
async fn resolve_idempotency_receipt(
&self,
idempotency: &ExecuteIdempotency,
) -> Result<IdempotencyReceiptResolution, LixError> {
let visible = self
.load_idempotency_receipt(idempotency, StorageReadDurability::Visible)
.await?;
let Some(visible) = visible else {
return Ok(IdempotencyReceiptResolution::Absent);
};
Self::require_matching_idempotency_receipt(&visible, idempotency)?;
let durable = match self
.load_idempotency_receipt(idempotency, StorageReadDurability::Durable)
.await
{
Ok(receipt) => receipt,
Err(error) if error.code == LixError::CODE_STORAGE_DURABILITY_UNAVAILABLE => {
return Err(idempotency_outcome_unknown());
}
Err(error) => return Err(error),
};
let Some(durable) = durable else {
return Err(idempotency_outcome_unknown());
};
Self::require_matching_idempotency_receipt(&durable, idempotency)?;
Ok(IdempotencyReceiptResolution::Replay(durable))
}
async fn resolve_idempotency_receipt_with_expired_read_retry(
&self,
idempotency: &ExecuteIdempotency,
expired_read_retries: &mut ExpiredReadRetryState,
) -> Result<IdempotencyReceiptResolution, LixError> {
loop {
match self.resolve_idempotency_receipt(idempotency).await {
Err(error)
if error.code == LixError::CODE_STORAGE_READ_EXPIRED
&& retry_expired_auto_commit(expired_read_retries, &error).await =>
{
continue;
}
result => return result,
}
}
}
async fn load_idempotency_receipt(
&self,
idempotency: &ExecuteIdempotency,
durability: StorageReadDurability,
) -> Result<Option<ExecuteIdempotencyReceipt>, LixError> {
self.ensure_open()?;
let read = self
.storage
.begin_read(StorageReadOptions {
durability,
..StorageReadOptions::default()
})
.await?;
let receipt = load_receipt(&read, idempotency).await?;
Ok(receipt)
}
fn require_matching_idempotency_receipt(
receipt: &ExecuteIdempotencyReceipt,
idempotency: &ExecuteIdempotency,
) -> Result<(), LixError> {
if receipt.matches(idempotency) {
return Ok(());
}
Err(LixError::new(
LixError::CODE_IDEMPOTENCY_KEY_REUSED,
"Idempotency-Key was already used for a different SQL mutation or branch",
)
.with_details(serde_json::json!({
"retryable": false,
})))
}
pub async fn execute_batch(
&self,
statements: &[ExecuteBatchStatement],
) -> Result<ExecuteBatchResult, LixError> {
Box::pin(self.execute_batch_with_options(statements, ExecuteOptions::default()))
.await
.map(ExecuteBatchResult::from_results)
}
pub(crate) async fn execute_batch_with_options(
&self,
statements: &[ExecuteBatchStatement],
options: ExecuteOptions,
) -> Result<Vec<ExecuteResult>, LixError> {
Box::pin(self.execute_batch_with_options_and_metadata(
statements,
options,
vec![ExecuteStatementMetadata::default(); statements.len()],
))
.await
}
pub(crate) async fn execute_batch_with_options_and_metadata(
&self,
statements: &[ExecuteBatchStatement],
options: ExecuteOptions,
statement_metadata: Vec<ExecuteStatementMetadata>,
) -> Result<Vec<ExecuteResult>, LixError> {
let results = Box::pin(self.execute_batch_with_options_and_metadata_inner(
statements,
options,
statement_metadata,
None,
false,
))
.await?;
annotate_batch_results(statements, results)
}
async fn execute_batch_with_options_and_metadata_inner(
&self,
statements: &[ExecuteBatchStatement],
options: ExecuteOptions,
statement_metadata: Vec<ExecuteStatementMetadata>,
idempotency: Option<ExecuteIdempotency>,
require_idempotency_for_writes: bool,
) -> Result<Vec<ExecuteResult>, LixError> {
let telemetry = start_batch(
self.telemetry.as_ref(),
&crate::telemetry::SQL_BATCH,
statements.len(),
statements.iter().map(|statement| statement.sql.as_str()),
);
let outer_query_span_covers_operation = statements.len() == 1 && telemetry.is_some();
let operation = self.execute_batch_with_options_inner(
statements,
options,
statement_metadata,
idempotency,
require_idempotency_for_writes,
outer_query_span_covers_operation,
);
let result = match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
};
if let Some(telemetry) = telemetry {
if outer_query_span_covers_operation {
finish_single_statement_batch(telemetry, &result);
} else {
finish_operation(telemetry, &result);
}
}
result
}
pub(crate) fn execute_batch_with_idempotency_and_options_and_metadata(
self: Arc<Self>,
statements: Vec<ExecuteBatchStatement>,
options: ExecuteOptions,
statement_metadata: Vec<ExecuteStatementMetadata>,
idempotency: Option<ExecuteIdempotency>,
) -> impl Future<Output = Result<Vec<ExecuteResult>, LixError>> + Send + 'static {
unsafe {
super::AssumeSendFuture::new(async move {
let results = self
.execute_batch_with_options_and_metadata_inner(
&statements,
options,
statement_metadata,
idempotency,
true,
)
.await?;
annotate_batch_results(&statements, results)
})
}
}
async fn execute_batch_with_options_inner(
&self,
statements: &[ExecuteBatchStatement],
options: ExecuteOptions,
statement_metadata: Vec<ExecuteStatementMetadata>,
idempotency: Option<ExecuteIdempotency>,
require_idempotency_for_writes: bool,
outer_query_span_covers_operation: bool,
) -> Result<Vec<ExecuteResult>, LixError> {
self.ensure_open()?;
if statements.is_empty() {
return Err(LixError::new(
LixError::CODE_INVALID_PARAM,
"execute_batch requires at least one statement",
)
.with_details(serde_json::json!({
"operation": "executeBatch",
"argument": "statements",
"expected": "non-empty array",
})));
}
if statement_metadata.len() != statements.len() {
return Err(LixError::new(
LixError::CODE_INVALID_PARAM,
"execute batch statement metadata must align with statements",
)
.with_details(serde_json::json!({
"operation": "executeBatch",
"statementCount": statements.len(),
"metadataCount": statement_metadata.len(),
})));
}
for (statement_index, (statement, metadata)) in
statements.iter().zip(&statement_metadata).enumerate()
{
validate_execute_statement_metadata(
statement.params.len(),
metadata,
Some(statement_index),
)?;
}
match classify_execute_batch(statements, &self.sql_planning_cache)? {
ExecuteBatchExecution::ReadOnly(parsed) => {
self.execute_read_only_batch(
statements,
parsed,
outer_query_span_covers_operation,
)
.await
}
ExecuteBatchExecution::Transaction(parsed) => {
let contains_write = parsed.contains_write()?;
if !contains_write {
return self
.execute_transaction_batch_with_auto_commit_retry(
statements,
parsed,
options,
statement_metadata,
outer_query_span_covers_operation,
)
.await;
}
if require_idempotency_for_writes && idempotency.is_none() {
return Err(LixError::new(
LixError::CODE_IDEMPOTENCY_KEY_REQUIRED,
"Idempotency-Key is required for SQL mutation batches",
));
}
let Some(idempotency) = idempotency else {
return self
.execute_transaction_batch_with_auto_commit_retry(
statements,
parsed,
options,
statement_metadata,
outer_query_span_covers_operation,
)
.await;
};
self.execute_with_idempotency_recovery(
&idempotency,
ExecuteIdempotencyReceipt::into_results,
options.max_auto_commit_retries,
|| {
self.execute_transaction_batch(
statements,
parsed.clone(),
options.clone(),
statement_metadata.clone(),
Some(idempotency.clone()),
outer_query_span_covers_operation,
)
},
)
.await
}
}
}
async fn execute_transaction_batch_with_auto_commit_retry(
&self,
statements: &[ExecuteBatchStatement],
parsed: TransactionBatchStatements,
options: ExecuteOptions,
statement_metadata: Vec<ExecuteStatementMetadata>,
outer_query_span_covers_operation: bool,
) -> Result<Vec<ExecuteResult>, LixError> {
let mut retries = AutoCommitRetries::new(options.max_auto_commit_retries);
loop {
let result = self
.execute_transaction_batch(
statements,
parsed.clone(),
options.clone(),
statement_metadata.clone(),
None,
outer_query_span_covers_operation,
)
.await;
match result {
Ok(results) => return Ok(results),
Err(error) => {
if retries.retry(&error).await {
continue;
}
return Err(retries.annotate(error));
}
}
}
}
async fn execute_transaction_batch(
&self,
statements: &[ExecuteBatchStatement],
parsed: TransactionBatchStatements,
options: ExecuteOptions,
statement_metadata: Vec<ExecuteStatementMetadata>,
idempotency: Option<ExecuteIdempotency>,
outer_query_span_covers_operation: bool,
) -> Result<Vec<ExecuteResult>, LixError> {
let telemetry_sink = self.telemetry.clone();
let transaction_telemetry_sink = telemetry_sink.clone();
let carries_span = parsed.contains_write()?;
let result = self
.with_write_transaction_lending_spanned(async move |transaction| {
if let Some(results) = try_execute_transaction_parameter_batch(
transaction,
statements,
&parsed,
&options,
&statement_metadata,
)
.await?
{
let results = if carries_span {
with_staged_commit_span(transaction, results)?
} else {
results
};
if let Some(idempotency) = &idempotency {
let receipt = ExecuteIdempotencyReceipt::batch(idempotency, &results)?;
transaction.stage_execute_idempotency_receipt(idempotency, &receipt)?;
}
return Ok(results);
}
let mut results = Vec::with_capacity(statements.len());
match parsed {
TransactionBatchStatements::AutoParameterizedUpdate {
sql,
statement: parsed,
parameter_batch,
} => {
for (statement_index, (statement, metadata)) in
statements.iter().zip(statement_metadata).enumerate()
{
let params = sql2::parameter_row(¶meter_batch, statement_index)
.map_err(|error| {
with_batch_statement_index(error, statement_index)
})?;
let telemetry = (!outer_query_span_covers_operation)
.then(|| {
SqlStatementTelemetry::start(
transaction_telemetry_sink.as_ref(),
&statement.sql,
"batch",
Some(statement_index),
)
})
.flatten();
let operation = Box::pin(execute_transaction_statement(
transaction,
&sql,
parsed.clone(),
¶ms,
options.clone(),
metadata,
));
let result = match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
}
.map_err(|error| {
with_batch_statement_index(
normalize_sql_surface_error(error, &statement.sql),
statement_index,
)
});
if let Some(telemetry) = telemetry {
telemetry.finish(&result);
}
results.push(result?);
}
}
parsed => {
for (statement_index, ((statement, parsed), metadata)) in statements
.iter()
.zip(parsed.into_vec())
.zip(statement_metadata)
.enumerate()
{
let telemetry = (!outer_query_span_covers_operation)
.then(|| {
SqlStatementTelemetry::start(
transaction_telemetry_sink.as_ref(),
&statement.sql,
"batch",
Some(statement_index),
)
})
.flatten();
let operation = Box::pin(execute_transaction_statement(
transaction,
&statement.sql,
parsed,
&statement.params,
options.clone(),
metadata,
));
let result = match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
}
.map_err(|error| {
with_batch_statement_index(
normalize_sql_surface_error(error, &statement.sql),
statement_index,
)
});
if let Some(telemetry) = telemetry {
telemetry.finish(&result);
}
results.push(result?);
}
}
}
let results = if carries_span {
with_staged_commit_span(transaction, results)?
} else {
results
};
if let Some(idempotency) = &idempotency {
let receipt = ExecuteIdempotencyReceipt::batch(idempotency, &results)?;
transaction.stage_execute_idempotency_receipt(idempotency, &receipt)?;
}
Ok(results)
})
.await
.map(|(results, commit)| {
if !carries_span {
return results;
}
results
.into_iter()
.map(|result| result.with_commit(commit.clone()))
.collect::<Vec<_>>()
});
result
}
async fn execute_read_only_batch(
&self,
statements: &[ExecuteBatchStatement],
parsed: Vec<datafusion::sql::parser::Statement>,
outer_query_span_covers_operation: bool,
) -> Result<Vec<ExecuteResult>, LixError> {
let statements = statements
.iter()
.map(|statement| (statement.sql.as_str(), statement.params.as_slice()))
.collect::<Vec<_>>();
Ok(self
.execute_read_batch(
&statements,
parsed,
ReadBatchKind::Ordinary,
outer_query_span_covers_operation,
)
.await?
.results)
}
async fn execute_read_batch(
&self,
statements: &[(&str, &[Value])],
parsed: Vec<datafusion::sql::parser::Statement>,
kind: ReadBatchKind,
outer_query_span_covers_operation: bool,
) -> Result<ReadBatchResult, LixError> {
let acknowledge_file_views = parsed.iter().zip(statements).any(|(parsed, (_, params))| {
is_acknowledgeable_file_content_read(parsed, params)
|| late_materialized_lix_file_content_read(parsed, params)
.is_some_and(|plan| plan.projection.acknowledges_content())
});
let _operation_guard = self.begin_waitable_session_operation().await?;
let (results, file_view_mutations, captured_interests) =
execute_coherent_session_read::<StorageImpl, _, _, _>(
&self.storage,
true,
|read_store: SharedStorageAdapterRead<StorageImpl::Read<'static>>| {
let parsed = parsed.clone();
async move {
let capture = self.hot_state.capture_foreground_read_interests();
if let Some((_, capture)) = &capture {
let active_branch_id = self.bound_branch_id()?;
for ((_, params), statement) in statements.iter().zip(&parsed) {
seed_foreground_filesystem_interest(
Some(capture),
&active_branch_id,
statement,
params,
)?;
}
}
let read_hot = capture.as_ref().map_or_else(
|| Arc::clone(&self.hot_state),
|(hot, _)| Arc::new(hot.clone()),
);
let file_view_collector =
acknowledge_file_views.then(|| self.file_views.fork_for_read());
let active_branch_id =
self.active_branch_id_from_reader(&read_store).await?;
let (snapshot, active_branch_head) = if kind == ReadBatchKind::Coherent {
let head = self
.branch_ctx
.ref_reader(read_store.clone())
.load_head(&active_branch_id)
.await?
.ok_or_else(|| {
LixError::branch_not_found(
active_branch_id.clone(),
"execute coherent read batch",
"active branch",
)
})?;
let snapshot = ReadBatchSnapshot {
active_branch_id: active_branch_id.clone(),
active_branch_commit_id: head.commit_id.to_string(),
storage_mutation_revision:
StorageAdapter::<StorageImpl>::load_mutation_revision_from_read(
&read_store,
)
.await?
.map(|revision| revision.to_vec()),
};
(Some(snapshot), Some(head))
} else {
(None, None)
};
if parsed.is_empty() {
return Ok((
ReadBatchResult {
results: Vec::new(),
snapshot,
},
Vec::new(),
Vec::<Arc<crate::hot_state::ReadInterestRegistry>>::new(),
));
}
let ctx = SessionSqlExecutionContext {
active_branch_id: &active_branch_id,
active_account_id: self.active_account_id(),
read_store: read_store.clone(),
hot_state: Arc::clone(&read_hot),
binary_cas: Arc::clone(&self.binary_cas),
branch_ctx: Arc::clone(&self.branch_ctx),
catalog_context: Arc::clone(&self.catalog_context),
sql_planning_cache: Arc::clone(&self.sql_planning_cache),
functions: FunctionProviderHandle::system(),
plugin_host: self.plugin_host.clone(),
file_views: file_view_collector.clone(),
};
let read_session = match active_branch_head {
Some(head) => {
sql2::prepare_read_session_at_head(&ctx, head, &parsed).await?
}
None => sql2::prepare_read_session(&ctx, &parsed).await?,
};
let mut result_budget = crate::common::ReadResultBudget::default();
let mut results = Vec::with_capacity(statements.len());
let mut file_view_mutations = Vec::new();
let mut captured_interests = Vec::new();
for (statement_index, ((sql, params), parsed)) in
statements.iter().zip(parsed).enumerate()
{
let acknowledge_statement =
is_acknowledgeable_file_content_read(&parsed, params)
|| late_materialized_lix_file_content_read(&parsed, params)
.is_some_and(|plan| {
plan.projection.acknowledges_content()
});
if let Some(collector) = &file_view_collector {
collector.clear();
}
let telemetry = (!outer_query_span_covers_operation)
.then(|| {
SqlStatementTelemetry::start(
self.telemetry.as_ref(),
sql,
kind.telemetry_name(),
Some(statement_index),
)
})
.flatten();
let operation = async {
if let Some(plan) =
late_materialized_lix_file_content_read(&parsed, params)
{
let (result, mutations, _, captured) = self
.execute_read_statement_with_store(
read_store.clone(),
sql,
parsed,
params,
true,
sql2::StatementReadPlan {
native: None,
acknowledge_file_views: plan
.projection
.acknowledges_content(),
late_content: Some(plan),
},
false,
)
.await
.map_err(|error| {
kind.normalize_error(error, sql, statement_index)
})?;
file_view_mutations.extend(mutations);
captured_interests.extend(captured);
return Ok(ExecuteResult::from_session_read_result(result));
}
sql2::execute_read_statement_in_session_from_parsed(
&read_session,
sql,
parsed,
params,
)
.await
.map(ExecuteResult::from_sql_query_result)
.map_err(|error| kind.normalize_error(error, sql, statement_index))
};
let result = match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
};
if let Some(telemetry) = telemetry {
telemetry.finish(&result);
}
let result = result.map_err(|error| {
crate::sync::annotate_read_fulfillment_capture(
error,
capture.as_ref().map(|(_, capture)| capture.as_ref()),
)
})?;
charge_execute_result(&mut result_budget, &result)?;
results.push(result);
if acknowledge_statement {
if let Some(collector) = &file_view_collector {
file_view_mutations.extend(collector.plugin_file_mutations());
}
}
}
drop(read_session);
drop(ctx);
async {
if let Some((_, capture)) = &capture {
let reader = self.hot_state.reader(read_store.clone());
let executable_rows = reader
.prepare_captured_read_interests(
&capture.snapshot()?,
self.active_account_id(),
)
.await?;
self.catalog_context
.prepare_returned_row_catalogs(
&reader,
&executable_rows,
crate::catalog::load_catalog_revision(&read_store)
.await?
.as_ref(),
)
.await?;
crate::plugin::runtime::prepare_returned_row_executables(
&reader,
&self.binary_cas.reader(read_store),
&executable_rows,
)
.await?;
}
Ok::<_, LixError>(())
}
.await
.map_err(|error| {
crate::sync::annotate_read_fulfillment_capture(
error,
capture.as_ref().map(|(_, capture)| capture.as_ref()),
)
})?;
captured_interests.extend(capture.map(|(_, capture)| capture));
Ok((
ReadBatchResult { results, snapshot },
file_view_mutations,
captured_interests,
))
}
},
)
.await?;
for capture in captured_interests {
capture.publish_capture()?;
}
self.file_views.apply_mutations(file_view_mutations);
self.flush_partial_read_interests().await?;
Ok(results)
}
#[doc(hidden)]
pub async fn execute_coherent_read_batch(
&self,
statements: &[(&str, &[Value])],
) -> Result<CoherentReadBatch, LixError> {
let telemetry = start_batch(
self.telemetry.as_ref(),
&crate::telemetry::SQL_COHERENT_READ_BATCH,
statements.len(),
statements.iter().map(|(sql, _)| *sql),
);
let operation = self.execute_coherent_read_batch_inner(statements);
let result = match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
};
if let Some(telemetry) = telemetry {
finish_operation(telemetry, &result);
}
result
}
pub(crate) fn execute_coherent_read_batch_owned(
self: Arc<Self>,
statements: Arc<Vec<(String, Vec<Value>)>>,
) -> impl Future<Output = Result<CoherentReadBatch, LixError>> + Send + 'static {
unsafe {
super::AssumeSendFuture::new(async move {
let statement_refs = statements
.iter()
.map(|(sql, params)| (sql.as_str(), params.as_slice()))
.collect::<Vec<_>>();
self.execute_coherent_read_batch(&statement_refs).await
})
}
}
async fn execute_coherent_read_batch_inner(
&self,
statements: &[(&str, &[Value])],
) -> Result<CoherentReadBatch, LixError> {
self.ensure_open()?;
let parsed = statements
.iter()
.map(|(sql, _)| {
let statement = self.sql_planning_cache.parse_statement(sql)?;
if sql2::statement_has_durable_runtime_function(&statement) {
return Err(LixError::new(
LixError::CODE_INVALID_PARAM,
"execute_coherent_read_batch does not support durable runtime functions",
));
}
match sql2::bind_statement_route(&statement)? {
sql2::BoundStatementRoute::Read => Ok(statement),
sql2::BoundStatementRoute::Write => Err(LixError::new(
LixError::CODE_INVALID_PARAM,
"execute_coherent_read_batch only accepts read statements",
)),
}
})
.collect::<Result<Vec<_>, LixError>>()?;
self.refresh_active_branch_base_if_stale().await?;
let ReadBatchResult { results, snapshot } = self
.execute_read_batch(statements, parsed, ReadBatchKind::Coherent, false)
.await?;
let snapshot = snapshot.expect("coherent read batch captures snapshot metadata");
Ok(CoherentReadBatch {
active_branch_id: snapshot.active_branch_id,
active_branch_commit_id: snapshot.active_branch_commit_id,
storage_mutation_revision: snapshot.storage_mutation_revision,
results,
})
}
#[cfg(test)]
pub(crate) async fn execute_with_write_executor_mode(
&self,
sql: &str,
params: &[Value],
mode: sql2::WriteExecutorMode,
) -> Result<ExecuteResult, LixError> {
self.ensure_open()?;
let statement = self.sql_planning_cache.parse_statement(sql)?;
if sql2::bind_statement_route(&statement)? == sql2::BoundStatementRoute::Write {
let write_access = self.begin_session_write_access().await?;
let sql_for_error = sql.to_string();
let sql_for_planning = sql_for_error.clone();
let params = params.to_vec();
return self
.with_write_transaction_reserved_lending(
write_access,
async move |transaction| {
let tx_plan = transaction
.prepare_sql_write_logical_plan(&sql_for_planning, &statement)?;
let result = sql2::execute_write_logical_plan_with_mode_result(
transaction,
tx_plan,
¶ms,
mode,
)
.await?;
Ok(ExecuteResult::from_sql_write_result(result))
},
|_| Ok(()),
)
.await
.map_err(|error| normalize_sql_surface_error(error, &sql_for_error));
}
self.execute(sql, params).await
}
async fn persist_runtime_functions_if_needed(
&self,
runtime_functions: FunctionContext,
has_runtime_write_access: bool,
) -> Result<Option<crate::storage_adapter::StorageWriteSetStats>, LixError> {
let mut writes = StorageWriteSet::new();
let read = SharedStorageAdapterRead::new(
self.storage
.begin_read(StorageReadOptions::default())
.await?,
);
let function_preconditions = runtime_functions
.stage_persist_if_needed(&read, &mut writes)
.await?;
if writes.is_empty() {
return Ok(None);
}
if !has_runtime_write_access {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"runtime function state changed without reserved write access",
));
}
let commit_boundary = self.transaction_commit_boundary();
let _commit_guard = begin_commit_boundary(Some(&commit_boundary));
let mut write_options = StorageWriteOptions::default();
write_options.preconditions.extend(function_preconditions);
let prepared_commit = self
.storage
.prepare_write_set(writes, write_options)
.await?;
let stats = commit_at_boundary(Some(&commit_boundary), || async move {
let (_commit, stats) = prepared_commit.commit().await?;
Ok(stats)
})
.await?;
Ok(Some(stats))
}
async fn execute_read_statement_with_store(
&self,
read_store: SharedStorageAdapterRead<StorageImpl::Read<'static>>,
sql: &str,
statement: datafusion::sql::parser::Statement,
params: &[Value],
acknowledge_file_views: bool,
read_plan: sql2::StatementReadPlan,
has_durable_runtime_function: bool,
) -> Result<
(
sql2::SessionReadSqlResult,
Vec<sql2::SessionFileViewMutation>,
usize,
Option<Arc<crate::hot_state::ReadInterestRegistry>>,
),
LixError,
> {
let capture = self.hot_state.capture_foreground_read_interests();
if let Some((_, capture)) = &capture {
let active_branch_id = self.bound_branch_id()?;
seed_foreground_filesystem_interest(
Some(capture),
&active_branch_id,
&statement,
params,
)?;
}
let read_hot = capture.as_ref().map_or_else(
|| Arc::clone(&self.hot_state),
|(hot, _)| Arc::new(hot.clone()),
);
let result = async {
let result = Box::pin(self.execute_read_statement_with_scoped_hot(
read_store.clone(),
read_hot,
sql,
statement,
params,
acknowledge_file_views,
read_plan,
has_durable_runtime_function,
))
.await?;
if let Some((_, capture)) = &capture {
let reader = self.hot_state.reader(read_store.clone());
let executable_rows = reader
.prepare_captured_read_interests(&capture.snapshot()?, self.active_account_id())
.await?;
self.catalog_context
.prepare_returned_row_catalogs(
&reader,
&executable_rows,
crate::catalog::load_catalog_revision(&read_store)
.await?
.as_ref(),
)
.await?;
crate::plugin::runtime::prepare_returned_row_executables(
&reader,
&self.binary_cas.reader(read_store),
&executable_rows,
)
.await?;
}
Ok::<_, LixError>(result)
}.await.map_err(|error| crate::sync::annotate_read_fulfillment_capture(error, capture.as_ref().map(|(_, capture)| capture.as_ref())))?;
validate_session_read_result(&result.0.query)?;
Ok((
result.0,
result.1,
result.2,
capture.map(|(_, capture)| capture),
))
}
async fn execute_read_statement_with_scoped_hot(
&self,
read_store: SharedStorageAdapterRead<StorageImpl::Read<'static>>,
read_hot: Arc<crate::hot_state::HotStateContext>,
sql: &str,
statement: datafusion::sql::parser::Statement,
params: &[Value],
acknowledge_file_views: bool,
read_plan: sql2::StatementReadPlan,
has_durable_runtime_function: bool,
) -> Result<
(
sql2::SessionReadSqlResult,
Vec<sql2::SessionFileViewMutation>,
usize,
),
LixError,
> {
let file_view_collector = acknowledge_file_views.then(|| self.file_views.fork_for_read());
let active_branch_id = self
.active_branch_id_from_reader(&read_store)
.instrument(tracing::debug_span!(
target: "lix_perf",
"lix.perf.public_read.active_branch"
))
.await?;
let native_ctx = SessionSqlExecutionContext {
active_branch_id: &active_branch_id,
active_account_id: self.active_account_id(),
read_store: read_store.clone(),
hot_state: Arc::clone(&read_hot),
binary_cas: Arc::clone(&self.binary_cas),
branch_ctx: Arc::clone(&self.branch_ctx),
catalog_context: Arc::clone(&self.catalog_context),
sql_planning_cache: Arc::clone(&self.sql_planning_cache),
functions: FunctionProviderHandle::system(),
plugin_host: self.plugin_host.clone(),
file_views: file_view_collector.clone(),
};
if let Some((query, examined)) = sql2::execute_native_read(&native_ctx, &read_plan).await? {
let mutations = file_view_collector
.map(|collector| collector.plugin_file_mutations())
.unwrap_or_default();
return Ok((
sql2::SessionReadSqlResult {
runtime_functions: None,
query: sql2::SessionReadResult::Rows(query),
},
mutations,
examined,
));
}
drop(native_ctx);
let hot_state: Arc<dyn crate::hot_state::HotStateReader> =
Arc::new(read_hot.reader(read_store.clone()));
let runtime_functions = if has_durable_runtime_function {
Some(FunctionContext::prepare(&read_store, None).await?)
} else {
None
};
let functions = runtime_functions
.as_ref()
.map_or_else(FunctionProviderHandle::system, FunctionContext::provider);
let late_content = read_plan.late_content.filter(|plan| {
read_hot.read_interest_registry().is_none()
|| matches!(
plan.projection,
sql2::LateLixFileProjection::OctetLength
| sql2::LateLixFileProjection::Substring { .. }
)
});
let (statement, late_file_projection, rewritten_sql) = match late_content {
Some(plan) => {
let statement = *plan.statement;
let rewritten_sql = statement.to_string();
(
statement,
Some((plan.data_column_index, plan.projection)),
Some(rewritten_sql),
)
}
None => (statement, None, None),
};
let ctx = SessionSqlExecutionContext {
active_branch_id: &active_branch_id,
active_account_id: self.active_account_id(),
read_store: read_store.clone(),
hot_state: Arc::clone(&read_hot),
binary_cas: Arc::clone(&self.binary_cas),
branch_ctx: Arc::clone(&self.branch_ctx),
catalog_context: Arc::clone(&self.catalog_context),
sql_planning_cache: Arc::clone(&self.sql_planning_cache),
functions: functions.clone(),
plugin_host: self.plugin_host.clone(),
file_views: file_view_collector.clone(),
};
let read_session =
sql2::prepare_read_session(&ctx, std::slice::from_ref(&statement)).await?;
let mut query = sql2::execute_read_statement_in_session_with_result(
&read_session,
rewritten_sql.as_deref().unwrap_or(sql),
statement,
params,
)
.await?;
drop(read_session);
drop(ctx);
if let Some((data_column_index, projection)) = late_file_projection {
let filesystem_path_index: Arc<dyn crate::filesystem::FilesystemPathIndexReader> =
Arc::new(read_hot.reader(read_store.clone()));
let branch_ref: Arc<dyn BranchRefReader> =
Arc::new(self.branch_ctx.ref_reader(read_store.clone()));
let blob_reader: Arc<dyn crate::binary_cas::BlobDataReader> =
Arc::new(self.binary_cas.reader(read_store));
let mut materialized = query.query.into_sql_query_result()?;
match projection {
sql2::LateLixFileProjection::Content => {
hydrate_lix_file_content_result(
&active_branch_id,
Arc::clone(&hot_state),
filesystem_path_index,
branch_ref,
blob_reader,
self.plugin_host.clone(),
file_view_collector.clone(),
&mut materialized,
data_column_index,
)
.await?;
}
sql2::LateLixFileProjection::OctetLength => {
hydrate_lix_file_size_result(
&active_branch_id,
Arc::clone(&hot_state),
filesystem_path_index,
branch_ref,
blob_reader,
self.plugin_host.clone(),
&mut materialized,
data_column_index,
)
.await?;
}
sql2::LateLixFileProjection::Substring { start, length } => {
hydrate_lix_file_substring_result(
&active_branch_id,
Arc::clone(&hot_state),
filesystem_path_index,
branch_ref,
blob_reader,
self.plugin_host.clone(),
file_view_collector.clone(),
&mut materialized,
data_column_index,
start,
length,
)
.await?;
}
}
query.query = sql2::SessionReadResult::Rows(materialized);
}
drop(hot_state);
let file_view_mutations = file_view_collector
.map(|collector| collector.plugin_file_mutations())
.unwrap_or_default();
Ok((
sql2::SessionReadSqlResult {
runtime_functions,
query: query.query,
},
file_view_mutations,
0,
))
}
}
fn native_file_read_from_exact_result(
result: SqlQueryResult,
requested_paths: &BTreeSet<String>,
requested_range: Option<Range<u64>>,
) -> Result<Option<FileRead>, LixError> {
if requested_range.is_none() {
return native_file_content_from_exact_result(result, requested_paths)?
.map(|data| materialize_file_read(data, None))
.transpose();
}
if result.columns.as_slice()
!= [
"path",
"content",
"total_size",
"range_start",
"range_end",
"content_identity",
]
{
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native ranged file read returned an unexpected result schema",
));
}
let mut rows = result.rows.into_iter();
let Some(mut row) = rows.next() else {
return Ok(None);
};
if rows.next().is_some() {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native ranged file read returned more than one path",
));
}
let [
Value::Text(path),
data,
Value::Integer(total_size),
Value::Integer(range_start),
Value::Integer(range_end),
Value::Text(content_identity),
] = row.as_mut_slice()
else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native ranged file read returned an invalid row",
));
};
if !requested_paths.contains(path.as_str()) {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native ranged file read returned an unrequested path",
));
}
let data = match std::mem::replace(data, Value::Null) {
Value::Blob(data) => data,
_ => {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native ranged file read returned non-binary data",
));
}
};
let total_size = u64::try_from(*total_size).map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file size is negative",
)
})?;
let range_start = u64::try_from(*range_start).map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file range is negative",
)
})?;
let range_end = u64::try_from(*range_end).map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file range is negative",
)
})?;
Ok(Some(FileRead {
content: data,
total_size,
range: range_start..range_end,
content_identity: std::mem::take(content_identity),
}))
}
fn native_file_content_from_exact_result(
result: SqlQueryResult,
requested_paths: &BTreeSet<String>,
) -> Result<Option<Blob>, LixError> {
if result.columns.as_slice() != ["path", "content"] {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file read returned an unexpected result schema",
));
}
let mut rows = result.rows.into_iter();
let Some(mut row) = rows.next() else {
return Ok(None);
};
if rows.next().is_some() {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file read returned more than one path",
));
}
let [Value::Text(path), data] = row.as_mut_slice() else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file read returned an invalid row",
));
};
if !requested_paths.contains(path.as_str()) {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file read returned an unrequested path",
));
}
let content = match std::mem::replace(data, Value::Null) {
Value::Blob(content) => content,
Value::Null => Blob::default(),
_ => {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file read returned a non-binary data value",
));
}
};
Ok(Some(content))
}
fn materialize_file_read(
data: Blob,
requested_range: Option<Range<u64>>,
) -> Result<FileRead, LixError> {
let total_size = u64::try_from(data.len()).map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"native file size does not fit the public 64-bit range",
)
})?;
let content_identity = BlobId::from_content(data.as_ref()).to_hex();
let range = match requested_range {
None => 0..total_size,
Some(range) => {
if range.start >= range.end || range.start >= total_size {
return Err(LixError::new(
LixError::CODE_INVALID_PARAM,
"file read range is not satisfiable",
)
.with_details(serde_json::json!({
"rangeStart": range.start,
"rangeEnd": range.end,
"totalSize": total_size,
})));
}
range.start..range.end.min(total_size)
}
};
let start = usize::try_from(range.start)
.map_err(|_| LixError::new(LixError::CODE_INVALID_PARAM, "file read range is too large"))?;
let end = usize::try_from(range.end)
.map_err(|_| LixError::new(LixError::CODE_INVALID_PARAM, "file read range is too large"))?;
let data = Blob::from(data.as_bytes().slice(start..end));
Ok(FileRead {
content: data,
total_size,
range,
content_identity,
})
}
fn validate_execute_statement_metadata(
parameter_count: usize,
metadata: &ExecuteStatementMetadata,
statement_index: Option<usize>,
) -> Result<(), LixError> {
let metadata_count = metadata.parameter_blob_splices.len();
if metadata_count == 0 || metadata_count == parameter_count {
return Ok(());
}
let mut details = serde_json::json!({
"operation": if statement_index.is_some() { "executeBatch" } else { "execute" },
"parameterCount": parameter_count,
"metadataCount": metadata_count,
});
if let Some(statement_index) = statement_index {
details["statementIndex"] = statement_index.into();
}
Err(LixError::new(
LixError::CODE_INVALID_PARAM,
"execute statement metadata must align with SQL parameters",
)
.with_details(details))
}
#[allow(clippy::too_many_arguments)]
async fn hydrate_lix_file_content_result(
active_branch_id: &str,
hot_state: Arc<dyn crate::hot_state::HotStateReader>,
filesystem_path_index: Arc<dyn crate::filesystem::FilesystemPathIndexReader>,
branch_ref: Arc<dyn BranchRefReader>,
blob_reader: Arc<dyn crate::binary_cas::BlobDataReader>,
plugin_host: crate::plugin::runtime::PluginRuntimeHost,
session_file_views: Option<sql2::SessionFileViews>,
query: &mut SqlQueryResult,
data_column_index: usize,
) -> Result<(), LixError> {
let Some(column_type) = query.column_types.get_mut(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file content result was missing its column type",
));
};
*column_type = ResultColumnType::Blob;
let mut paths = BTreeSet::new();
for row in &query.rows {
let Some(Value::Text(path)) = row.get(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file content placeholder was not a path",
));
};
paths.insert(path.clone());
}
if paths.is_empty() {
return Ok(());
}
let hydrated = sql2::execute_exact_lix_file_batch_read(
active_branch_id,
hot_state,
filesystem_path_index,
branch_ref,
blob_reader,
plugin_host,
session_file_views,
None,
&paths,
None,
)
.await?;
let mut data_by_path = BTreeMap::new();
for mut row in hydrated.rows {
let [Value::Text(path), data] = row.as_mut_slice() else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file content hydration returned an invalid row",
));
};
data_by_path.insert(path.clone(), std::mem::replace(data, Value::Null));
}
query.notices.extend(hydrated.notices);
for row in &mut query.rows {
let Some(placeholder) = row.get_mut(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file content result was missing its placeholder column",
));
};
let Value::Text(path) = std::mem::replace(placeholder, Value::Null) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file content placeholder was not a path",
));
};
*placeholder = data_by_path.remove(&path).ok_or_else(|| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
format!("late lix_file content hydration did not return '{path}'"),
)
})?;
}
Ok(())
}
async fn hydrate_lix_file_size_result(
active_branch_id: &str,
hot_state: Arc<dyn crate::hot_state::HotStateReader>,
filesystem_path_index: Arc<dyn crate::filesystem::FilesystemPathIndexReader>,
branch_ref: Arc<dyn BranchRefReader>,
blob_reader: Arc<dyn crate::binary_cas::BlobDataReader>,
plugin_host: crate::plugin::runtime::PluginRuntimeHost,
query: &mut SqlQueryResult,
data_column_index: usize,
) -> Result<(), LixError> {
let Some(column_type) = query.column_types.get_mut(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file size result was missing its column type",
));
};
*column_type = ResultColumnType::Integer;
let paths = late_lix_file_placeholder_paths(query, data_column_index)?;
if paths.is_empty() {
return Ok(());
}
let sizes = sql2::execute_exact_lix_file_size_batch_read(
active_branch_id,
hot_state,
filesystem_path_index,
branch_ref,
blob_reader,
plugin_host,
None,
&paths,
)
.await?;
let mut size_by_path = BTreeMap::new();
for row in sizes.rows {
let [Value::Text(path), size @ Value::Integer(_)] = row.as_slice() else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file size lookup returned an invalid row",
));
};
size_by_path.insert(path.clone(), size.clone());
}
for row in &mut query.rows {
let Some(placeholder) = row.get_mut(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file size result was missing its placeholder column",
));
};
let Value::Text(path) = std::mem::replace(placeholder, Value::Null) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file size placeholder was not a path",
));
};
*placeholder = size_by_path.get(&path).cloned().ok_or_else(|| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
format!("late lix_file size lookup did not return '{path}'"),
)
})?;
}
Ok(())
}
async fn hydrate_lix_file_substring_result(
active_branch_id: &str,
hot_state: Arc<dyn crate::hot_state::HotStateReader>,
filesystem_path_index: Arc<dyn crate::filesystem::FilesystemPathIndexReader>,
branch_ref: Arc<dyn BranchRefReader>,
blob_reader: Arc<dyn crate::binary_cas::BlobDataReader>,
plugin_host: crate::plugin::runtime::PluginRuntimeHost,
session_file_views: Option<sql2::SessionFileViews>,
query: &mut SqlQueryResult,
data_column_index: usize,
start: i64,
length: u64,
) -> Result<(), LixError> {
let Some(column_type) = query.column_types.get_mut(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file substring result was missing its column type",
));
};
*column_type = ResultColumnType::Blob;
let paths = late_lix_file_placeholder_paths(query, data_column_index)?;
if paths.is_empty() {
return Ok(());
}
let sizes = sql2::execute_exact_lix_file_size_batch_read(
active_branch_id,
Arc::clone(&hot_state),
Arc::clone(&filesystem_path_index),
Arc::clone(&branch_ref),
Arc::clone(&blob_reader),
plugin_host.clone(),
None,
&paths,
)
.await?;
let mut size_by_path = BTreeMap::new();
for row in sizes.rows {
let [Value::Text(path), Value::Integer(size)] = row.as_slice() else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file size lookup returned an invalid row",
));
};
let size = u64::try_from(*size).map_err(|_| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file size lookup returned a negative size",
)
})?;
size_by_path.insert(path.clone(), size);
}
let mut data_by_path = BTreeMap::new();
let mut paths_by_range = BTreeMap::<(u64, u64), BTreeSet<String>>::new();
for path in &paths {
let size = size_by_path.get(path).copied().ok_or_else(|| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
format!("late lix_file size lookup did not return '{path}'"),
)
})?;
let (range_start, range_end) = sql_substring_byte_range(start, length, size);
if range_start == range_end {
data_by_path.insert(path.clone(), Value::Blob(Vec::new().into()));
} else {
paths_by_range
.entry((range_start, range_end))
.or_default()
.insert(path.clone());
}
}
for ((range_start, range_end), selected_paths) in paths_by_range {
let ranged = sql2::execute_exact_lix_file_batch_read(
active_branch_id,
Arc::clone(&hot_state),
Arc::clone(&filesystem_path_index),
Arc::clone(&branch_ref),
Arc::clone(&blob_reader),
plugin_host.clone(),
session_file_views.clone(),
None,
&selected_paths,
Some(range_start..range_end),
)
.await?;
query.notices.extend(ranged.notices);
for row in ranged.rows {
let [Value::Text(path), Value::Blob(bytes), ..] = row.as_slice() else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file substring read returned an invalid row",
));
};
data_by_path.insert(path.clone(), Value::Blob(bytes.clone()));
}
}
for row in &mut query.rows {
let Some(placeholder) = row.get_mut(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file substring result was missing its placeholder column",
));
};
let Value::Text(path) = std::mem::replace(placeholder, Value::Null) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file substring placeholder was not a path",
));
};
*placeholder = data_by_path.get(&path).cloned().ok_or_else(|| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
format!("late lix_file substring read did not return '{path}'"),
)
})?;
}
Ok(())
}
fn late_lix_file_placeholder_paths(
query: &SqlQueryResult,
data_column_index: usize,
) -> Result<BTreeSet<String>, LixError> {
let mut paths = BTreeSet::new();
for row in &query.rows {
let Some(Value::Text(path)) = row.get(data_column_index) else {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"late lix_file projection placeholder was not a path",
));
};
paths.insert(path.clone());
}
Ok(paths)
}
fn sql_substring_byte_range(start: i64, length: u64, size: u64) -> (u64, u64) {
let zero_based_start = i128::from(start) - 1;
let effective_start = zero_based_start.max(0).min(i128::from(size));
let effective_end = (zero_based_start + i128::from(length))
.max(0)
.min(i128::from(size));
let effective_start = effective_start as u64;
let effective_end = effective_end.max(i128::from(effective_start)) as u64;
(effective_start, effective_end)
}
#[cfg(feature = "storage-benches")]
async fn consume_profile_cursor(
cursor: &mut sql2::BatchRowCursor<'_>,
row_limit: Option<usize>,
) -> Result<(), LixError> {
let limit = row_limit.unwrap_or(usize::MAX);
let mut consumed = 0usize;
let mut checksum = 0u64;
while consumed < limit {
let Some(values) = cursor.next_values().await? else {
break;
};
checksum = profile_result_checksum(checksum, &values)?;
consumed += 1;
}
crate::sql_profile::record_result_rows(consumed, consumed, 0);
crate::sql_profile::record_result_checksum(checksum);
Ok(())
}
#[cfg(feature = "storage-benches")]
fn profile_result_checksum(checksum: u64, values: &[Value]) -> Result<u64, LixError> {
if values.len() != 3 {
return Err(LixError::new(
LixError::CODE_TYPE_MISMATCH,
"streaming profile expected exactly three projected values",
));
}
let mut checksum = if checksum == 0 {
0xcbf2_9ce4_8422_2325
} else {
checksum
};
checksum = profile_checksum_bytes(checksum, &[0xff]);
for value in values {
checksum = match value {
Value::Null => profile_checksum_bytes(checksum, &[0]),
Value::Boolean(value) => profile_checksum_bytes(checksum, &[1, u8::from(*value)]),
Value::Integer(value) => {
let checksum = profile_checksum_bytes(checksum, &[2]);
profile_checksum_bytes(checksum, &value.to_le_bytes())
}
Value::Real(value) => {
let checksum = profile_checksum_bytes(checksum, &[3]);
profile_checksum_bytes(checksum, &value.to_bits().to_le_bytes())
}
Value::Text(value) => profile_checksum_sized_bytes(checksum, 4, value.as_bytes()),
Value::Jsonb(value) => {
profile_checksum_sized_bytes(checksum, 5, value.to_string().as_bytes())
}
Value::Blob(value) => {
profile_checksum_sized_bytes(checksum, 6, value.as_bytes().as_ref())
}
Value::Timestamptz(value) => {
let checksum = profile_checksum_bytes(checksum, &[7]);
profile_checksum_bytes(checksum, &value.to_le_bytes())
}
Value::RowRef(value) => {
profile_checksum_sized_bytes(checksum, 8, value.as_str().as_bytes())
}
};
}
Ok(checksum)
}
#[cfg(feature = "storage-benches")]
fn profile_checksum_sized_bytes(checksum: u64, tag: u8, bytes: &[u8]) -> u64 {
let checksum = profile_checksum_bytes(checksum, &[tag]);
let checksum = profile_checksum_bytes(checksum, &(bytes.len() as u64).to_le_bytes());
profile_checksum_bytes(checksum, bytes)
}
#[cfg(feature = "storage-benches")]
fn profile_checksum_bytes(mut checksum: u64, bytes: &[u8]) -> u64 {
for byte in bytes {
checksum ^= u64::from(*byte);
checksum = checksum.wrapping_mul(0x0000_0100_0000_01b3);
}
checksum
}
fn validate_session_read_result(result: &sql2::SessionReadResult) -> Result<(), LixError> {
let mut budget = crate::common::ReadResultBudget::default();
match result {
sql2::SessionReadResult::Rows(result) => {
budget.charge(result.columns.iter().map(|name| name.len()).sum(), 0)?;
for notice in &result.notices {
budget.charge(
notice
.code
.len()
.saturating_add(notice.message.len())
.saturating_add(notice.hint.as_ref().map_or(0, String::len)),
0,
)?;
}
for row in &result.rows {
budget.charge_values(row)?;
}
}
sql2::SessionReadResult::Columnar {
fields,
batches,
notices,
} => {
budget.charge(fields.iter().map(|field| field.name().len()).sum(), 0)?;
for notice in notices {
budget.charge(
notice
.code
.len()
.saturating_add(notice.message.len())
.saturating_add(notice.hint.as_ref().map_or(0, String::len)),
0,
)?;
}
for batch in batches.iter() {
budget.charge(batch.get_array_memory_size(), batch.num_rows())?;
}
}
}
Ok(())
}
fn charge_execute_result(
budget: &mut crate::common::ReadResultBudget,
result: &ExecuteResult,
) -> Result<(), LixError> {
if let Some(backing) = &result.backing {
budget.charge(backing.columns.iter().map(|name| name.len()).sum(), 0)?;
for notice in &backing.notices {
budget.charge(
notice
.code
.len()
.saturating_add(notice.message.len())
.saturating_add(notice.hint.as_ref().map_or(0, String::len)),
0,
)?;
}
let columnar = backing
.columnar
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(columnar) = columnar.as_ref() {
for batch in columnar.batches.iter() {
budget.charge(batch.get_array_memory_size(), batch.num_rows())?;
}
} else if let Some(rows) = backing.rows.get() {
for row in rows {
budget.charge_values(row.values())?;
}
}
}
Ok(())
}
pub(crate) fn seed_foreground_filesystem_interest(
capture: Option<&Arc<crate::hot_state::ReadInterestRegistry>>,
active_branch_id: &str,
statement: &DataFusionStatement,
params: &[Value],
) -> Result<(), LixError> {
let Some(capture) = capture else {
return Ok(());
};
let Some(route) = exact_filesystem_read_interest_route(statement, params) else {
return Ok(());
};
let (file_ids, path_predicate, content) = match route {
ExactFilesystemRead::RootFileListing
| ExactFilesystemRead::RootDirectoryListing => {
(None, crate::hot_state::FilePathInterest::All, false)
}
ExactFilesystemRead::Point(selector, column) => {
let content = column == ExactLixFileReadColumn::Content;
match selector {
ExactLixFileReadSelector::Id(id) => {
(Some(vec![id]), crate::hot_state::FilePathInterest::All, content)
}
ExactLixFileReadSelector::Path(path) => (
None,
crate::hot_state::FilePathInterest::Comparison {
operation: crate::hot_state::FilePathInterestComparison::Equal,
value: path,
},
content,
),
}
}
ExactFilesystemRead::PathContentBatch(paths) => (
None,
crate::hot_state::FilePathInterest::In {
values: paths.into_iter().collect(),
},
true,
),
ExactFilesystemRead::IdManifestBatch(ids) => (
Some(ids.into_iter().collect()),
crate::hot_state::FilePathInterest::All,
true,
),
};
register_seeded_file_interest(
capture,
active_branch_id,
file_ids,
path_predicate,
content,
None,
)
}
fn register_seeded_file_interest(
capture: &crate::hot_state::ReadInterestRegistry,
active_branch_id: &str,
file_ids: Option<Vec<String>>,
path_predicate: crate::hot_state::FilePathInterest,
content: bool,
byte_range: Option<(u64, u64)>,
) -> Result<(), LixError> {
capture.register(crate::hot_state::LogicalReadInterest::FilesystemPaths {
file_ids: file_ids.clone(),
branch_ids: vec![active_branch_id.to_owned()],
include_blob_refs: content,
cache_small_blob_data: false,
})?;
if content {
capture.register(crate::hot_state::LogicalReadInterest::FileContent {
request: crate::hot_state::HotStateScanRequest {
filter: crate::hot_state::HotStateFilter {
schema_keys: vec![
"lix_file_descriptor".to_owned(),
"lix_binary_blob_ref".to_owned(),
"lix_directory_descriptor".to_owned(),
],
branch_ids: vec![active_branch_id.to_owned()],
..Default::default()
},
projection: crate::hot_state::HotStateProjection {
columns: vec!["snapshot_content".to_owned()],
},
limit: None,
},
file_ids,
directory_ids: None,
root_directory: false,
indexed: true,
path_predicate,
byte_range,
})?;
}
Ok(())
}
async fn execute_coherent_session_read<StorageImpl, F, Fut, T>(
storage: &StorageAdapter<StorageImpl>,
replayable: bool,
mut attempt: F,
) -> Result<T, LixError>
where
StorageImpl: Storage + 'static,
F: FnMut(SharedStorageAdapterRead<StorageImpl::Read<'static>>) -> Fut,
Fut: Future<Output = Result<T, LixError>>,
{
let operation = Box::pin(async {
let mut retries = ExpiredReadRetryState::default();
loop {
let result = match storage.begin_read(StorageReadOptions::default()).await {
Ok(read) => {
with_static_session_sql_read::<StorageImpl, _, _, _>(read, &mut attempt).await
}
Err(error) => Err(error.into()),
};
match result {
Ok(buffered) => return Ok(buffered),
Err(error) => {
if !replayable {
return Err(error);
}
let Some(delay) = retries.next_delay(&error) else {
if error.code == LixError::CODE_STORAGE_READ_EXPIRED
&& !error.automatic_retry_is_forbidden()
{
return Err(LixError::new(
"LIX_READ_PROGRESS_EXHAUSTED",
"coherent read could not complete within its retry budget",
)
.with_details(serde_json::json!({
"causeCode": error.code,
"retryBudgetMs": 3000,
})));
}
return Err(error);
};
tokio::task::yield_now().await;
if !delay.is_zero() {
crate::sync::sleep(delay).await;
}
}
}
}
});
if replayable {
crate::common::with_read_deadline(operation).await
} else {
operation.await
}
}
async fn with_static_session_sql_read<StorageImpl, F, Fut, T>(
read: StorageAdapterReadScope<StorageImpl::Read<'_>>,
f: F,
) -> Result<T, LixError>
where
StorageImpl: Storage + 'static,
F: FnOnce(SharedStorageAdapterRead<StorageImpl::Read<'static>>) -> Fut,
Fut: Future<Output = Result<T, LixError>>,
{
let read = unsafe { assume_static_storage_read::<StorageImpl>(read) };
let read = SharedStorageAdapterRead::new(read);
let finish = read.clone();
let result = f(read).await;
let finish_result = finish.finish().map_err(LixError::from);
match (result, finish_result) {
(Ok(value), Ok(())) => Ok(value),
(Err(error), Ok(())) => Err(error),
(_, Err(finish_error)) => Err(finish_error),
}
}
unsafe fn assume_static_storage_read<StorageImpl>(
read: StorageAdapterReadScope<StorageImpl::Read<'_>>,
) -> StorageAdapterReadScope<StorageImpl::Read<'static>>
where
StorageImpl: Storage + 'static,
{
let read = std::mem::ManuallyDrop::new(read);
unsafe {
std::ptr::read(
std::ptr::from_ref(&*read)
.cast::<StorageAdapterReadScope<StorageImpl::Read<'static>>>(),
)
}
}
impl<StorageImpl> SessionTransaction<StorageImpl>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
pub async fn execute(
&mut self,
sql: &str,
params: &[Value],
) -> Result<ExecuteResult, LixError> {
Box::pin(self.execute_with_options_inner(sql, params, ExecuteOptions::default())).await
}
pub(super) async fn flush_prepared_mutations_with_sync(&mut self) -> Result<(), LixError> {
let sender = self
.sync_demand_tx
.clone()
.filter(|_| self.sync_mode.role() == crate::sync::SyncRole::PartialReplica);
let mut retry = crate::sync::SyncDemandRetry::default();
loop {
match Box::pin(self.transaction_mut()?.flush_prepared_mutations()).await {
Ok(()) => return Ok(()),
Err(error) => {
let Some(sender) = &sender else {
return Err(error);
};
let hydrated = retry
.hydrate_pinned_for_retry(Some(sender), error.clone())
.await
.map_err(|error| {
if error.code == "LIX_ERROR_SYNC_DEMAND_STALLED" {
LixError::new(LixError::CODE_TRANSACTION_CONFLICT, "transaction could not preserve its staged snapshot while loading inputs")
} else { error }
})?;
if !self
.transaction_mut()?
.refresh_hydrated_native_inputs(&error, &hydrated)
.await?
{
return Err(LixError::new(
LixError::CODE_TRANSACTION_CONFLICT,
"transaction snapshot requires inputs outside its retained authority",
));
}
}
}
}
}
async fn execute_with_options_inner(
&mut self,
sql: &str,
params: &[Value],
options: ExecuteOptions,
) -> Result<ExecuteResult, LixError> {
let Some(sender) = self
.sync_demand_tx
.clone()
.filter(|_| self.sync_mode.role() == crate::sync::SyncRole::PartialReplica)
else {
return Box::pin(self.execute_with_options_once(sql, params, options)).await;
};
let mut retry = crate::sync::SyncDemandRetry::default();
loop {
Box::pin(self.flush_prepared_mutations_with_sync()).await?;
let checkpoint = self.transaction_mut()?.begin_sql_statement_checkpoint()?;
let error = match Box::pin(self.execute_with_options_once(sql, params, options.clone()))
.await
{
Ok(result) => return Ok(result),
Err(error) => error,
};
self.transaction_mut()?
.rollback_sql_statement_checkpoint(checkpoint)
.await?;
if error.automatic_retry_is_forbidden() {
return Err(error);
}
let native = crate::tracked_state::NativeObjectRef::from_missing_error(&error)?
.is_some()
|| crate::tracked_state::NativeMetadataRef::from_missing_error(&error)?.is_some()
|| crate::binary_cas::BlobManifestRequired::from_error(&error)?.is_some()
|| error.code == "LIX_SYNC_CHUNKS_REQUIRED";
if !native {
return Err(error);
}
let hydrated = retry
.hydrate_pinned_for_retry(Some(&sender), error.clone())
.await?;
self.transaction_mut()?
.refresh_hydrated_native_inputs(&error, &hydrated)
.await?;
}
}
async fn execute_with_options_once(
&mut self,
sql: &str,
params: &[Value],
options: ExecuteOptions,
) -> Result<ExecuteResult, LixError> {
self.ensure_session_open()?;
let telemetry =
SqlStatementTelemetry::start(self.telemetry.as_ref(), sql, "transaction", None);
let may_reuse_literal_shape = self.has_started_statement;
self.has_started_statement = true;
let operation = async {
if may_reuse_literal_shape
&& params.is_empty()
&& let Some((normalized_shape, parameter_count)) = self
.transaction
.as_ref()
.and_then(|transaction| transaction.prepared_literal_mutation_shape())
{
if let Some(decoded_values) = self
.sql_planning_cache
.decode_update_literals_for_cached_shape(
sql,
normalized_shape,
parameter_count,
&mut self.prepared_literal_escape_scratch,
&mut self.prepared_literal_shape,
)
{
#[cfg(feature = "storage-benches")]
{
let key_bytes = decoded_values.first().map_or(0, |value| value.len());
let value_bytes = decoded_values.get(1).map_or(0, |value| value.len());
let owned_bytes = decoded_values
.iter()
.filter_map(|value| match value {
std::borrow::Cow::Borrowed(_) => None,
std::borrow::Cow::Owned(value) => Some(value.len()),
})
.sum::<usize>();
crate::storage_bench::record_crud_ownership(
crate::storage_bench::CRUD_OWNERSHIP_SQL_BOUND,
1,
key_bytes,
value_bytes,
decoded_values.len(),
decoded_values
.iter()
.filter(|value| matches!(value, std::borrow::Cow::Owned(_)))
.count(),
0,
);
crate::storage_bench::record_crud_ownership_transfer(
crate::storage_bench::CRUD_OWNERSHIP_SQL_BOUND,
owned_bytes,
0,
owned_bytes,
0,
);
}
let transaction = self
.transaction
.as_mut()
.ok_or_else(|| transaction_state_error("Lix transaction is closed"))?;
let result = transaction
.try_execute_cached_literal_prepared_mutation(
options.origin_key.as_deref(),
&decoded_values,
)
.await;
for (index, value) in decoded_values.into_iter().enumerate() {
if let std::borrow::Cow::Owned(value) = value {
self.prepared_literal_escape_scratch[index] = value;
}
}
match result {
Ok(Some(result)) => {
self.has_written_statement = true;
return Ok(ExecuteResult::from_sql_write_result(result));
}
Ok(None) => {}
Err(error) => return Err(normalize_sql_surface_error(error, sql)),
}
}
}
let auto_parameterized = params
.is_empty()
.then(|| self.sql_planning_cache.auto_parameterized_update(sql))
.flatten();
let (planning_sql, statement, auto_params) =
if let Some(auto_parameterized) = auto_parameterized {
(
auto_parameterized.sql,
auto_parameterized.statement,
Some(auto_parameterized.params),
)
} else {
(
Arc::from(sql),
self.sql_planning_cache.parse_statement(sql)?,
None,
)
};
let params = auto_params.as_deref().unwrap_or(params);
let transaction = self.transaction_mut()?;
if transaction.prepared_mutation_matches(&planning_sql) {
transaction
.flush_prepared_mutation_barrier(
&planning_sql,
options.origin_key.as_deref(),
params,
)
.await?;
let previous_origin_key =
transaction.replace_origin_key(options.origin_key.clone());
let result = transaction
.try_execute_prepared_mutation(&planning_sql, params)
.await;
transaction.replace_origin_key(previous_origin_key);
match result {
Ok(Some(result)) => {
self.has_written_statement = true;
return Ok(ExecuteResult::from_sql_write_result(result));
}
Ok(None) => {}
Err(error) => return Err(normalize_sql_surface_error(error, sql)),
}
}
let is_read = matches!(
sql2::bind_statement_route(&statement)?,
sql2::BoundStatementRoute::Read
);
if is_read {
transaction.flush_prepared_mutations_for_read().await?;
} else {
transaction
.flush_prepared_mutation_barrier(
&planning_sql,
options.origin_key.as_deref(),
params,
)
.await?;
}
let function_checkpoint = transaction.functions().statement_checkpoint();
let read_set_checkpoint = transaction.checkpoint_sql_statement_reads();
let result = async {
let result = if is_read {
execute_transaction_statement(
transaction,
sql,
statement,
params,
options,
ExecuteStatementMetadata::default(),
)
.await
} else {
execute_transaction_write_auto(
transaction,
&planning_sql,
statement,
params,
options,
ExecuteStatementMetadata::default(),
true,
)
.await
};
let result = result.map_err(|error| normalize_sql_surface_error(error, sql))?;
if !is_read {
transaction.release_pending_plugin_actor_leases().await;
}
Ok(result)
}
.await;
transaction.finish_sql_statement_reads();
if result.is_err() {
transaction.restore_sql_statement_reads(read_set_checkpoint);
if let Some(function_checkpoint) = function_checkpoint {
transaction
.functions()
.restore_statement_checkpoint(function_checkpoint);
}
}
if result.is_ok() {
if is_read {
transaction.protect_sql_read_snapshot();
} else {
self.has_written_statement = true;
}
}
result
};
let result = match telemetry.as_ref() {
Some(telemetry) => telemetry.instrument(operation).await,
None => operation.await,
};
if let Some(telemetry) = telemetry {
telemetry.finish(&result);
}
result
}
pub(crate) fn execute_with_options(
&mut self,
sql: String,
params: Vec<Value>,
options: ExecuteOptions,
) -> impl Future<Output = Result<ExecuteResult, LixError>> + Send + '_ {
unsafe {
super::AssumeSendFuture::new(async move {
self.execute_with_options_inner(&sql, ¶ms, options)
.await
})
}
}
#[cfg(test)]
pub(crate) async fn execute_with_write_executor_mode(
&mut self,
sql: &str,
params: &[Value],
mode: sql2::WriteExecutorMode,
) -> Result<ExecuteResult, LixError> {
let _operation_guard = self.begin_session_operation()?;
let statement = self.sql_planning_cache.parse_statement(sql)?;
let transaction = self.transaction_mut()?;
transaction.flush_prepared_mutations().await?;
match sql2::bind_statement_route(&statement)? {
sql2::BoundStatementRoute::Write => {
execute_transaction_write_with_mode(transaction, sql, statement, params, mode)
.await
.map_err(|error| normalize_sql_surface_error(error, sql))
}
sql2::BoundStatementRoute::Read => self.execute(sql, params).await,
}
}
#[cfg(test)]
pub(crate) async fn execute_with_write_executor_mode_and_trace(
&mut self,
sql: &str,
params: &[Value],
mode: sql2::WriteExecutorMode,
) -> Result<(ExecuteResult, Option<sql2::WriteExecutorPath>), LixError> {
let _operation_guard = self.begin_session_operation()?;
let statement = self.sql_planning_cache.parse_statement(sql)?;
let transaction = self.transaction_mut()?;
transaction.flush_prepared_mutations().await?;
match sql2::bind_statement_route(&statement)? {
sql2::BoundStatementRoute::Write => execute_transaction_write_with_mode_and_trace(
transaction,
sql,
statement,
params,
mode,
)
.await
.map_err(|error| normalize_sql_surface_error(error, sql)),
sql2::BoundStatementRoute::Read => {
self.execute(sql, params).await.map(|result| (result, None))
}
}
}
}
async fn try_execute_transaction_parameter_batch<StorageImpl>(
transaction: &mut crate::transaction::Transaction<StorageImpl>,
statements: &[ExecuteBatchStatement],
parsed: &TransactionBatchStatements,
options: &ExecuteOptions,
statement_metadata: &[ExecuteStatementMetadata],
) -> Result<Option<Vec<ExecuteResult>>, LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
let Some(first_statement) = statements.first() else {
return Ok(None);
};
if statements.len() < 2
|| parsed.len() != statements.len()
|| statement_metadata.len() != statements.len()
|| statement_metadata
.iter()
.any(|metadata| metadata != &ExecuteStatementMetadata::default())
{
return Ok(None);
}
let (planning_sql, parsed_statement, parameter_rows) = match parsed {
TransactionBatchStatements::AutoParameterizedUpdate { sql, statement, .. } => {
(sql.as_ref(), statement, None)
}
TransactionBatchStatements::Shared { statement, .. }
if statements
.iter()
.all(|candidate| candidate.sql == first_statement.sql) =>
{
(
first_statement.sql.as_str(),
statement,
Some(
statements
.iter()
.map(|statement| statement.params.as_slice())
.collect::<Vec<_>>(),
),
)
}
TransactionBatchStatements::Shared { .. } | TransactionBatchStatements::Distinct(_) => {
return Ok(None);
}
};
if sql2::bind_statement_route(parsed_statement)? != sql2::BoundStatementRoute::Write {
return Ok(None);
}
transaction.ensure_sql_mutation_allowed_after_undo_redo()?;
let previous_origin_key = transaction.replace_origin_key(options.origin_key.clone());
let execution = async {
let plan = transaction.prepare_sql_write_logical_plan(planning_sql, parsed_statement)?;
if let TransactionBatchStatements::AutoParameterizedUpdate {
parameter_batch, ..
} = parsed
{
return sql2::execute_write_logical_plan_parameter_batch(
transaction,
plan,
parameter_batch,
)
.await;
}
let parameter_rows = parameter_rows
.as_ref()
.expect("shared parameter execution retains borrowed rows");
if let Some(results) =
sql2::execute_write_logical_plan_value_batch(transaction, &plan, parameter_rows).await?
{
return Ok(Some(results));
}
let Some(parameter_batch) = sql2::parameter_record_batch(parameter_rows)? else {
return Ok(None);
};
sql2::execute_write_logical_plan_parameter_batch(transaction, plan, ¶meter_batch).await
}
.await;
transaction.replace_origin_key(previous_origin_key);
let result = execution
.map(|results| {
results.map(|results| {
results
.into_iter()
.map(ExecuteResult::from_sql_write_result)
.collect()
})
})
.map_err(|error| {
let error = normalize_sql_surface_error(error, planning_sql);
if batch_statement_index(&error).is_some() {
error
} else {
with_batch_statement_index(error, 0)
}
});
result
}
fn batch_statement_index(error: &LixError) -> Option<usize> {
error
.details()
.and_then(JsonValue::as_object)
.and_then(|details| details.get("statementIndex"))
.and_then(JsonValue::as_u64)
.and_then(|index| usize::try_from(index).ok())
}
async fn execute_transaction_write_auto<StorageImpl>(
transaction: &mut crate::transaction::Transaction<StorageImpl>,
sql: &str,
statement: datafusion::sql::parser::Statement,
params: &[Value],
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
checkpoint_post_stage_returning: bool,
) -> Result<ExecuteResult, LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
transaction.ensure_sql_mutation_allowed_after_undo_redo()?;
transaction.ensure_statement_allowed_after_restore()?;
let previous_origin_key = transaction.replace_origin_key(options.origin_key);
let result = async {
match transaction.try_execute_prepared_mutation(sql, params).await {
Ok(Some(result)) => return Ok(ExecuteResult::from_sql_write_result(result)),
Ok(None) => {}
Err(error) => return Err(error),
}
let tx_plan = transaction.prepare_sql_write_logical_plan(sql, &statement)?;
transaction.remember_prepared_mutation(sql, &tx_plan)?;
match transaction.try_execute_prepared_mutation(sql, params).await {
Ok(Some(result)) => return Ok(ExecuteResult::from_sql_write_result(result)),
Ok(None) => {}
Err(error) => return Err(error),
}
let checkpoint = (checkpoint_post_stage_returning
&& sql2::write_plan_requires_post_stage_returning_checkpoint(&tx_plan))
.then(|| transaction.begin_sql_statement_checkpoint())
.transpose()?;
let result =
execute_prepared_transaction_write(transaction, tx_plan, params, &metadata).await;
if result.is_err() {
if let Some(checkpoint) = checkpoint {
transaction
.rollback_sql_statement_checkpoint(checkpoint)
.await?;
}
}
let result = result?;
Ok(ExecuteResult::from_sql_write_result(result))
}
.await;
transaction.replace_origin_key(previous_origin_key);
result
}
async fn execute_prepared_transaction_write<StorageImpl>(
transaction: &mut crate::transaction::Transaction<StorageImpl>,
plan: sql2::SqlLogicalPlan,
params: &[Value],
metadata: &ExecuteStatementMetadata,
) -> Result<sql2::SqlWriteResult, LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
if let sql2::SqlLogicalPlan::Checkpoint(checkpoint) = plan {
let recovery = matches!(checkpoint, sql2::CheckpointFunctionPlan::Recovery { .. } | sql2::CheckpointFunctionPlan::UndoRedo { .. });
let outcome = transaction
.execute_checkpoint_function(checkpoint, params.to_vec())
.await?;
if recovery {
return Ok(sql2::SqlWriteResult::returning(outcome.rows_affected, SqlQueryResult {
columns: vec!["commit_id".to_string()],
column_types: vec![ResultColumnType::Text],
rows: vec![vec![outcome.commit_id.map(Value::Text).unwrap_or(Value::Null)]],
notices: Vec::new(),
}));
}
return sql2::SqlWriteResult::checkpoint_function(outcome);
}
sql2::execute_write_logical_plan_result_with_metadata(transaction, plan, params, metadata).await
}
fn execution_disposition(
statement: &datafusion::sql::parser::Statement,
) -> Result<ExecutionDisposition, LixError> {
match sql2::bind_statement_route(statement)? {
sql2::BoundStatementRoute::Write => Ok(ExecutionDisposition::Durable),
sql2::BoundStatementRoute::Read
if sql2::statement_has_durable_runtime_function(statement) =>
{
Ok(ExecutionDisposition::Durable)
}
sql2::BoundStatementRoute::Read => Ok(ExecutionDisposition::CancellableRead),
}
}
fn classify_execute_batch(
statements: &[ExecuteBatchStatement],
planning_cache: &sql2::SqlPlanningCache<crate::catalog::CatalogFingerprint>,
) -> Result<ExecuteBatchExecution, LixError> {
if let Some(first) = statements.first()
&& statements
.iter()
.skip(1)
.all(|statement| statement.sql == first.sql)
{
let parsed = planning_cache
.parse_statement(&first.sql)
.map_err(|error| with_batch_statement_index(error, 0))?;
let disposition =
execution_disposition(&parsed).map_err(|error| with_batch_statement_index(error, 0))?;
return if disposition == ExecutionDisposition::Durable {
Ok(ExecuteBatchExecution::Transaction(
TransactionBatchStatements::Shared {
statement: parsed,
len: statements.len(),
},
))
} else {
Ok(ExecuteBatchExecution::ReadOnly(vec![
parsed;
statements.len()
]))
};
}
if statements.len() >= 2
&& statements
.iter()
.all(|statement| statement.params.is_empty())
&& let Some(first) = planning_cache.auto_parameterized_update(&statements[0].sql)
&& statements[1..].iter().all(|statement| {
planning_cache.update_literal_shape_matches(&statement.sql, first.sql.as_ref())
})
{
let mut builders = Vec::with_capacity(first.params.len());
let large_offsets = statements.iter().fold(0_usize, |total, statement| {
total.saturating_add(statement.sql.len())
}) > i32::MAX as usize;
let per_column_byte_cap = MAX_INITIAL_LITERAL_COLUMN_BYTES / first.params.len().max(1);
for param in &first.params {
let Value::Text(value) = param else {
unreachable!("auto-parameterized string literals produce text parameters")
};
let mut builder = LiteralParameterBuilder::with_capacity(
large_offsets,
statements.len(),
value
.len()
.saturating_mul(statements.len())
.min(per_column_byte_cap),
);
builder.append_value(value);
builders.push(builder);
}
let mut decoded_params = first
.params
.iter()
.map(|param| match param {
Value::Text(value) => String::with_capacity(value.len()),
_ => unreachable!("auto-parameterized string literals produce text parameters"),
})
.collect::<Vec<_>>();
for statement in &statements[1..] {
assert!(
planning_cache
.decode_certified_update_literals_into(&statement.sql, &mut decoded_params,),
"the non-retaining pass certified this UPDATE shape"
);
for (builder, value) in builders.iter_mut().zip(&decoded_params) {
builder.append_value(value);
}
}
let data_type = if large_offsets {
DataType::LargeUtf8
} else {
DataType::Utf8
};
let fields = (0..builders.len())
.map(|index| Field::new(format!("${}", index + 1), data_type.clone(), false))
.collect::<Vec<_>>();
let columns = builders
.iter_mut()
.map(LiteralParameterBuilder::finish)
.collect::<Vec<_>>();
let parameter_batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
.map_err(|error| {
LixError::unknown(format!(
"failed to construct literal UPDATE parameter batch: {error}"
))
})?;
return Ok(ExecuteBatchExecution::Transaction(
TransactionBatchStatements::AutoParameterizedUpdate {
sql: first.sql,
statement: first.statement,
parameter_batch,
},
));
}
let mut parsed = Vec::with_capacity(statements.len());
let mut is_read_only = true;
for (statement_index, statement) in statements.iter().enumerate() {
let parsed_statement = planning_cache
.parse_statement(&statement.sql)
.map_err(|error| with_batch_statement_index(error, statement_index))?;
let disposition = execution_disposition(&parsed_statement)
.map_err(|error| with_batch_statement_index(error, statement_index))?;
if disposition == ExecutionDisposition::Durable {
is_read_only = false;
}
parsed.push(parsed_statement);
}
if is_read_only {
Ok(ExecuteBatchExecution::ReadOnly(parsed))
} else {
Ok(ExecuteBatchExecution::Transaction(
TransactionBatchStatements::Distinct(parsed),
))
}
}
async fn execute_transaction_statement<StorageImpl>(
transaction: &mut crate::transaction::Transaction<StorageImpl>,
sql: &str,
statement: datafusion::sql::parser::Statement,
params: &[Value],
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
) -> Result<ExecuteResult, LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
match sql2::bind_statement_route(&statement)? {
sql2::BoundStatementRoute::Write => {
execute_transaction_write_auto(
transaction,
sql,
statement,
params,
options,
metadata,
false,
)
.await
}
sql2::BoundStatementRoute::Read => transaction
.execute_read_sql_statement(sql.to_string(), statement, params.to_vec())
.await
.map(ExecuteResult::from_sql_query_result),
}
}
fn idempotency_outcome_unknown() -> LixError {
LixError::new(
LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN,
"the matching idempotency receipt is not yet durably visible",
)
.with_hint("Retry later with the same Idempotency-Key; do not issue a new mutation.")
.with_details(serde_json::json!({
"retryable": true,
"retryScope": "same-idempotency-key",
"outcome": "unknown",
}))
}
fn with_batch_statement_index(mut error: LixError, statement_index: usize) -> LixError {
let mut details = match error.details.take().map(|details| *details) {
Some(JsonValue::Object(details)) => details,
Some(details) => {
let mut wrapped = JsonMap::new();
wrapped.insert("cause".to_string(), details);
wrapped
}
None => JsonMap::new(),
};
details.insert(
"statementIndex".to_string(),
JsonValue::from(statement_index),
);
error.details = Some(Box::new(JsonValue::Object(details)));
error
}
#[cfg(test)]
async fn execute_transaction_write_with_mode<StorageImpl>(
transaction: &mut crate::transaction::Transaction<StorageImpl>,
sql: &str,
statement: datafusion::sql::parser::Statement,
params: &[Value],
mode: sql2::WriteExecutorMode,
) -> Result<ExecuteResult, LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
transaction.ensure_sql_mutation_allowed_after_undo_redo()?;
let tx_plan = transaction.prepare_sql_write_logical_plan(sql, &statement)?;
let result =
sql2::execute_write_logical_plan_with_mode_result(transaction, tx_plan, params, mode)
.await?;
Ok(ExecuteResult::from_sql_write_result(result))
}
#[cfg(test)]
async fn execute_transaction_write_with_mode_and_trace<StorageImpl>(
transaction: &mut crate::transaction::Transaction<StorageImpl>,
sql: &str,
statement: datafusion::sql::parser::Statement,
params: &[Value],
mode: sql2::WriteExecutorMode,
) -> Result<(ExecuteResult, Option<sql2::WriteExecutorPath>), LixError>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
transaction.ensure_sql_mutation_allowed_after_undo_redo()?;
let tx_plan = transaction.prepare_sql_write_logical_plan(sql, &statement)?;
let (result, path) = sql2::execute_write_logical_plan_with_mode_and_trace_result(
transaction,
tx_plan,
params,
mode,
)
.await?;
Ok((ExecuteResult::from_sql_write_result(result), Some(path)))
}
fn normalize_sql_surface_error(error: LixError, sql: &str) -> LixError {
if (error.code.starts_with("LIX_ERROR_PATH_") && sql_uses_public_filesystem_path_surface(sql))
|| (error.code == LixError::CODE_SCHEMA_DEFINITION
&& error.message.to_ascii_lowercase().contains("system schema"))
{
return LixError {
code: LixError::CODE_INVALID_PARAM.to_string(),
..error
};
}
if error.code == LixError::CODE_INVALID_JSON_PATH
&& error
.message
.to_ascii_lowercase()
.contains("uses variadic path segments")
{
return LixError {
code: LixError::CODE_INVALID_PARAM.to_string(),
..error
};
}
error
}
struct AutoCommitRetries {
limit: Option<u32>,
attempts: u32,
conflicts: usize,
expired: ExpiredReadRetryState,
}
impl AutoCommitRetries {
fn new(limit: Option<u32>) -> Self {
Self {
limit,
attempts: 0,
conflicts: 0,
expired: ExpiredReadRetryState::default(),
}
}
async fn retry(&mut self, error: &LixError) -> bool {
if error.automatic_retry_is_forbidden()
|| self.limit.is_some_and(|limit| self.attempts >= limit)
{
return false;
}
let retry = if error.code == LixError::CODE_TRANSACTION_CONFLICT {
if self.limit.is_none() && self.conflicts >= MAX_AUTO_COMMIT_RETRIES {
false
} else {
self.conflicts += 1;
true
}
} else {
retry_expired_auto_commit(&mut self.expired, error).await
};
if retry {
self.attempts += 1;
tracing::debug!(retry_count = self.attempts, error_code = %error.code,
"replaying automatic transaction after known failure");
}
retry
}
fn annotate(&self, mut error: LixError) -> LixError {
let forbidden = error.automatic_retry_is_forbidden();
let mut details = match error.details.take().map(|details| *details) {
Some(JsonValue::Object(details)) => details,
Some(cause) => JsonMap::from_iter([("cause".to_owned(), cause)]),
None => JsonMap::new(),
};
details.insert("autoCommitRetryCount".into(), self.attempts.into());
details.insert(
"autoCommitRetryStopReason".into(),
JsonValue::from(if forbidden {
"replay-forbidden"
} else if !matches!(
error.code.as_str(),
LixError::CODE_TRANSACTION_CONFLICT | LixError::CODE_STORAGE_READ_EXPIRED
) {
"non-retryable-error"
} else if self.limit.is_some_and(|limit| self.attempts >= limit) {
"retry-limit"
} else if error.code == LixError::CODE_TRANSACTION_CONFLICT {
"retry-limit"
} else if error.code == LixError::CODE_STORAGE_READ_EXPIRED {
"snapshot-recovery-deadline"
} else {
"non-retryable-error"
}),
);
if let Some(limit) = self.limit {
details.insert("maxAutoCommitRetries".into(), limit.into());
}
error.details = Some(Box::new(JsonValue::Object(details)));
error
}
}
async fn retry_expired_auto_commit(
expired_read_retries: &mut ExpiredReadRetryState,
error: &LixError,
) -> bool {
let Some(delay) = expired_read_retries.next_delay(error) else {
return false;
};
tokio::task::yield_now().await;
if !delay.is_zero() {
crate::sync::sleep(delay).await;
}
true
}
fn sql_uses_public_filesystem_path_surface(sql: &str) -> bool {
let lower = sql.to_ascii_lowercase();
(lower.contains("lix_file") || lower.contains("lix_directory")) && lower.contains("path")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::changelog::{ChangelogContext, ChangelogReader, CommitLoadRequest};
use crate::row_pk::RowPk;
use crate::storage_adapter::{MemoryRead, MemoryWrite, StorageError, StorageSessionToken};
use crate::telemetry::{CallbackTelemetrySink, CompletedTelemetrySpan, TelemetryValue};
use crate::transaction_types::{RawWriteBatch, TransactionJson, TransactionWriteRow};
use crate::{
Memory,
engine::{Engine, EngineOptions},
};
#[test]
fn durable_completion_errors_preserve_the_receipt_and_forbid_retry() {
let receipt = CommitReceipt {
commit: Some(CommitSpan::new("before".into(), "after".into())),
};
let error = receipt.annotate_completion_error(LixError::new(
LixError::CODE_TRANSACTION_CONFLICT,
"derived preview failed",
));
assert!(error.automatic_retry_is_forbidden());
assert_eq!(
error.details.as_ref().unwrap()["commit"],
serde_json::json!({"before": "before", "after": "after"})
);
}
#[tokio::test]
async fn read_result_byte_budget_rejects_oversize_and_releases_session() {
let storage = Memory::new();
Engine::initialize(storage.clone()).await.unwrap();
let engine = Engine::new(storage).await.unwrap();
let session = engine.open_session().await.unwrap();
let value = Value::Text("x".repeat(crate::common::MAX_READ_RESULT_BYTES + 1));
let error = session
.execute("SELECT $1 AS value", &[value])
.await
.unwrap_err();
assert_eq!(error.code, "LIX_READ_RESOURCE_EXHAUSTED");
assert_eq!(
session
.execute("SELECT 1 AS value", &[])
.await
.unwrap()
.rows()[0]
.get::<i64>("value"),
Ok(1)
);
}
#[tokio::test]
async fn coherent_read_executor_discards_failed_attempt_results_and_handles() {
let storage = Memory::new();
Engine::initialize(storage.clone()).await.unwrap();
let engine = Engine::new(storage).await.unwrap();
let session = engine.open_session().await.unwrap();
let attempts = std::sync::atomic::AtomicUsize::new(0);
let result =
execute_coherent_session_read::<Memory, _, _, _>(&session.storage, true, |read| {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
async move {
let buffered = vec![attempt];
drop(read);
if attempt == 0 {
Err(LixError::from(StorageError::ReadExpired))
} else {
Ok(buffered)
}
}
})
.await
.unwrap();
assert_eq!(result, vec![1]);
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn coherent_read_executor_never_replays_effectful_or_completed_operations() {
let storage = Memory::new();
Engine::initialize(storage.clone()).await.unwrap();
let engine = Engine::new(storage).await.unwrap();
let session = engine.open_session().await.unwrap();
for (replayable, marker) in [
(false, None),
(true, Some("nonRetryableAfterCommit")),
(true, Some("nonRetryableAfterExecution")),
] {
let attempts = std::sync::atomic::AtomicUsize::new(0);
let result = execute_coherent_session_read::<Memory, _, _, ()>(
&session.storage,
replayable,
|read| {
attempts.fetch_add(1, Ordering::SeqCst);
async move {
drop(read);
let mut error = LixError::from(StorageError::ReadExpired);
if let Some(marker) = marker {
error = error.with_details(serde_json::json!({marker: true}));
}
Err(error)
}
},
)
.await;
assert!(result.is_err());
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn automatic_retry_limits_cover_conflicts_and_expiry() {
let conflict = LixError::new(LixError::CODE_TRANSACTION_CONFLICT, "conflict");
let expired = LixError::new(LixError::CODE_STORAGE_READ_EXPIRED, "expired");
let mut defaults = AutoCommitRetries::new(None);
for _ in 0..16 {
assert!(defaults.retry(&conflict).await);
}
assert!(!defaults.retry(&conflict).await);
assert!(defaults.retry(&expired).await);
for error in [&conflict, &expired] {
let mut fail_fast = AutoCommitRetries::new(Some(0));
assert!(!fail_fast.retry(error).await);
assert_eq!(fail_fast.attempts, 0);
}
let mut capped = AutoCommitRetries::new(Some(2));
assert!(capped.retry(&conflict).await);
assert!(capped.retry(&expired).await);
assert!(!capped.retry(&conflict).await);
assert!(!capped.retry(&expired).await);
let error =
capped.annotate(conflict.with_details(serde_json::json!({"statementIndex": 1})));
assert_eq!(error.details.as_ref().unwrap()["statementIndex"], 1);
assert_eq!(error.details.as_ref().unwrap()["autoCommitRetryCount"], 2);
assert_eq!(
error.details.as_ref().unwrap()["autoCommitRetryStopReason"],
"retry-limit"
);
}
#[tokio::test]
async fn retry_diagnostics_preserve_nonretryable_failure_reason_at_limit() {
for limit in [0, 1] {
let mut retries = AutoCommitRetries::new(Some(limit));
for _ in 0..limit {
assert!(
retries
.retry(&LixError::new(
LixError::CODE_TRANSACTION_CONFLICT,
"conflict"
))
.await
);
}
let error = LixError::new(LixError::CODE_INVALID_PARAM, "invalid parameter");
assert!(!retries.retry(&error).await);
let error = retries.annotate(error);
assert_eq!(error.code, LixError::CODE_INVALID_PARAM);
assert_eq!(
error.details.as_ref().unwrap()["autoCommitRetryCount"],
limit
);
assert_eq!(
error.details.as_ref().unwrap()["autoCommitRetryStopReason"],
"non-retryable-error"
);
}
}
#[tokio::test]
async fn idempotent_replay_policy_never_reexecutes_ambiguous_or_completed_writes() {
let storage = Memory::new();
Engine::initialize(storage.clone()).await.unwrap();
let engine = Engine::new(storage).await.unwrap();
let session = engine.open_session().await.unwrap();
let idempotency = ExecuteIdempotency::new(None, "retry-policy-test".into(), [4; 32])
.with_branch(session.active_branch_id().await.unwrap());
for (code, marker, limit, expected_attempts) in [
(LixError::CODE_TRANSACTION_CONFLICT, None, 1, 2),
(LixError::CODE_TRANSACTION_CONFLICT, None, 0, 1),
(LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN, None, 1, 1),
(
LixError::CODE_TRANSACTION_CONFLICT,
Some("nonRetryableAfterCommit"),
1,
1,
),
(
LixError::CODE_STORAGE_READ_EXPIRED,
Some("nonRetryableAfterExecution"),
1,
1,
),
] {
let attempts = std::sync::atomic::AtomicUsize::new(0);
let result = session
.execute_with_idempotency_recovery(
&idempotency,
|_| Ok(99_usize),
Some(limit),
|| async {
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
if attempt > 0 {
return Ok(attempt);
}
let mut error = LixError::new(code, "injected known failure");
if let Some(marker) = marker {
error = error.with_details(serde_json::json!({marker: true}));
}
Err(error)
},
)
.await;
assert_eq!(attempts.load(Ordering::SeqCst), expected_attempts);
if expected_attempts == 2 {
assert_eq!(result.unwrap(), 1);
} else {
let error = result.unwrap_err();
assert_eq!(error.code, code);
assert_eq!(error.details.as_ref().unwrap()["autoCommitRetryCount"], 0);
if let Some(marker) = marker {
assert_eq!(error.details.unwrap()[marker], true);
}
}
}
}
#[tokio::test]
async fn statement_and_batch_retry_caps_leave_failed_writes_unpublished() {
for batch in [false, true] {
for limit in [0, 1] {
let storage = RepeatedExpiringStorage::new();
Engine::initialize(storage.clone()).await.unwrap();
let engine = Engine::new(storage.clone()).await.unwrap();
let session = engine.open_session().await.unwrap();
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('cap', 'keep')",
&[],
)
.await
.unwrap();
let sql = "SELECT commit_id FROM lix_restore((SELECT working_base_commit_id FROM lix_branch WHERE id = lix_active_branch_id()), ARRAY(SELECT row_ref FROM lix_diff('lix_key_value', lix_root_commit_id(), lix_active_branch_commit_id()) WHERE key = 'cap'))";
storage.expire_after_each_transaction_open(3);
let options = ExecuteOptions {
max_auto_commit_retries: Some(limit),
..Default::default()
};
let error = if batch {
session
.execute_batch_with_options(
&[ExecuteBatchStatement {
sql: sql.into(),
params: vec![],
label: None,
}],
options,
)
.await
.unwrap_err()
} else {
session
.execute_with_options(sql, &[], options)
.await
.unwrap_err()
};
assert_eq!(error.code, LixError::CODE_STORAGE_READ_EXPIRED);
assert_eq!(
error.details.as_ref().unwrap()["autoCommitRetryCount"],
limit
);
*storage.schedule.lock().unwrap() = None;
let result = session
.execute("SELECT value FROM lix_key_value WHERE key = 'cap'", &[])
.await
.unwrap();
assert_eq!(
result.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("keep")
);
}
}
}
#[tokio::test]
async fn auto_commit_retry_policy_preserves_completion_boundaries() {
for code in [
LixError::CODE_TRANSACTION_CONFLICT,
LixError::CODE_STORAGE_READ_EXPIRED,
LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN,
] {
for marker in [
None,
Some("nonRetryableAfterCommit"),
Some("nonRetryableAfterExecution"),
] {
let mut error = LixError::new(code, "retry policy probe");
if let Some(marker) = marker {
error = error.with_details(serde_json::json!({marker: true}));
}
let mut retries = AutoCommitRetries::new(None);
assert_eq!(
retries.retry(&error).await,
marker.is_none() && code != LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN
);
if marker.is_some() {
assert_eq!(retries.attempts, 0);
}
}
}
}
#[tokio::test]
async fn committed_sql_callback_conflict_and_expiry_are_not_replayed() {
for code in [
LixError::CODE_TRANSACTION_CONFLICT,
LixError::CODE_STORAGE_READ_EXPIRED,
] {
let storage = Memory::default();
Engine::initialize(storage.clone()).await.unwrap();
let engine = Engine::new(storage.clone()).await.unwrap();
let session = engine.open_session().await.unwrap();
let access = session.begin_session_write_access().await.unwrap();
let error = session
.with_write_transaction_reserved_lending(
access,
async |transaction| {
transaction
.stage_engine_test_rows(RawWriteBatch::from_test_rows(vec![
TransactionWriteRow {
row_pk: Some(RowPk::single("committed-retry-boundary")),
schema_key: "lix_key_value".into(),
file_id: None,
snapshot: Some(TransactionJson::from_value_for_test(
serde_json::json!({
"key": "committed-retry-boundary", "value": "persisted"
}),
)),
metadata: None,
origin: None,
created_at: None,
updated_at: None,
global: true,
change_id: None,
commit_id: None,
untracked: false,
branch_id: crate::GLOBAL_BRANCH_ID.into(),
},
]))
.await?;
Ok(())
},
|_| Err(LixError::new(code, "injected completion failure")),
)
.await
.unwrap_err();
assert_eq!(error.code, code);
assert_eq!(error.message, "injected completion failure");
assert_eq!(
error.details.as_ref().unwrap()["nonRetryableAfterCommit"],
true
);
let mut retries = AutoCommitRetries::new(None);
assert!(!retries.retry(&error).await);
assert_eq!(retries.attempts, 0);
drop(session);
drop(engine);
let reopened = Engine::new(storage).await.unwrap();
let reader = reopened.open_session().await.unwrap();
let rows = reader
.execute(
"SELECT key FROM lix_key_value WHERE key = 'committed-retry-boundary'",
&[],
)
.await
.unwrap();
assert_eq!(
rows.len(),
1,
"completion failure followed a durable mutation"
);
}
}
async fn open_session() -> SessionContext<Memory> {
let storage = Memory::default();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let engine = Engine::new(storage)
.await
.expect("initialized storage should create engine");
engine.open_session().await.expect("session should open")
}
async fn active_head(session: &SessionContext<Memory>) -> String {
session
.execute("SELECT lix_active_branch_commit_id() AS commit_id", &[])
.await
.expect("head should read")
.rows()[0]
.get::<String>("commit_id")
.expect("head should be text")
}
#[tokio::test]
async fn write_results_carry_the_commit_span_they_published() {
let session = open_session().await;
let read = session
.execute("SELECT 1 AS value", &[])
.await
.expect("read should run");
assert_eq!(read.commit(), None, "reads commit nothing");
let head_before_first = active_head(&session).await;
let first = session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('span-a', 'one')",
&[],
)
.await
.expect("write should commit");
let first_span = first.commit().expect("a write carries its span");
assert_eq!(first_span.before(), head_before_first);
assert_eq!(first_span.after(), active_head(&session).await);
assert_ne!(first_span.before(), first_span.after());
let second = session
.execute(
"UPDATE lix_key_value SET value = 'two' WHERE key = 'span-a' RETURNING key",
&[],
)
.await
.expect("returning write should commit");
assert_eq!(second.rows().len(), 1, "RETURNING rows still come back");
let second_span = second.commit().expect("a RETURNING write carries its span");
assert_eq!(second_span.before(), first_span.after());
assert_eq!(second_span.after(), active_head(&session).await);
let head_before_batch = active_head(&session).await;
let batch = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: "INSERT INTO lix_key_value (key, value) VALUES ('span-b', 'one')"
.to_string(),
params: Vec::new(),
},
ExecuteBatchStatement {
label: None,
sql: "INSERT INTO lix_key_value (key, value) VALUES ('span-c', 'one')"
.to_string(),
params: Vec::new(),
},
])
.await
.expect("batch should commit");
let span = batch.commit.as_ref().expect("batch carries one span");
assert!(batch.results.iter().all(|result| result.commit().is_none()));
assert_eq!(span.before(), head_before_batch);
assert_eq!(span.after(), active_head(&session).await);
assert_ne!(span.before(), span.after());
}
#[tokio::test]
async fn spans_are_absent_where_nothing_auto_committed() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('span-d', 'one')",
&[],
)
.await
.expect("seed should commit");
let head = active_head(&session).await;
let noop = session
.execute(
"UPDATE lix_key_value SET value = 'x' WHERE key = 'span-missing'",
&[],
)
.await
.expect("no-op write should run");
assert_eq!(noop.rows_affected(), 0);
let span = noop.commit().expect("a no-op write still carries its span");
assert_eq!(span.before(), head);
assert_eq!(span.after(), active_head(&session).await);
let target = span.before().to_owned();
let restored = session
.execute(
"SELECT commit_id FROM lix_restore($1)",
&[Value::Text(target.clone())],
)
.await
.expect("restore should run");
let restore_span = restored.commit().expect("a restore carries its span");
assert_eq!(restore_span.before(), head);
assert_eq!(restore_span.after(), target);
assert_eq!(active_head(&session).await, target);
let nondeterministic = session
.execute_batch(&[ExecuteBatchStatement {
label: None,
sql: "SELECT uuidv7() AS id".to_string(),
params: Vec::new(),
}])
.await
.expect("nondeterministic read batch should run")
.results;
assert!(
nondeterministic
.iter()
.all(|result| result.commit().is_none())
);
let reads = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: "SELECT 1 AS value".to_string(),
params: Vec::new(),
},
ExecuteBatchStatement {
label: None,
sql: "SELECT 2 AS value".to_string(),
params: Vec::new(),
},
])
.await
.expect("read batch should run")
.results;
assert!(reads.iter().all(|result| result.commit().is_none()));
let mut transaction = session.begin_transaction().await.unwrap();
let staged = transaction
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('span-e', 'one')",
&[],
)
.await
.expect("statement should stage");
assert_eq!(staged.rows_affected(), 1);
assert_eq!(staged.commit(), None);
transaction
.commit()
.await
.expect("transaction should commit");
}
#[derive(Clone)]
struct RepeatedExpiringStorage {
inner: Memory,
schedule: Arc<StdMutex<Option<ExpirySchedule>>>,
}
struct ExpirySchedule {
reads_until_expiry: usize,
remaining_expiries: usize,
}
impl RepeatedExpiringStorage {
fn new() -> Self {
Self {
inner: Memory::new(),
schedule: Arc::new(StdMutex::new(None)),
}
}
fn expire_after_each_transaction_open(&self, count: usize) {
self.expire_after_reads(1, count);
}
fn expire_after_reads(&self, reads_until_expiry: usize, count: usize) {
assert!(count > 0);
*self.schedule.lock().expect("expiry schedule should lock") = Some(ExpirySchedule {
reads_until_expiry,
remaining_expiries: count,
});
}
fn remaining_expiries(&self) -> usize {
self.schedule
.lock()
.expect("expiry schedule should lock")
.as_ref()
.map_or(0, |schedule| schedule.remaining_expiries)
}
}
impl Storage for RepeatedExpiringStorage {
type Read<'a>
= MemoryRead
where
Self: 'a;
type Write<'a>
= MemoryWrite
where
Self: 'a;
async fn acquire_session(&self) -> Result<StorageSessionToken, StorageError> {
self.inner.acquire_session().await
}
async fn begin_read(
&self,
options: StorageReadOptions,
) -> Result<Self::Read<'_>, StorageError> {
let should_expire = {
let mut state = self.schedule.lock().expect("expiry schedule should lock");
match state.as_mut() {
None => false,
Some(schedule) if schedule.reads_until_expiry > 0 => {
schedule.reads_until_expiry -= 1;
false
}
Some(schedule) => {
schedule.remaining_expiries -= 1;
if schedule.remaining_expiries == 0 {
*state = None;
} else {
schedule.reads_until_expiry = 1;
}
true
}
}
};
if should_expire {
Err(StorageError::ReadExpired)
} else {
self.inner.begin_read(options).await
}
}
async fn begin_write(
&self,
options: StorageWriteOptions,
) -> Result<Self::Write<'_>, StorageError> {
self.inner.begin_write(options).await
}
}
#[tokio::test]
async fn auto_commit_retries_repeated_expired_transaction_reader_opens() {
let storage = RepeatedExpiringStorage::new();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let engine = Engine::new(storage.clone())
.await
.expect("initialized storage should create engine");
let session = engine.open_session().await.expect("session should open");
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('retry-revert', 'value')",
&[],
)
.await
.expect("seed row should commit");
storage.expire_after_each_transaction_open(3);
let reverted = session
.execute(
"SELECT commit_id FROM lix_restore((SELECT working_base_commit_id FROM lix_branch WHERE id = lix_active_branch_id()), ARRAY(\
SELECT row_ref \
FROM lix_diff(\
'lix_key_value', lix_root_commit_id(), lix_active_branch_commit_id()\
) \
WHERE key = 'retry-revert'))",
&[],
)
.await
.expect("revert should outlast transient transaction-reader expiry");
assert_eq!(reverted.rows_affected(), 1);
assert_eq!(storage.remaining_expiries(), 0);
let live = session
.execute(
"SELECT key FROM lix_key_value WHERE key = 'retry-revert'",
&[],
)
.await
.expect("live row should read");
assert!(live.rows().is_empty(), "revert should remove the live row");
let history = session
.execute(
"SELECT COUNT(*) AS count \
FROM lix_history('lix_key_value') \
WHERE key = 'retry-revert'",
&[],
)
.await
.expect("history should read");
assert_eq!(
history.rows()[0].get::<i64>("count"),
Ok(2),
"three failed attempts must still publish exactly one revert"
);
}
#[tokio::test]
async fn idempotent_auto_commit_retries_expired_transaction_reader_opens() {
let storage = RepeatedExpiringStorage::new();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let engine = Engine::new(storage.clone())
.await
.expect("initialized storage should create engine");
let session = engine.open_session().await.expect("session should open");
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('idempotent-revert', 'value')",
&[],
)
.await
.expect("seed row should commit");
let branch_id = session
.active_branch_id()
.await
.expect("active branch should load");
let idempotency = ExecuteIdempotency::new(
Some("retry-test".to_string()),
"idempotent-revert-key".to_string(),
[7; 32],
)
.with_branch(branch_id);
let sql = "SELECT commit_id FROM lix_restore((SELECT working_base_commit_id FROM lix_branch WHERE id = lix_active_branch_id()), ARRAY(\
SELECT row_ref \
FROM lix_diff(\
'lix_key_value', lix_root_commit_id(), lix_active_branch_commit_id()\
) \
WHERE key = 'idempotent-revert'))";
storage.expire_after_reads(2, 3);
let reverted = session
.execute_with_kind(
sql,
&[],
ExecuteOptions::default(),
ExecuteStatementMetadata::default(),
"execute",
Some(idempotency.clone()),
true,
)
.await
.expect("idempotent revert should retry transient reader expiry");
assert_eq!(reverted.rows_affected(), 1);
assert_eq!(storage.remaining_expiries(), 0);
let history = session
.execute(
"SELECT COUNT(*) AS count \
FROM lix_history('lix_key_value') \
WHERE key = 'idempotent-revert'",
&[],
)
.await
.expect("history should read");
assert_eq!(
history.rows()[0].get::<i64>("count"),
Ok(2),
"idempotent retries must publish one revert"
);
}
#[tokio::test]
async fn one_argument_diff_uses_private_cursor_without_changing_public_latest_checkpoint() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('private-cursor', 'ancestor')",
&[],
)
.await
.expect("seed arbitrary non-checkpoint branch root");
let ancestor = session
.execute("SELECT lix_active_branch_commit_id() AS commit_id", &[])
.await
.expect("ancestor head should read")
.rows()[0]
.get::<String>("commit_id")
.expect("ancestor head should be text");
let branch = session
.create_branch(crate::CreateBranchOptions {
id: None,
name: "private-working-diff-cursor".to_owned(),
from_commit_id: Some(ancestor),
})
.await
.expect("branch from arbitrary commit should be created");
session
.switch_branch(crate::SwitchBranchOptions {
branch_id: branch.id.clone(),
})
.await
.expect("branch should switch");
session
.execute(
"UPDATE lix_key_value SET value = 'child' WHERE key = 'private-cursor'",
&[],
)
.await
.expect("child mutation should commit");
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("branch control read should open");
let control = crate::branch::BranchHeadControlContext::new()
.reader(&read)
.load(&branch.id)
.await
.expect("branch control should read")
.expect("branch control should exist");
drop(read);
let private_cursor = control
.working_diff_checkpoint_commit_id
.expect("working diff cursor should exist")
.to_string();
let public_checkpoints = session
.execute("SELECT commit_id FROM lix_log() WHERE is_checkpoint", &[])
.await
.expect("public checkpoint inventory should read");
assert!(
public_checkpoints.is_empty(),
"a private cursor does not create a checkpoint"
);
assert!(!private_cursor.is_empty());
let diff = session
.execute(
"SELECT diff_type FROM lix_diff('lix_key_value') \
WHERE key = 'private-cursor'",
&[],
)
.await
.expect("one-argument diff should bind the private cursor");
assert_eq!(diff.rows().len(), 1);
}
#[tokio::test]
async fn mainline_reports_a_sparse_graph_miss_for_a_missing_anchor_commit() {
let session = open_session().await;
let missing = crate::changelog::CommitId::for_test_label("missing-mainline-anchor");
let error = session
.execute(
"SELECT commit_id FROM lix_log($1)",
&[Value::Text(missing.to_string())],
)
.await
.expect_err("missing anchor commit should surface a graph miss");
assert_eq!(error.code, LixError::CODE_COMMIT_NOT_FOUND);
assert_eq!(
error.details.as_ref().expect("graph miss details")["commit_id"],
missing.to_string()
);
}
#[tokio::test]
async fn exact_registered_schema_point_preserves_typed_public_projection() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "native_point_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "payload", "type": "jsonb", "nullable": true },
{ "name": "optional", "type": "text", "nullable": true }
],
"primary_key": ["id"]
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO native_point_probe (id, payload, optional) VALUES ($1, CAST($2 AS JSONB), NULL)",
&[
Value::Text("row-a".into()),
Value::Text(r#"{"nested":[true,null],"count":7}"#.into()),
],
)
.await
.unwrap();
let native = session
.execute(
"SELECT payload AS body, optional FROM native_point_probe WHERE id = $1 LIMIT 1",
&[Value::Text("row-a".into())],
)
.await
.unwrap();
let planned = session
.execute(
"SELECT payload AS body, optional FROM native_point_probe WHERE id = CAST($1 AS TEXT) LIMIT 1",
&[Value::Text("row-a".into())],
)
.await
.unwrap();
assert_eq!(native.columns(), &["body", "optional"]);
assert_eq!(native, planned);
let missing = session
.execute(
"SELECT payload FROM native_point_probe WHERE id = $1 LIMIT 1",
&[Value::Text("missing".into())],
)
.await
.unwrap();
assert!(missing.is_empty());
}
#[tokio::test]
async fn parameterized_history_reads_runtime_registered_relation_rows() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameterized_history_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false }
],
"primary_key": ["id"]
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("runtime relation schema should register");
session
.execute(
"INSERT INTO parameterized_history_probe (id, value) VALUES ('row-a', 'history-value')",
&[],
)
.await
.expect("runtime relation row should insert");
let history = session
.execute(
"SELECT id, to_value FROM lix_history($1) WHERE id = 'row-a'",
&[Value::Text("parameterized_history_probe".into())],
)
.await
.expect("parameterized history should resolve runtime relation metadata");
assert_eq!(history.len(), 1);
assert_eq!(history.rows()[0].get::<String>("id").unwrap(), "row-a");
assert_eq!(
history.rows()[0].get::<String>("to_value").unwrap(),
"history-value"
);
}
#[tokio::test]
async fn returning_subqueries_read_relations_and_parameterized_lix_table_functions() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('returning-dependency', 'related-value')",
&[],
)
.await
.expect("related row should insert");
session
.execute(
"INSERT INTO lix_file (path, content) VALUES ('/returning-dependency.md', $1)",
&[Value::Blob(b"before".to_vec().into())],
)
.await
.expect("write target row should insert");
let result = session
.execute(
"UPDATE lix_file SET path = '/returning-dependency-after.md' \
WHERE path = '/returning-dependency.md' \
RETURNING \
(SELECT value FROM lix_key_value WHERE key = 'returning-dependency') AS related_value, \
(SELECT COUNT(*) FROM lix_history($1)) AS history_rows",
&[Value::Text("lix_key_value".into())],
)
.await
.expect("RETURNING subqueries should discover and plan their dependencies");
assert_eq!(result.len(), 1);
assert_eq!(
result.rows()[0]
.get::<serde_json::Value>("related_value")
.unwrap(),
serde_json::json!("related-value")
);
assert!(result.rows()[0].get::<i64>("history_rows").unwrap() > 0);
}
async fn assert_typed_lifecycle_current(
session: &SessionContext<Memory>,
row_count: usize,
first: &str,
sample: &str,
) {
let rows = session
.execute(
"SELECT id, value FROM columnar_lifecycle_probe ORDER BY id",
&[],
)
.await
.expect("typed lifecycle scan should succeed");
assert_eq!(rows.len(), row_count);
assert_eq!(rows.rows()[0].get::<String>("id").unwrap(), "00000");
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), first);
assert_eq!(rows.rows()[1_023].get::<String>("id").unwrap(), "01023");
assert_eq!(rows.rows()[1_023].get::<String>("value").unwrap(), sample);
}
async fn assert_current_head_uses_packed_delta_without_columnar_sidecar(
session: &SessionContext<Memory>,
schema_key: &str,
expected_rows: u64,
) {
let branch_id = session
.active_branch_id()
.await
.expect("active branch should resolve");
let head_read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("branch-head read should open");
let head = session
.branch_ctx
.ref_reader(head_read)
.load_head(&branch_id)
.await
.expect("branch head should load")
.expect("active branch should have a head");
let state_read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("replacement read should open");
let replay =
crate::tracked_state::load_commit_delta_replay_metadata(&state_read, head.commit_id)
.await
.expect("replacement metadata should load")
.expect("current head must publish replacement metadata");
assert_eq!(u64::from(replay.member_count), expected_rows);
let id = crate::hot_state::row_group_set_id(head.commit_id, schema_key);
let manifest = crate::columnar_row_group::load_row_group_manifest(&state_read, id)
.await
.expect("columnar manifest lookup should succeed");
assert!(
manifest.is_none(),
"UPDATE replacement parts supersede the synchronous typed sidecar"
);
}
async fn open_session_with_telemetry(
spans: Arc<std::sync::Mutex<Vec<CompletedTelemetrySpan>>>,
) -> SessionContext<Memory> {
let storage = Memory::default();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let sink = CallbackTelemetrySink::new(move |span| {
spans.lock().expect("telemetry span lock").push(span);
});
let engine =
Engine::new_with_options(storage, EngineOptions::new().with_telemetry(Arc::new(sink)))
.await
.expect("initialized storage should create engine");
engine.open_session().await.expect("session should open")
}
fn batch_statement(sql: &str) -> ExecuteBatchStatement {
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: Vec::new(),
}
}
#[test]
fn exact_filesystem_read_recognizes_only_the_narrow_shapes() {
let root_file_listing = sql2::parse_statement(
"SELECT id, path, name, lixcol_metadata, lixcol_updated_at \
FROM lix_file WHERE directory_id IS NULL ORDER BY name",
)
.unwrap();
assert_eq!(
exact_filesystem_read_route(&root_file_listing, &[]),
Some(ExactFilesystemRead::RootFileListing)
);
let root_directory_listing = sql2::parse_statement(
"SELECT id, path, name, lixcol_updated_at \
FROM lix_directory WHERE parent_id IS NULL ORDER BY name",
)
.unwrap();
assert_eq!(
exact_filesystem_read_route(&root_directory_listing, &[]),
Some(ExactFilesystemRead::RootDirectoryListing)
);
let data_by_id =
sql2::parse_statement("SELECT content FROM lix_file WHERE id = $1").unwrap();
assert_eq!(
exact_filesystem_read_route(
&data_by_id,
&[Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string()
)]
),
Some(ExactFilesystemRead::Point(
ExactLixFileReadSelector::Id(
"01920000-0000-7000-8000-0000000000a2".to_string()
),
ExactLixFileReadColumn::Content,
))
);
let literal_data_by_id = sql2::parse_statement(
"SELECT content FROM lix_file WHERE id = \
'01920000-0000-7000-8000-0000000000a2'",
)
.unwrap();
assert_eq!(
exact_filesystem_read_route(&literal_data_by_id, &[]),
None,
"literal reads remain on DataFusion's ordinary execution path"
);
assert_eq!(
exact_filesystem_read_interest_route(&literal_data_by_id, &[]),
Some(ExactFilesystemRead::Point(
ExactLixFileReadSelector::Id(
"01920000-0000-7000-8000-0000000000a2".to_string()
),
ExactLixFileReadColumn::Content,
))
);
let change_by_path =
sql2::parse_statement("SELECT lixcol_change_id FROM lix_file WHERE path = $1").unwrap();
assert_eq!(
exact_filesystem_read_route(&change_by_path, &[Value::Text("/a.txt".to_string())]),
Some(ExactFilesystemRead::Point(
ExactLixFileReadSelector::Path("/a.txt".to_string()),
ExactLixFileReadColumn::ChangeId,
))
);
let data_by_paths =
sql2::parse_statement("SELECT path, content FROM lix_file WHERE path IN ($1, $2, $3)")
.unwrap();
assert_eq!(
exact_filesystem_read_route(
&data_by_paths,
&[
Value::Text("/b.txt".to_string()),
Value::Text("/a.txt".to_string()),
Value::Text("/b.txt".to_string()),
],
),
Some(ExactFilesystemRead::PathContentBatch(BTreeSet::from([
"/a.txt".to_string(),
"/b.txt".to_string(),
])))
);
let manifests_by_id = sql2::parse_statement(
"SELECT id, path, content, lixcol_metadata FROM lix_file WHERE id IN ($1, $2)",
)
.unwrap();
assert_eq!(
exact_filesystem_read_route(
&manifests_by_id,
&[
Value::Text("01920000-0000-7000-8000-0000000000a2".to_string()),
Value::Text("01920000-0000-7000-8000-0000000000a1".to_string()),
],
),
Some(ExactFilesystemRead::IdManifestBatch(BTreeSet::from([
"01920000-0000-7000-8000-0000000000a1".to_string(),
"01920000-0000-7000-8000-0000000000a2".to_string(),
])))
);
for (sql, params) in [
(
"SELECT id, path, name, lixcol_updated_at \
FROM lix_directory AS directory \
WHERE parent_id IS NULL ORDER BY name",
vec![],
),
(
"SELECT id, path, name, lixcol_updated_at \
FROM lix_directory WHERE parent_id IS NULL ORDER BY path",
vec![],
),
(
"SELECT id, path, name, lixcol_updated_at \
FROM lix_directory WHERE parent_id IS NULL ORDER BY name DESC",
vec![],
),
(
"SELECT id, path, name, lixcol_updated_at \
FROM lix_directory WHERE parent_id IS NULL ORDER BY name LIMIT 1",
vec![],
),
(
"SELECT id, path, name, lixcol_metadata, lixcol_updated_at \
FROM lix_file AS file WHERE directory_id IS NULL ORDER BY name",
vec![],
),
(
"SELECT id, path, name, lixcol_metadata, lixcol_updated_at \
FROM lix_file WHERE directory_id IS NULL ORDER BY path",
vec![],
),
(
"SELECT id, path, name, lixcol_metadata, lixcol_updated_at \
FROM lix_file WHERE directory_id IS NULL ORDER BY name DESC",
vec![],
),
(
"SELECT id, path, name, lixcol_metadata, lixcol_updated_at \
FROM lix_file WHERE directory_id IS NULL ORDER BY name LIMIT 1",
vec![],
),
(
"SELECT id, path, name, lixcol_metadata, lixcol_updated_at \
FROM lix_file WHERE directory_id IS NULL ORDER BY name",
vec![Value::Text("unused".to_string())],
),
(
"SELECT content, path FROM lix_file WHERE path IN ($1, $2)",
vec![
Value::Text("/a.txt".to_string()),
Value::Text("/b.txt".to_string()),
],
),
(
"SELECT path, content FROM lix_file WHERE path IN ($2, $1)",
vec![
Value::Text("/a.txt".to_string()),
Value::Text("/b.txt".to_string()),
],
),
(
"SELECT path, content FROM lix_file WHERE path IN ($1, $2) ORDER BY path",
vec![
Value::Text("/a.txt".to_string()),
Value::Text("/b.txt".to_string()),
],
),
(
"SELECT path, content FROM lix_file WHERE path IN ($1, $2) LIMIT 1",
vec![
Value::Text("/a.txt".to_string()),
Value::Text("/b.txt".to_string()),
],
),
(
"SELECT path, content FROM lix_file WHERE path IN ($1, $2)",
vec![Value::Text("/a.txt".to_string()), Value::Null],
),
] {
let statement = sql2::parse_statement(sql).unwrap();
assert_eq!(
exact_filesystem_read_route(&statement, ¶ms),
None,
"unexpected batch fast-path match for {sql}"
);
}
for (sql, params) in [
(
"SELECT id FROM lix_file WHERE id = $1",
vec![Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string(),
)],
),
(
"SELECT content AS bytes FROM lix_file WHERE id = $1",
vec![Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string(),
)],
),
(
"SELECT content FROM lix_file AS file WHERE id = $1",
vec![Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string(),
)],
),
(
"SELECT content FROM lix_file WHERE id = '01920000-0000-7000-8000-0000000000a2'",
vec![],
),
(
"SELECT content FROM lix_file WHERE id = $1 LIMIT 1",
vec![Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string(),
)],
),
(
"SELECT \"DATA\" FROM lix_file WHERE id = $1",
vec![Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string(),
)],
),
(
"SELECT content FROM \"LIX_FILE\" WHERE id = $1",
vec![Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string(),
)],
),
(
"SELECT content FROM lix_file WHERE id = $1 AND true",
vec![Value::Text(
"01920000-0000-7000-8000-0000000000a2".to_string(),
)],
),
(
"SELECT content FROM lix_file WHERE id = $1",
vec![Value::Null],
),
(
"SELECT content FROM lix_file WHERE id = $1",
vec![
Value::Text("01920000-0000-7000-8000-0000000000a2".to_string()),
Value::Text("extra".to_string()),
],
),
] {
let statement = sql2::parse_statement(sql).unwrap();
assert_eq!(
exact_filesystem_read_route(&statement, ¶ms),
None,
"unexpected fast-path match for {sql}"
);
}
}
#[tokio::test]
async fn exact_schema_batch_matches_relational_duplicate_missing_null_and_jsonb_semantics() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "batch_route_row",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "note", "type": "text", "nullable": true },
{ "name": "payload", "type": "jsonb", "nullable": false }
],
"primary_key": ["id"]
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) \
VALUES ('batch_route_row', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("register batch-route schema");
session
.execute(
"INSERT INTO batch_route_row (id, note, payload) VALUES \
('a', NULL, CAST('{\"rank\":1}' AS JSONB)), \
('b', 'present', CAST('{\"rank\":2}' AS JSONB))",
&[],
)
.await
.expect("seed batch-route rows");
let params = [
Value::Text("b".to_owned()),
Value::Text("missing".to_owned()),
Value::Text("a".to_owned()),
Value::Text("b".to_owned()),
];
let exact = session
.execute(
"SELECT id, note, payload FROM batch_route_row \
WHERE id IN ($1, $2, $3, $4) \
AND lixcol_file_id IS NULL ORDER BY id",
¶ms,
)
.await
.expect("native batch route should execute");
let relational = session
.execute(
"SELECT row.id, row.note, row.payload FROM batch_route_row AS row \
WHERE row.id IN ($1, $2, $3, $4) \
AND row.lixcol_file_id IS NULL ORDER BY row.id",
¶ms,
)
.await
.expect("relational control should execute");
assert_eq!(exact, relational);
assert_eq!(
exact.rows().len(),
2,
"duplicates and misses emit no extra rows"
);
assert_eq!(
exact.rows()[0].value("note").expect("note column"),
&Value::Null
);
assert_eq!(
exact.rows()[1].value("payload").expect("payload column"),
&Value::Jsonb(serde_json::json!({"rank": 2}).into())
);
#[cfg(feature = "storage-benches")]
{
let (profiled, profile) = session
.execute_profiled(
"SELECT id, note, payload FROM batch_route_row \
WHERE id IN ($1, $2, $3, $4) \
AND lixcol_file_id IS NULL ORDER BY id",
¶ms,
)
.await
.expect("profiled native batch route should execute");
assert_eq!(profiled.rows().len(), 2);
assert_eq!(
profile.scan_rows, 2,
"only returned public rows are scanned"
);
assert_eq!(
profile.provider_rows_examined, 3,
"present, missing, and duplicate slots examine three unique exact identities"
);
}
}
#[tokio::test]
async fn exact_root_filesystem_listings_match_the_relational_path() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_directory (id, path) VALUES \
('01920000-0000-7000-8000-0000000000d1', '/nested'), \
('01920000-0000-7000-8000-0000000000d2', '/alpha-dir')",
&[],
)
.await
.unwrap();
session
.execute(
"INSERT INTO lix_file (id, path, content, lixcol_metadata) VALUES \
('01920000-0000-7000-8000-0000000000f1', '/b.txt', $1, CAST('{\"rank\":2}' AS JSONB)), \
('01920000-0000-7000-8000-0000000000f2', '/nested/a.txt', $2, NULL), \
('01920000-0000-7000-8000-0000000000f3', '/a.txt', $3, NULL)",
&[
Value::Blob(b"bravo".to_vec().into()),
Value::Blob(b"nested".to_vec().into()),
Value::Blob(b"alpha".to_vec().into()),
],
)
.await
.unwrap();
let exact = session
.execute(
"SELECT id, path, name, lixcol_metadata, lixcol_updated_at \
FROM lix_file WHERE directory_id IS NULL ORDER BY name",
&[],
)
.await
.unwrap();
let relational = session
.execute(
"SELECT file.id AS id, file.path AS path, file.name AS name, \
file.lixcol_metadata AS lixcol_metadata, \
file.lixcol_updated_at AS lixcol_updated_at \
FROM lix_file AS file \
WHERE file.directory_id IS NULL ORDER BY file.name",
&[],
)
.await
.unwrap();
assert_eq!(exact, relational);
assert_eq!(exact.rows().len(), 2);
assert_eq!(
exact.rows()[0].get::<String>("id").unwrap(),
"01920000-0000-7000-8000-0000000000f3"
);
assert_eq!(
exact.rows()[1].get::<String>("id").unwrap(),
"01920000-0000-7000-8000-0000000000f1"
);
assert_eq!(
exact.rows()[1].value("lixcol_metadata").unwrap(),
&Value::Jsonb(serde_json::json!({"rank": 2}).into())
);
let exact_directories = session
.execute(
"SELECT id, path, name, lixcol_updated_at \
FROM lix_directory WHERE parent_id IS NULL ORDER BY name",
&[],
)
.await
.unwrap();
let relational_directories = session
.execute(
"SELECT directory.id AS id, directory.path AS path, \
directory.name AS name, \
directory.lixcol_updated_at AS lixcol_updated_at \
FROM lix_directory AS directory \
WHERE directory.parent_id IS NULL ORDER BY directory.name",
&[],
)
.await
.unwrap();
assert_eq!(exact_directories, relational_directories);
assert_eq!(exact_directories.rows().len(), 3);
assert_eq!(
exact_directories.rows()[0].get::<String>("path").unwrap(),
"/.lix"
);
assert_eq!(
exact_directories.rows()[1].get::<String>("id").unwrap(),
"01920000-0000-7000-8000-0000000000d2"
);
assert_eq!(
exact_directories.rows()[2].get::<String>("id").unwrap(),
"01920000-0000-7000-8000-0000000000d1"
);
}
#[test]
fn late_file_content_read_rewrites_only_unchanged_blob_projections() {
let statement = sql2::parse_statement(
"SELECT path, content FROM lix_file WHERE path LIKE $1 ORDER BY path LIMIT 2",
)
.unwrap();
let plan = late_materialized_lix_file_content_read(&statement, &[]).unwrap();
assert_eq!(plan.data_column_index, 1);
assert_eq!(
plan.statement.to_string(),
"SELECT path, path AS content FROM lix_file WHERE path LIKE $1 ORDER BY path LIMIT 2"
);
let aliased = sql2::parse_statement(
"SELECT file.content AS bytes, file.path AS label FROM lix_file AS file WHERE file.path LIKE $1 ORDER BY file.path",
)
.unwrap();
let plan = late_materialized_lix_file_content_read(&aliased, &[]).unwrap();
assert_eq!(plan.data_column_index, 0);
assert_eq!(
plan.statement.to_string(),
"SELECT file.path AS bytes, file.path AS label FROM lix_file AS file WHERE file.path LIKE $1 ORDER BY file.path"
);
for sql in [
"SELECT path, length(content) FROM lix_file",
"SELECT content, upper(path) FROM lix_file",
"SELECT content FROM lix_file WHERE content = $1",
"SELECT content FROM lix_file ORDER BY content",
"SELECT content AS bytes FROM lix_file ORDER BY bytes",
"SELECT content FROM lix_file ORDER BY 1",
"SELECT DISTINCT content FROM lix_file",
"SELECT content, content FROM lix_file",
"SELECT * FROM lix_file",
"SELECT file.content FROM lix_file AS file JOIN lix_file AS other ON file.id = other.id",
] {
let statement = sql2::parse_statement(sql).unwrap();
assert_eq!(
late_materialized_lix_file_content_read(&statement, &[]),
None,
"unexpected late materialization for {sql}"
);
}
}
#[test]
fn sql_substring_byte_ranges_follow_one_based_positions_and_clip_boundaries() {
assert_eq!(sql_substring_byte_range(1, 3, 5), (0, 3));
assert_eq!(sql_substring_byte_range(0, 3, 5), (0, 2));
assert_eq!(sql_substring_byte_range(-2, 4, 5), (0, 1));
assert_eq!(sql_substring_byte_range(7, 5, 5), (5, 5));
assert_eq!(sql_substring_byte_range(1, 8, 0), (0, 0));
assert_eq!(sql_substring_byte_range(3, 0, 5), (2, 2));
}
#[test]
fn execute_batch_classifies_only_pure_reads_for_the_fast_path() {
let cache = sql2::SqlPlanningCache::default();
assert!(matches!(
classify_execute_batch(
&[
batch_statement("SELECT 1"),
batch_statement("SELECT * FROM lix_file"),
],
&cache
)
.unwrap(),
ExecuteBatchExecution::ReadOnly(_)
));
assert!(matches!(
classify_execute_batch(
&[
batch_statement("SELECT 1"),
batch_statement("DELETE FROM lix_file WHERE id = 'missing'"),
],
&cache
)
.unwrap(),
ExecuteBatchExecution::Transaction(_)
));
assert!(matches!(
classify_execute_batch(&[batch_statement("SELECT uuidv7()")], &cache).unwrap(),
ExecuteBatchExecution::Transaction(_)
));
}
#[test]
fn execute_batch_reuses_one_parsed_statement_for_homogeneous_writes() {
let cache = sql2::SqlPlanningCache::default();
let statements = [
batch_statement("UPDATE lix_file SET path = '/a' WHERE id = 'a'"),
batch_statement("UPDATE lix_file SET path = '/a' WHERE id = 'a'"),
];
let ExecuteBatchExecution::Transaction(TransactionBatchStatements::Shared { len, .. }) =
classify_execute_batch(&statements, &cache).unwrap()
else {
panic!("homogeneous durable statements should share one parsed statement");
};
assert_eq!(len, statements.len());
}
#[test]
fn execute_batch_auto_parameterizes_distinct_literal_update_shapes() {
let cache = sql2::SqlPlanningCache::default();
let statements = [
batch_statement("UPDATE notes SET value = 'first' WHERE id = 'a'"),
batch_statement("UPDATE notes SET value = 'second' WHERE id = 'b'"),
];
let ExecuteBatchExecution::Transaction(
TransactionBatchStatements::AutoParameterizedUpdate {
sql,
parameter_batch,
..
},
) = classify_execute_batch(&statements, &cache).unwrap()
else {
panic!("literal UPDATE statements should share one parameterized shape");
};
assert_eq!(sql.as_ref(), "UPDATE notes SET value = $1 WHERE id = $2");
let parameter_rows = (0..parameter_batch.num_rows())
.map(|row_index| sql2::parameter_row(¶meter_batch, row_index).unwrap())
.collect::<Vec<_>>();
assert_eq!(
parameter_rows,
[
vec![
Value::Text("first".to_string()),
Value::Text("a".to_string())
],
vec![
Value::Text("second".to_string()),
Value::Text("b".to_string())
],
]
);
}
#[test]
fn execute_batch_declines_a_late_literal_shape_mismatch() {
let cache = sql2::SqlPlanningCache::default();
let statements = [
batch_statement("UPDATE notes SET value = 'first' WHERE id = 'a'"),
batch_statement("UPDATE notes SET value = 'second' WHERE id = 'b'"),
batch_statement("UPDATE notes SET other = 'third' WHERE id = 'c'"),
];
let ExecuteBatchExecution::Transaction(TransactionBatchStatements::Distinct(parsed)) =
classify_execute_batch(&statements, &cache).unwrap()
else {
panic!("a heterogeneous batch must retain sequential classification");
};
assert_eq!(parsed.len(), statements.len());
}
#[tokio::test]
async fn execution_disposition_uses_the_parsed_bound_statement_route() {
let session = open_session().await;
assert_eq!(
session.execution_disposition("SELECT 1").unwrap(),
ExecutionDisposition::CancellableRead
);
assert_eq!(
session.execution_disposition("SELECT uuidv7()").unwrap(),
ExecutionDisposition::Durable
);
assert_eq!(
session
.execution_disposition(
"INSERT INTO lix_file (path, content) VALUES ('/disposition.txt', 'content')",
)
.unwrap(),
ExecutionDisposition::Durable
);
assert_eq!(
session
.execute_batch_disposition(&[
batch_statement("SELECT 1"),
batch_statement("SELECT * FROM lix_file"),
])
.unwrap(),
ExecutionDisposition::CancellableRead
);
assert_eq!(
session
.execute_batch_disposition(&[
batch_statement("SELECT 1"),
batch_statement("SELECT CURRENT_TIMESTAMP"),
])
.unwrap(),
ExecutionDisposition::Durable
);
}
#[tokio::test]
async fn current_and_historical_reads_share_replica_retry_disposition() {
let session = open_session().await;
let reads = [
"SELECT * FROM lix_file",
"SELECT * FROM lix_diff('lix_file')",
"SELECT * FROM lix_change",
"SELECT * FROM lix_log()",
"SELECT * FROM lix_commit",
"SELECT * FROM lix_history('lix_file')",
"SELECT * FROM lix_as_of('lix_file', $1)",
"SELECT * FROM lix_diff('lix_file', $1, $2)",
"SELECT * FROM lix_commit_ancestry($1)",
"EXPLAIN SELECT * FROM lix_history('lix_file')",
];
for sql in reads {
assert_eq!(
session.execution_disposition(sql).unwrap(),
ExecutionDisposition::CancellableRead,
"{sql}"
);
}
let mut batch = reads.into_iter().map(batch_statement).collect::<Vec<_>>();
assert_eq!(
session.execute_batch_disposition(&batch).unwrap(),
ExecutionDisposition::CancellableRead
);
for sql in [
"SELECT uuidv7()",
"SELECT CURRENT_TIMESTAMP",
"UPDATE lix_file SET path = '/b' WHERE path = '/a'",
"EXPLAIN SELECT uuidv7()",
] {
batch.push(batch_statement(sql));
assert_eq!(
session.execution_disposition(sql).unwrap(),
ExecutionDisposition::Durable,
"{sql}"
);
assert_eq!(
session.execute_batch_disposition(&batch).unwrap(),
ExecutionDisposition::Durable,
"{sql}"
);
batch.pop();
}
}
#[test]
fn execute_batch_classification_preserves_the_invalid_statement_index() {
let cache = sql2::SqlPlanningCache::default();
let result = classify_execute_batch(
&[
batch_statement("SELECT 1"),
batch_statement("this is not SQL"),
],
&cache,
);
let Err(error) = result else {
panic!("invalid SQL should fail classification");
};
assert_eq!(error.details.unwrap()["statementIndex"], 1);
}
#[tokio::test]
async fn execute_batch_pure_read_fast_path_preserves_order_and_parameters() {
let session = open_session().await;
let results = session
.execute_batch(&[
ExecuteBatchStatement {
label: Some("first".to_string()),
sql: "SELECT $1 AS value".to_string(),
params: vec![Value::Integer(11)],
},
ExecuteBatchStatement {
label: None,
sql: "SELECT $1 AS value".to_string(),
params: vec![Value::Integer(22)],
},
])
.await
.unwrap()
.results;
assert_eq!(results[0].rows()[0].get::<i64>("value").unwrap(), 11);
assert_eq!(results[1].rows()[0].get::<i64>("value").unwrap(), 22);
assert_eq!(results[0].statement_index(), Some(0));
assert_eq!(results[0].label(), Some("first"));
assert_eq!(results[1].statement_index(), Some(1));
assert_eq!(results[1].label(), None);
}
#[tokio::test]
async fn execute_batch_metadata_preserves_returning_rows_and_duplicate_labels() {
let session = open_session().await;
let results = session
.execute_batch(&[
ExecuteBatchStatement {
label: Some("write".to_string()),
sql: "INSERT INTO lix_key_value (key, value) VALUES ('batch-metadata', 'one') RETURNING key, value".to_string(),
params: Vec::new(),
},
ExecuteBatchStatement {
label: Some("write".to_string()),
sql: "UPDATE lix_key_value SET value = 'two' WHERE key = 'batch-metadata' RETURNING key, value".to_string(),
params: Vec::new(),
},
])
.await
.unwrap().results;
assert_eq!(results.len(), 2);
assert_eq!(results[0].statement_index(), Some(0));
assert_eq!(results[1].statement_index(), Some(1));
assert_eq!(results[0].label(), Some("write"));
assert_eq!(results[1].label(), Some("write"));
assert_eq!(results[0].columns(), ["key", "value"]);
assert_eq!(results[0].rows_affected(), 1);
assert_eq!(
results[0].rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!("one")
);
assert_eq!(
results[1].rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!("two")
);
}
#[tokio::test]
async fn public_insert_preserves_declared_integer_primary_key_type() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "integer_primary_key_insert_probe",
"columns": [
{ "name": "id", "type": "int8", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let inserted = session
.execute(
"INSERT INTO integer_primary_key_insert_probe (id, value) VALUES ($1, $2)",
&[Value::Integer(42), Value::Text("answer".to_string())],
)
.await
.unwrap();
assert_eq!(inserted.rows_affected(), 1);
let result = session
.execute(
"SELECT id, value FROM integer_primary_key_insert_probe WHERE id = $1",
&[Value::Integer(42)],
)
.await
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows()[0].get::<i64>("id").unwrap(), 42);
assert_eq!(result.rows()[0].get::<String>("value").unwrap(), "answer");
}
#[tokio::test]
async fn execute_batch_lowers_distinct_bound_row_inserts_once() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_insert_batch_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
sql2::take_certified_row_insert_parameter_batch_executions();
let sql = "INSERT INTO parameter_insert_batch_probe (id, value) VALUES ($1, $2)";
let results = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Text("value-a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Text("value-b".to_string()),
],
},
])
.await
.unwrap()
.results;
assert_eq!(
sql2::take_certified_row_insert_parameter_batch_executions(),
1
);
assert_eq!(
results
.iter()
.map(ExecuteResult::rows_affected)
.collect::<Vec<_>>(),
vec![1, 1]
);
let rows = session
.execute(
"SELECT id, value FROM parameter_insert_batch_probe ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "value-a");
assert_eq!(rows.rows()[1].get::<String>("value").unwrap(), "value-b");
sql2::take_certified_row_insert_parameter_batch_executions();
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("c".to_string()),
Value::Text("value-c".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Text("duplicate-b".to_string()),
],
},
])
.await
.expect_err("the second INSERT conflicts with committed row b");
assert_eq!(error.details.unwrap()["statementIndex"], 1);
assert_eq!(
sql2::take_certified_row_insert_parameter_batch_executions(),
0
);
let rows = session
.execute(
"SELECT id FROM parameter_insert_batch_probe WHERE id = 'c'",
&[],
)
.await
.unwrap();
assert!(
rows.is_empty(),
"the fresh prefix must roll back with the conflicting batch"
);
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Text("duplicate-b".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![Value::Text("d".to_string()), Value::Text("x".to_string())],
},
])
.await
.expect_err("the committed conflict should be reported first");
assert_eq!(error.code, LixError::CODE_UNIQUE);
assert_eq!(error.details.unwrap()["statementIndex"], 0);
session
.create_checkpoint()
.await
.expect("packed insert base should checkpoint through its commit reference");
assert_eq!(
session
.execute(
"UPDATE parameter_insert_batch_probe SET value = 'updated-b' WHERE id = 'b'",
&[],
)
.await
.unwrap()
.rows_affected(),
1
);
assert_eq!(
session
.execute(
"DELETE FROM parameter_insert_batch_probe WHERE id = 'a'",
&[],
)
.await
.unwrap()
.rows_affected(),
1
);
session
.create_checkpoint()
.await
.expect("sparse packed-base overlays should checkpoint");
let rows = session
.execute(
"SELECT id, value FROM parameter_insert_batch_probe ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows.rows()[0].get::<String>("id").unwrap(), "b");
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "updated-b");
}
#[tokio::test]
async fn large_ordered_parameter_insert_reuses_commit_delta_as_current_base() {
const ROW_COUNT: usize = 1_024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "ordered_packed_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
crate::transaction::take_ordered_packed_current_base_publications();
let sql = "INSERT INTO ordered_packed_insert_probe (id, value) VALUES ($1, $2)";
let statements = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text(format!("value-{row_index:04}")),
],
})
.collect::<Vec<_>>();
let affected = session
.execute_batch(&statements)
.await
.unwrap()
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(affected, ROW_COUNT as u64);
assert_eq!(
crate::transaction::take_ordered_packed_current_base_publications(),
1,
"the certified ordered batch must publish its commit delta directly as current state"
);
let rows = session
.execute(
"SELECT id, value FROM ordered_packed_insert_probe WHERE id IN ('0000', '1023') ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "value-0000");
assert_eq!(rows.rows()[1].get::<String>("value").unwrap(), "value-1023");
session
.create_checkpoint()
.await
.expect("ordered packed current base should remain checkpointable");
}
#[tokio::test]
async fn large_certified_insert_publishes_rooted_history_and_reads_packed_head() {
const ROW_COUNT: usize = 32 * 1_024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "rootless_ordered_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("rootless ordered schema should register");
let branch_id = session
.active_branch_id()
.await
.expect("active branch should resolve");
let baseline_read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("baseline read should open");
let baseline = session
.branch_ctx
.ref_reader(baseline_read)
.load_head(&branch_id)
.await
.expect("baseline head should load")
.expect("schema registration should publish a head");
let sql = "INSERT INTO rootless_ordered_insert_probe (id, value) VALUES ($1, $2)";
let inserts = (0..ROW_COUNT)
.map(|index| ExecuteBatchStatement {
label: None,
sql: sql.to_owned(),
params: vec![
Value::Text(format!("{index:05}")),
Value::Text(format!("value-{index:05}")),
],
})
.collect::<Vec<_>>();
let inserted = session
.execute_batch(&inserts)
.await
.expect("large certified parameter batch should insert")
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(inserted, ROW_COUNT as u64);
let rows = session
.execute(
"SELECT id, value FROM rootless_ordered_insert_probe ORDER BY id",
&[],
)
.await
.expect("packed current head should serve the rootless commit");
assert_eq!(rows.len(), ROW_COUNT);
assert_eq!(rows.rows()[0].get::<String>("id").unwrap(), "00000");
assert_eq!(
rows.rows()[ROW_COUNT - 1].get::<String>("id").unwrap(),
"32767"
);
let head_read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("head read should open");
let head = session
.branch_ctx
.ref_reader(head_read)
.load_head(&branch_id)
.await
.expect("branch head should load")
.expect("large insert should publish a head");
let history_read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("history read should open");
let commit_state =
crate::tracked_state::load_commit_state_manifest(&history_read, head.commit_id)
.await
.expect("head physical state should load")
.expect("head physical state should exist");
assert_eq!(commit_state.replay_debt, Default::default());
assert!(
crate::tracked_state::load_snapshot_commit_root(
&history_read,
&head.commit_id.to_string(),
)
.await
.expect("root lookup should succeed")
.is_some(),
"production commits must publish persistent root authority"
);
let diff = session
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('rootless_ordered_insert_probe', $1, $2) \
WHERE diff_type = 'added'",
&[
Value::Text(baseline.commit_id.to_string()),
Value::Text(head.commit_id.to_string()),
],
)
.await
.expect("rootless insert diff should replay from the ordered delta");
assert_eq!(
diff.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64
);
let history = session
.execute(
&format!(
"SELECT COUNT(*) AS entries \
FROM lix_history('rootless_ordered_insert_probe', '{}') \
WHERE diff_type <> 'removed'",
head.commit_id
),
&[],
)
.await
.expect("rootless insert history should replay from the ordered delta");
assert_eq!(
history.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64
);
session
.execute(
"UPDATE rootless_ordered_insert_probe SET value = 'updated' WHERE id = '00000'",
&[],
)
.await
.expect("a sparse descendant of a rootless commit should remain writable");
let descendant_read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("descendant read should open");
let descendant = session
.branch_ctx
.ref_reader(descendant_read)
.load_head(&branch_id)
.await
.expect("descendant head should load")
.expect("sparse update should publish a head");
let descendant_history_read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("descendant history read should open");
let descendant_state = crate::tracked_state::load_commit_state_manifest(
&descendant_history_read,
descendant.commit_id,
)
.await
.expect("descendant physical state should load")
.expect("descendant physical state should exist");
assert_eq!(descendant_state.replay_debt, Default::default());
assert!(
crate::tracked_state::load_snapshot_commit_root(
&descendant_history_read,
&descendant.commit_id.to_string(),
)
.await
.expect("descendant root lookup should succeed")
.is_some(),
"sparse descendants must retain persistent root authority"
);
let updated = session
.execute(
"SELECT value FROM rootless_ordered_insert_probe WHERE id = '00000'",
&[],
)
.await
.expect("rootless descendant should remain readable");
assert_eq!(updated.rows()[0].get::<String>("value").unwrap(), "updated");
let update_diff = session
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('rootless_ordered_insert_probe', $1, $2) \
WHERE diff_type = 'modified'",
&[
Value::Text(head.commit_id.to_string()),
Value::Text(descendant.commit_id.to_string()),
],
)
.await
.expect("rootless descendant diff should remain queryable");
assert_eq!(update_diff.rows()[0].get::<i64>("entries").unwrap(), 1);
let draft = session
.create_branch(crate::CreateBranchOptions {
id: Some("01930000-0000-7000-8000-00000000b001".to_owned()),
name: "rootless-ordered-draft".to_owned(),
from_commit_id: Some(descendant.commit_id.to_string()),
})
.await
.expect("a branch should start from a rootless history commit");
session
.switch_branch(crate::SwitchBranchOptions {
branch_id: draft.id.clone(),
})
.await
.expect("rootless draft should open");
session
.execute(
"UPDATE rootless_ordered_insert_probe SET value = 'draft' WHERE id = '00001'",
&[],
)
.await
.expect("rootless draft should remain writable");
session
.switch_branch(crate::SwitchBranchOptions {
branch_id: branch_id.clone(),
})
.await
.expect("repository should switch back to the rootless main branch");
let main_session = &session;
session
.execute(
"UPDATE rootless_ordered_insert_probe SET value = 'main' WHERE id = '32767'",
&[],
)
.await
.expect("rootless main branch should remain writable");
let merge = session
.merge_branch(crate::MergeBranchOptions {
source_branch_id: draft.id,
})
.await
.expect("disjoint changes descending from a rootless base should merge");
assert_eq!(merge.outcome, crate::MergeBranchOutcome::MergeCommitted);
let merged = main_session
.execute(
"SELECT id, value FROM rootless_ordered_insert_probe \
WHERE id IN ('00001', '32767') ORDER BY id",
&[],
)
.await
.expect("merged rootless head should remain readable");
assert_eq!(merged.rows()[0].get::<String>("value").unwrap(), "draft");
assert_eq!(merged.rows()[1].get::<String>("value").unwrap(), "main");
let deleted = main_session
.execute("DELETE FROM rootless_ordered_insert_probe", &[])
.await
.expect("the merged fixture should delete through the file cascade");
assert_eq!(deleted.rows_affected(), ROW_COUNT as u64);
let reinserts = (0..ROW_COUNT)
.map(|index| ExecuteBatchStatement {
label: None,
sql: sql.to_owned(),
params: vec![
Value::Text(format!("{index:05}")),
Value::Text("second-seed".to_owned()),
],
})
.collect::<Vec<_>>();
let reseeded = main_session
.execute_batch(&reinserts)
.await
.expect("a second large ordered insert should start a bounded interval")
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(reseeded, ROW_COUNT as u64);
let reseed_read = main_session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("reseed read should open");
let reseed_head = main_session
.branch_ctx
.ref_reader(&reseed_read)
.load_head(&branch_id)
.await
.expect("reseed head should load")
.expect("reseed head should exist");
let reseed_state =
crate::tracked_state::load_commit_state_manifest(&reseed_read, reseed_head.commit_id)
.await
.expect("reseed physical state should load")
.expect("reseed physical state should exist");
assert_eq!(reseed_state.replay_debt, Default::default());
let mut rooted_fence = None;
for generation_offset in 1..=32 {
main_session
.execute(
"UPDATE rootless_ordered_insert_probe SET value = $1 WHERE id = '00002'",
&[Value::Text(format!("fence-{generation_offset}"))],
)
.await
.expect("a bounded rootless descendant should commit");
let read = main_session
.storage
.begin_read(StorageReadOptions::default())
.await
.expect("root-fence read should open");
let head = main_session
.branch_ctx
.ref_reader(&read)
.load_head(&branch_id)
.await
.expect("root-fence head should load")
.expect("root-fence head should exist");
let state = crate::tracked_state::load_commit_state_manifest(&read, head.commit_id)
.await
.expect("root-fence physical state should load")
.expect("root-fence physical state should exist");
if state.replay_debt.depth == 0 {
assert_eq!(state.replay_debt.rows, 0);
assert_eq!(state.replay_debt.bytes, 0);
assert!(
crate::tracked_state::load_snapshot_commit_root(
&read,
&head.commit_id.to_string(),
)
.await
.expect("root-fence lookup should succeed")
.is_some(),
"a rooted fence must publish its immutable accelerator"
);
rooted_fence = Some(head.commit_id);
break;
}
}
assert!(
rooted_fence.is_some(),
"every generation must retain a persistent root"
);
let rooted_fence = rooted_fence.unwrap();
let rebuilt_rows = main_session
.execute(
"SELECT id, value FROM rootless_ordered_insert_probe \
WHERE id IN ('00000', '00001', '00002', '32767') ORDER BY id",
&[],
)
.await
.expect("rebuilt root fence should serve representative rows");
assert_eq!(
rebuilt_rows.rows()[0].get::<String>("value").unwrap(),
"second-seed"
);
assert_eq!(
rebuilt_rows.rows()[1].get::<String>("value").unwrap(),
"second-seed"
);
assert!(
rebuilt_rows.rows()[2]
.get::<String>("value")
.unwrap()
.starts_with("fence-")
);
assert_eq!(
rebuilt_rows.rows()[3].get::<String>("value").unwrap(),
"second-seed"
);
let fence_diff = main_session
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('rootless_ordered_insert_probe', $1, $2) \
WHERE diff_type = 'modified'",
&[
Value::Text(reseed_head.commit_id.to_string()),
Value::Text(rooted_fence.to_string()),
],
)
.await
.expect("diff should cross the rebuilt root fence");
assert_eq!(fence_diff.rows()[0].get::<i64>("entries").unwrap(), 1);
let fence_history = main_session
.execute(
&format!(
"SELECT COUNT(DISTINCT id) AS entries \
FROM lix_history('rootless_ordered_insert_probe', '{rooted_fence}') \
WHERE id IN ('00000', '00001', '00002', '32767') \
AND diff_type <> 'removed'"
),
&[],
)
.await
.expect("history should cross the rebuilt root fence");
assert_eq!(fence_history.rows()[0].get::<i64>("entries").unwrap(), 4);
let removed_history = main_session
.execute(
&format!("SELECT COUNT(*) AS entries FROM lix_history('rootless_ordered_insert_probe', '{rooted_fence}') WHERE id IN ('00000', '00001', '00002', '32767') AND diff_type = 'removed'"),
&[],
)
.await
.expect("collection generation deletion retains each endpoint removal");
assert_eq!(removed_history.rows()[0].get::<i64>("entries").unwrap(), 4);
let merge_history = main_session
.execute(
&format!(
"SELECT COUNT(*) AS entries \
FROM lix_history('rootless_ordered_insert_probe', '{rooted_fence}') \
WHERE (id = '00001' AND to_value = 'draft') \
OR (id = '32767' AND to_value = 'main')"
),
&[],
)
.await
.expect("merge-selected revisions should survive both root fences");
assert_eq!(merge_history.rows()[0].get::<i64>("entries").unwrap(), 2);
}
#[tokio::test]
async fn successive_columnar_inserts_preserve_the_existing_schema_base() {
const BATCH_ROWS: usize = 1_024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "successive_columnar_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("successive insert schema should register");
let sql = "INSERT INTO successive_columnar_insert_probe (id, value) VALUES ($1, $2)";
for generation in 0..2 {
let first = generation * BATCH_ROWS;
let inserts = (first..first + BATCH_ROWS)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: sql.to_owned(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text(format!("generation-{generation}")),
],
})
.collect::<Vec<_>>();
let inserted = session
.execute_batch(&inserts)
.await
.expect("each ordered insert generation should commit")
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(inserted, BATCH_ROWS as u64);
}
let rows = session
.execute(
"SELECT id, value FROM successive_columnar_insert_probe ORDER BY id",
&[],
)
.await
.expect("the second generation must retain the first columnar base");
assert_eq!(rows.len(), BATCH_ROWS * 2);
assert_eq!(rows.rows()[0].get::<String>("id").unwrap(), "0000");
assert_eq!(
rows.rows()[0].get::<String>("value").unwrap(),
"generation-0"
);
assert_eq!(rows.rows()[BATCH_ROWS].get::<String>("id").unwrap(), "1024");
assert_eq!(
rows.rows()[BATCH_ROWS].get::<String>("value").unwrap(),
"generation-1"
);
}
#[tokio::test]
async fn typed_packed_base_preserves_current_diff_and_history_across_lifecycle_changes() {
const ROW_COUNT: usize = 65_537;
let storage = Memory::default();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let engine = Engine::new(storage.clone())
.await
.expect("initialized storage should create engine");
let main = engine
.open_session_with_account(crate::SYSTEM_ACCOUNT_ID)
.await
.expect("session should open");
let main_branch_id = main
.active_branch_id()
.await
.expect("repository branch should resolve");
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "columnar_lifecycle_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
main.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("typed lifecycle schema should register");
let before_insert = engine
.load_branch_head_commit_id(&main_branch_id)
.await
.expect("pre-insert head should load")
.expect("pre-insert head should exist");
crate::transaction::take_ordered_packed_current_base_publications();
crate::transaction::take_certified_columnar_current_base_publications();
let insert_sql = "INSERT INTO columnar_lifecycle_probe (id, value) VALUES ($1, $2)";
let inserts = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: insert_sql.to_owned(),
params: vec![
Value::Text(format!("{row_index:05}")),
Value::Text(format!("base-{row_index:04}")),
],
})
.collect::<Vec<_>>();
let inserted = main
.execute_batch(&inserts)
.await
.expect("ordered typed batch should insert")
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(inserted, ROW_COUNT as u64);
assert_eq!(
crate::transaction::take_ordered_packed_current_base_publications(),
1,
"fixture must activate packed current-state publication"
);
assert_eq!(
crate::transaction::take_certified_columnar_current_base_publications(),
1,
"the certified columnar payload must replace the packed mutation payload"
);
let inserted_head = engine
.load_branch_head_commit_id(&main_branch_id)
.await
.expect("insert head should load")
.expect("insert head should exist");
let adapter = engine.storage();
let read = adapter
.begin_read(StorageReadOptions::default())
.await
.expect("native layout read scope should open");
let row_group_id = crate::hot_state::row_group_set_id(
crate::changelog::CommitId::parse_lix(&inserted_head, "typed lifecycle insert head")
.expect("insert head should be canonical"),
"columnar_lifecycle_probe",
);
let manifest = crate::columnar_row_group::load_row_group_manifest(&read, row_group_id)
.await
.expect("typed lifecycle sidecar lookup should succeed");
assert!(
manifest.is_some(),
"the certified row-group payload must exist"
);
let commit_state = crate::tracked_state::load_commit_state_manifest(
&read,
crate::changelog::CommitId::parse_lix(
&inserted_head,
"typed lifecycle mutation authority",
)
.expect("insert head should be canonical"),
)
.await
.expect("typed mutation authority should load")
.expect("typed mutation authority should exist");
assert!(commit_state.mutations.columnar_parts.is_some());
let semantic_commit_ids = [commit_state.commit_id];
let commit_records = ChangelogContext::new()
.reader(&read)
.load_commits(CommitLoadRequest {
commit_ids: &semantic_commit_ids,
})
.await
.expect("typed lifecycle semantic owner should load");
let semantic_owner = commit_records
.into_iter()
.next()
.and_then(|(_, record)| record)
.expect("typed lifecycle semantic owner should exist");
assert_eq!(semantic_owner.account_id, crate::SYSTEM_ACCOUNT_ID);
assert!(commit_state.mutations.inline_part.is_empty());
assert!(
commit_state.mutations.parts.is_empty(),
"the columnar authority must replace packed mutation parts"
);
assert!(
commit_state.current_state_scoped_ranges.is_some(),
"the certified columnar generation must serve current state directly"
);
drop(read);
assert_typed_lifecycle_current(&main, ROW_COUNT, "base-0000", "base-1023").await;
let boundary_current = main
.execute(
"SELECT id FROM columnar_lifecycle_probe \
WHERE id IN ('02047', '02048', '65535', '65536', '70000') ORDER BY id",
&[],
)
.await
.expect("native packed-base point reads should remain queryable");
assert_eq!(
boundary_current
.rows()
.iter()
.map(|row| row.get::<String>("id").unwrap())
.collect::<Vec<_>>(),
vec!["02047", "02048", "65535", "65536"]
);
let inserted_diff = main
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('columnar_lifecycle_probe', $1, $2) \
WHERE diff_type = 'added'",
&[
Value::Text(before_insert.to_string()),
Value::Text(inserted_head.to_string()),
],
)
.await
.expect("packed insert diff should remain queryable");
assert_eq!(
inserted_diff.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64
);
let inserted_history = main
.execute(
&format!(
"SELECT COUNT(*) AS entries \
FROM lix_history('columnar_lifecycle_probe', '{inserted_head}') \
WHERE diff_type <> 'removed'"
),
&[],
)
.await
.expect("packed insert history should remain queryable");
assert_eq!(
inserted_history.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64
);
let attributed_changes = main
.execute(
"SELECT COUNT(*) AS entries FROM lix_change \
WHERE schema_key = 'columnar_lifecycle_probe' AND account_id = $1",
&[Value::Text(crate::SYSTEM_ACCOUNT_ID.to_owned())],
)
.await
.expect("native mutation history must retain commit account attribution");
assert_eq!(
attributed_changes.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64
);
let boundary_history = main
.execute(
&format!(
"SELECT COUNT(DISTINCT id) AS entries \
FROM lix_history('columnar_lifecycle_probe', '{inserted_head}') \
WHERE id IN ('02047', '02048', '65535', '65536') \
AND diff_type <> 'removed'"
),
&[],
)
.await
.expect("history must address representative boundary identities");
assert_eq!(boundary_history.rows()[0].get::<i64>("entries").unwrap(), 4);
main.execute(
"UPDATE columnar_lifecycle_probe SET value = 'sparse-0512' WHERE id = '00512'",
&[],
)
.await
.expect("sparse typed update should commit");
let limited = main
.execute(
"SELECT id, value FROM columnar_lifecycle_probe ORDER BY id LIMIT 3",
&[],
)
.await
.expect("DataFusion LIMIT should remain above the native scan");
assert_eq!(limited.len(), 3);
assert_eq!(limited.rows()[0].get::<String>("id").unwrap(), "00000");
assert_eq!(limited.rows()[2].get::<String>("id").unwrap(), "00002");
let zero = main
.execute(
"SELECT id FROM columnar_lifecycle_probe ORDER BY id LIMIT 0",
&[],
)
.await
.expect("zero LIMIT should retain DataFusion semantics");
assert!(zero.is_empty());
let overlay_match = main
.execute(
"SELECT id FROM columnar_lifecycle_probe \
WHERE value = 'sparse-0512' LIMIT 1",
&[],
)
.await
.expect("filtered native scan should retain matching overlay winner");
assert_eq!(overlay_match.len(), 1);
assert_eq!(
overlay_match.rows()[0].get::<String>("id").unwrap(),
"00512"
);
let no_match = main
.execute(
"SELECT id FROM columnar_lifecycle_probe \
WHERE value = 'not-present' LIMIT 1",
&[],
)
.await
.expect("filtered native scan should return an exact empty result");
assert!(no_match.is_empty());
main.execute(
"UPDATE columnar_lifecycle_probe SET value = 'base-0512' WHERE id = '00512'",
&[],
)
.await
.expect("sparse typed restoration should commit");
let checkpoint = main
.create_checkpoint()
.await
.expect("packed typed base should checkpoint");
let draft = main
.create_branch(crate::CreateBranchOptions {
id: Some("01930000-0000-7000-8000-0000000000c1".to_owned()),
name: "columnar-lifecycle-draft".to_owned(),
from_commit_id: Some(checkpoint.commit_id.clone()),
})
.await
.expect("checkpoint branch should create");
let draft_session = engine
.open_session_at(draft.id.clone())
.await
.expect("draft session should open");
draft_session
.execute(
"UPDATE columnar_lifecycle_probe SET value = 'draft-0000' WHERE id = '00000'",
&[],
)
.await
.expect("draft update should commit");
draft_session
.execute("SELECT commit_id FROM lix_undo()", &[])
.await
.expect("draft update should undo");
assert_typed_lifecycle_current(&draft_session, ROW_COUNT, "base-0000", "base-1023").await;
draft_session
.execute("SELECT commit_id FROM lix_redo()", &[])
.await
.expect("draft update should redo");
main.execute(
"UPDATE columnar_lifecycle_probe SET value = 'main-1023' WHERE id = '01023'",
&[],
)
.await
.expect("main update should commit");
let merge = main
.merge_branch(crate::MergeBranchOptions {
source_branch_id: draft.id,
})
.await
.expect("disjoint typed updates should merge");
assert_eq!(merge.outcome, crate::MergeBranchOutcome::MergeCommitted);
assert_typed_lifecycle_current(&main, ROW_COUNT, "draft-0000", "main-1023").await;
let merged_head = engine
.load_branch_head_commit_id(&main_branch_id)
.await
.expect("merged head should load")
.expect("merged head should exist");
let merged_diff = main
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('columnar_lifecycle_probe', $1, $2) \
WHERE diff_type = 'modified'",
&[
Value::Text(checkpoint.commit_id.to_string()),
Value::Text(merged_head.to_string()),
],
)
.await
.expect("merged lifecycle diff should remain queryable");
assert_eq!(merged_diff.rows()[0].get::<i64>("entries").unwrap(), 2);
let merged_history = main
.execute(
&format!(
"SELECT to_value AS value, lixcol_position \
FROM lix_history('columnar_lifecycle_probe', '{merged_head}') \
WHERE id = '00000' ORDER BY lixcol_position"
),
&[],
)
.await
.expect("merged typed history should remain queryable");
assert_eq!(
merged_history.rows()[0].get::<String>("value").unwrap(),
"draft-0000"
);
assert!(
merged_history
.rows()
.iter()
.any(|row| row.get::<String>("value").ok().as_deref() == Some("base-0000"))
);
main.execute(
"UPDATE columnar_lifecycle_probe SET value = 'temporary' WHERE id = '00512'",
&[],
)
.await
.expect("post-merge update should commit");
main.execute("SELECT commit_id FROM lix_undo()", &[])
.await
.expect("post-merge update should undo");
let restored = main
.execute(
"SELECT value FROM columnar_lifecycle_probe WHERE id = '00512'",
&[],
)
.await
.expect("undone row should remain queryable");
assert_eq!(
restored.rows()[0].get::<String>("value").unwrap(),
"base-0512"
);
assert_typed_lifecycle_current(&main, ROW_COUNT, "draft-0000", "main-1023").await;
}
#[tokio::test]
async fn large_ordered_parameter_update_replaces_complete_packed_current_base() {
const ROW_COUNT: usize = 2_048;
const PARTIAL_ROW_COUNT: usize = ROW_COUNT / 2;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "ordered_packed_update_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let insert_sql =
"INSERT INTO ordered_packed_update_probe (path, value) VALUES ($1, CAST($2 AS JSONB))";
let insert_statements = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: insert_sql.to_string(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text(format!("\"value-{row_index:04}\"")),
],
})
.collect::<Vec<_>>();
session.execute_batch(&insert_statements).await.unwrap();
crate::transaction::take_complete_replacement_packed_current_base_retirements();
let update_sql =
"UPDATE ordered_packed_update_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
for version in 1..=2 {
let update_statements = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(format!("\"updated-{version}-{row_index:04}\"")),
Value::Text(format!("{row_index:04}")),
],
})
.collect::<Vec<_>>();
let affected = session
.execute_batch(&update_statements)
.await
.unwrap()
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(affected, ROW_COUNT as u64);
assert_eq!(
crate::transaction::take_complete_replacement_packed_current_base_retirements(),
1,
"each complete certified replacement should swap one packed base reference"
);
assert_current_head_uses_packed_delta_without_columnar_sidecar(
&session,
"ordered_packed_update_probe",
ROW_COUNT as u64,
)
.await;
}
let rows = session
.execute(
"SELECT path, value FROM ordered_packed_update_probe WHERE path IN ('0000', '2047') ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(
rows.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("updated-2-0000")
);
assert_eq!(
rows.rows()[1].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("updated-2-2047")
);
let working_diff = session
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('ordered_packed_update_probe', lix_root_commit_id(), lix_active_branch_commit_id()) \
WHERE diff_type = 'added'",
&[],
)
.await
.unwrap();
assert_eq!(
working_diff.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64,
"replacing a packed base must preserve its working-diff epoch"
);
let partial_update_statements = (0..PARTIAL_ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(format!("\"partial-{row_index:04}\"")),
Value::Text(format!("{row_index:04}")),
],
})
.collect::<Vec<_>>();
let partial_affected = session
.execute_batch(&partial_update_statements)
.await
.unwrap()
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(partial_affected, PARTIAL_ROW_COUNT as u64);
assert_eq!(
crate::transaction::take_complete_replacement_packed_current_base_retirements(),
0,
"a partial replacement must remain a point-addressable HOT overlay"
);
let partial = session
.execute(
"SELECT value FROM ordered_packed_update_probe WHERE path = '0000'",
&[],
)
.await
.unwrap();
assert_eq!(
partial.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("partial-0000")
);
let checkpoint = session
.create_checkpoint()
.await
.expect("replaced packed current base should remain checkpointable");
let working_diff = session
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('ordered_packed_update_probe', $1, lix_active_branch_commit_id())",
&[Value::Text(checkpoint.commit_id)],
)
.await
.unwrap();
assert_eq!(
working_diff.rows()[0].get::<i64>("entries").unwrap(),
0,
"checkpointing a replaced packed base must clear its working diff"
);
}
#[tokio::test]
async fn packed_replacement_over_hot_before_resolves_compact_working_diff() {
const ROW_COUNT: usize = 512;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "packed_replacement_working_diff_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let insert_statements = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: "INSERT INTO packed_replacement_working_diff_probe (path, value) VALUES ($1, $2)"
.to_owned(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text(format!("base-{row_index:04}")),
],
})
.collect::<Vec<_>>();
session.execute_batch(&insert_statements).await.unwrap();
session.create_checkpoint().await.unwrap();
session
.execute(
"UPDATE packed_replacement_working_diff_probe SET value = 'hot' WHERE path = '0000'",
&[],
)
.await
.unwrap();
let replacement_statements = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: "UPDATE packed_replacement_working_diff_probe SET value = $1 WHERE path = $2"
.to_owned(),
params: vec![
Value::Text(format!("packed-{row_index:04}")),
Value::Text(format!("{row_index:04}")),
],
})
.collect::<Vec<_>>();
session
.execute_batch(&replacement_statements)
.await
.unwrap();
assert_current_head_uses_packed_delta_without_columnar_sidecar(
&session,
"packed_replacement_working_diff_probe",
ROW_COUNT as u64,
)
.await;
let branch_id = session.active_branch_id().await.unwrap();
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let control = crate::branch::BranchHeadControlContext::new()
.reader(&read)
.load(&branch_id)
.await
.unwrap()
.unwrap();
drop(read);
let checkpoint_commit_id = control
.working_diff_checkpoint_commit_id
.expect("working checkpoint should be active")
.to_string();
let head_commit_id = control.head_commit_id.to_string();
crate::tracked_state::arm_diff_commits_test_probe(&checkpoint_commit_id, &head_commit_id);
let diff = session
.execute(
"SELECT count(*) AS count \
FROM lix_diff('packed_replacement_working_diff_probe', $1, $2) \
WHERE diff_type = 'modified'",
&[
Value::Text(checkpoint_commit_id.clone()),
Value::Text(head_commit_id.clone()),
],
)
.await
.expect("ambiguous packed payload comparison should resolve locally");
assert_eq!(
diff.rows()[0].get::<i64>("count").unwrap(),
ROW_COUNT as i64
);
assert_eq!(
crate::tracked_state::take_diff_commits_test_probe(
&checkpoint_commit_id,
&head_commit_id,
),
0,
"packed replacements over clean HOT rows must remain local",
);
}
#[tokio::test]
async fn ordered_batch_update_preserves_non_uniform_lifecycle_without_journal_admission() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "non_uniform_journal_admission_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
for path in ["a", "b"] {
session
.execute(
"INSERT INTO non_uniform_journal_admission_probe (path, value) VALUES ($1, $2)",
&[
Value::Text(path.to_string()),
Value::Text("base".to_string()),
],
)
.await
.unwrap();
}
let before = session
.execute(
"SELECT path, lixcol_created_at FROM non_uniform_journal_admission_probe ORDER BY path",
&[],
)
.await
.unwrap();
let update_sql =
"UPDATE non_uniform_journal_admission_probe SET value = $1 WHERE path = $2";
for version in ["first", "second"] {
session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(version.to_string()),
Value::Text("a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(version.to_string()),
Value::Text("b".to_string()),
],
},
])
.await
.unwrap();
}
let after = session
.execute(
"SELECT path, lixcol_created_at FROM non_uniform_journal_admission_probe ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(before.rows(), after.rows());
}
#[tokio::test]
async fn expdl_dense_scale_untracked_parameter_batch_stays_untracked() {
const ROW_COUNT: usize = 32 * 1024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "expdl_dense_untracked_lane_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema \
(value, lixcol_global, lixcol_untracked) \
VALUES (CAST($1 AS JSONB), false, true)",
&[Value::Text(schema.to_string())],
)
.await
.expect("untracked schema registration should succeed");
let sql = "INSERT INTO expdl_dense_untracked_lane_probe \
(path, value, lixcol_untracked) VALUES ($1, CAST($2 AS JSONB), TRUE)";
let statements = (0..ROW_COUNT)
.map(|index| ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(format!("/p-{index:05}")),
Value::Text(format!("\"v-{index:05}\"")),
],
})
.collect::<Vec<_>>();
session
.execute_batch(&statements)
.await
.expect("dense-scale untracked parameter batch should commit");
let totals = session
.execute(
"SELECT COUNT(*) AS entries FROM expdl_dense_untracked_lane_probe",
&[],
)
.await
.expect("probe rows should read");
assert_eq!(
totals.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64
);
let lanes = session
.execute(
"SELECT COUNT(*) AS entries FROM expdl_dense_untracked_lane_probe \
WHERE lixcol_untracked",
&[],
)
.await
.expect("probe lanes should read");
assert_eq!(
lanes.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64,
"every row of an untracked batch must stay in the untracked lane"
);
let commits = session
.execute(
"SELECT COUNT(*) AS entries FROM expdl_dense_untracked_lane_probe \
WHERE lixcol_commit_id IS NOT NULL",
&[],
)
.await
.expect("probe commit ids should read");
assert_eq!(
commits.rows()[0].get::<i64>("entries").unwrap(),
0,
"untracked rows carry no commit id"
);
}
#[tokio::test]
async fn certified_parameter_batch_revalidates_after_staged_schema_amendment() {
const ROW_COUNT: usize = 32 * 1024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "amended_parameter_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let amended_schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "amended_parameter_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
{ "name": "source", "type": "text", "nullable": false, "default_value": "amended-plan" },
],
"primary_key": ["id"],
});
crate::transaction::take_direct_journal_replacement_publications(
"amended_parameter_insert_probe",
);
let mut transaction = session.begin_transaction().await.unwrap();
transaction
.execute(
"UPDATE lix_registered_schema SET value = $1 \
WHERE schema_key = 'amended_parameter_insert_probe'",
&[Value::Jsonb(amended_schema.into())],
)
.await
.expect("compatible schema amendment should stage");
let sql = "INSERT INTO amended_parameter_insert_probe (id, value) VALUES ($1, $2)";
let statements = (0..ROW_COUNT)
.map(|index| ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(format!("row-{index:05}")),
Value::Text(format!("value-{index:05}")),
],
})
.collect::<Vec<_>>();
let parsed = TransactionBatchStatements::Shared {
statement: sql2::parse_statement(sql).unwrap(),
len: statements.len(),
};
let staged = try_execute_transaction_parameter_batch(
transaction.transaction_mut().unwrap(),
&statements,
&parsed,
&ExecuteOptions::default(),
&vec![ExecuteStatementMetadata::default(); statements.len()],
)
.await
.expect("parameter batch should be revalidated");
assert!(
staged.is_some(),
"the SQL batch should still use its typed parameter route"
);
transaction
.commit()
.await
.expect("rows valid under the amended schema should commit");
let rows = session
.execute(
"SELECT COUNT(*) AS entries FROM amended_parameter_insert_probe \
WHERE source = 'amended-plan'",
&[],
)
.await
.unwrap();
assert_eq!(
rows.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64,
"transaction normalization must apply the staged schema's default"
);
}
#[tokio::test]
async fn execute_batch_commits_rows_from_multiple_typed_schemas() {
let session = open_session().await;
let first_schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "mixed_batch_first",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
let second_schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "mixed_batch_second",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
{ "name": "enabled", "type": "boolean", "nullable": false },
],
"primary_key": ["id"],
});
for schema in [first_schema, second_schema] {
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("register typed schema");
}
session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: "INSERT INTO mixed_batch_first (id, value) VALUES ($1, $2)".to_owned(),
params: vec![
Value::Text("first".to_owned()),
Value::Text("one".to_owned()),
],
},
ExecuteBatchStatement {
label: None,
sql: "INSERT INTO mixed_batch_second (id, value, enabled) VALUES ($1, $2, $3)"
.to_owned(),
params: vec![
Value::Text("second".to_owned()),
Value::Text("two".to_owned()),
Value::Boolean(true),
],
},
])
.await
.expect("one logical batch may atomically commit several typed schemas");
for table in ["mixed_batch_first", "mixed_batch_second"] {
let result = session
.execute(&format!("SELECT COUNT(*) AS count FROM {table}"), &[])
.await
.expect("read committed mixed-schema row");
assert_eq!(result.rows()[0].get::<i64>("count").unwrap(), 1);
}
}
#[tokio::test]
async fn certified_replacement_batch_revalidates_after_staged_schema_amendment() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "amended_parameter_update_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO amended_parameter_update_probe (path, value) VALUES ('a', CAST('\"old-a\"' AS JSONB)), ('b', CAST('\"old-b\"' AS JSONB))",
&[],
)
.await
.unwrap();
let amended_schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "amended_parameter_update_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
{ "name": "source", "type": "text", "nullable": true, "default_value": "amended-plan" },
],
"primary_key": ["path"],
});
let mut transaction = session.begin_transaction().await.unwrap();
transaction
.execute(
"UPDATE lix_registered_schema SET value = $1 \
WHERE schema_key = 'amended_parameter_update_probe'",
&[Value::Jsonb(amended_schema.into())],
)
.await
.expect("compatible schema amendment should stage");
let sql =
"UPDATE amended_parameter_update_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
let statements = [
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("\"updated-a\"".to_string()),
Value::Text("a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("\"updated-b\"".to_string()),
Value::Text("b".to_string()),
],
},
];
let parsed = TransactionBatchStatements::Shared {
statement: sql2::parse_statement(sql).unwrap(),
len: statements.len(),
};
sql2::take_certified_replacement_parameter_batch_executions();
let staged = try_execute_transaction_parameter_batch(
transaction.transaction_mut().unwrap(),
&statements,
&parsed,
&ExecuteOptions::default(),
&vec![ExecuteStatementMetadata::default(); statements.len()],
)
.await
.expect("replacement batch should be revalidated");
assert!(
staged.is_some(),
"the UPDATE batch should retain its typed parameter route"
);
assert_eq!(
sql2::take_certified_replacement_parameter_batch_executions(),
1,
"the UPDATE batch must reach the certified replacement subroute"
);
transaction.commit().await.unwrap();
let rows = session
.execute(
"SELECT path, value, source FROM amended_parameter_update_probe ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(
rows.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("updated-a")
);
assert_eq!(
rows.rows()[1].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("updated-b")
);
assert!(
rows.rows()
.iter()
.all(|row| row.get::<String>("source").unwrap() == "amended-plan"),
"replacement normalization must apply the staged schema's default"
);
}
#[tokio::test]
async fn certified_replacement_preserves_generated_defaults_after_staged_amendment() {
let session = open_session().await;
let mut schema = serde_json::json!({
"$schema":"https://lix.dev/schema-v1.json", "key":"generated_replacement_probe",
"columns":[{"name":"path","type":"text","nullable":false},{"name":"value","type":"jsonb","nullable":false}],
"primary_key":["path"]
});
session
.execute(
"INSERT INTO lix_registered_schema (value) VALUES ($1)",
&[Value::Jsonb(schema.clone().into())],
)
.await
.unwrap();
session.execute("INSERT INTO generated_replacement_probe (path,value) VALUES ('a',CAST('1' AS JSONB)),('b',CAST('2' AS JSONB))", &[]).await.unwrap();
schema["columns"].as_array_mut().unwrap().push(serde_json::json!({"name":"generated_id","type":"uuid","nullable":false,"default_expression":"uuidv7()"}));
let mut transaction = session.begin_transaction().await.unwrap();
transaction.execute("UPDATE lix_registered_schema SET value=$1 WHERE schema_key='generated_replacement_probe'", &[Value::Jsonb(schema.into())]).await.unwrap();
let mut snapshots = Vec::new();
for stage in 0..3 {
if stage == 1 {
let sql =
"UPDATE generated_replacement_probe SET value=CAST($1 AS JSONB) WHERE path=$2";
let statements = ["a", "b"].map(|path| ExecuteBatchStatement {
label: None,
sql: sql.into(),
params: vec![Value::Text("3".into()), Value::Text(path.into())],
});
let parsed = TransactionBatchStatements::Shared {
statement: sql2::parse_statement(sql).unwrap(),
len: statements.len(),
};
let result = try_execute_transaction_parameter_batch(
transaction.transaction_mut().unwrap(),
&statements,
&parsed,
&ExecuteOptions::default(),
&vec![ExecuteStatementMetadata::default(); statements.len()],
)
.await
.unwrap();
if result.is_none() {
for statement in &statements {
transaction
.execute(&statement.sql, &statement.params)
.await
.unwrap();
}
}
}
if stage == 2 {
let returned = transaction.execute("UPDATE generated_replacement_probe SET value=CAST('4' AS JSONB) RETURNING path", &[]).await.unwrap();
assert_eq!(returned.len(), 2);
}
let context = transaction.transaction_mut().unwrap();
let branch_id = context.active_branch_id().to_owned();
let rows = SqlWriteExecutionContext::scan_hot_state_batch(
context,
&crate::hot_state::HotStateScanRequest {
filter: crate::hot_state::HotStateFilter {
schema_keys: vec!["generated_replacement_probe".into()],
branch_ids: vec![branch_id],
..Default::default()
},
projection: crate::hot_state::HotStateProjection {
columns: vec!["snapshot_content".into()],
},
..Default::default()
},
)
.await
.unwrap();
let mut generated = rows
.iter()
.map(|row| {
let typed = row.materialize_decoded_snapshot().unwrap().unwrap();
let Some(lix_schema::Value::Uuid(value)) = typed.row.get("generated_id") else {
panic!("amendment must materialize UUID before the UPDATE")
};
(
row.row_pk().as_single_string().unwrap().to_owned(),
value.to_string(),
)
})
.collect::<Vec<_>>();
generated.sort();
assert_eq!(generated.len(), 2);
snapshots.push(generated);
}
assert_eq!(
snapshots[0], snapshots[1],
"updating an opening-schema field must retain already-materialized generated values"
);
assert_eq!(
snapshots[0], snapshots[2],
"generic UPDATE RETURNING must retain generated values too"
);
transaction.commit().await.unwrap();
let rows = session
.execute(
"SELECT path,generated_id,value FROM generated_replacement_probe ORDER BY path",
&[],
)
.await
.unwrap();
for (row, (path, generated)) in rows.rows().iter().zip(&snapshots[0]) {
assert_eq!(row.get::<String>("path").unwrap(), *path);
assert_eq!(row.get::<String>("generated_id").unwrap(), *generated);
assert_eq!(
row.get::<serde_json::Value>("value").unwrap(),
serde_json::json!(4)
);
}
}
#[tokio::test]
async fn certified_batch_reconciles_concurrent_insert_at_commit_snapshot() {
let storage = Memory::default();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let engine = Engine::new(storage)
.await
.expect("initialized storage should create engine");
let setup = engine.open_session().await.unwrap();
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "concurrent_parameter_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
setup
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let first = engine.open_session().await.unwrap();
let second = engine.open_session().await.unwrap();
let sql = "INSERT INTO concurrent_parameter_insert_probe (id, value) VALUES ($1, $2)";
let first_statements = [
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("first-only".to_string()),
Value::Text("first".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("shared".to_string()),
Value::Text("first".to_string()),
],
},
];
let parsed = TransactionBatchStatements::Shared {
statement: sql2::parse_statement(sql).unwrap(),
len: first_statements.len(),
};
let mut first_transaction = first.begin_transaction().await.unwrap();
let staged = try_execute_transaction_parameter_batch(
first_transaction.transaction_mut().unwrap(),
&first_statements,
&parsed,
&ExecuteOptions::default(),
&vec![ExecuteStatementMetadata::default(); first_statements.len()],
)
.await
.unwrap();
assert!(
staged.is_some(),
"first batch should take the certified route"
);
second
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("second-only".to_string()),
Value::Text("second".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("shared".to_string()),
Value::Text("second".to_string()),
],
},
])
.await
.expect("concurrent batch should commit first");
first_transaction
.commit()
.await
.expect("same-identity concurrent inserts use row-existence LWW");
let rows = second
.execute(
"SELECT value FROM concurrent_parameter_insert_probe WHERE id = 'shared'",
&[],
)
.await
.unwrap();
assert!(matches!(
rows.rows()[0].get::<String>("value").unwrap().as_str(),
"first" | "second"
));
}
#[tokio::test]
async fn consecutive_parameter_batches_preserve_local_conflict_statement_index() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "consecutive_parameter_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO consecutive_parameter_insert_probe (id, value) VALUES ('z', 'old')",
&[],
)
.await
.unwrap();
let sql = "INSERT INTO consecutive_parameter_insert_probe (id, value) VALUES ($1, $2)";
let first_statements = [
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Text("first-a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Text("first-b".to_string()),
],
},
];
let second_statements = [
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("c".to_string()),
Value::Text("second-c".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("z".to_string()),
Value::Text("duplicate-z".to_string()),
],
},
];
let mut transaction = session.begin_transaction().await.unwrap();
let mut conflict = None;
for (batch_index, statements) in [&first_statements[..], &second_statements[..]]
.into_iter()
.enumerate()
{
let parsed = TransactionBatchStatements::Shared {
statement: sql2::parse_statement(sql).unwrap(),
len: statements.len(),
};
let staged = try_execute_transaction_parameter_batch(
transaction.transaction_mut().unwrap(),
statements,
&parsed,
&ExecuteOptions::default(),
&vec![ExecuteStatementMetadata::default(); statements.len()],
)
.await;
if batch_index == 0 {
assert!(
staged.unwrap().is_some(),
"the first batch should take the certified route"
);
} else {
conflict =
Some(staged.expect_err(
"the second row in the second batch conflicts with committed z",
));
}
}
let error = conflict.expect("the second batch must report its committed conflict");
assert_eq!(error.code, LixError::CODE_UNIQUE);
assert_eq!(error.details.unwrap()["statementIndex"], 1);
drop(transaction);
let rows = session
.execute(
"SELECT id FROM consecutive_parameter_insert_probe ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), 1, "both staged batches must roll back");
assert_eq!(rows.rows()[0].get::<String>("id").unwrap(), "z");
}
#[tokio::test]
async fn typed_insert_batches_match_individual_inserts_without_json_roundtrips() {
let cases = [
(
serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json", "key": "native_batch_probe",
"columns": [
{"name": "id", "type": "text", "nullable": false},
{"name": "text", "type": "text", "nullable": true},
{"name": "enabled", "type": "boolean", "nullable": false},
{"name": "uuid", "type": "uuid", "nullable": false},
{"name": "omitted", "type": "int8", "nullable": true}
], "primary_key": ["id"]
}),
"INSERT INTO native_batch_probe (uuid, text, enabled, id) VALUES ($1, $2, $3, $4)",
vec![
vec![Value::Text("550E8400-E29B-41D4-A716-446655440000".into()), Value::Text("quote\" slash\\ newline\n emoji 🦀".into()), Value::Boolean(true), Value::Text("a".into())],
vec![Value::Text("550e8400-e29b-41d4-a716-446655440001".into()), Value::Null, Value::Boolean(false), Value::Text("b".into())],
],
"SELECT id, text, enabled, uuid, omitted FROM native_batch_probe ORDER BY id",
),
(
serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json", "key": "native_batch_probe",
"columns": [
{"name": "id", "type": "text", "nullable": false},
{"name": "text", "type": "text", "nullable": false},
{"name": "at", "type": "timestamptz", "nullable": false},
{"name": "omitted", "type": "boolean", "nullable": true}
], "primary_key": ["id"]
}),
"INSERT INTO native_batch_probe (id, text, at) VALUES ($1, $2, $3)",
vec![
vec![Value::Text("a".into()), Value::Text("\t🦀".into()), Value::Text("2026-01-02T03:04:05.123456+02:00".into())],
vec![Value::Text("b".into()), Value::Text("\"\\".into()), Value::Text("2026-01-02T01:04:05.123456Z".into())],
],
"SELECT id, text, at, omitted FROM native_batch_probe ORDER BY id",
),
(
serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json", "key": "native_batch_probe",
"columns": [
{"name": "path", "type": "text", "nullable": false},
{"name": "value", "type": "jsonb", "nullable": false}
], "primary_key": ["path"]
}),
"INSERT INTO native_batch_probe (path, value) VALUES ($1, CAST($2 AS JSONB))",
vec![
vec![Value::Text("/a\"\\🦀".into()), Value::Text(serde_json::json!({"nested": [null, true, {"text": "x".repeat(8192)}], "number": 9223372036854775807_i64}).to_string())],
vec![Value::Text("/b".into()), Value::Text("null".into())],
],
"SELECT path, value FROM native_batch_probe ORDER BY path",
),
];
for (case_index, (schema, sql, params, probe)) in cases.into_iter().enumerate() {
let batch = open_session().await;
let individual = open_session().await;
for session in [&batch, &individual] {
session.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
).await.unwrap();
}
let statements = params
.iter()
.map(|params| ExecuteBatchStatement {
label: None,
sql: sql.into(),
params: params.clone(),
})
.collect::<Vec<_>>();
sql2::take_certified_row_insert_parameter_batch_executions();
let results = batch.execute_batch(&statements).await.unwrap().results;
let executions = sql2::take_certified_row_insert_parameter_batch_executions();
if case_index == 0 {
assert_eq!(
executions, 1,
"direct typed INSERT batch must retain its dense lane: {sql}"
);
}
assert!(results.iter().all(|result| result.rows_affected() == 1));
for params in ¶ms {
individual.execute(sql, params).await.unwrap();
}
let actual = batch.execute(probe, &[]).await.unwrap();
let expected = individual.execute(probe, &[]).await.unwrap();
assert_eq!(actual.len(), expected.len(), "{sql}");
for (actual, expected) in actual.rows().iter().zip(expected.rows()) {
assert_eq!(actual.values(), expected.values(), "{sql}");
}
}
}
#[tokio::test]
async fn typed_insert_batch_rejects_invalid_uuid_with_statement_index_and_no_prefix() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json", "key": "native_invalid_batch_probe",
"columns": [
{"name": "id", "type": "text", "nullable": false},
{"name": "uuid", "type": "uuid", "nullable": false}
], "primary_key": ["id"]
});
session.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
).await.unwrap();
let sql = "INSERT INTO native_invalid_batch_probe (id, uuid) VALUES ($1, $2)";
for invalid in [Value::Text("not-a-uuid".into()), Value::Null] {
let invalid_params = vec![Value::Text("b".into()), invalid];
let ordinary = session.execute(sql, &invalid_params).await.unwrap_err();
let batch = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.into(),
params: vec![
Value::Text("a".into()),
Value::Text("550e8400-e29b-41d4-a716-446655440000".into()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.into(),
params: invalid_params,
},
])
.await
.unwrap_err();
assert_eq!(batch.code, ordinary.code);
assert_eq!(batch.details.as_ref().unwrap()["statementIndex"], 1);
assert!(
session
.execute("SELECT id FROM native_invalid_batch_probe", &[])
.await
.unwrap()
.is_empty()
);
}
}
#[tokio::test]
async fn execute_batch_declines_uncertified_row_insert_rows() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_insert_fallback_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
sql2::take_certified_row_insert_parameter_batch_executions();
let sql = "INSERT INTO parameter_insert_fallback_probe (id, value) VALUES ($1, CAST($2 AS JSONB))";
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Text("\"invalid-shape\"".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Text("not-json".to_string()),
],
},
])
.await
.expect_err("the later invalid JSON expression should be reported");
assert_eq!(error.code, LixError::CODE_TYPE_MISMATCH);
assert_eq!(error.details.unwrap()["statementIndex"], 1);
assert_eq!(
sql2::take_certified_row_insert_parameter_batch_executions(),
0
);
}
#[tokio::test]
async fn execute_batch_declines_json_marked_utf8_for_string_columns() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_insert_json_string_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
sql2::take_certified_row_insert_parameter_batch_executions();
let sql = "INSERT INTO parameter_insert_json_string_probe (id, value) VALUES ($1, $2)";
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Jsonb(serde_json::json!({"not": "text"}).into()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Jsonb(serde_json::json!({"also": "not text"}).into()),
],
},
])
.await
.expect_err("JSON objects must not be coerced into string column values");
assert_eq!(error.details.unwrap()["statementIndex"], 0);
assert_eq!(
sql2::take_certified_row_insert_parameter_batch_executions(),
0
);
let rows = session
.execute("SELECT id FROM parameter_insert_json_string_probe", &[])
.await
.unwrap();
assert!(rows.rows().is_empty());
}
#[tokio::test]
async fn execute_batch_preserves_early_duplicate_before_later_schema_error() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_insert_error_order_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
sql2::take_certified_row_insert_parameter_batch_executions();
let sql = "INSERT INTO parameter_insert_error_order_probe (id, value) VALUES ($1, $2)";
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Text("valid".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Text("also valid".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![Value::Text("b".to_string()), Value::Text(String::new())],
},
])
.await
.expect_err("the earlier duplicate must precede later schema validation");
assert_eq!(error.code, LixError::CODE_UNIQUE);
assert_eq!(error.details.unwrap()["statementIndex"], 1);
assert_eq!(
sql2::take_certified_row_insert_parameter_batch_executions(),
0
);
let rows = session
.execute("SELECT id FROM parameter_insert_error_order_probe", &[])
.await
.unwrap();
assert!(rows.rows().is_empty());
}
#[tokio::test]
async fn execute_batch_preserves_later_bind_error_index() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_insert_branch_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: "INSERT INTO parameter_insert_branch_probe (id, value) VALUES ($1, $2)"
.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Text("value-a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: "INSERT INTO parameter_insert_branch_probe (id, missing) VALUES ($1, $2)"
.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Text("value-b".to_string()),
],
},
])
.await
.expect_err("the second statement's bind error must retain its statement index");
assert_eq!(error.code, LixError::CODE_COLUMN_NOT_FOUND);
assert_eq!(error.details.unwrap()["statementIndex"], 1);
let rows = session
.execute(
"SELECT id FROM parameter_insert_branch_probe WHERE id = 'a'",
&[],
)
.await
.unwrap();
assert!(
rows.is_empty(),
"the valid prefix must roll back with the missing-branch batch"
);
}
#[tokio::test]
async fn execute_batch_preserves_later_durability_domain_error_index() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_insert_durability_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema \
(value, lixcol_global, lixcol_untracked) \
VALUES (CAST($1 AS JSONB), false, true)",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let sql = "INSERT INTO parameter_insert_durability_probe \
(id, value, lixcol_untracked) VALUES ($1, $2, $3)";
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("a".to_string()),
Value::Text("value-a".to_string()),
Value::Boolean(true),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("b".to_string()),
Value::Text("value-b".to_string()),
Value::Boolean(false),
],
},
])
.await
.expect_err("the second row's tracked-catalog failure must retain its statement index");
assert_eq!(error.code, LixError::CODE_SCHEMA_DEFINITION);
assert_eq!(error.details.unwrap()["statementIndex"], 1);
let rows = session
.execute(
"SELECT id FROM parameter_insert_durability_probe WHERE id = 'a'",
&[],
)
.await
.unwrap();
assert!(
rows.is_empty(),
"the untracked prefix must roll back with the mixed-durability batch"
);
}
#[tokio::test]
async fn execute_batch_lowers_distinct_bound_row_updates_once() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_batch_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO parameter_batch_probe (id, value) VALUES \
('a', 'old-a'), ('b', 'old-b')",
&[],
)
.await
.unwrap();
sql2::take_row_update_parameter_batch_executions();
let sql = "UPDATE parameter_batch_probe SET value = $1 WHERE id = $2";
let results = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-a".to_string()),
Value::Text("a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-b".to_string()),
Value::Text("b".to_string()),
],
},
])
.await
.unwrap()
.results;
assert_eq!(sql2::take_row_update_parameter_batch_executions(), 1);
assert_eq!(
results
.iter()
.map(ExecuteResult::rows_affected)
.collect::<Vec<_>>(),
vec![1, 1]
);
let rows = session
.execute(
"SELECT id, value FROM parameter_batch_probe ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "new-a");
assert_eq!(rows.rows()[1].get::<String>("value").unwrap(), "new-b");
}
#[tokio::test]
async fn execute_batch_lowers_distinct_literal_row_updates_once() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "literal_parameter_batch_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO literal_parameter_batch_probe (id, value) VALUES \
('a', 'old-a'), ('b', 'old-b')",
&[],
)
.await
.unwrap();
sql2::take_row_update_parameter_batch_executions();
let results = session
.execute_batch(&[
batch_statement(
"UPDATE literal_parameter_batch_probe SET value = 'new-a' WHERE id = 'a'",
),
batch_statement(
"UPDATE literal_parameter_batch_probe SET value = 'new-b' WHERE id = 'b'",
),
])
.await
.unwrap()
.results;
assert_eq!(sql2::take_row_update_parameter_batch_executions(), 1);
assert_eq!(
results
.iter()
.map(ExecuteResult::rows_affected)
.collect::<Vec<_>>(),
vec![1, 1]
);
let rows = session
.execute(
"SELECT id, value FROM literal_parameter_batch_probe ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "new-a");
assert_eq!(rows.rows()[1].get::<String>("value").unwrap(), "new-b");
}
#[tokio::test]
async fn row_insert_values_use_native_transaction_batch() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "certified_insert_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("schema registration should succeed");
sql2::take_certified_row_insert_batch_executions();
session
.execute(
"INSERT INTO certified_insert_probe (value, id) VALUES \
('value-a', 'a'), ('value-b', 'b')",
&[],
)
.await
.expect("certified insert batch should commit");
assert_eq!(
sql2::take_certified_row_insert_batch_executions(),
0,
"registered v69 rows use the native transaction path, not the legacy JSON batch"
);
let rows = session
.execute(
"SELECT id, value FROM certified_insert_probe ORDER BY id",
&[],
)
.await
.expect("certified rows should be readable");
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "value-a");
assert_eq!(rows.rows()[1].get::<String>("value").unwrap(), "value-b");
}
#[tokio::test]
async fn conflict_insert_filters_rows_before_snapshot_validation() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "conflict_validation_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("schema registration should succeed");
session
.execute(
"INSERT INTO conflict_validation_probe (id, value) VALUES ('a', 'valid')",
&[],
)
.await
.expect("seed row should commit");
sql2::take_certified_row_insert_batch_executions();
session
.execute(
"INSERT INTO conflict_validation_probe (id, value) VALUES ('a', 'x') \
ON CONFLICT (id) DO NOTHING",
&[],
)
.await
.expect("conflicting invalid payload should be discarded before validation");
assert_eq!(sql2::take_certified_row_insert_batch_executions(), 0);
let rows = session
.execute(
"SELECT value FROM conflict_validation_probe WHERE id = 'a'",
&[],
)
.await
.expect("seed row should remain readable");
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "valid");
}
#[tokio::test]
async fn insert_accepts_explicit_uuid_keys_by_external_value() {
const UUID: &str = "550e8400-e29b-41d4-a716-446655440000";
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "explicit_uuid_key_probe",
"columns": [
{ "name": "id", "type": "uuid", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("schema registration should succeed");
session
.execute(
"INSERT INTO explicit_uuid_key_probe (id, value) VALUES ($1, 'value')",
&[Value::Text(UUID.to_string())],
)
.await
.expect("matching typed and external UUID keys should commit");
let rows = session
.execute(
"SELECT value FROM explicit_uuid_key_probe WHERE id = $1",
&[Value::Text(UUID.to_string())],
)
.await
.expect("inserted UUID row should be readable");
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "value");
}
#[tokio::test]
async fn execute_batch_certifies_out_of_order_complete_path_value_replacements() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "certified_replacement_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO certified_replacement_probe (path, value) VALUES \
('/a', CAST('\"old-a\"' AS JSONB)), ('/b', CAST('\"old-b\"' AS JSONB))",
&[],
)
.await
.unwrap();
let sql =
"UPDATE certified_replacement_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
let missing_results = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("invalid-missing-1".to_string()),
Value::Text("/missing".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("invalid-missing-2".to_string()),
Value::Text("/missing".to_string()),
],
},
])
.await
.expect("missing rows must not evaluate their replacement expression")
.results;
assert_eq!(
missing_results
.iter()
.map(ExecuteResult::rows_affected)
.collect::<Vec<_>>(),
vec![0, 0]
);
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("not-json".to_string()),
Value::Text("/b".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(r#"{"later":"valid"}"#.to_string()),
Value::Text("/b".to_string()),
],
},
])
.await
.expect_err("a later replacement must not hide earlier invalid JSON");
assert_eq!(error.details.unwrap()["statementIndex"], 0);
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("invalid-b".to_string()),
Value::Text("/b".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("invalid-a".to_string()),
Value::Text("/a".to_string()),
],
},
])
.await
.expect_err("errors must retain statement order when identities are sorted");
assert_eq!(error.details.unwrap()["statementIndex"], 0);
sql2::take_certified_replacement_parameter_batch_executions();
let results = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(r#"{"nested":[1,true,"x"]}"#.to_string()),
Value::Text("/b".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(r#"{"missing":1}"#.to_string()),
Value::Text("/missing".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("true".to_string()),
Value::Text("/a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(r#"{"final":"b"}"#.to_string()),
Value::Text("/b".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text(r#"{"missing":2}"#.to_string()),
Value::Text("/missing".to_string()),
],
},
])
.await
.unwrap()
.results;
assert_eq!(
sql2::take_certified_replacement_parameter_batch_executions(),
1
);
assert_eq!(
results
.iter()
.map(ExecuteResult::rows_affected)
.collect::<Vec<_>>(),
vec![1, 0, 1, 1, 0]
);
let rows = session
.execute(
"SELECT path, value FROM certified_replacement_probe ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(
rows.rows()[0].value("value").unwrap(),
&Value::Jsonb(serde_json::json!(true).into())
);
assert_eq!(
rows.rows()[1].get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"final": "b"})
);
}
#[tokio::test]
async fn certified_insert_publishes_lifecycle_for_native_update() {
const ROW_COUNT: usize = 8;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "json_pointer",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("json pointer schema should register");
let insert_sql = "INSERT INTO json_pointer (path, value) VALUES ($1, CAST($2 AS JSONB))";
let inserts = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: insert_sql.to_owned(),
params: vec![
Value::Text(format!("/typed-journal-{row_index:02}")),
Value::Text(format!(r#"{{"old":{row_index}}}"#)),
],
})
.collect::<Vec<_>>();
session.execute_batch(&inserts).await.unwrap();
let inserted_head = session
.execute("SELECT commit_id FROM lix_branch WHERE name = 'main'", &[])
.await
.unwrap()
.rows()[0]
.get::<String>("commit_id")
.unwrap();
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let metadata = crate::tracked_state::load_commit_delta_replay_metadata(
&read,
crate::changelog::CommitId::parse_lix(
&inserted_head,
"certified plugin INSERT lifecycle head",
)
.unwrap(),
)
.await
.unwrap()
.expect("certified plugin INSERT must publish replay metadata");
assert_eq!(metadata.member_count, ROW_COUNT as u32);
assert!(
metadata.lifecycle_summary.is_some(),
"certified plugin INSERT must retain absence authority through typed lowering"
);
let update_sql = "UPDATE json_pointer SET value = CAST($1 AS JSONB) WHERE path = $2";
let updates = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: update_sql.to_owned(),
params: vec![
Value::Text(format!(r#"{{"updated":{row_index}}}"#)),
Value::Text(format!("/typed-journal-{row_index:02}")),
],
})
.collect::<Vec<_>>();
session.execute_batch(&updates).await.unwrap();
let rows = session
.execute("SELECT path, value FROM json_pointer ORDER BY path", &[])
.await
.expect("updated native rows should remain readable");
assert_eq!(rows.len(), ROW_COUNT);
assert_eq!(
rows.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"updated": 0})
);
assert_eq!(
rows.rows()[ROW_COUNT - 1]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"updated": ROW_COUNT - 1})
);
}
#[tokio::test]
async fn complete_replacement_publishes_packed_current_base_and_accepts_later_overlays() {
const ROW_COUNT: usize = 32 * 1_024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "packed_replacement_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let insert_sql =
"INSERT INTO packed_replacement_probe (path, value) VALUES ($1, CAST($2 AS JSONB))";
let inserts = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: insert_sql.to_string(),
params: vec![
Value::Text(format!("/{row_index:05}")),
Value::Text(format!(r#"{{"old":{row_index}}}"#)),
],
})
.collect::<Vec<_>>();
session.execute_batch(&inserts).await.unwrap();
crate::transaction::take_complete_replacement_packed_current_base_publications();
crate::transaction::take_rootless_replacement_generation_publications();
sql2::take_certified_generation_identity_replacements();
let update_sql =
"UPDATE packed_replacement_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
let updates = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(format!(r#"{{"updated":{row_index}}}"#)),
Value::Text(format!("/{row_index:05}")),
],
})
.collect::<Vec<_>>();
let affected = session
.execute_batch(&updates)
.await
.unwrap()
.results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>();
assert_eq!(affected, ROW_COUNT as u64);
assert_eq!(
sql2::take_certified_generation_identity_replacements(),
1,
"the untouched packed generation must prove the complete identity set without a row scan"
);
assert_eq!(
crate::transaction::take_complete_replacement_packed_current_base_publications(),
1,
"the certified full replacement must publish one packed current base"
);
assert_eq!(
crate::transaction::take_rootless_replacement_generation_publications(),
0,
"the certified replacement must publish persistent root authority"
);
let update_commit_id = session
.execute("SELECT commit_id FROM lix_branch WHERE name = 'main'", &[])
.await
.unwrap()
.rows()[0]
.get::<String>("commit_id")
.unwrap();
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let historical = session
.tracked_state
.reader(read)
.scan_batch_at_commit(
&update_commit_id,
&crate::tracked_state::TrackedStateScanRequest {
filter: crate::tracked_state::TrackedStateFilter {
schema_keys: vec!["packed_replacement_probe".to_string()],
..Default::default()
},
..Default::default()
},
)
.await
.unwrap();
assert_eq!(historical.len(), ROW_COUNT);
assert!(
historical
.iter()
.all(|row| row.snapshot_content().is_some_and(|snapshot| {
snapshot.contains("updated") && !snapshot.contains("old")
})),
"historical replay must stop at the replacement generation"
);
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let historical_all = session
.tracked_state
.reader(read)
.scan_batch_at_commit(
&update_commit_id,
&crate::tracked_state::TrackedStateScanRequest::default(),
)
.await
.unwrap();
assert_eq!(
historical_all
.iter()
.filter(|row| row.schema_key() == "packed_replacement_probe")
.count(),
ROW_COUNT,
"an unfiltered replay must compose the replacement with inherited partitions"
);
assert!(
historical_all
.iter()
.any(|row| row.schema_key() == "lix_registered_schema"),
"the replacement generation must retain unrelated durable partitions"
);
let mut rebuild_writes = session.storage.new_write_set();
{
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
session
.tracked_state
.root_rebuilder(&read, &mut rebuild_writes)
.rebuild_commit_root_at(&update_commit_id)
.await
.unwrap();
}
session
.storage
.commit_write_set(rebuild_writes, StorageWriteOptions::default())
.await
.unwrap();
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let rebuilt = session
.tracked_state
.reader(read)
.scan_batch_at_commit(
&update_commit_id,
&crate::tracked_state::TrackedStateScanRequest {
filter: crate::tracked_state::TrackedStateFilter {
schema_keys: vec!["packed_replacement_probe".to_string()],
..Default::default()
},
..Default::default()
},
)
.await
.unwrap();
let rebuilt_rows = rebuilt.into_rows();
let historical_rows = historical.into_rows();
assert_eq!(rebuilt_rows.len(), historical_rows.len());
for (row_index, (rebuilt_row, historical_row)) in
rebuilt_rows.iter().zip(&historical_rows).enumerate()
{
assert_eq!(
rebuilt_row, historical_row,
"rebuilt replacement row {row_index} must equal replayed state"
);
}
crate::transaction::take_rootless_replacement_generation_publications();
let second_updates = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(format!(r#"{{"second":{row_index}}}"#)),
Value::Text(format!("/{row_index:05}")),
],
})
.collect::<Vec<_>>();
session.execute_batch(&second_updates).await.unwrap();
assert_eq!(
crate::transaction::take_rootless_replacement_generation_publications(),
0,
"a repeated replacement must retain persistent root authority"
);
let second_commit_id = session
.execute("SELECT commit_id FROM lix_branch WHERE name = 'main'", &[])
.await
.unwrap()
.rows()[0]
.get::<String>("commit_id")
.unwrap();
for (before, after) in [
(&update_commit_id, &second_commit_id),
(&second_commit_id, &update_commit_id),
] {
let diff = session
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('packed_replacement_probe', $1, $2) \
WHERE diff_type = 'modified'",
&[Value::Text(before.clone()), Value::Text(after.clone())],
)
.await
.unwrap();
assert_eq!(
diff.rows()[0].get::<i64>("entries").unwrap(),
ROW_COUNT as i64,
"replacement generations must retain the real commit graph in both diff directions"
);
}
let main_branch_id = session.active_branch_id().await.unwrap();
let historical_branch = session
.create_branch(crate::CreateBranchOptions {
id: Some("01930000-0000-7000-8000-00000000b002".to_string()),
name: "replacement-generation-history".to_string(),
from_commit_id: Some(update_commit_id.clone()),
})
.await
.unwrap();
session
.switch_branch(crate::SwitchBranchOptions {
branch_id: historical_branch.id,
})
.await
.unwrap();
let historical_points = session
.execute(
"SELECT path, value FROM packed_replacement_probe \
WHERE path IN ('/00000', '/32767') ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(
historical_points.rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"updated": 0})
);
assert_eq!(
historical_points.rows()[1]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"updated": ROW_COUNT - 1})
);
session
.switch_branch(crate::SwitchBranchOptions {
branch_id: main_branch_id.clone(),
})
.await
.unwrap();
let broad_rows = session
.execute(
"SELECT path, value FROM packed_replacement_probe ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(broad_rows.len(), ROW_COUNT);
assert_eq!(
broad_rows.rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"second": 0})
);
assert_eq!(
broad_rows.rows()[ROW_COUNT - 1]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"second": ROW_COUNT - 1})
);
let rows = session
.execute(
"SELECT path, value FROM packed_replacement_probe WHERE path IN ('/00000', '/32767') ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(
rows.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"second": 0})
);
assert_eq!(
rows.rows()[1].get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"second": ROW_COUNT - 1})
);
let merge_base_branch = session
.create_branch(crate::CreateBranchOptions {
id: Some("01930000-0000-7000-8000-00000000b003".to_string()),
name: "replacement-generation-merge".to_string(),
from_commit_id: Some(second_commit_id.clone()),
})
.await
.unwrap();
session
.switch_branch(crate::SwitchBranchOptions {
branch_id: merge_base_branch.id.clone(),
})
.await
.unwrap();
session
.execute(
"UPDATE packed_replacement_probe SET value = CAST('{\"branch\":true}' AS JSONB) WHERE path = '/00000'",
&[],
)
.await
.unwrap();
session
.switch_branch(crate::SwitchBranchOptions {
branch_id: main_branch_id.clone(),
})
.await
.unwrap();
session
.execute(
"UPDATE packed_replacement_probe SET value = CAST('{\"main\":true}' AS JSONB) WHERE path = '/32767'",
&[],
)
.await
.unwrap();
let merge = session
.merge_branch(crate::MergeBranchOptions {
source_branch_id: merge_base_branch.id,
})
.await
.unwrap();
assert_eq!(merge.outcome, crate::MergeBranchOutcome::MergeCommitted);
let merged_commit_id = session
.execute("SELECT commit_id FROM lix_branch WHERE name = 'main'", &[])
.await
.unwrap()
.rows()[0]
.get::<String>("commit_id")
.unwrap();
let merged_rows = session
.execute(
"SELECT path, value FROM packed_replacement_probe ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(
merged_rows.len(),
ROW_COUNT,
"merging sparse descendants of a replacement generation must not lose rows"
);
assert_eq!(
merged_rows.rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"branch": true})
);
assert_eq!(
merged_rows.rows()[ROW_COUNT - 1]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"main": true})
);
let merged_diff = session
.execute(
"SELECT COUNT(*) AS entries \
FROM lix_diff('packed_replacement_probe', $1, $2) \
WHERE diff_type = 'modified'",
&[
Value::Text(second_commit_id.clone()),
Value::Text(merged_commit_id.clone()),
],
)
.await
.unwrap();
assert_eq!(merged_diff.rows()[0].get::<i64>("entries").unwrap(), 2);
let merged_history = session
.execute(
&format!(
"SELECT to_value AS value FROM lix_history('packed_replacement_probe', '{merged_commit_id}') \
WHERE path = '/00000' ORDER BY lixcol_position"
),
&[],
)
.await
.unwrap();
assert_eq!(
merged_history.rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"branch": true})
);
assert!(merged_history.rows().iter().any(|row| {
row.get::<serde_json::Value>("value").ok() == Some(serde_json::json!({"second": 0}))
}));
session
.execute(
"UPDATE packed_replacement_probe SET value = CAST('{\"overlay\":true}' AS JSONB) WHERE path = '/00000'",
&[],
)
.await
.unwrap();
let mixed_hot_cold = session
.execute(
"SELECT path, value FROM packed_replacement_probe \
WHERE path IN ('/00000', '/16000') ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(mixed_hot_cold.len(), 2);
assert_eq!(
mixed_hot_cold.rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"overlay": true}),
"a mixed exact batch must retain the head-delta hit"
);
assert_eq!(
mixed_hot_cold.rows()[1]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"second": 16_000}),
"a mixed exact batch must resolve its cold key from inherited current state"
);
session
.execute(
"DELETE FROM packed_replacement_probe WHERE path = '/32767'",
&[],
)
.await
.unwrap();
let rows = session
.execute(
"SELECT path, value FROM packed_replacement_probe ORDER BY path",
&[],
)
.await
.unwrap();
assert_eq!(rows.len(), ROW_COUNT - 1);
assert_eq!(rows.rows()[0].get::<String>("path").unwrap(), "/00000");
assert_eq!(
rows.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"overlay": true})
);
session
.execute(
"INSERT INTO packed_replacement_probe (path, value) VALUES ('/32767', CAST('{\"reinserted\":true}' AS JSONB))",
&[],
)
.await
.unwrap();
let reinsert_commit_id = session
.execute("SELECT commit_id FROM lix_branch WHERE name = 'main'", &[])
.await
.unwrap()
.rows()[0]
.get::<String>("commit_id")
.unwrap();
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let reinserted = session
.tracked_state
.reader(read)
.scan_batch_at_commit(
&reinsert_commit_id,
&crate::tracked_state::TrackedStateScanRequest {
filter: crate::tracked_state::TrackedStateFilter {
schema_keys: vec!["packed_replacement_probe".to_string()],
..Default::default()
},
..Default::default()
},
)
.await
.unwrap();
let reinserted_created_at = reinserted
.iter()
.find(|row| row.row_pk().as_single_string().ok() == Some("/32767"))
.expect("reinserted row must be visible")
.created_at();
crate::transaction::take_rootless_replacement_generation_publications();
let post_reinsert_updates = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(format!(r#"{{"post_reinsert":{row_index}}}"#)),
Value::Text(format!("/{row_index:05}")),
],
})
.collect::<Vec<_>>();
session.execute_batch(&post_reinsert_updates).await.unwrap();
assert_eq!(
crate::transaction::take_rootless_replacement_generation_publications(),
0,
"a delete/reinsert interval must decline replacement certification without complete lifecycle evidence"
);
let post_reinsert_commit_id = session
.execute("SELECT commit_id FROM lix_branch WHERE name = 'main'", &[])
.await
.unwrap()
.rows()[0]
.get::<String>("commit_id")
.unwrap();
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let post_reinsert = session
.tracked_state
.reader(read)
.scan_batch_at_commit(
&post_reinsert_commit_id,
&crate::tracked_state::TrackedStateScanRequest {
filter: crate::tracked_state::TrackedStateFilter {
schema_keys: vec!["packed_replacement_probe".to_string()],
..Default::default()
},
..Default::default()
},
)
.await
.unwrap();
assert_eq!(
post_reinsert
.iter()
.find(|row| row.row_pk().as_single_string().ok() == Some("/32767"))
.expect("updated reinserted row must be visible")
.created_at(),
reinserted_created_at,
"full updates must preserve the newer lifecycle of a reinserted identity"
);
let read = session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap();
let controls = crate::branch::BranchHeadControlContext::new()
.reader(&read)
.scan()
.await
.expect("branch-head controls should load");
let mut live_descriptor = None;
for (_, control) in controls {
let manifest =
crate::tracked_state::load_commit_state_manifest(&read, control.head_commit_id)
.await
.expect("active head manifest should load");
let Some(root) = manifest.and_then(|manifest| manifest.current_state_scoped_ranges)
else {
continue;
};
let reachable = crate::tracked_state::validate_scoped_range_trees(
&read,
std::slice::from_ref(&root.tree),
)
.await
.expect("active head current-state tree should authenticate");
live_descriptor = reachable
.parts
.iter()
.map(crate::tracked_state::current_state_descriptor_from_scoped_range_part)
.collect::<Result<Vec<_>, _>>()
.expect("active head part descriptors should decode")
.into_iter()
.find(|descriptor| {
matches!(
descriptor.source,
crate::tracked_state::CurrentStatePartSource::NativeDataPart
)
});
if live_descriptor.is_some() {
break;
}
}
let live_descriptor =
live_descriptor.expect("an active head should retain a live native part");
let native_key = crate::storage_adapter::StorageKey(bytes::Bytes::copy_from_slice(
&live_descriptor.content_digest,
));
let native_presence = crate::storage_adapter::PointReadPlan::new(
crate::tracked_state::CURRENT_STATE_DATA_PART_SPACE,
std::slice::from_ref(&native_key),
)
.materialize(&read, Default::default())
.await
.expect("live native part presence should read");
assert!(
native_presence.value[0].is_some(),
"live part must exist before delete"
);
drop(read);
let mut corrupt = session.storage.new_write_set();
corrupt.delete(
crate::tracked_state::CURRENT_STATE_DATA_PART_SPACE,
native_key,
);
session
.storage
.commit_write_set(corrupt, StorageWriteOptions::default())
.await
.unwrap();
let read = SharedStorageAdapterRead::new(
session
.storage
.begin_read(StorageReadOptions::default())
.await
.unwrap(),
);
let mut gc_writes = session.storage.new_write_set();
assert!(
crate::gc::stage_repository_gc(read, &mut gc_writes)
.await
.is_err(),
"GC must fail closed before sweeping when a live native part is missing"
);
}
#[tokio::test]
async fn staged_delete_disables_generation_identity_replacement() {
const ROW_COUNT: usize = 16;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "staged_generation_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let insert_sql =
"INSERT INTO staged_generation_probe (path, value) VALUES ($1, CAST($2 AS JSONB))";
let inserts = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: insert_sql.to_string(),
params: vec![
Value::Text(format!("/{row_index:04}")),
Value::Text(format!(r#"{{"old":{row_index}}}"#)),
],
})
.collect::<Vec<_>>();
session.execute_batch(&inserts).await.unwrap();
let mut transaction = session.begin_transaction().await.unwrap();
transaction
.execute(
"DELETE FROM staged_generation_probe WHERE path = '/0000'",
&[],
)
.await
.unwrap();
let update_sql =
"UPDATE staged_generation_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
let updates = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: update_sql.to_string(),
params: vec![
Value::Text(format!(r#"{{"updated":{row_index}}}"#)),
Value::Text(format!("/{row_index:04}")),
],
})
.collect::<Vec<_>>();
let parsed = TransactionBatchStatements::Shared {
statement: sql2::parse_statement(update_sql).unwrap(),
len: updates.len(),
};
sql2::take_certified_generation_identity_replacements();
let results = try_execute_transaction_parameter_batch(
transaction.transaction_mut().unwrap(),
&updates,
&parsed,
&ExecuteOptions::default(),
&vec![ExecuteStatementMetadata::default(); updates.len()],
)
.await
.unwrap()
.expect("the overlay-aware parameter batch should execute");
assert_eq!(
results
.iter()
.map(ExecuteResult::rows_affected)
.sum::<u64>(),
(ROW_COUNT - 1) as u64
);
assert_eq!(sql2::take_certified_generation_identity_replacements(), 0);
transaction.commit().await.unwrap();
let deleted = session
.execute(
"SELECT path FROM staged_generation_probe WHERE path = '/0000'",
&[],
)
.await
.unwrap();
assert_eq!(
deleted.len(),
0,
"the staged delete must not be resurrected"
);
}
#[tokio::test]
async fn execute_batch_keeps_repeated_generic_row_identity_sequential() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_batch_repeat_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
sql2::take_certified_row_insert_parameter_batch_executions();
let insert_sql = "INSERT INTO parameter_batch_repeat_probe (id, value) VALUES ($1, $2)";
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: insert_sql.to_string(),
params: vec![
Value::Text("duplicate".to_string()),
Value::Text("first".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: insert_sql.to_string(),
params: vec![
Value::Text("duplicate".to_string()),
Value::Text("second".to_string()),
],
},
])
.await
.expect_err("the second INSERT repeats the first identity");
assert_eq!(error.details.unwrap()["statementIndex"], 1);
assert_eq!(
sql2::take_certified_row_insert_parameter_batch_executions(),
0
);
session
.execute(
"INSERT INTO parameter_batch_repeat_probe (id, value) VALUES ('a', 'old')",
&[],
)
.await
.unwrap();
sql2::take_row_update_parameter_batch_executions();
let sql = "UPDATE parameter_batch_repeat_probe SET value = $1 WHERE id = $2";
session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("first".to_string()),
Value::Text("a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("second".to_string()),
Value::Text("a".to_string()),
],
},
])
.await
.unwrap();
assert_eq!(sql2::take_row_update_parameter_batch_executions(), 0);
let row = session
.execute(
"SELECT value FROM parameter_batch_repeat_probe WHERE id = 'a'",
&[],
)
.await
.unwrap();
assert_eq!(row.rows()[0].get::<String>("value").unwrap(), "second");
}
#[tokio::test]
async fn execute_batch_keeps_unsupported_parameterless_updates_sequential() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameterless_batch_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO parameterless_batch_probe (id, value) VALUES ('a', 'old')",
&[],
)
.await
.unwrap();
sql2::take_row_update_parameter_batch_executions();
let results = session
.execute_batch(&[
batch_statement(
"UPDATE parameterless_batch_probe SET value = 'first' WHERE id = 'a'",
),
batch_statement(
"UPDATE parameterless_batch_probe SET value = 'second' WHERE id = 'a'",
),
])
.await
.unwrap()
.results;
assert_eq!(results.len(), 2);
assert_eq!(
results
.iter()
.map(ExecuteResult::rows_affected)
.collect::<Vec<_>>(),
vec![1, 1]
);
assert_eq!(sql2::take_row_update_parameter_batch_executions(), 0);
let row = session
.execute(
"SELECT value FROM parameterless_batch_probe WHERE id = 'a'",
&[],
)
.await
.unwrap();
assert_eq!(row.rows()[0].get::<String>("value").unwrap(), "second");
}
#[tokio::test]
async fn execute_batch_keeps_inter_row_constraints_on_sequential_execution() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_batch_constraint_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
"unique": [["value"]],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO parameter_batch_constraint_probe (id, value) VALUES \
('a', 'old-a'), ('b', 'old-b')",
&[],
)
.await
.unwrap();
sql2::take_certified_row_insert_parameter_batch_executions();
let insert_sql = "INSERT INTO parameter_batch_constraint_probe (id, value) VALUES ($1, $2)";
session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: insert_sql.to_string(),
params: vec![
Value::Text("c".to_string()),
Value::Text("old-c".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: insert_sql.to_string(),
params: vec![
Value::Text("d".to_string()),
Value::Text("old-d".to_string()),
],
},
])
.await
.unwrap();
assert_eq!(
sql2::take_certified_row_insert_parameter_batch_executions(),
0
);
sql2::take_row_update_parameter_batch_executions();
let sql = "UPDATE parameter_batch_constraint_probe SET value = $1 WHERE id = $2";
session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-a".to_string()),
Value::Text("a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-b".to_string()),
Value::Text("b".to_string()),
],
},
])
.await
.unwrap();
assert_eq!(sql2::take_row_update_parameter_batch_executions(), 0);
}
#[tokio::test]
async fn execute_batch_parameter_batch_preserves_failing_statement_index() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_batch_error_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO parameter_batch_error_probe (id, value) VALUES \
('a', 'old-a'), ('b', 'old-b')",
&[],
)
.await
.unwrap();
sql2::take_row_update_parameter_batch_executions();
let sql = "UPDATE parameter_batch_error_probe SET value = CAST(CAST($1 AS JSONB) AS TEXT) WHERE id = $2";
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("\"new-a\"".to_string()),
Value::Text("a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("not-json".to_string()),
Value::Text("b".to_string()),
],
},
])
.await
.expect_err("the second statement has invalid JSON");
assert_eq!(error.details.unwrap()["statementIndex"], 1);
assert_eq!(sql2::take_row_update_parameter_batch_executions(), 0);
let rows = session
.execute(
"SELECT id, value FROM parameter_batch_error_probe ORDER BY id",
&[],
)
.await
.unwrap();
assert_eq!(rows.rows()[0].get::<String>("value").unwrap(), "old-a");
assert_eq!(rows.rows()[1].get::<String>("value").unwrap(), "old-b");
}
#[tokio::test]
async fn execute_batch_parameter_batch_indexes_parameter_count_errors() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_batch_count_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
let sql = "UPDATE parameter_batch_count_probe SET value = $1 WHERE id = $2";
let error = session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-a".to_string()),
Value::Text("a".to_string()),
Value::Text("extra".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-b".to_string()),
Value::Text("b".to_string()),
Value::Text("extra".to_string()),
],
},
])
.await
.expect_err("extra parameters must be rejected");
assert_eq!(error.details.unwrap()["statementIndex"], 0);
}
#[tokio::test]
async fn execute_batch_parameter_batch_measures_the_real_query_shape() {
let spans = Arc::new(std::sync::Mutex::new(Vec::new()));
let session = open_session_with_telemetry(Arc::clone(&spans)).await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "parameter_batch_telemetry_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "value", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO parameter_batch_telemetry_probe (id, value) VALUES \
('a', 'old-a'), ('b', 'old-b')",
&[],
)
.await
.unwrap();
spans.lock().expect("telemetry span lock").clear();
let sql = "UPDATE parameter_batch_telemetry_probe SET value = $1 WHERE id = $2";
session
.execute_batch(&[
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-a".to_string()),
Value::Text("a".to_string()),
],
},
ExecuteBatchStatement {
label: None,
sql: sql.to_string(),
params: vec![
Value::Text("new-b".to_string()),
Value::Text("b".to_string()),
],
},
])
.await
.unwrap();
let spans = spans.lock().expect("telemetry span lock");
let batch_span = spans
.iter()
.find(|span| span.start.name == "lix.sql.batch")
.expect("batch span");
let query_spans = spans
.iter()
.filter(|span| span.start.name == "lix.sql.query")
.collect::<Vec<_>>();
assert!(query_spans.is_empty());
assert!(batch_span.end.duration_ns > 0);
assert!(batch_span.start.attributes.iter().any(|attribute| {
attribute.key == "lix.sql.fingerprint"
&& matches!(&attribute.value, TelemetryValue::String(value) if !value.is_empty())
}));
assert!(batch_span.start.attributes.iter().any(|attribute| {
attribute.key == "db.query.text"
&& matches!(&attribute.value, TelemetryValue::String(value) if value == "UPDATE parameter_batch_telemetry_probe SET value = $1 WHERE id = $2")
}));
assert!(batch_span.start.attributes.iter().any(|attribute| {
attribute.key == "db.operation.batch.size"
&& attribute.value == TelemetryValue::I64(2)
}));
}
#[tokio::test]
async fn exact_batch_file_read_returns_each_matching_file_once() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_file (path, content) VALUES ($1, $2), ($3, $4)",
&[
Value::Text("/b.txt".to_string()),
Value::Blob(b"bravo".to_vec().into()),
Value::Text("/a.txt".to_string()),
Value::Blob(b"alpha".to_vec().into()),
],
)
.await
.unwrap();
let result = session
.execute(
"SELECT path, content FROM lix_file WHERE path IN ($1, $2, $3)",
&[
Value::Text("/b.txt".to_string()),
Value::Text("/a.txt".to_string()),
Value::Text("/b.txt".to_string()),
],
)
.await
.unwrap();
assert_eq!(result.columns(), &["path", "content"]);
assert_eq!(result.rows().len(), 2);
assert_eq!(result.rows()[0].get::<String>("path").unwrap(), "/a.txt");
assert_eq!(
result.rows()[0].value("content").unwrap(),
&Value::Blob(b"alpha".to_vec().into())
);
assert_eq!(result.rows()[1].get::<String>("path").unwrap(), "/b.txt");
assert_eq!(
result.rows()[1].value("content").unwrap(),
&Value::Blob(b"bravo".to_vec().into())
);
}
#[tokio::test]
async fn exact_id_manifest_batch_preserves_bytes_and_metadata() {
let session = open_session().await;
let a = "01920000-0000-7000-8000-0000000000a1";
let b = "01920000-0000-7000-8000-0000000000a2";
session
.execute(
"INSERT INTO lix_file (id, path, content, lixcol_metadata) \
VALUES ($1, $2, $3, $4), ($5, $6, $7, $8)",
&[
Value::Text(b.to_string()),
Value::Text("/b.txt".to_string()),
Value::Blob(b"bravo".to_vec().into()),
Value::Jsonb(serde_json::json!({"git_mode":"100644","git_oid":"b"}).into()),
Value::Text(a.to_string()),
Value::Text("/a.txt".to_string()),
Value::Blob(b"alpha".to_vec().into()),
Value::Jsonb(serde_json::json!({"git_mode":"100644","git_oid":"a"}).into()),
],
)
.await
.unwrap();
let result = session
.execute(
"SELECT id, path, content, lixcol_metadata FROM lix_file WHERE id IN ($1, $2)",
&[Value::Text(b.to_string()), Value::Text(a.to_string())],
)
.await
.unwrap();
assert_eq!(
result.columns(),
&["id", "path", "content", "lixcol_metadata"]
);
assert_eq!(result.rows().len(), 2);
assert_eq!(result.rows()[0].get::<String>("id").unwrap(), a);
assert_eq!(result.rows()[0].get::<String>("path").unwrap(), "/a.txt");
assert_eq!(
result.rows()[0].value("content").unwrap(),
&Value::Blob(b"alpha".to_vec().into())
);
assert_eq!(
result.rows()[0].value("lixcol_metadata").unwrap(),
&Value::Jsonb(serde_json::json!({"git_mode":"100644","git_oid":"a"}).into())
);
assert_eq!(result.rows()[1].get::<String>("id").unwrap(), b);
assert_eq!(result.rows()[1].get::<String>("path").unwrap(), "/b.txt");
assert_eq!(
result.rows()[1].value("content").unwrap(),
&Value::Blob(b"bravo".to_vec().into())
);
}
#[tokio::test]
async fn file_in_limit_fallback_delivers_content_with_acknowledgement_plan() {
let session = open_session().await;
let bytes = b"content must survive a declined native schema candidate";
session
.execute(
"INSERT INTO lix_file (path, content) VALUES ($1, $2)",
&[
Value::Text("/fallback.txt".into()),
Value::Blob(bytes.to_vec().into()),
],
)
.await
.unwrap();
use crate::plugin::runtime::{
PluginCapabilities, PluginFileOwner, PluginRegistry, PluginRegistryEntry,
PluginRegistryEntryInput, PluginRuntime, plugin_storage_archive_file_id,
plugin_storage_archive_path,
};
let plugin_key = "plugin_ack_probe";
let entry = PluginRegistryEntry::new(PluginRegistryEntryInput {
key: plugin_key.into(), runtime: PluginRuntime::WasmComponent,
api_version: "2.0.0".into(),
capabilities: PluginCapabilities { column_merger: false, file_projection: true },
path_glob: Some("*.txt".into()), content: None, entry: Some("plugin.wasm".into()),
schema_keys: vec!["ack_row".into()], create_schema_keys: Vec::new(),
manifest_json: serde_json::json!({"key": plugin_key, "entry": "plugin.wasm", "file_match": {"path_glob": "*.txt"}, "schemas": ["schema/ack_row.json"]}).to_string(),
archive_file_id: plugin_storage_archive_file_id(plugin_key),
archive_path: plugin_storage_archive_path(plugin_key),
archive_blob_hash: "a".repeat(64), wasm_blob_hash: Some("b".repeat(64)),
}).unwrap();
let file_id = session
.execute(
"SELECT id FROM lix_file WHERE path = $1",
&[Value::Text("/fallback.txt".into())],
)
.await
.unwrap()
.rows()[0]
.get::<String>("id")
.unwrap();
let branch_id = session.active_branch_id().await.unwrap();
let registry = PluginRegistry::new(vec![entry]).unwrap();
let owner = PluginFileOwner::new(&file_id, plugin_key, vec!["ack_row".into()]).unwrap();
let mut seed = session.begin_transaction().await.unwrap();
seed.transaction_mut()
.unwrap()
.stage_engine_test_rows(RawWriteBatch::from_test_rows(vec![
registry.write_row(&branch_id).unwrap(),
owner.write_row(&branch_id, false).unwrap(),
]))
.await
.unwrap();
seed.commit().await.unwrap();
session.file_views.clear();
let view_key = sql2::SessionFileViewKey::new(&branch_id, &file_id);
assert!(
session
.file_views
.unfiltered_plugin_file_view(&view_key)
.is_none()
);
let sql = "SELECT content FROM lix_file WHERE path IN ($1) LIMIT 1";
let params = [Value::Text("/fallback.txt".into())];
let plan = sql2::plan_read_statement(&sql2::parse_statement(sql).unwrap(), ¶ms);
assert!(plan.native.is_none());
assert!(plan.late_content.is_some());
assert!(
plan.acknowledge_file_views,
"delivered bytes must authorize the read collector"
);
let result = session.execute(sql, ¶ms).await.unwrap();
let view = session
.file_views
.unfiltered_plugin_file_view(&view_key)
.expect("delivering plugin-owned content must publish the acknowledgement");
assert_eq!(view.path, "/fallback.txt");
assert_eq!(view.plugin_key, plugin_key);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows()[0].value("content").unwrap(),
&Value::Blob(bytes.to_vec().into())
);
}
#[tokio::test]
async fn late_file_content_read_preserves_metadata_filters_order_and_limit() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_file (path, content) VALUES ($1, $2), ($3, $4), ($5, $6)",
&[
Value::Text("/a.txt".to_string()),
Value::Blob(b"alpha".to_vec().into()),
Value::Text("/b.txt".to_string()),
Value::Blob(b"bravo".to_vec().into()),
Value::Text("/c.txt".to_string()),
Value::Blob(b"charlie".to_vec().into()),
],
)
.await
.unwrap();
let result = session
.execute(
"SELECT path, content FROM lix_file WHERE path LIKE $1 ORDER BY path DESC LIMIT 2",
&[Value::Text("%.txt".to_string())],
)
.await
.unwrap();
assert_eq!(result.columns(), &["path", "content"]);
assert_eq!(result.rows().len(), 2);
assert_eq!(
result.rows()[0].values(),
&[
Value::Text("/c.txt".to_string()),
Value::Blob(b"charlie".to_vec().into()),
]
);
assert_eq!(
result.rows()[1].values(),
&[
Value::Text("/b.txt".to_string()),
Value::Blob(b"bravo".to_vec().into()),
]
);
}
#[tokio::test]
async fn sql_file_octet_length_and_bounded_substring_use_metadata_and_ranges() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_file (path, content) VALUES ($1, $2), ($3, $4)",
&[
Value::Text("/large.bin".to_string()),
Value::Blob(b"abcde".to_vec().into()),
Value::Text("/empty.bin".to_string()),
Value::Blob(Vec::new().into()),
],
)
.await
.unwrap();
let sizes = session
.execute(
"SELECT f.path, OCTET_LENGTH(f.content) AS size_bytes \
FROM lix_file AS f WHERE f.path IN ($1, $2) ORDER BY f.path",
&[
Value::Text("/empty.bin".into()),
Value::Text("/large.bin".into()),
],
)
.await
.unwrap();
assert_eq!(sizes.columns(), &["path", "size_bytes"]);
assert_eq!(sizes.rows()[0].values(), &[Value::Text("/empty.bin".into()), Value::Integer(0)]);
assert_eq!(sizes.rows()[1].values(), &[Value::Text("/large.bin".into()), Value::Integer(5)]);
let unaliased_size = session
.execute(
"SELECT OCTET_LENGTH(content) FROM lix_file WHERE path = '/large.bin'",
&[],
)
.await
.unwrap();
assert_eq!(unaliased_size.columns(), &["OCTET_LENGTH(content)"]);
assert_eq!(
unaliased_size.rows()[0].values(),
&[Value::Integer(5)]
);
let unaliased_slice = session
.execute(
"SELECT SUBSTRING(content FROM 2 FOR 3) \
FROM lix_file WHERE path = '/large.bin'",
&[],
)
.await
.unwrap();
assert_eq!(unaliased_slice.columns(), &["SUBSTRING(content FROM 2 FOR 3)"]);
assert_eq!(
unaliased_slice.rows()[0].values(),
&[Value::Blob(b"bcd".to_vec().into())]
);
let negative_start = session
.execute(
"SELECT SUBSTRING(f.content FROM $2 FOR $3) AS slice \
FROM lix_file AS f WHERE f.path = $1",
&[
Value::Text("/large.bin".into()),
Value::Integer(-2),
Value::Integer(4),
],
)
.await
.unwrap();
assert_eq!(negative_start.columns(), &["slice"]);
assert_eq!(
negative_start.rows()[0].value("slice").unwrap(),
&Value::Blob(b"a".to_vec().into())
);
let slice_params_only = session
.execute(
"SELECT SUBSTRING(content FROM $1 FOR $2) AS slice \
FROM lix_file WHERE path = '/large.bin'",
&[Value::Integer(2), Value::Integer(3)],
)
.await
.unwrap();
assert_eq!(
slice_params_only.rows()[0].value("slice").unwrap(),
&Value::Blob(b"bcd".to_vec().into())
);
let past_end = session
.execute(
"SELECT SUBSTRING(content FROM $1 FOR $2) AS slice \
FROM lix_file WHERE path = $3",
&[
Value::Integer(8),
Value::Integer(2),
Value::Text("/large.bin".into()),
],
)
.await
.unwrap();
assert_eq!(
past_end.rows()[0].value("slice").unwrap(),
&Value::Blob(Vec::new().into())
);
let empty_file = session
.execute(
"SELECT SUBSTRING(content FROM 1 FOR 2) AS slice \
FROM lix_file WHERE path = $1",
&[Value::Text("/empty.bin".into())],
)
.await
.unwrap();
assert_eq!(
empty_file.rows()[0].value("slice").unwrap(),
&Value::Blob(Vec::new().into())
);
}
#[tokio::test]
async fn sql_file_substring_reads_a_bounded_range_across_cas_chunks() {
let session = open_session().await;
let size = crate::binary_cas::CHUNK_ANCHOR_BYTES + 4096;
let content = (0..size).map(|index| (index % 251) as u8).collect::<Vec<_>>();
session
.execute(
"INSERT INTO lix_file (path, content) VALUES ($1, $2)",
&[
Value::Text("/chunked.bin".to_string()),
Value::Blob(content.clone().into()),
],
)
.await
.unwrap();
let size_result = session
.execute(
"SELECT OCTET_LENGTH(content) AS size_bytes FROM lix_file WHERE path = $1",
&[Value::Text("/chunked.bin".into())],
)
.await
.unwrap();
assert_eq!(
size_result.rows()[0].value("size_bytes").unwrap(),
&Value::Integer(size as i64)
);
let start = crate::binary_cas::CHUNK_ANCHOR_BYTES as i64 - 3;
let length = 12_i64;
let selected = session
.execute(
"SELECT SUBSTRING(content FROM $1 FOR $2) AS slice \
FROM lix_file WHERE path = $3",
&[
Value::Integer(start),
Value::Integer(length),
Value::Text("/chunked.bin".into()),
],
)
.await
.unwrap();
let byte_start = usize::try_from(start - 1).unwrap();
let byte_end = byte_start + usize::try_from(length).unwrap();
assert_eq!(
selected.rows()[0].value("slice").unwrap(),
&Value::Blob(content[byte_start..byte_end].to_vec().into())
);
}
#[test]
fn row_get_converts_native_values_and_value_keeps_wrapper() {
let result = ExecuteResult::from_rows(
vec!["title".to_string(), "done".to_string()],
vec![vec![Value::Text("Hello".to_string()), Value::Boolean(true)]],
);
let row = &result.rows()[0];
assert_eq!(
result.column_types(),
&[ResultColumnType::Text, ResultColumnType::Boolean]
);
assert_eq!(row.get::<String>("title").unwrap(), "Hello");
assert!(row.get::<bool>("done").unwrap());
assert_eq!(
row.value("title").unwrap(),
&Value::Text("Hello".to_string())
);
}
#[test]
fn columnar_result_keeps_batches_until_rows_are_requested() {
let fields = vec![
Field::new("id", DataType::Int64, false),
Field::new("title", DataType::Utf8, false),
];
let batch = RecordBatch::try_new(
Arc::new(Schema::new(fields.clone())),
vec![
Arc::new(datafusion::arrow::array::Int64Array::from(vec![1, 2])),
Arc::new(datafusion::arrow::array::StringArray::from(vec!["a", "b"])),
],
)
.expect("test columnar batch should be valid");
let batches: Arc<[RecordBatch]> = vec![batch].into();
let result = ExecuteResult::from_columnar_result(fields, batches, Vec::new());
assert_eq!(result.columns(), ["id", "title"]);
assert_eq!(
result.column_types(),
&[ResultColumnType::Integer, ResultColumnType::Text]
);
assert!(
result
.backing
.as_ref()
.unwrap()
.columnar
.lock()
.unwrap()
.is_some()
);
let rows = result.rows();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].get::<i64>("id").unwrap(), 1);
assert_eq!(rows[1].get::<String>("title").unwrap(), "b");
assert_eq!(result.rows().as_ptr(), rows.as_ptr());
assert!(
result
.backing
.as_ref()
.unwrap()
.columnar
.lock()
.unwrap()
.is_none()
);
}
#[test]
fn zero_column_result_preserves_empty_rows_and_row_traits() {
let result = ExecuteResult::from_rows(Vec::new(), vec![Vec::new(), Vec::new()]);
assert!(result.columns().is_empty());
assert_eq!(result.rows().len(), 2);
assert!(result.rows().iter().all(|row| row.values().is_empty()));
assert_eq!(result.rows()[0], result.rows()[1]);
assert_eq!(result.rows()[0].clone(), result.rows()[0]);
assert_eq!(
format!("{:?}", result.rows()[0]),
"Row { columns: [], values: [] }"
);
}
#[test]
fn cloned_row_detaches_from_shared_result_value_arena() {
let result = ExecuteResult::from_rows(
vec!["id".to_string(), "title".to_string()],
vec![
vec![Value::Integer(1), Value::Text("a".to_string())],
vec![Value::Integer(2), Value::Text("b".to_string())],
],
);
let source = &result.rows()[0];
let cloned = source.clone();
assert!(!Arc::ptr_eq(&source.backing, &cloned.backing));
assert!(Arc::ptr_eq(
&source.backing.columns,
&cloned.backing.columns
));
assert_eq!(source, &cloned);
drop(result);
assert_eq!(cloned.get::<i64>("id").unwrap(), 1);
assert_eq!(cloned.get::<String>("title").unwrap(), "a");
}
#[test]
fn execute_result_clone_shares_immutable_backing() {
let result = ExecuteResult::from_rows(
vec!["content".to_string()],
vec![vec![Value::Blob(vec![b'x'; 1024 * 1024].into())]],
);
let cloned = result.clone();
assert!(Arc::ptr_eq(
result.backing.as_ref().unwrap(),
cloned.backing.as_ref().unwrap()
));
assert_eq!(result, cloned);
}
#[test]
fn mutation_result_equality_is_independent_of_empty_backing_representation() {
let inline = ExecuteResult::from_rows_affected(7);
let materialized =
ExecuteResult::from_query_parts(Vec::new(), Vec::new(), Vec::new(), 7, Vec::new());
assert_eq!(inline, materialized);
}
#[test]
fn row_get_errors_on_missing_column_and_wrong_type() {
let result = ExecuteResult::from_rows(
vec!["title".to_string()],
vec![vec![Value::Text("Hello".to_string())]],
);
let row = &result.rows()[0];
let missing = row.get::<String>("missing").unwrap_err();
assert_eq!(missing.code, LixError::CODE_COLUMN_NOT_FOUND);
assert!(missing.message.contains("available columns: title"));
let wrong_type = row.get::<bool>("title").unwrap_err();
assert_eq!(wrong_type.code, "LIX_ERROR_VALUE_TYPE");
}
#[tokio::test]
async fn coherent_read_batch_rejects_write_statements() {
let session = open_session().await;
let statements: [(&str, &[Value]); 1] = [(
"INSERT INTO lix_key_value (key, value) VALUES ('batch-write', 'value')",
&[],
)];
let error = session
.execute_coherent_read_batch(&statements)
.await
.expect_err("write statement should be rejected");
assert_eq!(error.code, LixError::CODE_INVALID_PARAM);
assert!(
error
.message
.contains("execute_coherent_read_batch only accepts read statements")
);
}
#[tokio::test]
async fn tracked_insert_fast_lane_rejects_duplicate_committed_identity_without_overwrite() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_key_value (key, value) \
VALUES ('duplicate-fast-lane', 'original')",
&[],
)
.await
.expect("the original tracked row should commit");
let error = session
.execute(
"INSERT INTO lix_key_value (key, value) \
VALUES ('duplicate-fast-lane', 'replacement')",
&[],
)
.await
.expect_err("a committed tracked INSERT identity must remain absent-only");
assert_eq!(error.code, LixError::CODE_UNIQUE);
let result = session
.execute(
"SELECT value FROM lix_key_value \
WHERE key = 'duplicate-fast-lane'",
&[],
)
.await
.expect("the original row should remain readable after rejection");
assert_eq!(result.rows().len(), 1);
assert_eq!(
result.rows()[0]
.get::<serde_json::Value>("value")
.expect("value should remain JSON"),
serde_json::json!("original"),
"the rejected INSERT must not overwrite committed state"
);
}
#[tokio::test]
async fn empty_coherent_batch_keeps_snapshot_metadata() {
let session = open_session().await;
let empty = session.execute_coherent_read_batch(&[]).await.unwrap();
let nonempty = session
.execute_coherent_read_batch(&[("SELECT 1", &[])])
.await
.unwrap();
assert!(empty.results.is_empty());
assert_eq!(empty.active_branch_id, nonempty.active_branch_id);
assert_eq!(
empty.active_branch_commit_id,
nonempty.active_branch_commit_id
);
assert_eq!(
empty.storage_mutation_revision,
nonempty.storage_mutation_revision
);
}
#[tokio::test]
async fn shared_read_batch_preserves_each_apis_error_details() {
let session = open_session().await;
let sql = "SELECT nonexistent_column FROM lix_file";
let ordinary = session
.execute_batch_with_options(
&[batch_statement("SELECT 1"), batch_statement(sql)],
ExecuteOptions::default(),
)
.await
.unwrap_err();
let coherent = session
.execute_coherent_read_batch(&[("SELECT 1", &[]), (sql, &[])])
.await
.unwrap_err();
assert_eq!(ordinary.code, coherent.code);
assert_eq!(ordinary.details.as_ref().unwrap()["statementIndex"], 1);
assert!(
coherent
.details
.as_ref()
.and_then(|details| details.get("statementIndex"))
.is_none()
);
}
#[tokio::test]
async fn coherent_read_batch_returns_metadata_and_ordered_results() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('batch-read', 'value')",
&[],
)
.await
.expect("seed row");
let active_branch_id = session
.active_branch_id()
.await
.expect("active branch id should load");
let storage_mutation_revision = session
.storage_mutation_revision()
.await
.expect("mutation revision should load");
let active_branch_commit_id = session
.execute("SELECT lix_active_branch_commit_id() AS commit_id", &[])
.await
.expect("active branch commit should load")
.rows()[0]
.get::<String>("commit_id")
.expect("commit id should be text");
let statements: [(&str, &[Value]); 3] = [
("SELECT 'first' AS label", &[]),
(
"SELECT key, value FROM lix_key_value WHERE key = 'batch-read'",
&[],
),
(
"SELECT lixcol_position \
FROM lix_history('lix_key_value') \
WHERE key = 'batch-read'",
&[],
),
];
let batch = session
.execute_coherent_read_batch(&statements)
.await
.expect("coherent read batch should execute");
assert_eq!(batch.active_branch_id, active_branch_id);
assert_eq!(batch.active_branch_commit_id, active_branch_commit_id);
assert_eq!(batch.storage_mutation_revision, storage_mutation_revision);
assert_eq!(batch.results.len(), 3);
assert_eq!(
batch.results[0].rows()[0].get::<String>("label").unwrap(),
"first"
);
let row = &batch.results[1].rows()[0];
assert_eq!(row.get::<String>("key").unwrap(), "batch-read");
assert_eq!(
row.get::<serde_json::Value>("value").unwrap(),
serde_json::json!("value")
);
assert_eq!(
batch.results[2].rows()[0]
.get::<i64>("lixcol_position")
.unwrap(),
0
);
}
#[tokio::test]
async fn coherent_read_batch_registers_union_of_referenced_providers() {
let session = open_session().await;
let statements: [(&str, &[Value]); 3] = [
("SELECT 1 AS one", &[]),
("SELECT COUNT(*) AS files FROM lix_file", &[]),
("SELECT COUNT(*) AS changes FROM lix_change", &[]),
];
let batch = session
.execute_coherent_read_batch(&statements)
.await
.expect("coherent batch should register every referenced provider");
assert_eq!(batch.results.len(), 3);
assert_eq!(batch.results[0].rows()[0].get::<i64>("one").unwrap(), 1);
assert_eq!(batch.results[1].rows()[0].get::<i64>("files").unwrap(), 1);
assert!(batch.results[2].rows()[0].get::<i64>("changes").unwrap() > 0);
}
#[tokio::test]
async fn referenced_provider_reads_preserve_complex_and_catalog_wide_queries() {
let session = open_session().await;
let complex = session
.execute(
"WITH files AS (SELECT id FROM lix_file) \
SELECT COUNT(*) AS row_count \
FROM files AS file_a \
JOIN files AS file_b ON file_a.id = file_b.id \
LEFT JOIN (\
SELECT row_pk FROM lix_change \
UNION ALL \
SELECT row_pk FROM lix_change\
) AS changes ON false",
&[],
)
.await
.expect("nested CTE, self-join, and UNION should resolve providers");
assert_eq!(complex.rows()[0].get::<i64>("row_count").unwrap(), 1);
let catalog = session
.execute(
"SELECT COUNT(*) AS surfaces \
FROM information_schema.tables \
WHERE table_schema = 'public'",
&[],
)
.await
.expect("information_schema should retain catalog-wide visibility");
assert!(catalog.rows()[0].get::<i64>("surfaces").unwrap() > 1);
}
#[tokio::test]
async fn read_provider_selection_reuses_compiled_catalog_for_dynamic_visibility() {
let session = open_session().await;
let schema_loads = || {
session
.catalog_context
.sql_read_schema_load_count_for_test()
};
let before = schema_loads();
session
.execute("SELECT 1 AS one", &[])
.await
.expect("table-free read should execute");
assert_eq!(schema_loads(), before, "SELECT 1 needs no SQL catalog");
session
.execute("SELECT COUNT(*) AS rows FROM lix_key_value", &[])
.await
.expect("fixed schema surface should execute");
assert_eq!(
schema_loads(),
before,
"fixed row metadata comes from compile-time schemas"
);
session
.execute(
"SELECT COUNT(*) AS rows FROM lix_history('lix_key_value')",
&[],
)
.await
.expect("fixed history surface should execute");
assert_eq!(
schema_loads(),
before,
"fixed history metadata comes from compile-time schemas"
);
session
.execute(
"SELECT COUNT(*) AS rows FROM lix_key_value AS kv \
JOIN lix_change AS change ON false",
&[],
)
.await
.expect("join of fixed surfaces should execute");
assert_eq!(
schema_loads(),
before,
"a join remains storage-free when every table is fixed"
);
session
.execute(
"SELECT COUNT(*) AS surfaces FROM information_schema.tables",
&[],
)
.await
.expect("information schema should execute");
assert_eq!(
schema_loads(),
before,
"catalog-wide visibility must use the revision-keyed catalog instead of rescanning schemas"
);
let custom_schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "custom_catalog_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(custom_schema.to_string())],
)
.await
.expect("custom schema should register");
let before_custom_read = schema_loads();
session
.execute("SELECT COUNT(*) AS rows FROM custom_catalog_probe", &[])
.await
.expect("custom row should execute");
assert_eq!(
schema_loads(),
before_custom_read,
"custom row metadata must use the compiled catalog instead of rescanning schemas"
);
let before_mixed_join = schema_loads();
session
.execute(
"SELECT COUNT(*) AS rows FROM lix_key_value AS kv \
JOIN custom_catalog_probe AS custom ON false",
&[],
)
.await
.expect("mixed fixed/custom join should execute");
assert_eq!(
schema_loads(),
before_mixed_join,
"one custom table must keep using the compiled catalog without rescanning schemas"
);
let mut next_schema = custom_schema.clone();
next_schema["key"] = serde_json::json!("custom_catalog_probe_after_mutation");
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(next_schema.to_string())],
)
.await
.expect("second custom schema should register");
let before_changed_catalog_read = schema_loads();
session
.execute(
"SELECT COUNT(*) AS rows FROM custom_catalog_probe_after_mutation",
&[],
)
.await
.expect("schema revision must invalidate the cached SQL catalog");
assert_eq!(
schema_loads(),
before_changed_catalog_read,
"the next catalog generation must still avoid the uncached schema projection"
);
}
#[tokio::test]
async fn programmatic_replace_then_sql_update_preserves_durable_created_at() {
const KEY: &str = "created-at-overlay";
const FIRST_CREATED_AT: &str = "2020-01-01T00:00:00.000Z";
fn row(value: &str, created_at: Option<&str>) -> TransactionWriteRow {
TransactionWriteRow {
row_pk: Some(RowPk::single(KEY)),
schema_key: "lix_key_value".into(),
file_id: None,
snapshot: Some(TransactionJson::from_value_for_test(serde_json::json!({
"key": KEY,
"value": value,
}))),
metadata: None,
origin: None,
created_at: created_at.map(str::to_owned),
updated_at: None,
global: true,
change_id: None,
commit_id: None,
untracked: false,
branch_id: crate::GLOBAL_BRANCH_ID.into(),
}
}
let session = open_session().await;
let mut seed = session
.begin_transaction()
.await
.expect("seed transaction should begin");
seed.transaction_mut()
.expect("seed transaction should be open")
.stage_rows(RawWriteBatch::from_test_rows(vec![row(
"seed",
Some(FIRST_CREATED_AT),
)]))
.await
.expect("programmatic seed should stage");
seed.commit()
.await
.expect("programmatic seed should commit");
let mut transaction = session
.begin_transaction()
.await
.expect("transaction should begin");
transaction
.transaction_mut()
.expect("transaction should be open")
.stage_rows(RawWriteBatch::from_test_rows(vec![row(
"programmatic replacement",
None,
)]))
.await
.expect("programmatic replacement should stage");
transaction
.execute(
"UPDATE lix_key_value SET value = 'sql replacement' \
WHERE key = 'created-at-overlay'",
&[],
)
.await
.expect("SQL update should read and replace the staged row");
transaction
.commit()
.await
.expect("transaction should commit");
let created_at = session
.execute(
"SELECT lixcol_created_at FROM lix_key_value \
WHERE key = 'created-at-overlay'",
&[],
)
.await
.expect("final row should be readable")
.rows()[0]
.get::<String>("lixcol_created_at")
.expect("created timestamp should be text");
assert_eq!(created_at, FIRST_CREATED_AT);
}
#[tokio::test]
async fn transaction_declared_column_filters_see_staged_rows() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "transaction_filter_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "locale", "type": "text", "nullable": false },
{ "name": "count", "type": "int8", "nullable": false }
],
"primary_key": ["id"]
});
session
.execute(
"INSERT INTO lix_registered_schema (value) VALUES (CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("register schema");
session.execute(
"INSERT INTO transaction_filter_probe (id, locale, count) VALUES ('existing', 'de', 1)",
&[],
).await.expect("seed committed row");
let mut transaction = session
.begin_transaction()
.await
.expect("begin transaction");
transaction
.execute(
"INSERT INTO transaction_filter_probe (id, locale, count) VALUES ('new', 'en', 7)",
&[],
)
.await
.expect("stage inserted row");
for sql in [
"SELECT id FROM transaction_filter_probe WHERE locale = 'en'",
"SELECT id FROM transaction_filter_probe WHERE locale IN ('en', 'fr')",
"SELECT id FROM transaction_filter_probe WHERE count > 6",
"WITH matching AS (SELECT id FROM transaction_filter_probe WHERE locale = 'en') SELECT id FROM matching",
] {
let result = transaction
.execute(sql, &[])
.await
.expect("read staged row");
assert_eq!(result.len(), 1, "{sql}");
assert_eq!(
result.rows()[0].get::<String>("id").unwrap(),
"new",
"{sql}"
);
}
transaction.execute(
"UPDATE transaction_filter_probe SET locale = 'en', count = 8 WHERE id = 'existing'",
&[],
).await.expect("stage updated row");
let matching = transaction
.execute(
"SELECT id FROM transaction_filter_probe WHERE locale = $1 ORDER BY id",
&[Value::Text("en".into())],
)
.await
.expect("read inserted and updated rows");
assert_eq!(matching.len(), 2);
let old_value = transaction
.execute(
"SELECT id FROM transaction_filter_probe WHERE locale = 'de'",
&[],
)
.await
.expect("staged update masks committed value");
assert!(old_value.is_empty());
transaction
.execute("DELETE FROM transaction_filter_probe WHERE count = 7", &[])
.await
.expect("delete by staged non-primary value");
let remaining = transaction
.execute(
"SELECT id FROM transaction_filter_probe WHERE locale = 'en'",
&[],
)
.await
.expect("deleted rows stay hidden");
assert_eq!(remaining.len(), 1);
assert_eq!(remaining.rows()[0].get::<String>("id").unwrap(), "existing");
transaction.rollback().await.expect("roll back transaction");
let committed = session
.execute(
"SELECT id FROM transaction_filter_probe WHERE locale = 'de'",
&[],
)
.await
.expect("rollback preserves original row");
assert_eq!(committed.len(), 1);
assert_eq!(committed.rows()[0].get::<String>("id").unwrap(), "existing");
}
#[tokio::test]
async fn transaction_referenced_provider_reads_see_staged_writes() {
let session = open_session().await;
let mut transaction = session
.begin_transaction()
.await
.expect("transaction should begin");
transaction
.execute(
"INSERT INTO lix_file (id, path) VALUES ('01920000-0000-7000-8000-000000000422', '/selected.txt')",
&[],
)
.await
.expect("file should stage");
let result = transaction
.execute(
"WITH selected AS (\
SELECT id FROM lix_file WHERE id = '01920000-0000-7000-8000-000000000422'\
) \
SELECT id FROM selected",
&[],
)
.await
.expect("selected overlay provider should expose staged writes");
assert_eq!(
result.rows()[0].get::<String>("id").unwrap(),
"01920000-0000-7000-8000-000000000422"
);
transaction
.rollback()
.await
.expect("transaction should roll back");
}
#[tokio::test]
async fn explicit_transaction_literal_updates_preserve_escaped_string_values() {
let session = open_session().await;
for (key, value) in [("auto'one", "seed one"), ("auto'two", "seed two")] {
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ($1, $2)",
&[Value::Text(key.to_string()), Value::Text(value.to_string())],
)
.await
.expect("seed row should commit");
}
let mut transaction = session
.begin_transaction()
.await
.expect("transaction should begin");
let first = transaction
.execute(
"UPDATE lix_key_value SET value = 'second''s value' WHERE key = 'auto''two'",
&[],
)
.await
.expect("first literal update should stage");
let second = transaction
.execute(
"UPDATE lix_key_value SET value = 'first''s value' WHERE key = 'auto''one'",
&[],
)
.await
.expect("descending literal update should cross the order barrier");
assert_eq!(first.rows_affected(), 1);
assert_eq!(second.rows_affected(), 1);
transaction
.commit()
.await
.expect("literal updates should commit atomically");
let values = session
.execute(
"SELECT key, value FROM lix_key_value WHERE key IN ($1, $2) ORDER BY key",
&[
Value::Text("auto'one".to_string()),
Value::Text("auto'two".to_string()),
],
)
.await
.expect("updated rows should be readable");
assert_eq!(values.len(), 2);
assert_eq!(
values.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("first's value")
);
assert_eq!(
values.rows()[1].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("second's value")
);
}
#[tokio::test]
async fn explicit_transaction_parameter_updates_reset_membership_on_descending_keys() {
let session = open_session().await;
for key in ["parameter-a", "parameter-z"] {
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ($1, $2)",
&[
Value::Text(key.to_string()),
Value::Text("seed".to_string()),
],
)
.await
.expect("seed row should commit");
}
let mut transaction = session.begin_transaction().await.unwrap();
let sql = "UPDATE lix_key_value SET value = $1 WHERE key = $2";
for (key, value) in [("parameter-z", "updated-z"), ("parameter-a", "updated-a")] {
assert_eq!(
transaction
.execute(
sql,
&[Value::Text(value.to_string()), Value::Text(key.to_string()),],
)
.await
.expect("descending parameter update should stage")
.rows_affected(),
1
);
}
transaction.commit().await.unwrap();
let values = session
.execute(
"SELECT key, value FROM lix_key_value WHERE key IN ($1, $2) ORDER BY key",
&[
Value::Text("parameter-a".to_string()),
Value::Text("parameter-z".to_string()),
],
)
.await
.unwrap();
assert_eq!(
values.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("updated-a")
);
assert_eq!(
values.rows()[1].get::<serde_json::Value>("value").unwrap(),
serde_json::json!("updated-z")
);
}
#[tokio::test]
async fn explicit_transaction_certified_json_pointer_updates_observe_staged_rows() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "json_pointer",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("json pointer schema should register");
session
.execute(
"INSERT INTO json_pointer (path, value) VALUES ($1, CAST($2 AS JSONB))",
&[
Value::Text("/certified".to_string()),
Value::Text("{\"step\":0}".to_string()),
],
)
.await
.expect("json pointer seed should commit");
let mut transaction = session
.begin_transaction()
.await
.expect("transaction should begin");
assert_eq!(sql2::take_certified_single_path_value_replacements(), 0);
let sql = "UPDATE json_pointer SET value = CAST($1 AS JSONB) WHERE path = $2";
for step in [1, 2] {
let result = transaction
.execute(
sql,
&[
Value::Text(format!("{{\"step\":{step}}}")),
Value::Text("/certified".to_string()),
],
)
.await
.expect("certified replacement should stage");
assert_eq!(result.rows_affected(), 1);
}
let missing = transaction
.execute(
sql,
&[
Value::Text("{\"step\":3}".to_string()),
Value::Text("/missing".to_string()),
],
)
.await
.expect("missing certified replacement should succeed");
assert_eq!(missing.rows_affected(), 0);
assert_eq!(sql2::take_certified_single_path_value_replacements(), 2);
transaction
.commit()
.await
.expect("certified replacements should commit atomically");
let result = session
.execute(
"SELECT value FROM json_pointer WHERE path = $1",
&[Value::Text("/certified".to_string())],
)
.await
.expect("committed json pointer should be visible");
assert_eq!(result.len(), 1);
assert_eq!(
result.rows()[0]
.get::<serde_json::Value>("value")
.expect("JSON value should decode"),
serde_json::json!({"step": 2})
);
}
#[tokio::test]
async fn packed_mutation_membership_defers_to_transaction_overlay() {
const ROW_COUNT: usize = 1_024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "packed_journal_overlay_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("overlay probe schema should register");
let inserts = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: "INSERT INTO packed_journal_overlay_probe (path, value) VALUES ($1, CAST($2 AS JSONB))"
.to_string(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text("{\"state\":\"base\"}".to_string()),
],
})
.collect::<Vec<_>>();
session
.execute_batch(&inserts)
.await
.expect("packed base should seed");
let mut transaction = session
.begin_transaction()
.await
.expect("transaction should begin");
let update_sql =
"UPDATE packed_journal_overlay_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
transaction
.execute(
update_sql,
&[
Value::Text("{\"state\":\"updated-base\"}".to_string()),
Value::Text("0000".to_string()),
],
)
.await
.expect("base update should prepare packed membership");
transaction
.execute(
"INSERT INTO packed_journal_overlay_probe (path, value) VALUES ('2000', CAST('{\"state\":\"inserted\"}' AS JSONB))",
&[],
)
.await
.expect("transaction-local insert should stage");
let inserted_update = transaction
.execute(
update_sql,
&[
Value::Text("{\"state\":\"updated-insert\"}".to_string()),
Value::Text("2000".to_string()),
],
)
.await
.expect("update must observe the transaction-local insert");
assert_eq!(inserted_update.rows_affected(), 1);
transaction
.execute(
"DELETE FROM packed_journal_overlay_probe WHERE path = '0001'",
&[],
)
.await
.expect("transaction-local delete should stage");
let deleted_update = transaction
.execute(
update_sql,
&[
Value::Text("{\"state\":\"must-not-resurrect\"}".to_string()),
Value::Text("0001".to_string()),
],
)
.await
.expect("update after a staged delete should remain a no-op");
assert_eq!(deleted_update.rows_affected(), 0);
transaction
.commit()
.await
.expect("transaction should commit");
let rows = session
.execute(
"SELECT path, value FROM packed_journal_overlay_probe \
WHERE path IN ('0000', '0001', '2000') ORDER BY path",
&[],
)
.await
.expect("committed overlay result should be readable");
assert_eq!(rows.len(), 2);
assert_eq!(rows.rows()[0].get::<String>("path").unwrap(), "0000");
assert_eq!(
rows.rows()[0].get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"state": "updated-base"})
);
assert_eq!(rows.rows()[1].get::<String>("path").unwrap(), "2000");
assert_eq!(
rows.rows()[1].get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"state": "updated-insert"})
);
}
#[tokio::test]
async fn stale_complete_journal_replacement_rebases_over_a_disjoint_insert() {
const ROW_COUNT: usize = 1_024;
let storage = Memory::default();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let engine = Engine::new(storage)
.await
.expect("initialized storage should create engine");
let session = engine
.open_session()
.await
.expect("replacement session should open");
let concurrent_session = engine
.open_session()
.await
.expect("concurrent session should open");
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "stale_journal_replacement_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("stale replacement schema should register");
let inserts = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: "INSERT INTO stale_journal_replacement_probe (path, value) VALUES ($1, CAST($2 AS JSONB))"
.to_string(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text("{\"state\":\"base\"}".to_string()),
],
})
.collect::<Vec<_>>();
session
.execute_batch(&inserts)
.await
.expect("packed base should seed");
let original_created_at = session
.execute(
"SELECT lixcol_created_at FROM stale_journal_replacement_probe WHERE path = '0000'",
&[],
)
.await
.expect("seed lifecycle should be readable")
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
let mut replacement = session
.begin_transaction()
.await
.expect("replacement transaction should begin");
let update_sql =
"UPDATE stale_journal_replacement_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
for row_index in 0..ROW_COUNT {
let result = replacement
.execute(
update_sql,
&[
Value::Text("{\"state\":\"replacement\"}".to_string()),
Value::Text(format!("{row_index:04}")),
],
)
.await
.expect("replacement row should stage");
assert_eq!(result.rows_affected(), 1);
}
concurrent_session
.execute(
"INSERT INTO stale_journal_replacement_probe (path, value) \
VALUES ('2000', CAST('{\"state\":\"concurrent\"}' AS JSONB))",
&[],
)
.await
.expect("disjoint insert should commit first");
replacement
.commit()
.await
.expect("prepared SQL updates must rebase over a disjoint insert");
let replaced = session
.execute(
"SELECT COUNT(*) AS count FROM stale_journal_replacement_probe \
WHERE value ->> 'state' = 'replacement'",
&[],
)
.await
.expect("replaced rows should be readable");
assert_eq!(
replaced.rows()[0].get::<i64>("count").unwrap(),
ROW_COUNT as i64
);
let rows = concurrent_session
.execute(
"SELECT COUNT(*) AS count FROM stale_journal_replacement_probe",
&[],
)
.await
.expect("final generation should be readable");
assert_eq!(
rows.rows()[0].get::<i64>("count").unwrap(),
(ROW_COUNT + 1) as i64,
"rebasing the stale complete-set journal must preserve the disjoint insert"
);
let concurrent = concurrent_session
.execute(
"SELECT value FROM stale_journal_replacement_probe WHERE path = '2000'",
&[],
)
.await
.expect("concurrent row should remain point-readable");
assert_eq!(
concurrent.rows()[0]
.get::<serde_json::Value>("value")
.unwrap(),
serde_json::json!({"state": "concurrent"})
);
let unchanged_created_at = session
.execute(
"SELECT lixcol_created_at FROM stale_journal_replacement_probe WHERE path = '0000'",
&[],
)
.await
.expect("updated lifecycle should be readable")
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
assert_eq!(unchanged_created_at, original_created_at);
}
#[tokio::test]
async fn sequential_complete_update_publishes_direct_journal() {
const ROW_COUNT: usize = 512;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "direct_journal_seal_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("direct journal schema should register");
let inserts = (0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql:
"INSERT INTO direct_journal_seal_probe (path, value) VALUES ($1, CAST($2 AS JSONB))"
.to_string(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text("{\"state\":\"base\"}".to_string()),
],
})
.collect::<Vec<_>>();
session
.execute_batch(&inserts)
.await
.expect("packed base should seed");
let mut transaction = session
.begin_transaction()
.await
.expect("direct journal transaction should begin");
let update_sql =
"UPDATE direct_journal_seal_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
for row_index in 0..ROW_COUNT {
let result = transaction
.execute(
update_sql,
&[
Value::Text("{\"state\":\"replacement\"}".to_string()),
Value::Text(format!("{row_index:04}")),
],
)
.await
.expect("direct journal row should stage");
assert_eq!(result.rows_affected(), 1);
}
assert_eq!(
crate::transaction::take_direct_journal_replacement_publications(
"direct_journal_seal_probe",
),
0
);
let visible = transaction
.execute(
"SELECT path, value FROM direct_journal_seal_probe \
WHERE path IN ('0000', '0256', '0511') ORDER BY path",
&[],
)
.await
.expect("point reads must observe the immutable journal");
assert_eq!(visible.len(), 3);
for (row, expected_path) in visible.rows().iter().zip(["0000", "0256", "0511"]) {
assert_eq!(row.get::<String>("path").unwrap(), expected_path);
assert_eq!(
row.get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"state": "replacement"})
);
}
transaction
.commit()
.await
.expect("direct journal should commit");
assert_eq!(
crate::transaction::take_direct_journal_replacement_publications(
"direct_journal_seal_probe",
),
1,
"the complete scalar generation must seal without PreparedStateBatch"
);
let mut repeated = session
.begin_transaction()
.await
.expect("repeated direct journal transaction should begin");
for row_index in 0..ROW_COUNT {
repeated
.execute(
update_sql,
&[
Value::Text("{\"state\":\"replacement\"}".to_string()),
Value::Text(format!("{row_index:04}")),
],
)
.await
.expect("identical repeated journal row should stage");
}
repeated
.commit()
.await
.expect("identical replacement generation should commit");
assert_eq!(
crate::transaction::take_direct_journal_replacement_publications(
"direct_journal_seal_probe",
),
1
);
session
.create_checkpoint()
.await
.expect("a direct replacement must leave checkpointable root authority");
}
#[tokio::test]
async fn complete_mutation_journal_is_visible_before_commit() {
const ROW_COUNT: usize = 1_024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "journal_read_your_writes_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute_batch(
&(0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: "INSERT INTO journal_read_your_writes_probe (path, value) VALUES ($1, CAST($2 AS JSONB))".to_string(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text("{\"state\":\"base\"}".to_string()),
],
})
.collect::<Vec<_>>(),
)
.await
.unwrap();
let original_created_at = session
.execute(
"SELECT lixcol_created_at FROM journal_read_your_writes_probe WHERE path = '0000'",
&[],
)
.await
.unwrap()
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
let mut transaction = session.begin_transaction().await.unwrap();
let update_sql =
"UPDATE journal_read_your_writes_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
for row_index in 0..ROW_COUNT {
transaction
.execute(
update_sql,
&[
Value::Text("{\"state\":\"replacement\"}".to_string()),
Value::Text(format!("{row_index:04}")),
],
)
.await
.unwrap();
}
let visible = transaction
.execute(
"SELECT path, value, lixcol_created_at FROM journal_read_your_writes_probe \
WHERE path IN ('0000', '1023') ORDER BY path",
&[],
)
.await
.expect("a read barrier must expose the immutable mutation journal");
assert_eq!(visible.len(), 2);
for row in visible.rows() {
assert_eq!(
row.get::<serde_json::Value>("value").unwrap(),
serde_json::json!({"state": "replacement"})
);
assert_eq!(
row.get::<String>("lixcol_created_at").unwrap().as_str(),
original_created_at.as_str()
);
}
transaction.commit().await.unwrap();
assert_eq!(
crate::transaction::take_direct_journal_replacement_publications(
"journal_read_your_writes_probe",
),
1,
"an intervening read must not reconstruct the immutable journal"
);
}
#[tokio::test]
async fn mixed_journal_fallback_preserves_created_at() {
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "mixed_journal_lifecycle_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute(
"INSERT INTO mixed_journal_lifecycle_probe (path, value) \
VALUES ('existing', CAST('{\"state\":\"base\"}' AS JSONB))",
&[],
)
.await
.unwrap();
let original_created_at = session
.execute(
"SELECT lixcol_created_at FROM mixed_journal_lifecycle_probe WHERE path = 'existing'",
&[],
)
.await
.unwrap()
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
let mut transaction = session.begin_transaction().await.unwrap();
transaction
.execute(
"INSERT INTO mixed_journal_lifecycle_probe (path, value) \
VALUES ('inserted', CAST('{\"state\":\"inserted\"}' AS JSONB))",
&[],
)
.await
.unwrap();
transaction
.execute(
"UPDATE mixed_journal_lifecycle_probe SET value = CAST($1 AS JSONB) WHERE path = $2",
&[
Value::Text("{\"state\":\"updated\"}".to_string()),
Value::Text("existing".to_string()),
],
)
.await
.unwrap();
transaction.commit().await.unwrap();
let created_at = session
.execute(
"SELECT lixcol_created_at FROM mixed_journal_lifecycle_probe WHERE path = 'existing'",
&[],
)
.await
.unwrap()
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
assert_eq!(created_at, original_created_at);
}
#[tokio::test]
async fn checkpoint_parent_without_collection_lifecycle_uses_safe_lane() {
const ROW_COUNT: usize = 1_024;
let session = open_session().await;
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "rooted_journal_parent_probe",
"columns": [
{ "name": "path", "type": "text", "nullable": false },
{ "name": "value", "type": "jsonb", "nullable": false },
],
"primary_key": ["path"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.unwrap();
session
.execute_batch(
&(0..ROW_COUNT)
.map(|row_index| ExecuteBatchStatement {
label: None,
sql: "INSERT INTO rooted_journal_parent_probe (path, value) VALUES ($1, CAST($2 AS JSONB))".to_string(),
params: vec![
Value::Text(format!("{row_index:04}")),
Value::Text("{\"state\":\"base\"}".to_string()),
],
})
.collect::<Vec<_>>(),
)
.await
.unwrap();
let original_created_at = session
.execute(
"SELECT lixcol_created_at FROM rooted_journal_parent_probe WHERE path = '0000'",
&[],
)
.await
.unwrap()
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
session.create_checkpoint().await.unwrap();
crate::transaction::take_direct_journal_replacement_publications(
"rooted_journal_parent_probe",
);
let mut transaction = session.begin_transaction().await.unwrap();
let update_sql =
"UPDATE rooted_journal_parent_probe SET value = CAST($1 AS JSONB) WHERE path = $2";
for row_index in 0..ROW_COUNT {
transaction
.execute(
update_sql,
&[
Value::Text("{\"state\":\"replacement\"}".to_string()),
Value::Text(format!("{row_index:04}")),
],
)
.await
.unwrap();
}
let visible_created_at = transaction
.execute(
"SELECT lixcol_created_at FROM rooted_journal_parent_probe WHERE path = '0000'",
&[],
)
.await
.expect("fallback read must hydrate lifecycle without lowering the journal")
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
assert_eq!(visible_created_at, original_created_at);
transaction
.commit()
.await
.expect("a parent without collection lifecycle authority must use the safe lane");
assert_eq!(
crate::transaction::take_direct_journal_replacement_publications(
"rooted_journal_parent_probe",
),
0
);
let committed_created_at = session
.execute(
"SELECT lixcol_created_at FROM rooted_journal_parent_probe WHERE path = '0000'",
&[],
)
.await
.unwrap()
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap()
.clone();
assert_eq!(committed_created_at, original_created_at);
session
.create_checkpoint()
.await
.expect("a replacement immediately before checkpoint must retain a root alias");
let checkpointed_created_at = session
.execute(
"SELECT lixcol_created_at FROM rooted_journal_parent_probe WHERE path = '0000'",
&[],
)
.await
.unwrap()
.rows()[0]
.get::<String>("lixcol_created_at")
.unwrap();
assert_eq!(checkpointed_created_at, original_created_at);
}
#[tokio::test]
async fn explicit_transaction_origin_key_survives_addressable_change_assignment() {
let session = open_session().await;
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('origin-key-address', 'seed')",
&[],
)
.await
.expect("seed row should commit");
let mut transaction = session
.begin_transaction()
.await
.expect("transaction should begin");
transaction
.execute_with_options(
"UPDATE lix_key_value SET value = 'updated' \
WHERE key = 'origin-key-address'"
.to_owned(),
Vec::new(),
ExecuteOptions {
origin_key: Some("tx-origin".to_string()),
..Default::default()
},
)
.await
.expect("stamped update should stage");
transaction
.commit()
.await
.expect("stamped update should commit");
let result = session
.execute(
"SELECT change.origin_key \
FROM lix_key_value AS value \
JOIN lix_change AS change ON change.id = value.lixcol_change_id \
WHERE value.key = 'origin-key-address'",
&[],
)
.await
.expect("current change should be readable");
assert_eq!(
result.rows()[0]
.get::<String>("origin_key")
.expect("origin key should be text"),
"tx-origin"
);
}
#[tokio::test]
async fn explicit_file_transaction_origin_key_survives_addressable_change_assignment() {
const FILE_ID: &str = "01920000-0000-7000-8000-000000000411";
let session = open_session().await;
session
.execute_with_options(
"INSERT INTO lix_file (id, path, content) VALUES ($1, $2, $3)",
&[
Value::Text(FILE_ID.to_string()),
Value::Text("/origin-key.md".to_string()),
Value::Blob(b"one\n".to_vec().into()),
],
ExecuteOptions {
origin_key: Some("first-origin".to_string()),
..Default::default()
},
)
.await
.expect("seed file should commit");
session
.execute(
"UPDATE lix_file SET content = $1 WHERE id = $2",
&[
Value::Blob(b"two\n".to_vec().into()),
Value::Text(FILE_ID.to_string()),
],
)
.await
.expect("unstamped file update should commit");
let mut transaction = session
.begin_transaction()
.await
.expect("transaction should begin");
transaction
.execute_with_options(
"UPDATE lix_file SET content = $1 WHERE id = $2".to_owned(),
vec![
Value::Blob(b"three\n".to_vec().into()),
Value::Text(FILE_ID.to_string()),
],
ExecuteOptions {
origin_key: Some("tx-origin".to_string()),
..Default::default()
},
)
.await
.expect("stamped file update should stage");
transaction
.commit()
.await
.expect("stamped file update should commit");
let result = session
.execute(
"SELECT change.origin_key \
FROM lix_file AS file \
JOIN lix_change AS change ON change.id = file.lixcol_change_id \
WHERE file.id = $1",
&[Value::Text(FILE_ID.to_string())],
)
.await
.expect("current file change should be readable");
assert_eq!(
result.rows()[0]
.get::<String>("origin_key")
.expect("origin key should be text"),
"tx-origin"
);
}
#[tokio::test]
async fn reusable_read_plans_rebind_snapshots_concurrently_and_invalidate_on_catalog_change() {
let storage = Memory::default();
Engine::initialize(storage.clone())
.await
.expect("storage should initialize");
let engine = Engine::new(storage)
.await
.expect("initialized storage should create engine");
let session = engine
.open_session()
.await
.expect("first session should open");
let concurrent = engine
.open_session()
.await
.expect("second session should open");
let schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "read_plan_probe",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
{ "name": "revision", "type": "int8", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(schema.to_string())],
)
.await
.expect("register reusable-plan schema");
session
.execute(
"INSERT INTO read_plan_probe (id, revision) VALUES ('a', 1), ('b', 2)",
&[],
)
.await
.expect("seed reusable-plan rows");
let sql = "SELECT revision FROM read_plan_probe WHERE id = $1 AND revision >= 0";
let before = session.sql_planning_cache.read_plan_count();
let first = session
.execute(sql, &[Value::Text("a".to_string())])
.await
.expect("cold reusable plan should execute");
assert_eq!(first.rows()[0].get::<i64>("revision").unwrap(), 1);
assert_eq!(session.sql_planning_cache.read_plan_count(), before + 1);
let left_params = [Value::Text("a".to_string())];
let right_params = [Value::Text("b".to_string())];
let (left, right) = tokio::join!(
session.execute(sql, &left_params),
concurrent.execute(sql, &right_params),
);
assert_eq!(
left.expect("first concurrent cache hit").rows()[0]
.get::<i64>("revision")
.unwrap(),
1
);
assert_eq!(
right.expect("second concurrent cache hit").rows()[0]
.get::<i64>("revision")
.unwrap(),
2
);
assert_eq!(session.sql_planning_cache.read_plan_count(), before + 1);
session
.execute(
"INSERT INTO read_plan_probe (id, revision) VALUES ('c', 3)",
&[],
)
.await
.expect("commit ordinary data revision");
let revised = session
.execute(sql, &[Value::Text("c".to_string())])
.await
.expect("cached plan should bind the revised snapshot");
assert_eq!(revised.rows()[0].get::<i64>("revision").unwrap(), 3);
assert_eq!(session.sql_planning_cache.read_plan_count(), before + 1);
let added_schema = serde_json::json!({
"$schema": "https://lix.dev/schema-v1.json",
"key": "read_plan_catalog_revision",
"columns": [
{ "name": "id", "type": "text", "nullable": false },
],
"primary_key": ["id"],
});
session
.execute(
"INSERT INTO lix_registered_schema (schema_key, value) VALUES (CAST($1 AS JSONB) ->> 'key', CAST($1 AS JSONB))",
&[Value::Text(added_schema.to_string())],
)
.await
.expect("change the SQL catalog");
session
.execute(sql, &[Value::Text("c".to_string())])
.await
.expect("catalog change should compile a new plan");
assert_eq!(session.sql_planning_cache.read_plan_count(), before + 2);
let cached = session
.execute(sql, &[Value::Text("c".to_string())])
.await
.expect("cached differential query");
session.sql_planning_cache.clear_read_plans();
let uncached = session
.execute(sql, &[Value::Text("c".to_string())])
.await
.expect("uncached DataFusion differential query");
assert_eq!(
cached.rows()[0].values(),
uncached.rows()[0].values(),
"cached templates must match a fresh DataFusion plan"
);
}
}
#[cfg(test)]
mod assume_send_future_proofs {
use super::*;
use crate::storage_adapter::Memory;
fn is_send<T: Send>(_: &T) {}
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
#[allow(dead_code)]
fn read_file_content_inner_is_send(session: &SessionContext<Memory>) {
is_send(&session.read_file_content_inner(String::new(), None));
}
#[allow(dead_code)]
fn execute_with_kind_is_send(
session: &SessionContext<Memory>,
sql: &str,
params: &[Value],
options: ExecuteOptions,
metadata: ExecuteStatementMetadata,
) {
is_send(&session.execute_with_kind(sql, params, options, metadata, "execute", None, true));
}
#[allow(dead_code)]
fn execute_batch_inner_is_send(
session: &SessionContext<Memory>,
statements: &[ExecuteBatchStatement],
options: ExecuteOptions,
metadata: Vec<ExecuteStatementMetadata>,
) {
is_send(&session.execute_batch_with_options_and_metadata_inner(
statements, options, metadata, None, true,
));
}
#[allow(dead_code)]
fn transaction_execute_inner_is_send(
transaction: &mut SessionTransaction<Memory>,
sql: &str,
params: &[Value],
options: ExecuteOptions,
) {
is_send(&transaction.execute_with_options_inner(sql, params, options));
}
#[allow(dead_code)]
fn retained_values_are_send_for_every_storage<S>()
where
S: Storage + Clone + Send + Sync + 'static,
{
assert_send::<SessionContext<S>>();
assert_sync::<SessionContext<S>>();
assert_send::<SessionTransaction<S>>();
assert_send::<ExecuteResult>();
assert_sync::<ExecuteResult>();
assert_send::<Value>();
assert_sync::<Value>();
assert_send::<ExecuteBatchStatement>();
assert_sync::<ExecuteBatchStatement>();
assert_send::<ExecuteOptions>();
assert_send::<ExecuteStatementMetadata>();
assert_send::<S::Read<'static>>();
assert_sync::<S::Read<'static>>();
assert_send::<S::Write<'static>>();
}
#[allow(dead_code)]
fn storage_cursor_types_are_send() {
assert_send::<crate::storage::ScanCursor<'static>>();
assert_send::<crate::storage::GetManyResult>();
}
}
#[cfg(test)]
mod assume_send_future_proofs_borrowing {
use super::*;
use crate::session::borrowing_proof_storage::{BorrowingRead, BorrowingStorage};
fn is_send<T: Send>(_: &T) {}
fn assert_send<T: Send + ?Sized>() {}
fn assert_sync<T: Sync + ?Sized>() {}
#[allow(dead_code)]
fn read_file_content_inner_is_send(session: &SessionContext<BorrowingStorage>) {
is_send(&session.read_file_content_inner(String::new(), None));
}
#[allow(dead_code)]
fn shared_read_is_send_for_every_storage_and_lifetime<'a, S>()
where
S: Storage + Clone + Send + Sync + 'a,
{
assert_send::<SharedStorageAdapterRead<S::Read<'a>>>();
assert_sync::<SharedStorageAdapterRead<S::Read<'a>>>();
assert_send::<S::Read<'a>>();
assert_sync::<S::Read<'a>>();
}
#[allow(dead_code)]
fn shared_read_is_send_for_borrowing_adapter<'a>() {
assert_send::<SharedStorageAdapterRead<BorrowingRead<'a>>>();
assert_sync::<SharedStorageAdapterRead<BorrowingRead<'a>>>();
}
#[allow(dead_code)]
fn obstruction_pointees_are_sync<S>()
where
S: Storage + Clone + Send + Sync + 'static,
{
assert_sync::<str>();
assert_sync::<[Value]>();
assert_sync::<[ExecuteBatchStatement]>();
assert_sync::<SessionContext<S>>();
assert_sync::<crate::Lix<S>>();
assert_sync::<crate::storage_adapter::Memory>();
assert_sync::<tokio::sync::Mutex<()>>();
}
}
pub(crate) async fn prepare_partial_candidate_read_scope<StorageImpl>(
read: StorageAdapterReadScope<StorageImpl::Read<'_>>,
state: &crate::sync::PartialReplicaState,
interests: Option<&crate::hot_state::MovingReadInterestSnapshot>,
plugin_host: crate::plugin::runtime::PluginRuntimeHost,
hot: crate::hot_state::HotStateContext,
allow_missing_selected_control: bool,
) -> Result<crate::sync::PreparedCandidateState, LixError>
where
StorageImpl: Storage + 'static,
{
with_static_session_sql_read::<StorageImpl, _, _, _>(read, |read| async move {
crate::sync::prepare_candidate_state(
read,
state,
interests,
plugin_host,
hot,
allow_missing_selected_control,
)
.await
})
.await
}
pub(crate) async fn discover_read_fulfillment<StorageImpl: Storage + 'static>(
read: StorageAdapterReadScope<StorageImpl::Read<'_>>,
repository: &str, account: &str, lease_id: &str,
request: &crate::sync::ReadFulfillmentRequest,
hot: crate::hot_state::HotStateContext,
) -> Result<crate::sync::ReadFulfillmentResponse,LixError> {
with_static_session_sql_read::<StorageImpl,_,_,_>(read,|read| async move {
crate::sync::discover_read_fulfillment(read,repository,account,lease_id,request,hot).await
}).await
}