Implements a PostgreSQL logical replication module that streams WAL changes, buffers them per XID for transaction atomicity, and publishes CacheKey invalidation events to the existing framework broadcast channel. Key design choices: - Transaction atomicity: changes are buffered until COMMIT so the fanout always receives a consistent view. A 1000-row bulk-insert emits one table-level CacheKey, not 1000 row events. - Native protocol: uses a self-contained raw TCP + postgres-protocol implementation for the replication connection (tokio-postgres 0.7 does not expose copy_both_simple publicly). - Corrected pgoutput v1 parser: original maybe_sql_integration code incorrectly read XID from DML messages; DML messages carry no XID in proto v1 — only Begin does. - Clean integration: publishes CacheKey::Channel events to store.events so the existing fanout task picks them up with zero changes to the fanout loop. - New cx.subscribe(key) API on RenderContext for manually registering subscription keys (needed when queries go through PgPool, not cx.run). New files: src/pg_replication/mod.rs — PgReplicationListener, key helpers src/pg_replication/wal_parser.rs — pgoutput v1 binary protocol parser src/pg_replication/emitter.rs — WalEmitter (tx-buffering + CacheKey emit) src/pg_replication/proto.rs — raw TCP PG wire-protocol connection examples/pg_replication/main.rs — live message board example docker-compose.yml — PG 16 container with wal_level=logical Run the example: docker compose up -d cargo run --example pg_replication --features pg_replication https://claude.ai/code/session_01SLGgXeqKV2o7KTmCaQZZPg
342 lines
13 KiB
Rust
342 lines
13 KiB
Rust
//! Raw PostgreSQL wire-protocol connection for logical replication.
|
|
//!
|
|
//! `tokio-postgres` 0.7 does not expose a `copy_both_simple` API suitable for
|
|
//! the replication stream. This module implements the minimum subset of the
|
|
//! PostgreSQL wire protocol needed to:
|
|
//!
|
|
//! 1. Connect with `replication=database` startup parameter.
|
|
//! 2. Perform MD5 or cleartext password authentication.
|
|
//! 3. Send simple queries (for `CREATE_REPLICATION_SLOT` and `START_REPLICATION`).
|
|
//! 4. Enter `COPY BOTH` mode and stream `CopyData` frames.
|
|
//! 5. Send `StandbyStatusUpdate` CopyData frames back to the server.
|
|
//!
|
|
//! `postgres-protocol` is used for building most frontend messages and for
|
|
//! MD5 hash computation. The backend parser is bypassed for `CopyBothResponse`
|
|
//! (tag `'W'` / 0x57) which is not present in that crate's public API.
|
|
|
|
use super::standby_status_payload;
|
|
use anyhow::{bail, Context as _};
|
|
use bytes::{Buf, Bytes, BytesMut};
|
|
use postgres_protocol::authentication::md5_hash;
|
|
use postgres_protocol::message::frontend;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpStream;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RawPgConn
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A raw PostgreSQL connection suitable for replication streaming.
|
|
pub struct RawPgConn {
|
|
stream: TcpStream,
|
|
/// Accumulates bytes read from the server until a complete frame arrives.
|
|
read_buf: BytesMut,
|
|
}
|
|
|
|
impl RawPgConn {
|
|
// -----------------------------------------------------------------------
|
|
// Constructor
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Connect to a PostgreSQL server in **logical replication** mode.
|
|
///
|
|
/// Sends a startup message with `replication=database` so the backend
|
|
/// enters walsender mode. Handles MD5 and cleartext password auth.
|
|
pub async fn connect(
|
|
host: &str,
|
|
port: u16,
|
|
user: &str,
|
|
password: &str,
|
|
database: &str,
|
|
) -> anyhow::Result<Self> {
|
|
let addr = format!("{host}:{port}");
|
|
let stream = TcpStream::connect(&addr)
|
|
.await
|
|
.with_context(|| format!("TCP connect to {addr}"))?;
|
|
|
|
let mut conn = RawPgConn {
|
|
stream,
|
|
read_buf: BytesMut::with_capacity(4096),
|
|
};
|
|
|
|
// --- Startup message -----------------------------------------------
|
|
// Parameters include `replication=database` which instructs PostgreSQL
|
|
// to accept replication commands on this connection.
|
|
let mut startup_buf = BytesMut::new();
|
|
frontend::startup_message(
|
|
[
|
|
("user", user),
|
|
("database", database),
|
|
("replication", "database"),
|
|
("application_name", "livevue-rs"),
|
|
]
|
|
.iter()
|
|
.copied(),
|
|
&mut startup_buf,
|
|
)?;
|
|
conn.stream.write_all(&startup_buf).await?;
|
|
conn.stream.flush().await?;
|
|
|
|
// --- Authentication loop -------------------------------------------
|
|
loop {
|
|
let (tag, body) = conn.read_frame().await?;
|
|
match tag {
|
|
b'R' => {
|
|
// Authentication request
|
|
conn.handle_auth_request(&body, user, password).await?;
|
|
}
|
|
b'K' => {
|
|
// BackendKeyData — ignore
|
|
}
|
|
b'S' => {
|
|
// ParameterStatus — ignore
|
|
}
|
|
b'Z' => {
|
|
// ReadyForQuery — handshake complete
|
|
break;
|
|
}
|
|
b'E' => {
|
|
let msg = parse_error_message(&body);
|
|
bail!("PostgreSQL startup error: {msg}");
|
|
}
|
|
b'N' => {
|
|
// NoticeResponse — ignore
|
|
}
|
|
other => {
|
|
tracing::debug!("startup: unexpected tag 0x{:02x}", other);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(conn)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Simple query (used for CREATE_REPLICATION_SLOT etc.)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Send a simple query and drain responses until `ReadyForQuery`.
|
|
///
|
|
/// Returns `Err` if the server sends an `ErrorResponse`.
|
|
pub async fn simple_query(&mut self, query: &str) -> anyhow::Result<()> {
|
|
let mut buf = BytesMut::new();
|
|
frontend::query(query, &mut buf)?;
|
|
self.stream.write_all(&buf).await?;
|
|
self.stream.flush().await?;
|
|
|
|
loop {
|
|
let (tag, body) = self.read_frame().await?;
|
|
match tag {
|
|
b'Z' => break, // ReadyForQuery
|
|
b'E' => {
|
|
let msg = parse_error_message(&body);
|
|
bail!("query error: {msg}");
|
|
}
|
|
_ => {} // CommandComplete, RowDescription, DataRow, NoticeResponse …
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Enter COPY BOTH mode for replication streaming
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Send `START_REPLICATION` and absorb the `CopyBothResponse`.
|
|
///
|
|
/// After this call the connection is in COPY BOTH mode. Use
|
|
/// [`read_copy_data`] / [`send_standby_status`] to exchange messages.
|
|
pub async fn start_replication(&mut self, query: &str) -> anyhow::Result<()> {
|
|
let mut buf = BytesMut::new();
|
|
frontend::query(query, &mut buf)?;
|
|
self.stream.write_all(&buf).await?;
|
|
self.stream.flush().await?;
|
|
|
|
// Expect CopyBothResponse ('W') followed by the replication stream.
|
|
// postgres-protocol doesn't parse 'W', so we handle it manually.
|
|
loop {
|
|
let (tag, _body) = self.read_frame().await?;
|
|
match tag {
|
|
b'W' => {
|
|
// CopyBothResponse — body contains column-format info we
|
|
// don't need for the replication stream. Just break out.
|
|
break;
|
|
}
|
|
b'E' => {
|
|
let msg = parse_error_message(&_body);
|
|
bail!("START_REPLICATION error: {msg}");
|
|
}
|
|
b'N' => {} // NoticeResponse — skip
|
|
other => {
|
|
tracing::debug!("start_replication: unexpected tag 0x{:02x}", other);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Replication stream I/O
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Read one `CopyData` payload from the replication stream.
|
|
///
|
|
/// Returns:
|
|
/// - `Ok(Some(bytes))` — a CopyData payload (XLogData or PrimaryKeepalive).
|
|
/// - `Ok(None)` — the server sent `CopyDone` or `ErrorResponse` (stream end).
|
|
/// - `Err(_)` — I/O or protocol error.
|
|
pub async fn read_copy_data(&mut self) -> anyhow::Result<Option<Bytes>> {
|
|
let (tag, body) = self.read_frame().await?;
|
|
match tag {
|
|
b'd' => Ok(Some(body)),
|
|
b'c' => Ok(None), // CopyDone
|
|
b'E' => {
|
|
let msg = parse_error_message(&body);
|
|
bail!("replication error: {msg}");
|
|
}
|
|
other => {
|
|
tracing::debug!("copy stream: unexpected tag 0x{:02x}", other);
|
|
Ok(Some(Bytes::new()))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a `StandbyStatusUpdate` CopyData frame to acknowledge `lsn`.
|
|
pub async fn send_standby_status(&mut self, lsn: u64) -> anyhow::Result<()> {
|
|
let payload = standby_status_payload(lsn);
|
|
let mut buf = BytesMut::new();
|
|
frontend::CopyData::new(&payload[..])?.write(&mut buf);
|
|
self.stream.write_all(&buf).await?;
|
|
self.stream.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Low-level frame I/O
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Read one complete PostgreSQL message frame.
|
|
///
|
|
/// Frame format: `tag (u8) | length (i32, BE, includes itself) | body`.
|
|
/// Returns `(tag, body_bytes)`.
|
|
async fn read_frame(&mut self) -> anyhow::Result<(u8, Bytes)> {
|
|
// Ensure we have at least 5 bytes (1 tag + 4 length).
|
|
while self.read_buf.len() < 5 {
|
|
let n = self.stream.read_buf(&mut self.read_buf).await?;
|
|
if n == 0 {
|
|
bail!("server closed connection unexpectedly");
|
|
}
|
|
}
|
|
|
|
let tag = self.read_buf[0];
|
|
// Length field is i32 big-endian, includes the 4-length bytes but NOT
|
|
// the tag byte. Total frame length = 1 (tag) + length.
|
|
let body_len = (i32::from_be_bytes(
|
|
self.read_buf[1..5].try_into().expect("4 byte slice"),
|
|
) as usize)
|
|
.saturating_sub(4); // subtract the 4 length bytes themselves
|
|
|
|
let total = 5 + body_len; // tag(1) + length(4) + body
|
|
while self.read_buf.len() < total {
|
|
let n = self.stream.read_buf(&mut self.read_buf).await?;
|
|
if n == 0 {
|
|
bail!("server closed connection while reading message body");
|
|
}
|
|
}
|
|
|
|
self.read_buf.advance(5); // consume tag + length
|
|
let body = self.read_buf.split_to(body_len).freeze();
|
|
Ok((tag, body))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Authentication
|
|
// -----------------------------------------------------------------------
|
|
|
|
async fn handle_auth_request(
|
|
&mut self,
|
|
body: &[u8],
|
|
user: &str,
|
|
password: &str,
|
|
) -> anyhow::Result<()> {
|
|
if body.len() < 4 {
|
|
bail!("AuthenticationRequest body too short");
|
|
}
|
|
let auth_type = i32::from_be_bytes(body[0..4].try_into().expect("4 bytes"));
|
|
|
|
match auth_type {
|
|
0 => {
|
|
// AuthenticationOk — nothing to do
|
|
}
|
|
3 => {
|
|
// AuthenticationCleartextPassword
|
|
let mut buf = BytesMut::new();
|
|
frontend::password_message(password.as_bytes(), &mut buf)?;
|
|
self.stream.write_all(&buf).await?;
|
|
self.stream.flush().await?;
|
|
}
|
|
5 => {
|
|
// AuthenticationMD5Password — salt is bytes 4..8
|
|
if body.len() < 8 {
|
|
bail!("MD5 auth: body too short for salt");
|
|
}
|
|
let salt: [u8; 4] = body[4..8].try_into().expect("4 byte salt");
|
|
let hashed = md5_hash(user.as_bytes(), password.as_bytes(), salt);
|
|
let mut buf = BytesMut::new();
|
|
frontend::password_message(hashed.as_bytes(), &mut buf)?;
|
|
self.stream.write_all(&buf).await?;
|
|
self.stream.flush().await?;
|
|
}
|
|
10 => {
|
|
// AuthenticationSASL (SCRAM-SHA-256) — not implemented here.
|
|
// For Docker dev setups, use `md5` or `trust` in pg_hba.conf.
|
|
bail!(
|
|
"SCRAM-SHA-256 authentication is not supported by the \
|
|
replication module. Configure PostgreSQL to use `md5` \
|
|
or `trust` for this user in pg_hba.conf."
|
|
);
|
|
}
|
|
other => {
|
|
bail!("unsupported authentication type: {other}");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Extract a human-readable message from an `ErrorResponse` body.
|
|
///
|
|
/// Fields are null-terminated key-value pairs. We extract the 'M' (message)
|
|
/// and 'D' (detail) fields.
|
|
fn parse_error_message(body: &[u8]) -> String {
|
|
let mut parts = Vec::new();
|
|
let mut i = 0;
|
|
while i < body.len() {
|
|
let field_type = body[i];
|
|
i += 1;
|
|
if field_type == 0 {
|
|
break;
|
|
}
|
|
// Find the null terminator of the field value.
|
|
let start = i;
|
|
while i < body.len() && body[i] != 0 {
|
|
i += 1;
|
|
}
|
|
let value = String::from_utf8_lossy(&body[start..i]).into_owned();
|
|
i += 1; // skip null terminator
|
|
if matches!(field_type, b'M' | b'D' | b'C') {
|
|
parts.push(value);
|
|
}
|
|
}
|
|
if parts.is_empty() {
|
|
"unknown PostgreSQL error".into()
|
|
} else {
|
|
parts.join(" — ")
|
|
}
|
|
}
|