#[cfg(all(feature = "integration-postgres", feature = "replication"))]
mod pg_replication_identify {
use oxisql_postgres::{parse_pg_conn_str, PgReplicationConnection, TlsMode};
#[tokio::test]
#[ignore = "requires a live PostgreSQL server with wal_level=logical"]
async fn identify_system_returns_sane_fields() {
let url = match std::env::var("POSTGRES_URL") {
Ok(u) => u,
Err(_) => return,
};
let expected_dbname = parse_pg_conn_str(&url).expect("parse POSTGRES_URL").dbname;
let mut conn = PgReplicationConnection::connect(&url, TlsMode::Disabled)
.await
.expect("replication connect");
let identity = conn.identify_system().await.expect("IDENTIFY_SYSTEM");
assert!(
!identity.systemid.is_empty() && identity.systemid.chars().all(|c| c.is_ascii_digit()),
"systemid should be a non-empty decimal string, got {:?}",
identity.systemid
);
assert!(identity.timeline >= 1, "timeline should be >= 1");
assert!(
identity.xlogpos.as_u64() > 0,
"xlogpos should be a nonzero WAL position"
);
assert_eq!(
identity.dbname, expected_dbname,
"IDENTIFY_SYSTEM should echo back the connected database"
);
}
}
#[cfg(all(feature = "integration-postgres", feature = "replication"))]
mod pg_replication_slots {
use oxisql_postgres::{PgError, PgReplicationConnection, TlsMode};
const SLOT: &str = "oxisql_repl_temp_slot_test";
#[tokio::test]
#[ignore = "requires a live PostgreSQL server with wal_level=logical"]
async fn create_and_drop_temporary_slot() {
let url = match std::env::var("POSTGRES_URL") {
Ok(u) => u,
Err(_) => return,
};
let mut conn = PgReplicationConnection::connect(&url, TlsMode::Disabled)
.await
.expect("replication connect");
let created = conn
.create_replication_slot(SLOT, true)
.await
.expect("CREATE_REPLICATION_SLOT");
assert_eq!(created.slot_name, SLOT);
assert_eq!(created.output_plugin.as_deref(), Some("pgoutput"));
assert!(
created.consistent_point.as_u64() > 0,
"consistent_point should be a real WAL position"
);
conn.drop_replication_slot(SLOT)
.await
.expect("DROP_REPLICATION_SLOT");
let err = conn
.drop_replication_slot(SLOT)
.await
.expect_err("dropping an already-dropped slot should fail");
assert!(
matches!(err, PgError::Replication(_)),
"expected PgError::Replication, got {err:?}"
);
}
}
#[cfg(all(feature = "integration-postgres", feature = "replication"))]
mod pg_replication_streaming {
use futures::StreamExt;
use oxisql_core::{Connection, Value};
use oxisql_postgres::{
CellValue, LogicalReplicationMessage, Lsn, PgConnection, PgReplicationConnection,
ReplicationEvent, ReplicationStream, TlsMode,
};
async fn next_logical_message(
stream: &mut ReplicationStream,
) -> (Lsn, Lsn, LogicalReplicationMessage) {
loop {
let event = stream
.next()
.await
.expect("replication stream ended before the expected event arrived")
.expect("replication stream yielded an error");
if let ReplicationEvent::Logical {
wal_start,
wal_end,
message,
} = event
{
return (wal_start, wal_end, message);
}
}
}
const TABLE: &str = "oxisql_repl_stream_test";
const PUBLICATION: &str = "oxisql_repl_stream_pub";
const SLOT: &str = "oxisql_repl_stream_slot";
#[tokio::test]
#[ignore = "requires a live PostgreSQL server with wal_level=logical"]
async fn insert_update_delete_end_to_end() {
let url = match std::env::var("POSTGRES_URL") {
Ok(u) => u,
Err(_) => return,
};
let ddl_conn = PgConnection::connect(&url, TlsMode::Disabled)
.await
.expect("ddl connect");
ddl_conn
.execute_batch(&format!(
"DROP PUBLICATION IF EXISTS {PUBLICATION};
DROP TABLE IF EXISTS {TABLE};
CREATE TABLE {TABLE} (id BIGINT PRIMARY KEY, name TEXT);
CREATE PUBLICATION {PUBLICATION} FOR TABLE {TABLE};"
))
.await
.expect("setup DDL");
let mut repl_conn = PgReplicationConnection::connect(&url, TlsMode::Disabled)
.await
.expect("replication connect");
let slot = repl_conn
.create_replication_slot(SLOT, true)
.await
.expect("CREATE_REPLICATION_SLOT");
let mut stream = repl_conn
.start_logical_replication(SLOT, &[PUBLICATION], slot.consistent_point)
.await
.expect("START_REPLICATION");
ddl_conn
.execute_batch(&format!(
"BEGIN;
INSERT INTO {TABLE} (id, name) VALUES (1, 'Alice');
INSERT INTO {TABLE} (id, name) VALUES (2, 'Bob');
UPDATE {TABLE} SET name = 'Alicia' WHERE id = 1;
DELETE FROM {TABLE} WHERE id = 2;
COMMIT;"
))
.await
.expect("DML transaction");
let (_, _, begin) = next_logical_message(&mut stream).await;
assert!(
matches!(begin, LogicalReplicationMessage::Begin { .. }),
"expected Begin, got {begin:?}"
);
let (_, _, relation) = next_logical_message(&mut stream).await;
let rel_id = match relation {
LogicalReplicationMessage::Relation(body) => {
assert_eq!(body.name, TABLE);
assert_eq!(body.columns.len(), 2);
assert!(body.columns[0].key, "id should be the replica identity key");
assert!(!body.columns[1].key, "name should not be a key column");
body.rel_id
}
other => panic!("expected Relation, got {other:?}"),
};
let (_, _, insert1) = next_logical_message(&mut stream).await;
match insert1 {
LogicalReplicationMessage::Insert {
rel_id: r,
new_tuple,
} => {
assert_eq!(r, rel_id);
let values = stream.decode_tuple(r, &new_tuple).expect("decode insert 1");
assert_eq!(values[0], CellValue::Value(Value::I64(1)));
assert_eq!(
values[1],
CellValue::Value(Value::Text("Alice".to_string()))
);
}
other => panic!("expected Insert, got {other:?}"),
}
let (_, _, insert2) = next_logical_message(&mut stream).await;
match insert2 {
LogicalReplicationMessage::Insert {
rel_id: r,
new_tuple,
} => {
assert_eq!(r, rel_id);
let values = stream.decode_tuple(r, &new_tuple).expect("decode insert 2");
assert_eq!(values[0], CellValue::Value(Value::I64(2)));
assert_eq!(values[1], CellValue::Value(Value::Text("Bob".to_string())));
}
other => panic!("expected Insert, got {other:?}"),
}
let (_, _, update) = next_logical_message(&mut stream).await;
match update {
LogicalReplicationMessage::Update {
rel_id: r,
old_tuple,
new_tuple,
..
} => {
assert_eq!(r, rel_id);
let new_values = stream
.decode_tuple(r, &new_tuple)
.expect("decode update new");
assert_eq!(new_values[0], CellValue::Value(Value::I64(1)));
assert_eq!(
new_values[1],
CellValue::Value(Value::Text("Alicia".to_string()))
);
if let Some(old_tuple) = old_tuple {
let old_values = stream
.decode_tuple(r, &old_tuple)
.expect("decode update old");
assert_eq!(old_values[0], CellValue::Value(Value::I64(1)));
assert!(
matches!(
old_values[1],
CellValue::Value(Value::Null) | CellValue::UnchangedToast
),
"non-key column in a key-only old-row image should carry no real data, got {:?}",
old_values[1]
);
}
}
other => panic!("expected Update, got {other:?}"),
}
let (_, _, delete) = next_logical_message(&mut stream).await;
match delete {
LogicalReplicationMessage::Delete {
rel_id: r,
old_tuple,
..
} => {
assert_eq!(r, rel_id);
let values = stream.decode_tuple(r, &old_tuple).expect("decode delete");
assert_eq!(values[0], CellValue::Value(Value::I64(2)));
assert!(
matches!(
values[1],
CellValue::Value(Value::Null) | CellValue::UnchangedToast
),
"non-key column in a key-only DELETE old-row image should carry no real data, got {:?}",
values[1]
);
}
other => panic!("expected Delete, got {other:?}"),
}
let (_, commit_end_lsn, commit) = next_logical_message(&mut stream).await;
assert!(
matches!(commit, LogicalReplicationMessage::Commit { .. }),
"expected Commit, got {commit:?}"
);
stream.ack(commit_end_lsn).await.expect("ack");
drop(stream);
ddl_conn
.execute_batch(&format!(
"DROP PUBLICATION IF EXISTS {PUBLICATION}; DROP TABLE IF EXISTS {TABLE};"
))
.await
.expect("cleanup");
}
const TRUNCATE_TABLE: &str = "oxisql_repl_truncate_test";
const TRUNCATE_PUBLICATION: &str = "oxisql_repl_truncate_pub";
const TRUNCATE_SLOT: &str = "oxisql_repl_truncate_slot";
#[tokio::test]
#[ignore = "requires a live PostgreSQL server with wal_level=logical"]
async fn truncate_is_decoded() {
let url = match std::env::var("POSTGRES_URL") {
Ok(u) => u,
Err(_) => return,
};
let ddl_conn = PgConnection::connect(&url, TlsMode::Disabled)
.await
.expect("ddl connect");
ddl_conn
.execute_batch(&format!(
"DROP PUBLICATION IF EXISTS {TRUNCATE_PUBLICATION};
DROP TABLE IF EXISTS {TRUNCATE_TABLE};
CREATE TABLE {TRUNCATE_TABLE} (id BIGINT PRIMARY KEY);
INSERT INTO {TRUNCATE_TABLE} VALUES (1), (2), (3);
CREATE PUBLICATION {TRUNCATE_PUBLICATION} FOR TABLE {TRUNCATE_TABLE};"
))
.await
.expect("setup DDL");
let mut repl_conn = PgReplicationConnection::connect(&url, TlsMode::Disabled)
.await
.expect("replication connect");
let slot = repl_conn
.create_replication_slot(TRUNCATE_SLOT, true)
.await
.expect("CREATE_REPLICATION_SLOT");
let mut stream = repl_conn
.start_logical_replication(
TRUNCATE_SLOT,
&[TRUNCATE_PUBLICATION],
slot.consistent_point,
)
.await
.expect("START_REPLICATION");
ddl_conn
.execute(&format!("TRUNCATE {TRUNCATE_TABLE}"), &[])
.await
.expect("truncate");
let (_, _, begin) = next_logical_message(&mut stream).await;
assert!(
matches!(begin, LogicalReplicationMessage::Begin { .. }),
"expected Begin, got {begin:?}"
);
let (_, _, relation) = next_logical_message(&mut stream).await;
let rel_id = match relation {
LogicalReplicationMessage::Relation(body) => body.rel_id,
other => panic!("expected Relation, got {other:?}"),
};
let (_, _, truncate) = next_logical_message(&mut stream).await;
match truncate {
LogicalReplicationMessage::Truncate {
cascade,
restart_identity,
rel_ids,
} => {
assert!(!cascade, "no CASCADE was specified");
assert!(!restart_identity, "no RESTART IDENTITY was specified");
assert_eq!(rel_ids, vec![rel_id]);
}
other => panic!("expected Truncate, got {other:?}"),
}
let (_, commit_end_lsn, commit) = next_logical_message(&mut stream).await;
assert!(
matches!(commit, LogicalReplicationMessage::Commit { .. }),
"expected Commit, got {commit:?}"
);
stream.ack(commit_end_lsn).await.expect("ack");
drop(stream);
ddl_conn
.execute_batch(&format!(
"DROP PUBLICATION IF EXISTS {TRUNCATE_PUBLICATION}; \
DROP TABLE IF EXISTS {TRUNCATE_TABLE};"
))
.await
.expect("cleanup");
}
}
#[cfg(all(feature = "integration-postgres", feature = "replication"))]
mod pg_replication_resume {
use std::time::Duration;
use futures::StreamExt;
use oxisql_core::{Connection, Value};
use oxisql_postgres::{
CellValue, LogicalReplicationMessage, Lsn, PgConnection, PgReplicationConnection,
ReplicationEvent, ReplicationStream, TlsMode,
};
async fn next_logical_message(
stream: &mut ReplicationStream,
) -> (Lsn, Lsn, LogicalReplicationMessage) {
loop {
let event = stream
.next()
.await
.expect("replication stream ended before the expected event arrived")
.expect("replication stream yielded an error");
if let ReplicationEvent::Logical {
wal_start,
wal_end,
message,
} = event
{
return (wal_start, wal_end, message);
}
}
}
const TABLE: &str = "oxisql_repl_resume_test";
const PUBLICATION: &str = "oxisql_repl_resume_pub";
const SLOT: &str = "oxisql_repl_resume_slot";
#[tokio::test]
#[ignore = "requires a live PostgreSQL server with wal_level=logical"]
async fn resume_from_acked_lsn_skips_already_processed_transaction() {
let url = match std::env::var("POSTGRES_URL") {
Ok(u) => u,
Err(_) => return,
};
let ddl_conn = PgConnection::connect(&url, TlsMode::Disabled)
.await
.expect("ddl connect");
let _ = ddl_conn
.execute_batch(&format!(
"SELECT pg_drop_replication_slot('{SLOT}') FROM pg_replication_slots \
WHERE slot_name = '{SLOT}';"
))
.await;
ddl_conn
.execute_batch(&format!(
"DROP PUBLICATION IF EXISTS {PUBLICATION};
DROP TABLE IF EXISTS {TABLE};
CREATE TABLE {TABLE} (id BIGINT PRIMARY KEY);
CREATE PUBLICATION {PUBLICATION} FOR TABLE {TABLE};"
))
.await
.expect("setup DDL");
let mut repl_conn = PgReplicationConnection::connect(&url, TlsMode::Disabled)
.await
.expect("replication connect 1");
let slot = repl_conn
.create_replication_slot(SLOT, false)
.await
.expect("CREATE_REPLICATION_SLOT");
let mut stream = repl_conn
.start_logical_replication(SLOT, &[PUBLICATION], slot.consistent_point)
.await
.expect("START_REPLICATION 1");
ddl_conn
.execute(&format!("INSERT INTO {TABLE} (id) VALUES (1)"), &[])
.await
.expect("insert row 1");
let (_, _, begin1) = next_logical_message(&mut stream).await;
assert!(matches!(begin1, LogicalReplicationMessage::Begin { .. }));
let (_, _, relation1) = next_logical_message(&mut stream).await;
assert!(matches!(relation1, LogicalReplicationMessage::Relation(_)));
let (_, _, insert1) = next_logical_message(&mut stream).await;
assert!(matches!(insert1, LogicalReplicationMessage::Insert { .. }));
let (_, resume_lsn, commit1) = next_logical_message(&mut stream).await;
assert!(matches!(commit1, LogicalReplicationMessage::Commit { .. }));
stream.ack(resume_lsn).await.expect("ack");
drop(stream);
tokio::time::sleep(Duration::from_millis(200)).await;
ddl_conn
.execute(&format!("INSERT INTO {TABLE} (id) VALUES (2)"), &[])
.await
.expect("insert row 2");
let repl_conn2 = PgReplicationConnection::connect(&url, TlsMode::Disabled)
.await
.expect("replication connect 2");
let mut stream2 = repl_conn2
.start_logical_replication(SLOT, &[PUBLICATION], resume_lsn)
.await
.expect("START_REPLICATION 2");
let (_, _, begin2) = next_logical_message(&mut stream2).await;
assert!(matches!(begin2, LogicalReplicationMessage::Begin { .. }));
let (_, _, relation2) = next_logical_message(&mut stream2).await;
let rel_id2 = match relation2 {
LogicalReplicationMessage::Relation(body) => body.rel_id,
other => panic!("expected Relation, got {other:?}"),
};
let (_, _, insert2) = next_logical_message(&mut stream2).await;
match insert2 {
LogicalReplicationMessage::Insert { rel_id, new_tuple } => {
assert_eq!(rel_id, rel_id2);
let values = stream2
.decode_tuple(rel_id, &new_tuple)
.expect("decode insert 2");
assert_eq!(
values[0],
CellValue::Value(Value::I64(2)),
"the first event after resuming must be row 2, not a replay of row 1"
);
}
other => panic!("expected Insert, got {other:?}"),
}
let (_, _, commit2) = next_logical_message(&mut stream2).await;
assert!(matches!(commit2, LogicalReplicationMessage::Commit { .. }));
drop(stream2);
let mut cleanup_conn = PgReplicationConnection::connect(&url, TlsMode::Disabled)
.await
.expect("replication connect for cleanup");
cleanup_conn
.drop_replication_slot(SLOT)
.await
.expect("DROP_REPLICATION_SLOT");
ddl_conn
.execute_batch(&format!(
"DROP PUBLICATION IF EXISTS {PUBLICATION}; DROP TABLE IF EXISTS {TABLE};"
))
.await
.expect("cleanup");
}
}
#[cfg(feature = "replication")]
mod pg_replication_tuple_decode {
use oxisql_core::{ArrayElementType, Value};
use oxisql_postgres::{
tuple_to_values, CellValue, ColumnSpec, RelationBody, ReplicaIdentity, TupleColumn,
TupleData,
};
use tokio_postgres::types::Type;
fn col(name: &str, type_oid: u32) -> ColumnSpec {
ColumnSpec {
key: false,
name: name.to_string(),
type_oid,
type_modifier: -1,
}
}
#[test]
fn binary_cell_decodes_through_tuple_to_values() {
let rel = RelationBody {
rel_id: 1,
namespace: "public".to_string(),
name: "widgets".to_string(),
replica_identity: ReplicaIdentity::Default,
columns: vec![col("count", Type::INT4.oid())],
};
let tuple = TupleData {
columns: vec![TupleColumn::Binary(bytes::Bytes::copy_from_slice(
&42i32.to_be_bytes(),
))],
};
let values = tuple_to_values(&rel, &tuple).expect("binary cell should decode");
assert_eq!(values, vec![CellValue::Value(Value::I64(42))]);
}
#[test]
fn array_text_cell_decodes_through_tuple_to_values() {
let rel = RelationBody {
rel_id: 2,
namespace: "public".to_string(),
name: "matrices".to_string(),
replica_identity: ReplicaIdentity::Default,
columns: vec![col("row", Type::INT4_ARRAY.oid())],
};
let tuple = TupleData {
columns: vec![TupleColumn::Text("{1,2,3}".to_string())],
};
let values = tuple_to_values(&rel, &tuple).expect("array text cell should decode");
assert_eq!(
values,
vec![CellValue::Value(Value::TypedArray {
element_type: ArrayElementType::Int4,
values: vec![Value::I64(1), Value::I64(2), Value::I64(3)],
})]
);
}
}