#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, Mutex as StdMutex, MutexGuard, PoisonError};
use std::task::{Context, Poll};
use std::time::Duration;
use bytes::BytesMut;
use postgres_protocol::message::backend::{Header, Message};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, WriteHalf};
use tokio::sync::{mpsc, Mutex as TokioMutex};
use tokio::task::JoinHandle;
use crate::error::PgError;
use super::Lsn;
use super::PgReplicationConnection;
use super::{auth, commands, copyboth, lsn, pgoutput, tuple};
use auth::MaybeTlsStream;
const REPLICATION_EVENT_CHANNEL_CAPACITY: usize = 256;
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(10);
const MESSAGE_HEADER_LEN: usize = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplicationEvent {
Logical {
wal_start: Lsn,
wal_end: Lsn,
message: pgoutput::LogicalReplicationMessage,
},
KeepAlive {
wal_end: Lsn,
reply_requested: bool,
},
}
pub struct ReplicationStream {
rx: mpsc::Receiver<Result<ReplicationEvent, PgError>>,
write_half: Arc<TokioMutex<WriteHalf<MaybeTlsStream>>>,
confirmed: Arc<StdMutex<(Lsn, Lsn, Lsn)>>,
relations: Arc<StdMutex<HashMap<u32, pgoutput::RelationBody>>>,
reader_task: JoinHandle<()>,
keepalive_task: JoinHandle<()>,
}
impl Drop for ReplicationStream {
fn drop(&mut self) {
self.reader_task.abort();
self.keepalive_task.abort();
}
}
impl futures::Stream for ReplicationStream {
type Item = Result<ReplicationEvent, PgError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
impl ReplicationStream {
pub async fn ack(&self, lsn: Lsn) -> Result<(), PgError> {
self.standby_status_update(lsn, lsn, lsn).await
}
pub async fn standby_status_update(
&self,
written: Lsn,
flushed: Lsn,
applied: Lsn,
) -> Result<(), PgError> {
set_confirmed(&self.confirmed, written, flushed, applied);
let payload = copyboth::encode_standby_status_update(
written,
flushed,
applied,
now_pg_micros(),
false,
);
let mut write_half = self.write_half.lock().await;
write_half.write_all(&payload).await?;
Ok(())
}
pub fn relation(&self, rel_id: u32) -> Option<pgoutput::RelationBody> {
lookup_relation(&self.relations, rel_id)
}
pub fn decode_tuple(
&self,
rel_id: u32,
tuple: &pgoutput::TupleData,
) -> Result<Vec<tuple::CellValue>, PgError> {
decode_tuple_with(&self.relations, rel_id, tuple)
}
}
impl PgReplicationConnection {
pub async fn start_logical_replication(
mut self,
slot_name: &str,
publication_names: &[&str],
start_lsn: Lsn,
) -> Result<ReplicationStream, PgError> {
let options = commands::StartReplicationOptions {
proto_version: 2,
publication_names: publication_names.iter().map(|s| s.to_string()).collect(),
streaming: false,
binary: false,
messages: false,
};
let command = commands::build_start_replication(slot_name, start_lsn, &options)?;
super::send_query_message(&mut self.stream, &command).await?;
read_start_replication_response(&mut self.stream, &mut self.read_buf).await?;
let PgReplicationConnection { stream, read_buf } = self;
let (read_half, write_half) = tokio::io::split(stream);
let write_half = Arc::new(TokioMutex::new(write_half));
let confirmed = Arc::new(StdMutex::new((start_lsn, start_lsn, start_lsn)));
let relations = Arc::new(StdMutex::new(HashMap::new()));
let (tx, rx) = mpsc::channel(REPLICATION_EVENT_CHANNEL_CAPACITY);
let reader_task = tokio::spawn(run_reader_task(
read_half,
Arc::clone(&write_half),
Arc::clone(&confirmed),
Arc::clone(&relations),
tx,
read_buf,
));
let keepalive_task = tokio::spawn(run_keepalive_task(
Arc::clone(&write_half),
Arc::clone(&confirmed),
KEEPALIVE_INTERVAL,
));
Ok(ReplicationStream {
rx,
write_half,
confirmed,
relations,
reader_task,
keepalive_task,
})
}
}
async fn read_start_replication_response<S>(
stream: &mut S,
buf: &mut BytesMut,
) -> Result<copyboth::CopyBothResponse, PgError>
where
S: AsyncRead + Unpin,
{
loop {
if let Some(response) = try_parse_start_replication_response(buf)? {
return Ok(response);
}
let n = stream.read_buf(buf).await?;
if n == 0 {
return Err(PgError::Connection(
"server closed the connection while waiting for a response to \
START_REPLICATION"
.to_string(),
));
}
}
}
fn try_parse_start_replication_response(
buf: &mut BytesMut,
) -> Result<Option<copyboth::CopyBothResponse>, PgError> {
let Some(header) = Header::parse(&buf[..])
.map_err(|e| PgError::Protocol(format!("malformed response to START_REPLICATION: {e}")))?
else {
return Ok(None);
};
let declared_len = usize::try_from(header.len()).map_err(|e| {
PgError::Protocol(format!(
"START_REPLICATION response declared an out-of-range message length: {e}"
))
})?;
let total_len = 1 + declared_len;
if buf.len() < total_len {
return Ok(None);
}
let tag = header.tag();
let mut message = buf.split_to(total_len);
let result = match tag {
b'W' => copyboth::parse_copy_both_response(&message[MESSAGE_HEADER_LEN..]),
b'E' => match Message::parse(&mut message) {
Ok(Some(Message::ErrorResponse(body))) => Err(auth::server_error_response(&body)),
Ok(_) => Err(PgError::Protocol(
"inconsistent tag while re-parsing an ErrorResponse in reply to \
START_REPLICATION"
.to_string(),
)),
Err(e) => Err(PgError::Protocol(format!(
"malformed ErrorResponse in reply to START_REPLICATION: {e}"
))),
},
other => Err(PgError::Protocol(format!(
"unexpected response to START_REPLICATION: backend message tag {other:#04x} \
(expected 'W' CopyBothResponse or 'E' ErrorResponse)"
))),
};
result.map(Some)
}
async fn run_reader_task<R, W>(
mut read_half: R,
write_half: Arc<TokioMutex<W>>,
confirmed: Arc<StdMutex<(Lsn, Lsn, Lsn)>>,
relations: Arc<StdMutex<HashMap<u32, pgoutput::RelationBody>>>,
tx: mpsc::Sender<Result<ReplicationEvent, PgError>>,
mut buf: BytesMut,
) where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
loop {
let message = match auth::read_message(&mut read_half, &mut buf).await {
Ok(message) => message,
Err(PgError::Connection(_)) => return,
Err(e) => {
let _ = tx.send(Err(e)).await;
return;
}
};
match message {
Message::CopyDone => return,
Message::ErrorResponse(body) => {
let _ = tx.send(Err(auth::server_error_response(&body))).await;
return;
}
Message::CopyData(body) => {
let should_continue =
handle_copy_data(body.data(), &write_half, &confirmed, &relations, &tx).await;
if !should_continue {
return;
}
}
other => {
let _ = tx
.send(Err(PgError::Protocol(format!(
"unexpected {} message during CopyBoth replication streaming",
super::backend_message_name(&other)
))))
.await;
return;
}
}
}
}
async fn handle_copy_data<W>(
payload: &[u8],
write_half: &Arc<TokioMutex<W>>,
confirmed: &Arc<StdMutex<(Lsn, Lsn, Lsn)>>,
relations: &Arc<StdMutex<HashMap<u32, pgoutput::RelationBody>>>,
tx: &mpsc::Sender<Result<ReplicationEvent, PgError>>,
) -> bool
where
W: AsyncWrite + Unpin,
{
let copy_message = match copyboth::parse_copy_both_message(payload) {
Ok(m) => m,
Err(e) => {
let _ = tx.send(Err(e)).await;
return false;
}
};
match copy_message {
copyboth::CopyBothMessage::XLogData(xlog) => {
let decoded = match pgoutput::decode_message(&xlog.data) {
Ok(m) => m,
Err(e) => {
let _ = tx.send(Err(e)).await;
return false;
}
};
if let pgoutput::LogicalReplicationMessage::Relation(ref rel_body) = decoded {
lock_recover(relations).insert(rel_body.rel_id, rel_body.clone());
}
tx.send(Ok(ReplicationEvent::Logical {
wal_start: xlog.wal_start,
wal_end: xlog.wal_end,
message: decoded,
}))
.await
.is_ok()
}
copyboth::CopyBothMessage::KeepAlive(ka) => {
if tx
.send(Ok(ReplicationEvent::KeepAlive {
wal_end: ka.wal_end,
reply_requested: ka.reply_requested,
}))
.await
.is_err()
{
return false;
}
if ka.reply_requested {
send_standby_status_update(write_half, confirmed).await;
}
true
}
}
}
async fn run_keepalive_task<W>(
write_half: Arc<TokioMutex<W>>,
confirmed: Arc<StdMutex<(Lsn, Lsn, Lsn)>>,
interval: Duration,
) where
W: AsyncWrite + Unpin,
{
loop {
tokio::time::sleep(interval).await;
let wrote_ok = send_standby_status_update(&write_half, &confirmed).await;
if !wrote_ok {
return;
}
}
}
async fn send_standby_status_update<W>(
write_half: &Arc<TokioMutex<W>>,
confirmed: &Arc<StdMutex<(Lsn, Lsn, Lsn)>>,
) -> bool
where
W: AsyncWrite + Unpin,
{
let (written, flushed, applied) = *lock_recover(confirmed);
let payload =
copyboth::encode_standby_status_update(written, flushed, applied, now_pg_micros(), false);
let mut guard = write_half.lock().await;
guard.write_all(&payload).await.is_ok()
}
fn lock_recover<T>(mutex: &StdMutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
fn now_pg_micros() -> i64 {
let unix_micros = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_micros()).ok())
.unwrap_or(0);
lsn::unix_micros_to_pg_micros(unix_micros)
}
fn set_confirmed(confirmed: &StdMutex<(Lsn, Lsn, Lsn)>, written: Lsn, flushed: Lsn, applied: Lsn) {
*lock_recover(confirmed) = (written, flushed, applied);
}
fn lookup_relation(
relations: &StdMutex<HashMap<u32, pgoutput::RelationBody>>,
rel_id: u32,
) -> Option<pgoutput::RelationBody> {
lock_recover(relations).get(&rel_id).cloned()
}
fn decode_tuple_with(
relations: &StdMutex<HashMap<u32, pgoutput::RelationBody>>,
rel_id: u32,
tuple_data: &pgoutput::TupleData,
) -> Result<Vec<tuple::CellValue>, PgError> {
let guard = lock_recover(relations);
let rel = guard.get(&rel_id).ok_or_else(|| {
PgError::Protocol(format!(
"no Relation message observed yet for rel_id {rel_id}; cannot decode tuple \
(likely a protocol desync)"
))
})?;
tuple::tuple_to_values(rel, tuple_data)
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use bytes::BufMut;
use futures::StreamExt;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt, DuplexStream};
use tokio::net::{TcpListener, TcpStream};
use super::*;
fn encode_message(tag: u8, body: &[u8]) -> BytesMut {
let mut buf = BytesMut::new();
buf.put_u8(tag);
let len = u32::try_from(body.len() + 4).expect("test message body fits in u32");
buf.put_u32(len);
buf.put_slice(body);
buf
}
fn encode_copy_both_response(format: i8, column_formats: &[i16]) -> BytesMut {
let mut body = BytesMut::new();
body.put_i8(format);
let count = u16::try_from(column_formats.len()).expect("column count fits in u16");
body.put_u16(count);
for f in column_formats {
body.put_i16(*f);
}
encode_message(b'W', &body)
}
fn encode_error_fields(fields: &[(u8, &str)]) -> BytesMut {
let mut body = BytesMut::new();
for (type_, value) in fields {
body.put_u8(*type_);
body.put_slice(value.as_bytes());
body.put_u8(0);
}
body.put_u8(0);
body
}
fn encode_copy_data(payload: &[u8]) -> BytesMut {
encode_message(b'd', payload)
}
fn encode_xlog_data_payload(
wal_start: u64,
wal_end: u64,
server_time: i64,
data: &[u8],
) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(b'w');
buf.extend_from_slice(&wal_start.to_be_bytes());
buf.extend_from_slice(&wal_end.to_be_bytes());
buf.extend_from_slice(&server_time.to_be_bytes());
buf.extend_from_slice(data);
buf
}
fn encode_keepalive_payload(wal_end: u64, server_time: i64, reply_requested: bool) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(b'k');
buf.extend_from_slice(&wal_end.to_be_bytes());
buf.extend_from_slice(&server_time.to_be_bytes());
buf.push(u8::from(reply_requested));
buf
}
fn push_cstring(buf: &mut Vec<u8>, s: &str) {
buf.extend_from_slice(s.as_bytes());
buf.push(0);
}
fn encode_pgoutput_begin(final_lsn: u64, commit_time: i64, xid: u32) -> Vec<u8> {
let mut buf = vec![b'B'];
buf.extend_from_slice(&final_lsn.to_be_bytes());
buf.extend_from_slice(&commit_time.to_be_bytes());
buf.extend_from_slice(&xid.to_be_bytes());
buf
}
fn encode_pgoutput_commit(commit_lsn: u64, end_lsn: u64, commit_time: i64) -> Vec<u8> {
let mut buf = vec![b'C', 0];
buf.extend_from_slice(&commit_lsn.to_be_bytes());
buf.extend_from_slice(&end_lsn.to_be_bytes());
buf.extend_from_slice(&commit_time.to_be_bytes());
buf
}
fn encode_pgoutput_relation(rel_id: u32, namespace: &str, name: &str) -> Vec<u8> {
let mut buf = vec![b'R'];
buf.extend_from_slice(&rel_id.to_be_bytes());
push_cstring(&mut buf, namespace);
push_cstring(&mut buf, name);
buf.push(b'd'); buf.extend_from_slice(&2_i16.to_be_bytes());
buf.push(0x01);
push_cstring(&mut buf, "id");
buf.extend_from_slice(&25_u32.to_be_bytes());
buf.extend_from_slice(&(-1_i32).to_be_bytes());
buf.push(0x00);
push_cstring(&mut buf, "name");
buf.extend_from_slice(&25_u32.to_be_bytes());
buf.extend_from_slice(&(-1_i32).to_be_bytes());
buf
}
fn encode_pgoutput_insert(rel_id: u32, id_value: &str, name_value: &str) -> Vec<u8> {
let mut buf = vec![b'I'];
buf.extend_from_slice(&rel_id.to_be_bytes());
buf.push(b'N');
buf.extend_from_slice(&2_i16.to_be_bytes());
for value in [id_value, name_value] {
buf.push(b't');
let len = i32::try_from(value.len()).expect("test value fits in i32");
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(value.as_bytes());
}
buf
}
async fn read_raw_frontend_message<S: AsyncRead + Unpin>(stream: &mut S) -> (u8, Vec<u8>) {
let mut header = [0_u8; 5];
stream
.read_exact(&mut header)
.await
.expect("read frontend message header");
let tag = header[0];
let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize;
let mut body = vec![0_u8; len - 4];
stream
.read_exact(&mut body)
.await
.expect("read frontend message body");
(tag, body)
}
async fn read_standby_status_update<S: AsyncRead + Unpin>(
stream: &mut S,
) -> (Lsn, Lsn, Lsn, bool) {
let (tag, body) = read_raw_frontend_message(stream).await;
assert_eq!(tag, b'd', "expected a CopyData-wrapped message");
assert_eq!(body[0], b'r', "expected a Standby Status Update");
let field = |range: std::ops::Range<usize>| -> u64 {
let bytes: [u8; 8] = body[range]
.try_into()
.expect("standby status update field is 8 bytes");
u64::from_be_bytes(bytes)
};
let written = Lsn::from_u64(field(1..9));
let flushed = Lsn::from_u64(field(9..17));
let applied = Lsn::from_u64(field(17..25));
let reply_requested = body[33] != 0;
(written, flushed, applied, reply_requested)
}
#[test]
fn try_parse_valid_w_response_empty_columns() {
let mut buf = BytesMut::from(&encode_copy_both_response(0, &[])[..]);
let result = try_parse_start_replication_response(&mut buf)
.expect("should parse")
.expect("should be complete");
assert_eq!(result.format, 0);
assert!(result.column_formats.is_empty());
assert!(buf.is_empty(), "the whole message should be consumed");
}
#[test]
fn try_parse_valid_w_response_with_columns() {
let mut buf = BytesMut::from(&encode_copy_both_response(0, &[0, 1])[..]);
let result = try_parse_start_replication_response(&mut buf)
.expect("should parse")
.expect("should be complete");
assert_eq!(result.column_formats, vec![0, 1]);
assert!(buf.is_empty());
}
#[test]
fn try_parse_valid_e_error_response() {
let body = encode_error_fields(&[
(b'S', "ERROR"),
(b'C', "55006"),
(b'M', "replication slot already in use"),
]);
let mut buf = BytesMut::from(&encode_message(b'E', &body)[..]);
let result = try_parse_start_replication_response(&mut buf);
let Err(PgError::Replication(text)) = result else {
panic!("expected Err(PgError::Replication(_)), got {result:?}");
};
assert!(text.contains("55006"), "should surface SQLSTATE: {text}");
assert!(
text.contains("already in use"),
"should surface message: {text}"
);
assert!(buf.is_empty(), "the whole message should be consumed");
}
#[test]
fn try_parse_unexpected_tag_is_protocol_error() {
let mut buf = BytesMut::from(&encode_message(b'Z', b"I")[..]);
let result = try_parse_start_replication_response(&mut buf);
assert!(
matches!(result, Err(PgError::Protocol(_))),
"expected a protocol error, got {result:?}"
);
}
#[test]
fn try_parse_incomplete_header_returns_none_and_buf_untouched() {
let mut buf = BytesMut::from(&b"\x57\x00\x00"[..]); let original = buf.clone();
let result = try_parse_start_replication_response(&mut buf).expect("should not error");
assert!(result.is_none());
assert_eq!(buf, original, "an incomplete header must not consume bytes");
}
#[test]
fn try_parse_incomplete_body_returns_none_and_buf_untouched() {
let full = encode_copy_both_response(0, &[0, 1]);
let mut buf = BytesMut::from(&full[..MESSAGE_HEADER_LEN + 1]);
let original = buf.clone();
let result = try_parse_start_replication_response(&mut buf).expect("should not error");
assert!(result.is_none());
assert_eq!(buf, original, "an incomplete body must not consume bytes");
}
#[test]
fn try_parse_leftover_bytes_after_w_response_are_preserved() {
let mut buf = BytesMut::from(&encode_copy_both_response(0, &[])[..]);
let leftover = b"the-start-of-copyboth-traffic";
buf.extend_from_slice(leftover);
let result = try_parse_start_replication_response(&mut buf)
.expect("should parse")
.expect("should be complete");
assert_eq!(result.format, 0);
assert_eq!(
&buf[..],
leftover,
"bytes after the CopyBothResponse must be preserved, not discarded"
);
}
#[test]
fn try_parse_malformed_w_body_is_protocol_error() {
let mut body = BytesMut::new();
body.put_i8(0);
body.put_u16(2);
let mut buf = BytesMut::from(&encode_message(b'W', &body)[..]);
let result = try_parse_start_replication_response(&mut buf);
assert!(
matches!(result, Err(PgError::Protocol(_))),
"expected a protocol error, got {result:?}"
);
}
#[tokio::test]
async fn read_start_replication_response_across_partial_writes() {
let (mut client, mut server) = duplex(4096);
let full = encode_copy_both_response(0, &[0]);
let split_at = 3; let (first, second) = full.split_at(split_at);
let first = first.to_vec();
let second = second.to_vec();
let server_task = tokio::spawn(async move {
server.write_all(&first).await.expect("write first half");
tokio::task::yield_now().await;
server.write_all(&second).await.expect("write second half");
});
let mut buf = BytesMut::new();
let response = read_start_replication_response(&mut client, &mut buf)
.await
.expect("should eventually parse the full response");
assert_eq!(response.column_formats, vec![0]);
server_task.await.expect("server task panicked");
}
#[tokio::test]
async fn read_start_replication_response_eof_before_complete_is_connection_error() {
let (mut client, mut server) = duplex(4096);
let server_task = tokio::spawn(async move {
server
.write_all(b"\x57\x00")
.await
.expect("write partial header");
drop(server);
});
let mut buf = BytesMut::new();
let result = read_start_replication_response(&mut client, &mut buf).await;
assert!(
matches!(result, Err(PgError::Connection(_))),
"expected a connection error, got {result:?}"
);
server_task.await.expect("server task panicked");
}
#[allow(clippy::type_complexity)]
fn spawn_reader_task_over_duplex() -> (
DuplexStream,
mpsc::Receiver<Result<ReplicationEvent, PgError>>,
Arc<StdMutex<(Lsn, Lsn, Lsn)>>,
Arc<StdMutex<HashMap<u32, pgoutput::RelationBody>>>,
JoinHandle<()>,
) {
let (client, server) = duplex(65536);
let (read_half, write_half) = tokio::io::split(client);
let write_half = Arc::new(TokioMutex::new(write_half));
let confirmed = Arc::new(StdMutex::new((
Lsn::from_u64(0),
Lsn::from_u64(0),
Lsn::from_u64(0),
)));
let relations = Arc::new(StdMutex::new(HashMap::new()));
let (tx, rx) = mpsc::channel(16);
let task = tokio::spawn(run_reader_task(
read_half,
write_half,
Arc::clone(&confirmed),
Arc::clone(&relations),
tx,
BytesMut::new(),
));
(server, rx, confirmed, relations, task)
}
async fn expect_event(
rx: &mut mpsc::Receiver<Result<ReplicationEvent, PgError>>,
) -> Result<ReplicationEvent, PgError> {
rx.recv()
.await
.expect("expected an event, but the channel closed with no error")
}
#[tokio::test]
async fn reader_task_full_flow_xlogdata_then_keepalive_with_reply() {
let (mut server, mut rx, confirmed, _relations, task) = spawn_reader_task_over_duplex();
let begin_payload = encode_pgoutput_begin(0x2000, 12345, 42);
let xlog = encode_xlog_data_payload(0x1000, 0x2000, 12345, &begin_payload);
server
.write_all(&encode_copy_data(&xlog))
.await
.expect("write XLogData");
let event = expect_event(&mut rx).await.expect("expected Ok event");
let ReplicationEvent::Logical {
wal_start,
wal_end,
message,
} = event
else {
panic!("expected ReplicationEvent::Logical, got {event:?}");
};
assert_eq!(wal_start, Lsn::from_u64(0x1000));
assert_eq!(wal_end, Lsn::from_u64(0x2000));
assert_eq!(
message,
pgoutput::LogicalReplicationMessage::Begin {
final_lsn: Lsn::from_u64(0x2000),
commit_time: 12345,
xid: 42,
}
);
set_confirmed(
&confirmed,
Lsn::from_u64(0x1234),
Lsn::from_u64(0x1234),
Lsn::from_u64(0x1234),
);
let ka_payload = encode_keepalive_payload(0x3000, 99_999, true);
server
.write_all(&encode_copy_data(&ka_payload))
.await
.expect("write PrimaryKeepalive");
let event = expect_event(&mut rx).await.expect("expected Ok event");
let ReplicationEvent::KeepAlive {
wal_end,
reply_requested,
} = event
else {
panic!("expected ReplicationEvent::KeepAlive, got {event:?}");
};
assert_eq!(wal_end, Lsn::from_u64(0x3000));
assert!(reply_requested);
let (written, flushed, applied, reply_req) = read_standby_status_update(&mut server).await;
assert_eq!(written, Lsn::from_u64(0x1234));
assert_eq!(flushed, Lsn::from_u64(0x1234));
assert_eq!(applied, Lsn::from_u64(0x1234));
assert!(
!reply_req,
"the client's own reply should not itself request a reply"
);
drop(server);
drop(rx);
let _ = task.await;
}
#[tokio::test]
async fn reader_task_stops_cleanly_on_copy_done() {
let (mut server, mut rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
server
.write_all(&encode_message(b'c', &[]))
.await
.expect("write CopyDone");
let event = rx.recv().await;
assert!(
event.is_none(),
"expected the stream to end with no error, got {event:?}"
);
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reader task should finish promptly")
.expect("reader task panicked");
}
#[tokio::test]
async fn reader_task_stops_cleanly_on_eof() {
let (server, mut rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
drop(server);
let event = rx.recv().await;
assert!(
event.is_none(),
"expected the stream to end with no error, got {event:?}"
);
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reader task should finish promptly")
.expect("reader task panicked");
}
#[tokio::test]
async fn reader_task_reports_error_on_error_response() {
let (mut server, mut rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
let body = encode_error_fields(&[
(b'S', "FATAL"),
(b'C', "57P01"),
(b'M', "terminating connection due to administrator command"),
]);
server
.write_all(&encode_message(b'E', &body))
.await
.expect("write ErrorResponse");
let event = expect_event(&mut rx).await;
let Err(PgError::Replication(text)) = event else {
panic!("expected Err(PgError::Replication(_)), got {event:?}");
};
assert!(text.contains("57P01"), "should surface SQLSTATE: {text}");
let next = rx.recv().await;
assert!(next.is_none(), "no more events should follow the error");
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reader task should finish promptly")
.expect("reader task panicked");
}
#[tokio::test]
async fn reader_task_reports_error_on_malformed_copy_data_submessage() {
let (mut server, mut rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
server
.write_all(&encode_copy_data(b"X"))
.await
.expect("write malformed CopyData");
let event = expect_event(&mut rx).await;
assert!(
matches!(event, Err(PgError::Protocol(_))),
"expected a protocol error, got {event:?}"
);
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reader task should finish promptly")
.expect("reader task panicked");
}
#[tokio::test]
async fn reader_task_reports_error_on_malformed_pgoutput_payload_and_does_not_resync() {
let (mut server, mut rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
let garbage = vec![b'Z'];
let xlog = encode_xlog_data_payload(0x1000, 0x2000, 0, &garbage);
server
.write_all(&encode_copy_data(&xlog))
.await
.expect("write XLogData with undecodable pgoutput payload");
let event = expect_event(&mut rx).await;
assert!(
matches!(event, Err(PgError::Protocol(_))),
"expected a protocol error, got {event:?}"
);
let begin_payload = encode_pgoutput_begin(0x3000, 0, 1);
let valid_xlog = encode_xlog_data_payload(0x2000, 0x3000, 0, &begin_payload);
let _ = server.write_all(&encode_copy_data(&valid_xlog)).await;
let next = rx.recv().await;
assert!(
next.is_none(),
"task must stop after a malformed message, not resync: got {next:?}"
);
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reader task should finish promptly")
.expect("reader task panicked");
}
#[tokio::test]
async fn reader_task_decodes_commit_message() {
let (mut server, mut rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
let commit_payload = encode_pgoutput_commit(0x2000, 0x2008, 12345);
let xlog = encode_xlog_data_payload(0x2000, 0x2008, 12345, &commit_payload);
server
.write_all(&encode_copy_data(&xlog))
.await
.expect("write Commit XLogData");
let event = expect_event(&mut rx).await.expect("expected Ok event");
let ReplicationEvent::Logical { message, .. } = event else {
panic!("expected ReplicationEvent::Logical, got {event:?}");
};
assert_eq!(
message,
pgoutput::LogicalReplicationMessage::Commit {
flags: 0,
commit_lsn: Lsn::from_u64(0x2000),
end_lsn: Lsn::from_u64(0x2008),
commit_time: 12345,
}
);
drop(server);
drop(rx);
let _ = task.await;
}
#[tokio::test]
async fn reader_task_populates_relations_before_sending_event() {
let (mut server, mut rx, _confirmed, relations, task) = spawn_reader_task_over_duplex();
let rel_payload = encode_pgoutput_relation(777, "public", "users");
let xlog = encode_xlog_data_payload(0x1000, 0x2000, 0, &rel_payload);
server
.write_all(&encode_copy_data(&xlog))
.await
.expect("write Relation XLogData");
let event = expect_event(&mut rx).await.expect("expected Ok event");
let cached = lookup_relation(&relations, 777).expect("relation should be cached");
assert_eq!(cached.name, "users");
assert_eq!(cached.namespace, "public");
assert_eq!(cached.columns.len(), 2);
let ReplicationEvent::Logical {
message: pgoutput::LogicalReplicationMessage::Relation(body),
..
} = event
else {
panic!("expected a Relation event, got {event:?}");
};
assert_eq!(body.rel_id, 777);
drop(server);
drop(rx);
let _ = task.await;
}
#[tokio::test]
async fn reader_task_relation_then_insert_decodes_end_to_end() {
let (mut server, mut rx, _confirmed, relations, task) = spawn_reader_task_over_duplex();
let rel_payload = encode_pgoutput_relation(42, "public", "widgets");
let rel_xlog = encode_xlog_data_payload(0x1000, 0x1000, 0, &rel_payload);
server
.write_all(&encode_copy_data(&rel_xlog))
.await
.expect("write Relation XLogData");
let _relation_event = expect_event(&mut rx).await.expect("expected Ok event");
let insert_payload = encode_pgoutput_insert(42, "7", "gizmo");
let insert_xlog = encode_xlog_data_payload(0x1000, 0x2000, 0, &insert_payload);
server
.write_all(&encode_copy_data(&insert_xlog))
.await
.expect("write Insert XLogData");
let event = expect_event(&mut rx).await.expect("expected Ok event");
let ReplicationEvent::Logical {
message: pgoutput::LogicalReplicationMessage::Insert { rel_id, new_tuple },
..
} = event
else {
panic!("expected an Insert event, got {event:?}");
};
assert_eq!(rel_id, 42);
let values = decode_tuple_with(&relations, rel_id, &new_tuple).expect("should decode");
assert_eq!(
values,
vec![
tuple::CellValue::Value(oxisql_core::Value::Text("7".to_string())),
tuple::CellValue::Value(oxisql_core::Value::Text("gizmo".to_string())),
]
);
drop(server);
drop(rx);
let _ = task.await;
}
#[tokio::test]
async fn reader_task_stops_when_receiver_dropped() {
let (mut server, rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
drop(rx);
let begin_payload = encode_pgoutput_begin(0x1000, 0, 1);
let xlog = encode_xlog_data_payload(0x1000, 0x1000, 0, &begin_payload);
let _ = server.write_all(&encode_copy_data(&xlog)).await;
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reader task should stop promptly once the receiver is dropped")
.expect("reader task panicked");
}
#[tokio::test]
async fn reader_task_reports_error_on_unexpected_message_type() {
let (mut server, mut rx, _confirmed, _relations, task) = spawn_reader_task_over_duplex();
server
.write_all(&encode_message(b'2', &[]))
.await
.expect("write BindComplete");
let event = expect_event(&mut rx).await;
let Err(PgError::Protocol(text)) = event else {
panic!("expected Err(PgError::Protocol(_)), got {event:?}");
};
assert!(
text.contains("BindComplete"),
"error should name the unexpected message: {text}"
);
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("reader task should finish promptly")
.expect("reader task panicked");
}
#[tokio::test]
async fn keepalive_task_sends_updates_using_latest_confirmed_value() {
let (client, mut server) = duplex(4096);
let (_read_half, write_half) = tokio::io::split(client);
let write_half = Arc::new(TokioMutex::new(write_half));
let confirmed = Arc::new(StdMutex::new((
Lsn::from_u64(0x1000),
Lsn::from_u64(0x1000),
Lsn::from_u64(0x1000),
)));
let short_interval = Duration::from_millis(20);
let task = tokio::spawn(run_keepalive_task(
Arc::clone(&write_half),
Arc::clone(&confirmed),
short_interval,
));
let (written, ..) = read_standby_status_update(&mut server).await;
assert_eq!(written, Lsn::from_u64(0x1000));
set_confirmed(
&confirmed,
Lsn::from_u64(0x9999),
Lsn::from_u64(0x9999),
Lsn::from_u64(0x9999),
);
let (written, ..) = read_standby_status_update(&mut server).await;
assert_eq!(
written,
Lsn::from_u64(0x9999),
"the second update must reflect the newly set confirmed value, not a stale copy"
);
task.abort();
}
#[tokio::test]
async fn keepalive_task_stops_quietly_when_write_fails() {
let (client, server) = duplex(4096);
let (_read_half, write_half) = tokio::io::split(client);
let write_half = Arc::new(TokioMutex::new(write_half));
let confirmed = Arc::new(StdMutex::new((
Lsn::from_u64(0),
Lsn::from_u64(0),
Lsn::from_u64(0),
)));
drop(server);
let task = tokio::spawn(run_keepalive_task(
write_half,
confirmed,
Duration::from_millis(10),
));
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("keepalive task should stop promptly once writes fail")
.expect("keepalive task panicked");
}
#[test]
fn set_confirmed_updates_the_tuple() {
let confirmed = StdMutex::new((Lsn::from_u64(0), Lsn::from_u64(0), Lsn::from_u64(0)));
set_confirmed(
&confirmed,
Lsn::from_u64(1),
Lsn::from_u64(2),
Lsn::from_u64(3),
);
assert_eq!(
*confirmed.lock().expect("lock"),
(Lsn::from_u64(1), Lsn::from_u64(2), Lsn::from_u64(3))
);
}
#[test]
fn lock_recover_survives_poisoning() {
let mutex = Arc::new(StdMutex::new(41));
let mutex_clone = Arc::clone(&mutex);
let handle = std::thread::spawn(move || {
let _guard = mutex_clone.lock().expect("lock");
panic!("intentional panic to poison the mutex");
});
let _ = handle.join();
let guard = lock_recover(&mutex);
assert_eq!(*guard, 41);
}
#[test]
fn lookup_relation_present() {
let mut map = HashMap::new();
let rel = pgoutput::RelationBody {
rel_id: 5,
namespace: "public".to_string(),
name: "widgets".to_string(),
replica_identity: pgoutput::ReplicaIdentity::Default,
columns: vec![],
};
map.insert(5, rel.clone());
let relations = StdMutex::new(map);
assert_eq!(lookup_relation(&relations, 5), Some(rel));
}
#[test]
fn lookup_relation_absent() {
let relations: StdMutex<HashMap<u32, pgoutput::RelationBody>> =
StdMutex::new(HashMap::new());
assert_eq!(lookup_relation(&relations, 999), None);
}
#[test]
fn decode_tuple_with_present_relation() {
let rel = pgoutput::RelationBody {
rel_id: 1,
namespace: "public".to_string(),
name: "users".to_string(),
replica_identity: pgoutput::ReplicaIdentity::Default,
columns: vec![
pgoutput::ColumnSpec {
key: true,
name: "id".to_string(),
type_oid: 23, type_modifier: -1,
},
pgoutput::ColumnSpec {
key: false,
name: "name".to_string(),
type_oid: 25, type_modifier: -1,
},
],
};
let mut map = HashMap::new();
map.insert(1, rel);
let relations = StdMutex::new(map);
let tuple_data = pgoutput::TupleData {
columns: vec![
pgoutput::TupleColumn::Text("42".to_string()),
pgoutput::TupleColumn::Text("alice".to_string()),
],
};
let values = decode_tuple_with(&relations, 1, &tuple_data).expect("should decode");
assert_eq!(
values,
vec![
tuple::CellValue::Value(oxisql_core::Value::I64(42)),
tuple::CellValue::Value(oxisql_core::Value::Text("alice".to_string())),
]
);
}
#[test]
fn decode_tuple_with_absent_relation_is_protocol_error() {
let relations: StdMutex<HashMap<u32, pgoutput::RelationBody>> =
StdMutex::new(HashMap::new());
let tuple_data = pgoutput::TupleData { columns: vec![] };
let result = decode_tuple_with(&relations, 42, &tuple_data);
let Err(PgError::Protocol(text)) = result else {
panic!("expected Err(PgError::Protocol(_)), got {result:?}");
};
assert!(text.contains("42"), "error should name the rel_id: {text}");
}
#[tokio::test]
async fn start_logical_replication_end_to_end_over_loopback_tcp() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr: SocketAddr = listener.local_addr().expect("read local_addr");
let server_task = tokio::spawn(async move {
let (mut server, _peer) = listener.accept().await.expect("accept");
let (tag, body) = read_raw_frontend_message(&mut server).await;
assert_eq!(
tag, b'Q',
"expected a simple-query message for START_REPLICATION"
);
let command_text = String::from_utf8(body[..body.len() - 1].to_vec())
.expect("command text should be valid UTF-8");
assert!(
command_text.starts_with("START_REPLICATION SLOT \"e2e_slot\" LOGICAL 0/1000 ("),
"unexpected command text: {command_text}"
);
assert!(command_text.contains("\"e2e_pub\""), "{command_text}");
server
.write_all(&encode_copy_both_response(0, &[]))
.await
.expect("write CopyBothResponse");
let begin_payload = encode_pgoutput_begin(0x2000, 555, 7);
let xlog = encode_xlog_data_payload(0x1000, 0x2000, 555, &begin_payload);
server
.write_all(&encode_copy_data(&xlog))
.await
.expect("write XLogData");
let ka_payload = encode_keepalive_payload(0x2000, 777, true);
server
.write_all(&encode_copy_data(&ka_payload))
.await
.expect("write PrimaryKeepalive");
let (_written, _flushed, _applied, reply_req) =
read_standby_status_update(&mut server).await;
assert!(
!reply_req,
"the client's reply should not itself request a reply"
);
let (written, flushed, applied, _reply_req) =
read_standby_status_update(&mut server).await;
assert_eq!(written, Lsn::from_u64(0x3000));
assert_eq!(flushed, Lsn::from_u64(0x3000));
assert_eq!(applied, Lsn::from_u64(0x3000));
});
let tcp = TcpStream::connect(addr)
.await
.expect("connect to loopback listener");
let conn = PgReplicationConnection {
stream: MaybeTlsStream::Plain(tcp),
read_buf: BytesMut::new(),
};
let client_script = async {
let mut replication_stream = conn
.start_logical_replication("e2e_slot", &["e2e_pub"], Lsn::from_u64(0x1000))
.await
.expect("start_logical_replication should succeed");
let event = replication_stream
.next()
.await
.expect("expected a Logical event")
.expect("expected Ok event");
assert!(
matches!(
event,
ReplicationEvent::Logical {
message: pgoutput::LogicalReplicationMessage::Begin { .. },
..
}
),
"expected a Begin event, got {event:?}"
);
let event = replication_stream
.next()
.await
.expect("expected a KeepAlive event")
.expect("expected Ok event");
assert!(
matches!(
event,
ReplicationEvent::KeepAlive {
reply_requested: true,
..
}
),
"expected a KeepAlive event, got {event:?}"
);
replication_stream
.ack(Lsn::from_u64(0x3000))
.await
.expect("ack should succeed");
};
tokio::time::timeout(Duration::from_secs(10), client_script)
.await
.expect("client script timed out");
server_task.await.expect("server task panicked");
}
#[tokio::test]
async fn start_logical_replication_surfaces_server_error_response() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback listener");
let addr: SocketAddr = listener.local_addr().expect("read local_addr");
let server_task = tokio::spawn(async move {
let (mut server, _peer) = listener.accept().await.expect("accept");
let _request = read_raw_frontend_message(&mut server).await;
let body = encode_error_fields(&[
(b'S', "ERROR"),
(b'C', "42704"),
(b'M', "publication \"nope\" does not exist"),
]);
server
.write_all(&encode_message(b'E', &body))
.await
.expect("write ErrorResponse");
});
let tcp = TcpStream::connect(addr)
.await
.expect("connect to loopback listener");
let conn = PgReplicationConnection {
stream: MaybeTlsStream::Plain(tcp),
read_buf: BytesMut::new(),
};
let client_script = async {
conn.start_logical_replication("e2e_slot", &["nope"], Lsn::from_u64(0))
.await
};
let result = tokio::time::timeout(Duration::from_secs(10), client_script)
.await
.expect("client script timed out");
let Err(PgError::Replication(text)) = result else {
panic!(
"expected Err(PgError::Replication(_)); \
note ReplicationStream has no Debug impl to print the Ok case"
);
};
assert!(text.contains("42704"), "should surface SQLSTATE: {text}");
server_task.await.expect("server task panicked");
}
}