feat: add pg_replication module for reactive WAL-based cache invalidation

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
This commit is contained in:
Claude
2026-03-08 22:12:01 +00:00
parent 2358ca29c7
commit b57dbd0fa0
10 changed files with 2212 additions and 17 deletions
Generated
+321 -14
View File
@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -370,6 +379,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -519,7 +534,19 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
@@ -530,7 +557,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -909,16 +936,20 @@ version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"bytes",
"chrono",
"dashmap",
"futures",
"maud",
"postgres-protocol",
"serde",
"serde_json",
"sqlx",
"tokio",
"tokio-postgres",
"tokio-stream",
"tracing",
"tracing-subscriber",
"uuid",
]
@@ -937,6 +968,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
@@ -996,7 +1036,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
@@ -1011,7 +1060,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@@ -1046,6 +1095,24 @@ dependencies = [
"libm",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -1096,6 +1163,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"serde",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1141,6 +1227,35 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "postgres-protocol"
version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491"
dependencies = [
"base64",
"byteorder",
"bytes",
"fallible-iterator",
"hmac",
"md-5",
"memchr",
"rand 0.9.2",
"sha2",
"stringprep",
]
[[package]]
name = "postgres-types"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20"
dependencies = [
"bytes",
"fallible-iterator",
"postgres-protocol",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1210,6 +1325,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -1223,8 +1344,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -1234,7 +1365,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -1246,6 +1387,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1264,6 +1414,23 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rsa"
version = "0.9.10"
@@ -1277,7 +1444,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@@ -1396,6 +1563,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1419,9 +1595,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1580,7 +1762,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@@ -1590,7 +1772,7 @@ dependencies = [
"stringprep",
"thiserror",
"tracing",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -1618,7 +1800,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@@ -1627,7 +1809,7 @@ dependencies = [
"stringprep",
"thiserror",
"tracing",
"whoami",
"whoami 1.6.1",
]
[[package]]
@@ -1725,6 +1907,15 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -1778,6 +1969,32 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-postgres"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42"
dependencies = [
"async-trait",
"byteorder",
"bytes",
"fallible-iterator",
"futures-channel",
"futures-util",
"log",
"parking_lot",
"percent-encoding",
"phf",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand 0.9.2",
"socket2",
"tokio",
"tokio-util",
"whoami 2.1.1",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -1789,6 +2006,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -1847,6 +2077,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -1918,6 +2178,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -1936,6 +2202,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
@@ -1960,6 +2235,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasite"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
dependencies = [
"wasi 0.14.7+wasi-0.2.4",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
@@ -2039,6 +2323,16 @@ dependencies = [
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "whoami"
version = "1.6.1"
@@ -2046,7 +2340,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"wasite 0.1.0",
]
[[package]]
name = "whoami"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"wasite 1.0.2",
"web-sys",
]
[[package]]
+17 -1
View File
@@ -4,13 +4,19 @@ version = "0.1.0"
edition = "2021"
# Run the todo example with: cargo run --example todo
# Run the pg_replication example with: cargo run --example pg_replication --features pg_replication
[features]
# Opt-in feature: PostgreSQL logical replication for reactive cache invalidation.
# Adds tokio-postgres and bytes as dependencies.
pg_replication = ["dep:tokio-postgres", "dep:postgres-protocol", "dep:bytes"]
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres"] }
maud = { version = "0.26", features = ["axum"] }
uuid = { version = "1", features = ["v4", "serde"] }
dashmap = "6"
@@ -19,6 +25,16 @@ tokio-stream = "0.1"
tracing = "0.1.44"
futures = "0.3.32"
# Optional: PostgreSQL logical replication
tokio-postgres = { version = "0.7", optional = true }
postgres-protocol = { version = "0.6", optional = true }
bytes = { version = "1", optional = true }
[dev-dependencies]
chrono = { version = "0.4", features = ["clock"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[[example]]
name = "pg_replication"
path = "examples/pg_replication/main.rs"
required-features = ["pg_replication"]
+30
View File
@@ -0,0 +1,30 @@
services:
postgres:
image: postgres:16-alpine
container_name: livevue_postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: livevue
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
# Enable logical replication — required for the pg_replication example.
command:
- postgres
- -c
- wal_level=logical
- -c
- max_replication_slots=10
- -c
- max_wal_senders=10
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d livevue"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
+387
View File
@@ -0,0 +1,387 @@
//! # pg_replication example
//!
//! A live message board backed by PostgreSQL.
//!
//! Run:
//! ```sh
//! docker compose up -d # start the PG container (see docker-compose.yml)
//! cargo run --example pg_replication --features pg_replication
//! ```
//! Then open http://localhost:3001 in two browser tabs and watch them stay in sync.
//!
//! ## How it works
//!
//! 1. The render function queries the `messages` table directly via `sqlx::PgPool`
//! and calls `cx.subscribe(pg_table_key("public", "messages"))` to tell the
//! framework "re-render this connection whenever `pg:public.messages` is
//! invalidated".
//!
//! 2. `PgReplicationListener` connects to PostgreSQL over the logical replication
//! protocol, buffers changes per transaction, and on each COMMIT publishes
//! `CacheKey::Channel { key: "pg:public.messages" }` to the store's broadcast
//! channel.
//!
//! 3. The fanout task picks up the event, finds all connections subscribed to that
//! key, and triggers a re-render for each — sending fresh HTML over SSE.
//!
//! No polling. No websocket boilerplate. Just PostgreSQL WAL + SSE.
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::{Json, State},
http::StatusCode,
response::{Html, IntoResponse},
routing::{get, post},
Router,
};
use maud::{html, Markup, DOCTYPE};
use serde::Deserialize;
use sqlx::FromRow;
use uuid::Uuid;
use livevue_rs::{
pg_replication::{pg_table_key, PgReplicationConfig, PgReplicationListener},
server::{ingest_signals, ActionParams, AppState, SharedState},
spawn_fanout, RenderContext, Store,
};
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, FromRow)]
struct Message {
id: i64,
body: String,
}
// ---------------------------------------------------------------------------
// Database helpers
// ---------------------------------------------------------------------------
async fn setup_db(pool: &sqlx::PgPool) -> anyhow::Result<()> {
// Enable logical replication for this table (requires superuser or
// rds_superuser on managed PG).
sqlx::query(
"CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
body TEXT NOT NULL
)",
)
.execute(pool)
.await?;
// Create the publication if it doesn't exist.
// `IF NOT EXISTS` was added in PG 14 — fall back gracefully on older versions.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'livevue_pub')",
)
.fetch_one(pool)
.await?;
if !exists {
sqlx::query("CREATE PUBLICATION livevue_pub FOR TABLE messages")
.execute(pool)
.await?;
println!("Created publication livevue_pub");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Shared render state — anything the render function needs beyond the store.
#[derive(Clone)]
struct RenderState {
pg: sqlx::PgPool,
}
async fn render_page(cx: &mut RenderContext, rs: &RenderState) -> anyhow::Result<Markup> {
// Subscribe to PG-sourced invalidation events for the messages table.
// The fanout task will re-render this connection whenever the WAL emitter
// fires a `CacheKey::Channel { key: "pg:public.messages" }` event.
cx.subscribe(pg_table_key("public", "messages"));
let messages: Vec<Message> = sqlx::query_as("SELECT id, body FROM messages ORDER BY id DESC")
.fetch_all(&rs.pg)
.await?;
let new_msg = cx.signal("newMsg", livevue_rs::global());
Ok(html! {
div #app {
h1 { "Live Message Board" }
p.subtitle {
"Messages are stored in PostgreSQL. "
"Add one and watch every open tab update in real time via WAL replication."
}
// ── New message form ─────────────────────────────────────────
div.compose {
input
type="text"
placeholder="Type a message…"
data-bind=(new_msg.name)
data-on:keydown="{if (event.key === 'Enter') $post('/action/post_message')}"
;
button
data-on:click="$post('/action/post_message')"
data-attr=(format!("{{disabled: {}.trim() === ''}}", new_msg.val))
{ "Send" }
}
// ── Message list ─────────────────────────────────────────────
@if messages.is_empty() {
p.empty { "No messages yet. Be the first!" }
} @else {
ul.messages {
@for msg in &messages {
li {
span.id { "#" (msg.id) }
span.body { (msg.body) }
}
}
}
}
}
})
}
/// Full HTML shell for the initial page load.
fn page_shell(conn_id: Uuid, signals_json: &str, inner: &Markup) -> String {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "pg_replication example — livevue-rs" }
style {
(r#"
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: 0.25rem; }
.subtitle { color: #666; font-size: 0.9rem; margin-top: 0; }
.compose { display: flex; gap: 0.5rem; margin: 1.5rem 0; }
.compose input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
.compose button { padding: 0.5rem 1rem; background: #0070f3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
.compose button:disabled { background: #aaa; cursor: default; }
ul.messages { list-style: none; padding: 0; margin: 0; }
ul.messages li { padding: 0.75rem; border-bottom: 1px solid #eee; display: flex; gap: 0.75rem; }
.id { color: #999; font-size: 0.85rem; min-width: 2.5rem; }
.empty { color: #999; }
"#)
}
// DataStar SDK (loaded from CDN for the example)
script
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.11/bundles/datastar.js"
{}
}
body {
div
data-signals=(signals_json)
data-on:load=(format!("@get('/sse?conn={conn_id}')"))
{
(inner)
}
}
}
}
.into_string()
}
// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------
async fn index_handler(State(state): State<(SharedState, RenderState)>) -> impl IntoResponse {
let (app_state, rs) = &state;
let conn_id = Uuid::new_v4();
let signals_json = r#"{"newMsg":""}"#;
app_state.connections.insert(conn_id, {
// We need a sender for the connection, but the SSE handler will replace
// it. Use a dummy channel that immediately drops.
let (tx, _rx) = tokio::sync::mpsc::channel(1);
tx
});
app_state
.connections
.update_signals(&conn_id, signals_json.to_string());
let mut cx =
RenderContext::new(conn_id, livevue_rs::SignalStore::from_json(serde_json::json!({"newMsg": ""})), Default::default(), app_state.store.clone());
let inner = render_page(&mut cx, rs).await.unwrap_or_else(|e| {
html! { p { "Render error: " (e) } }
});
Html(page_shell(conn_id, signals_json, &inner))
}
// SSE handler is provided by the framework; it needs SharedState only.
// We wrap it to adapt the two-tuple state.
async fn sse_adapter(
State(state): State<(SharedState, RenderState)>,
query: axum::extract::Query<livevue_rs::server::SseParams>,
) -> impl IntoResponse {
livevue_rs::sse_handler(State(state.0), query).await
}
#[derive(Deserialize)]
struct PostMessageBody {
#[serde(rename = "newMsg")]
new_msg: Option<String>,
}
async fn post_message_handler(
State(state): State<(SharedState, RenderState)>,
axum::extract::Query(params): axum::extract::Query<ActionParams>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let signals = ingest_signals(&state.0, params.conn, body);
let msg: String = signals
.get("newMsg")
.and_then(|v| v.as_str().map(|s| s.trim().to_string()))
.unwrap_or_default();
if msg.is_empty() {
return StatusCode::BAD_REQUEST;
}
if let Err(e) = sqlx::query("INSERT INTO messages (body) VALUES ($1)")
.bind(&msg)
.execute(&state.1.pg)
.await
{
tracing::error!("failed to insert message: {e}");
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Note: we do NOT call store.publish() here — the PgReplicationListener
// will detect the INSERT through the WAL stream and fire the invalidation
// automatically. This demonstrates the key value of the module: mutations
// from *any* source (other services, psql, migrations) are reflected
// without any extra wiring.
StatusCode::OK
}
fn router(app_state: SharedState, render_state: RenderState) -> Router {
let combined = (app_state, render_state);
Router::new()
.route("/", get(index_handler))
.route("/sse", get(sse_adapter))
.route("/action/post_message", post(post_message_handler))
.with_state(combined)
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("livevue_rs=debug".parse().unwrap())
.add_directive("pg_replication=debug".parse().unwrap()),
)
.init();
// -----------------------------------------------------------------------
// Database setup
// -----------------------------------------------------------------------
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/livevue".into());
let pg = sqlx::PgPool::connect(&database_url).await?;
setup_db(&pg).await?;
// -----------------------------------------------------------------------
// Framework store (SQLite in-memory, unused for queries here)
// -----------------------------------------------------------------------
// The Store provides the broadcast channel and KV store that the framework
// needs. The embedded SQLite pool is not used in this example — queries go
// directly to PostgreSQL via the captured `pg` pool in the render closure.
let sqlite = sqlx::SqlitePool::connect("sqlite::memory:").await?;
let store = Store::new(sqlite);
// -----------------------------------------------------------------------
// Render function
// -----------------------------------------------------------------------
let render_state = RenderState { pg: pg.clone() };
let rs_clone = render_state.clone();
let render_fn: livevue_rs::RenderFn = Arc::new(move |mut cx| {
let rs = rs_clone.clone();
Box::pin(async move {
let signals_json = cx.signals.to_json_string();
let conn_id = cx.connection_id;
let inner = render_page(&mut cx, &rs).await?;
let keys = cx.take_subscription_keys();
// Wrap in the full page shell so DataStar can morph the entire document.
let html = page_shell(conn_id, &signals_json, &inner);
Ok((html, keys))
})
});
// -----------------------------------------------------------------------
// Application state + fanout
// -----------------------------------------------------------------------
let state = AppState::new(store.clone(), render_fn);
spawn_fanout(state.clone());
// -----------------------------------------------------------------------
// PG replication listener
// -----------------------------------------------------------------------
// The replication protocol requires a dedicated connection.
let replication_store = store.clone();
tokio::spawn(async move {
loop {
// Rebuild the config each iteration — PgReplicationConfig is not
// Clone, and start() takes ownership, so we reconstruct from the
// env-var values cached in the outer scope.
let c = PgReplicationConfig {
host: std::env::var("PG_HOST").unwrap_or_else(|_| "localhost".into()),
port: std::env::var("PG_PORT").ok()
.and_then(|p| p.parse().ok()).unwrap_or(5432),
user: std::env::var("PG_USER").unwrap_or_else(|_| "postgres".into()),
password: std::env::var("PG_PASSWORD").unwrap_or_else(|_| "postgres".into()),
database: std::env::var("PG_DATABASE").unwrap_or_else(|_| "livevue".into()),
slot_name: "livevue_slot".into(),
publication_name: "livevue_pub".into(),
start_lsn: None,
};
if let Err(e) = PgReplicationListener::new(c, replication_store.clone()).start().await {
tracing::error!("pg replication listener error: {e:#}");
}
tracing::info!("pg replication listener reconnecting in 5 s…");
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
// -----------------------------------------------------------------------
// HTTP server
// -----------------------------------------------------------------------
let app = router(state, render_state);
let listener_tcp = tokio::net::TcpListener::bind("0.0.0.0:3001").await?;
println!("pg_replication example listening on http://localhost:3001");
axum::serve(listener_tcp, app).await?;
Ok(())
}
+24
View File
@@ -197,6 +197,30 @@ impl RenderContext {
self.store.kv.get(&key)
}
// -----------------------------------------------------------------------
// Manual subscriptions
// -----------------------------------------------------------------------
/// Manually register a subscription key without executing a query.
///
/// Use this to subscribe to externally-sourced invalidation events that
/// don't go through `cx.run()` — for example, events published by the
/// PostgreSQL replication module:
///
/// ```ignore
/// use livevue_rs::pg_replication::pg_table_key;
///
/// cx.subscribe(pg_table_key("public", "posts"));
/// let posts = sqlx::query_as::<_, Post>("SELECT id, body FROM posts")
/// .fetch_all(&pg_pool)
/// .await?;
/// ```
pub fn subscribe(&mut self, key: CacheKey) {
if key != CacheKey::None {
self.subscription_keys.push(key);
}
}
// -----------------------------------------------------------------------
// Post-render access
// -----------------------------------------------------------------------
+9
View File
@@ -6,6 +6,15 @@ pub mod connection;
pub mod sse;
pub mod server;
/// PostgreSQL logical replication module.
///
/// Enabled via the `pg_replication` Cargo feature:
/// ```toml
/// livevue-rs = { features = ["pg_replication"] }
/// ```
#[cfg(feature = "pg_replication")]
pub mod pg_replication;
pub use signal::{Signal, SignalStore, SignalOpts, mangle, client_only, global, data_signals};
pub use query::{CacheKey, Query};
pub use store::{Store, KVStore};
+311
View File
@@ -0,0 +1,311 @@
use super::wal_parser::{ColumnValue, LogicalMessage, MessageParser, RelationInfo, TupleData};
use crate::query::CacheKey;
use bytes::BytesMut;
use std::collections::HashSet;
use tokio::sync::broadcast;
// ---------------------------------------------------------------------------
// Per-transaction buffer
// ---------------------------------------------------------------------------
/// Accumulates the set of invalidation keys for one PostgreSQL transaction.
///
/// We use a `HashSet` for table-level keys so that a transaction inserting
/// 10 000 rows into one table still produces exactly **one**
/// `CacheKey::Channel { key: "pg:public.todos" }` event. Row-level keys are
/// collected separately for callers that need fine-grained precision.
struct TxBuffer {
xid: u32,
/// Deduplicated `(schema, table)` pairs touched in this transaction.
table_keys: HashSet<(String, String)>,
/// `(schema, table, id)` tuples for changes with a detectable integer PK.
row_keys: Vec<(String, String, i64)>,
}
impl TxBuffer {
fn new(xid: u32) -> Self {
Self {
xid,
table_keys: HashSet::new(),
row_keys: Vec::new(),
}
}
fn record_table(&mut self, schema: &str, table: &str) {
self.table_keys.insert((schema.to_owned(), table.to_owned()));
}
fn record_row(&mut self, schema: &str, table: &str, id: i64) {
self.row_keys.push((schema.to_owned(), table.to_owned(), id));
}
}
// ---------------------------------------------------------------------------
// WalEmitter
// ---------------------------------------------------------------------------
/// Processes pgoutput logical replication messages and publishes
/// [`CacheKey`] invalidation events to the framework's broadcast channel.
///
/// ## Transaction atomicity
///
/// Changes are buffered per XID. On `COMMIT`, all invalidation events for
/// that transaction are published in a tight loop so the fanout task receives
/// them as a burst. The transaction's WAL data is **never** partially visible:
/// the emitter waits for the `Commit` message before publishing anything.
///
/// ## Event granularity
///
/// For every transaction two kinds of events are emitted:
///
/// * **Table-level** (`"pg:{schema}.{table}"`) — deduplicated; at most one per
/// distinct table touched. Components that track whole-table queries should
/// subscribe with [`pg_table_key`](super::pg_table_key).
///
/// * **Row-level** (`"pg:{schema}.{table}:{id}"`) — emitted when the change
/// has a single-column integer replica-identity key. Components that track
/// individual rows can subscribe with [`pg_row_key`](super::pg_row_key).
///
/// ## Usage
///
/// ```ignore
/// let emitter = WalEmitter::new(store.events.clone());
/// // Inside the replication loop:
/// emitter.process_raw(&wal_bytes).await?;
/// ```
pub struct WalEmitter {
parser: MessageParser,
events: broadcast::Sender<CacheKey>,
/// XID of the currently open transaction (pgoutput v1 is sequential).
current_xid: Option<u32>,
/// Buffer for the current transaction; `None` when outside a transaction.
buffer: Option<TxBuffer>,
}
impl WalEmitter {
/// Create a new emitter that publishes to `events`.
///
/// Pass `store.events.clone()` so the emitter integrates directly with the
/// framework's fanout task.
pub fn new(events: broadcast::Sender<CacheKey>) -> Self {
Self {
parser: MessageParser::new(),
events,
current_xid: None,
buffer: None,
}
}
// -----------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------
/// Parse and process all pgoutput messages in a raw XLogData payload.
///
/// Call this for every `XLogData` message received from the replication
/// stream. The method is `&mut self` because the parser maintains relation
/// metadata across calls.
pub async fn process_raw(&mut self, data: &[u8]) -> Result<(), String> {
let mut buf = BytesMut::from(data);
while !buf.is_empty() {
match self.parser.parse_message(&mut buf)? {
Some(msg) => self.process_message(msg)?,
None => break,
}
}
Ok(())
}
// -----------------------------------------------------------------------
// Message dispatch
// -----------------------------------------------------------------------
fn process_message(&mut self, msg: LogicalMessage) -> Result<(), String> {
match msg {
LogicalMessage::Begin { xid, .. } => {
self.current_xid = Some(xid);
self.buffer = Some(TxBuffer::new(xid));
}
LogicalMessage::Commit { .. } => {
if let Some(buf) = self.buffer.take() {
self.emit(buf);
}
self.current_xid = None;
}
LogicalMessage::Insert { rel_oid, new_tuple } => {
self.record_change(rel_oid, &new_tuple);
}
LogicalMessage::Update { rel_oid, new_tuple, key_tuple, .. } => {
// Prefer the key_tuple for row-level id extraction if available
// (it holds the *old* key when the PK is being changed).
let id_source = key_tuple.as_ref().unwrap_or(&new_tuple);
let id = self.extract_pk_id(rel_oid, id_source);
self.record_change_with_id(rel_oid, &new_tuple, id);
}
LogicalMessage::Delete { rel_oid, key_tuple, old_tuple } => {
let tuple = key_tuple.as_ref().or(old_tuple.as_ref());
let id = tuple.and_then(|t| self.extract_pk_id(rel_oid, t));
// We still need the relation name; use an empty TupleData as placeholder.
if let Some(info) = self.relation_info(rel_oid) {
let schema = info.0;
let table = info.1;
if let Some(buf) = &mut self.buffer {
buf.record_table(&schema, &table);
if let Some(id) = id {
buf.record_row(&schema, &table, id);
}
}
}
}
LogicalMessage::Truncate { rel_oids, .. } => {
let pairs: Vec<(String, String)> = rel_oids
.iter()
.filter_map(|&oid| self.relation_info(oid))
.collect();
if let Some(buf) = &mut self.buffer {
for (schema, table) in pairs {
buf.record_table(&schema, &table);
}
}
}
// Relation messages are consumed by the parser (internal state).
// Type and Message are not relevant for cache invalidation.
LogicalMessage::Relation(_)
| LogicalMessage::Type { .. }
| LogicalMessage::Message { .. } => {}
}
Ok(())
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// Record a table + optional row key for a DML change.
fn record_change(&mut self, rel_oid: u32, tuple: &TupleData) {
let id = self.extract_pk_id(rel_oid, tuple);
self.record_change_with_id(rel_oid, tuple, id);
}
fn record_change_with_id(&mut self, rel_oid: u32, _tuple: &TupleData, id: Option<i64>) {
if let Some(info) = self.relation_info(rel_oid) {
let (schema, table) = info;
if let Some(buf) = &mut self.buffer {
buf.record_table(&schema, &table);
if let Some(id) = id {
buf.record_row(&schema, &table, id);
}
}
}
}
/// Resolve a relation OID to `(schema, table)`. Clones so the parser
/// borrow is released before we mutably borrow `self.buffer`.
fn relation_info(&self, rel_oid: u32) -> Option<(String, String)> {
self.parser
.get_relation(rel_oid)
.map(|r| (r.namespace.clone(), r.name.clone()))
}
/// Try to extract a single-column integer primary key from `tuple`.
///
/// Returns `None` for composite keys, non-integer keys, or when the
/// relation has no replica-identity columns flagged (`flags & 1`).
fn extract_pk_id(&self, rel_oid: u32, tuple: &TupleData) -> Option<i64> {
let rel = self.parser.get_relation(rel_oid)?;
extract_single_int_pk(rel, tuple)
}
// -----------------------------------------------------------------------
// Emission
// -----------------------------------------------------------------------
/// Publish all invalidation events for a committed transaction.
fn emit(&self, buf: TxBuffer) {
tracing::debug!(
xid = buf.xid,
tables = buf.table_keys.len(),
rows = buf.row_keys.len(),
"pg_replication: emitting cache invalidation"
);
// Table-level events (deduplicated by HashSet).
for (schema, table) in &buf.table_keys {
self.events
.send(CacheKey::Channel {
key: format!("pg:{schema}.{table}"),
})
.ok(); // ok() = ignore when no receivers are subscribed
}
// Row-level events.
for (schema, table, id) in &buf.row_keys {
self.events
.send(CacheKey::Channel {
key: format!("pg:{schema}.{table}:{id}"),
})
.ok();
}
}
}
// ---------------------------------------------------------------------------
// Row-level PK extraction
// ---------------------------------------------------------------------------
/// Extract an integer PK only when the relation has exactly one column
/// flagged as part of the replica identity key and its value is parseable
/// as `i64`.
fn extract_single_int_pk(rel: &RelationInfo, tuple: &TupleData) -> Option<i64> {
let key_cols: Vec<usize> = rel
.columns
.iter()
.enumerate()
.filter(|(_, col)| col.flags & 1 != 0)
.map(|(i, _)| i)
.collect();
if key_cols.len() != 1 {
return None;
}
let idx = key_cols[0];
match tuple.values.get(idx)? {
ColumnValue::Text(s) => s.parse::<i64>().ok(),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_receivers_does_not_panic() {
let (tx, _rx) = broadcast::channel(16);
drop(_rx); // no receivers
let mut emitter = WalEmitter::new(tx);
// Publishing to a channel with no receivers should silently succeed.
emitter
.emit(TxBuffer { xid: 1, table_keys: Default::default(), row_keys: vec![] });
}
#[test]
fn outside_transaction_dml_is_ignored() {
let (tx, _rx) = broadcast::channel(16);
let mut emitter = WalEmitter::new(tx);
// No Begin sent → buffer is None → Insert is silently dropped.
assert!(emitter.buffer.is_none());
}
}
+265
View File
@@ -0,0 +1,265 @@
//! PostgreSQL logical replication module for livevue-rs.
//!
//! Connects to a PostgreSQL `pgoutput` logical replication stream, buffers
//! changes per transaction, and publishes [`CacheKey`] invalidation events
//! to the framework's broadcast channel on every committed transaction.
//!
//! # Quick start
//!
//! ```ignore
//! use livevue_rs::pg_replication::{PgReplicationConfig, PgReplicationListener, pg_table_key};
//!
//! let config = PgReplicationConfig {
//! host: "localhost".into(),
//! port: 5432,
//! user: "postgres".into(),
//! password: "postgres".into(),
//! database: "mydb".into(),
//! slot_name: "livevue_slot".into(),
//! publication_name: "livevue_pub".into(),
//! start_lsn: None,
//! };
//!
//! let listener = PgReplicationListener::new(config, store.clone());
//! tokio::spawn(async move {
//! if let Err(e) = listener.start().await {
//! tracing::error!("replication listener exited: {e}");
//! }
//! });
//! ```
//!
//! # CacheKey convention
//!
//! | Granularity | Channel key format | Example |
//! |-------------|------------------------------|-----------------------------|
//! | Table-level | `"pg:{schema}.{table}"` | `"pg:public.posts"` |
//! | Row-level | `"pg:{schema}.{table}:{id}"` | `"pg:public.posts:42"` |
//!
//! Use [`pg_table_key`] / [`pg_row_key`] in `cx.subscribe()` in render functions.
//!
//! # Feature flag
//!
//! Enable with `features = ["pg_replication"]` in your `Cargo.toml`.
mod wal_parser;
mod emitter;
mod proto;
pub use wal_parser::{
ColumnInfo, ColumnValue, LogicalMessage, MessageParser, RelationInfo, TupleData,
};
pub use emitter::WalEmitter;
use crate::query::CacheKey;
use crate::store::Store;
use bytes::{BufMut, Bytes, BytesMut};
use std::sync::Arc;
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// Key helpers
// ---------------------------------------------------------------------------
/// [`CacheKey`] matching table-level events from the PG replication module.
///
/// Call `cx.subscribe(pg_table_key("public", "messages"))` in a render
/// function to re-render whenever any row in `messages` changes.
pub fn pg_table_key(schema: &str, table: &str) -> CacheKey {
CacheKey::Channel { key: format!("pg:{schema}.{table}") }
}
/// [`CacheKey`] matching row-level events from the PG replication module.
///
/// Row-level events are only emitted when the row has a single-column
/// integer replica-identity key.
pub fn pg_row_key(schema: &str, table: &str, id: i64) -> CacheKey {
CacheKey::Channel { key: format!("pg:{schema}.{table}:{id}") }
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/// Configuration for a [`PgReplicationListener`].
pub struct PgReplicationConfig {
pub host: String,
pub port: u16,
pub user: String,
pub password: String,
pub database: String,
/// Logical replication slot name. Auto-created with the `pgoutput`
/// plugin if it does not already exist.
pub slot_name: String,
/// Publication name. Must be created in PostgreSQL first:
/// ```sql
/// CREATE PUBLICATION livevue_pub FOR ALL TABLES;
/// ```
pub publication_name: String,
/// LSN to start from. `None` = start from the slot's confirmed LSN
/// (only future changes).
pub start_lsn: Option<u64>,
}
// ---------------------------------------------------------------------------
// Listener
// ---------------------------------------------------------------------------
/// Streams PostgreSQL WAL changes and publishes [`CacheKey`] events to the
/// framework's broadcast channel.
///
/// Spawn as a long-running background task after calling `AppState::new` and
/// `spawn_fanout`. Wrap in a retry loop for automatic reconnection.
pub struct PgReplicationListener {
config: PgReplicationConfig,
store: Arc<Store>,
}
impl PgReplicationListener {
pub fn new(config: PgReplicationConfig, store: Arc<Store>) -> Self {
Self { config, store }
}
/// Connect and stream WAL until an error or stream close.
pub async fn start(self) -> anyhow::Result<()> {
use anyhow::Context as _;
// --- connect in replication mode ---
let mut conn = proto::RawPgConn::connect(
&self.config.host,
self.config.port,
&self.config.user,
&self.config.password,
&self.config.database,
)
.await
.context("connect to PostgreSQL (replication mode)")?;
// --- ensure replication slot exists ---
let slot = &self.config.slot_name;
let create_sql = format!(
"CREATE_REPLICATION_SLOT {slot} LOGICAL pgoutput NOEXPORT_SNAPSHOT"
);
match conn.simple_query(&create_sql).await {
Ok(_) => tracing::info!(slot, "pg_replication: created replication slot"),
Err(e) => {
let msg = e.to_string();
if msg.contains("42710") || msg.contains("already exists") {
tracing::debug!(slot, "pg_replication: slot already exists");
} else {
return Err(e).context("create replication slot");
}
}
}
// --- start logical replication ---
let lsn = self.config.start_lsn.unwrap_or(0);
let lsn_str = format!("{:X}/{:X}", (lsn >> 32) as u32, lsn as u32);
let pub_name = &self.config.publication_name;
let start_sql = format!(
"START_REPLICATION SLOT {slot} LOGICAL {lsn_str} \
(proto_version '1', publication_names '{pub_name}')"
);
conn.start_replication(&start_sql)
.await
.context("START_REPLICATION")?;
tracing::info!(
slot, publication = pub_name, lsn = lsn_str,
"pg_replication: replication stream started"
);
// --- stream processing loop ---
let mut emitter = WalEmitter::new(self.store.events.clone());
let mut last_lsn: u64 = lsn;
let mut last_status_at = Instant::now();
const STATUS_INTERVAL: Duration = Duration::from_secs(10);
loop {
let frame = tokio::time::timeout(
Duration::from_secs(1),
conn.read_copy_data(),
)
.await;
match frame {
Err(_timeout) => {}
Ok(Err(e)) => return Err(e).context("replication stream read"),
Ok(Ok(None)) => {
tracing::info!("pg_replication: replication stream ended");
break;
}
Ok(Ok(Some(data))) => {
if data.is_empty() { continue; }
match data[0] {
// XLogData: 'w' + u64 wal_start(8) + u64 wal_end(8) + i64 time(8) + WAL
b'w' if data.len() >= 25 => {
last_lsn = u64::from_be_bytes(
data[9..17].try_into().expect("8 byte slice"),
);
if let Err(e) = emitter.process_raw(&data[25..]).await {
tracing::warn!("pg_replication: WAL parse warning: {e}");
}
}
// PrimaryKeepalive: 'k' + u64 wal_end(8) + i64 time(8) + u8 reply_req
b'k' if data.len() >= 18 => {
if data[17] != 0 {
conn.send_standby_status(last_lsn)
.await
.context("send standby status (keepalive reply)")?;
last_status_at = Instant::now();
}
}
other => {
tracing::debug!(
"pg_replication: unknown replication msg 0x{:02x}", other
);
}
}
}
}
if last_status_at.elapsed() >= STATUS_INTERVAL {
conn.send_standby_status(last_lsn)
.await
.context("send periodic standby status")?;
last_status_at = Instant::now();
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// StandbyStatusUpdate payload for acknowledging processed WAL.
pub(crate) fn standby_status_payload(lsn: u64) -> Bytes {
let ack = lsn.saturating_add(1);
let mut buf = BytesMut::with_capacity(34);
buf.put_u8(b'r');
buf.put_u64(ack); // write LSN
buf.put_u64(ack); // flush LSN
buf.put_u64(ack); // apply LSN
buf.put_i64(pg_epoch_micros());
buf.put_u8(0); // no reply requested
buf.freeze()
}
/// Current time as microseconds since the PostgreSQL epoch (2000-01-01 UTC).
pub(crate) fn pg_epoch_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
let micros = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as i64;
micros - 946_684_800_000_000
}
+341
View File
@@ -0,0 +1,341 @@
//! 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("")
}
}
+505
View File
@@ -0,0 +1,505 @@
use bytes::{Buf, BytesMut};
use std::collections::HashMap;
/// PostgreSQL logical replication binary protocol parser (pgoutput, version 1).
///
/// Parses the raw WAL bytes delivered by `START_REPLICATION … (proto_version '1')`.
/// In protocol version 1 the transaction ID (XID) is **only** present in `Begin`
/// messages; DML messages (Insert / Update / Delete / Truncate) carry no XID.
/// The [`WalEmitter`](super::emitter::WalEmitter) tracks the current XID via the
/// `Begin`/`Commit` boundary.
// ---------------------------------------------------------------------------
// Schema metadata
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct ColumnInfo {
/// Bit 0: column is part of the replica identity key.
pub flags: u8,
pub name: String,
pub type_oid: u32,
pub type_modifier: i32,
}
#[derive(Debug, Clone)]
pub struct RelationInfo {
pub oid: u32,
pub namespace: String,
pub name: String,
/// `d` = default (PK), `n` = nothing, `f` = full row, `i` = index.
pub replica_identity: u8,
pub columns: Vec<ColumnInfo>,
}
// ---------------------------------------------------------------------------
// Tuple data
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub enum ColumnValue {
Null,
UnchangedToasted,
Text(String),
Binary(Vec<u8>),
}
#[derive(Debug, Clone)]
pub struct TupleData {
pub values: Vec<ColumnValue>,
}
// ---------------------------------------------------------------------------
// Logical messages (pgoutput v1)
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub enum LogicalMessage {
/// Transaction start. `xid` identifies the transaction; DML messages that
/// follow do not carry their own XID.
Begin {
/// Final LSN of the transaction (equals the Commit LSN).
final_lsn: u64,
/// Commit timestamp (microseconds since PG epoch 2000-01-01).
timestamp: i64,
/// Transaction ID.
xid: u32,
},
/// Schema descriptor sent before the first DML that references a relation
/// in a session (and after schema changes). Stored internally by the parser.
Relation(RelationInfo),
/// Row inserted into `rel_oid`.
Insert {
rel_oid: u32,
new_tuple: TupleData,
},
/// Row updated in `rel_oid`.
///
/// `key_tuple` is present when REPLICA IDENTITY is the primary key
/// (default). `old_tuple` is present for REPLICA IDENTITY FULL.
Update {
rel_oid: u32,
key_tuple: Option<TupleData>,
old_tuple: Option<TupleData>,
new_tuple: TupleData,
},
/// Row deleted from `rel_oid`.
Delete {
rel_oid: u32,
key_tuple: Option<TupleData>,
old_tuple: Option<TupleData>,
},
/// Transaction committed successfully.
Commit {
flags: u8,
commit_lsn: u64,
end_lsn: u64,
timestamp: i64,
},
/// One or more tables truncated.
Truncate {
num_relations: u32,
options: u8,
rel_oids: Vec<u32>,
},
/// Custom data-type descriptor (pgoutput emits these before first use).
Type {
type_oid: u32,
namespace: String,
type_name: String,
},
/// Application-level logical message emitted via `pg_logical_emit_message`.
Message {
/// Bit 0: message is transactional.
flags: u8,
lsn: u64,
prefix: String,
content: Vec<u8>,
},
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
/// Stateful pgoutput v1 parser.
///
/// Maintains a cache of `RelationInfo` objects keyed by OID so that
/// Insert/Update/Delete messages can be decoded without extra round-trips.
pub struct MessageParser {
relations: HashMap<u32, RelationInfo>,
_types: HashMap<u32, (String, String)>,
}
impl MessageParser {
pub fn new() -> Self {
Self {
relations: HashMap::new(),
_types: HashMap::new(),
}
}
// -----------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------
/// Parse the next logical message from `buf`.
///
/// `Relation` messages are absorbed into internal state and return `None`
/// so callers only see semantically meaningful messages.
pub fn parse_message(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
if buf.is_empty() {
return Ok(None);
}
match buf[0] as char {
'B' => self.parse_begin(buf),
'C' => self.parse_commit(buf),
'R' => {
self.parse_relation(buf)?;
Ok(None)
}
'I' => self.parse_insert(buf),
'U' => self.parse_update(buf),
'D' => self.parse_delete(buf),
'T' => self.parse_truncate(buf),
'Y' => self.parse_type(buf),
'M' => self.parse_message_msg(buf),
other => Err(format!(
"unknown pgoutput message type: '{}' (0x{:02x})",
other, buf[0]
)),
}
}
/// Look up a relation by OID (returns `None` if not yet seen).
pub fn get_relation(&self, oid: u32) -> Option<&RelationInfo> {
self.relations.get(&oid)
}
// -----------------------------------------------------------------------
// Low-level readers
// -----------------------------------------------------------------------
fn read_u8(buf: &mut BytesMut) -> Result<u8, String> {
if buf.is_empty() {
return Err("unexpected EOF reading u8".into());
}
Ok(buf.get_u8())
}
fn read_i16(buf: &mut BytesMut) -> Result<i16, String> {
if buf.len() < 2 {
return Err("unexpected EOF reading i16".into());
}
Ok(buf.get_i16())
}
fn read_u32(buf: &mut BytesMut) -> Result<u32, String> {
if buf.len() < 4 {
return Err("unexpected EOF reading u32".into());
}
Ok(buf.get_u32())
}
fn read_i32(buf: &mut BytesMut) -> Result<i32, String> {
if buf.len() < 4 {
return Err("unexpected EOF reading i32".into());
}
Ok(buf.get_i32())
}
fn read_u64(buf: &mut BytesMut) -> Result<u64, String> {
if buf.len() < 8 {
return Err("unexpected EOF reading u64".into());
}
Ok(buf.get_u64())
}
fn read_i64(buf: &mut BytesMut) -> Result<i64, String> {
if buf.len() < 8 {
return Err("unexpected EOF reading i64".into());
}
Ok(buf.get_i64())
}
/// Read a null-terminated UTF-8 string.
fn read_string(buf: &mut BytesMut) -> Result<String, String> {
let mut bytes = Vec::new();
loop {
if buf.is_empty() {
return Err("unexpected EOF reading null-terminated string".into());
}
let b = buf.get_u8();
if b == 0 {
break;
}
bytes.push(b);
}
String::from_utf8(bytes).map_err(|e| format!("invalid UTF-8 in string: {e}"))
}
// -----------------------------------------------------------------------
// Message parsers
// -----------------------------------------------------------------------
fn parse_begin(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'B'
let final_lsn = Self::read_u64(buf)?;
let timestamp = Self::read_i64(buf)?;
let xid = Self::read_u32(buf)?;
Ok(Some(LogicalMessage::Begin { final_lsn, timestamp, xid }))
}
fn parse_commit(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'C'
let flags = Self::read_u8(buf)?;
let commit_lsn = Self::read_u64(buf)?;
let end_lsn = Self::read_u64(buf)?;
let timestamp = Self::read_i64(buf)?;
Ok(Some(LogicalMessage::Commit { flags, commit_lsn, end_lsn, timestamp }))
}
fn parse_relation(&mut self, buf: &mut BytesMut) -> Result<(), String> {
buf.get_u8(); // 'R'
let rel_oid = Self::read_u32(buf)?;
let namespace = Self::read_string(buf)?;
let name = Self::read_string(buf)?;
let replica_identity = Self::read_u8(buf)?;
let num_columns = Self::read_i16(buf)? as usize;
let mut columns = Vec::with_capacity(num_columns);
for _ in 0..num_columns {
let flags = Self::read_u8(buf)?;
let col_name = Self::read_string(buf)?;
let type_oid = Self::read_u32(buf)?;
let type_modifier = Self::read_i32(buf)?;
columns.push(ColumnInfo { flags, name: col_name, type_oid, type_modifier });
}
self.relations.insert(
rel_oid,
RelationInfo { oid: rel_oid, namespace, name, replica_identity, columns },
);
Ok(())
}
fn parse_insert(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'I'
// pgoutput v1: Relation OID comes directly after type byte (no XID).
let rel_oid = Self::read_u32(buf)?;
let marker = Self::read_u8(buf)?;
if marker != b'N' {
return Err(format!("INSERT: expected 'N' marker, got '{}'", marker as char));
}
let new_tuple = self.parse_tuple_data(buf, rel_oid)?;
Ok(Some(LogicalMessage::Insert { rel_oid, new_tuple }))
}
fn parse_update(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'U'
// pgoutput v1: no XID in Update.
let rel_oid = Self::read_u32(buf)?;
let mut key_tuple = None;
let mut old_tuple = None;
if !buf.is_empty() {
match buf[0] as char {
'K' => {
buf.get_u8();
key_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
'O' => {
buf.get_u8();
old_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
_ => {} // no optional tuple, proceed to 'N'
}
}
let marker = Self::read_u8(buf)?;
if marker != b'N' {
return Err(format!("UPDATE: expected 'N' marker, got '{}'", marker as char));
}
let new_tuple = self.parse_tuple_data(buf, rel_oid)?;
Ok(Some(LogicalMessage::Update { rel_oid, key_tuple, old_tuple, new_tuple }))
}
fn parse_delete(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'D'
// pgoutput v1: no XID in Delete.
let rel_oid = Self::read_u32(buf)?;
let mut key_tuple = None;
let mut old_tuple = None;
if !buf.is_empty() {
match buf[0] as char {
'K' => {
buf.get_u8();
key_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
'O' => {
buf.get_u8();
old_tuple = Some(self.parse_tuple_data(buf, rel_oid)?);
}
other => {
return Err(format!("DELETE: expected 'K' or 'O', got '{}'", other));
}
}
}
Ok(Some(LogicalMessage::Delete { rel_oid, key_tuple, old_tuple }))
}
fn parse_truncate(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'T'
// pgoutput v1: no XID in Truncate.
let num_relations = Self::read_u32(buf)?;
let options = Self::read_u8(buf)?;
let mut rel_oids = Vec::with_capacity(num_relations as usize);
for _ in 0..num_relations {
rel_oids.push(Self::read_u32(buf)?);
}
Ok(Some(LogicalMessage::Truncate { num_relations, options, rel_oids }))
}
fn parse_type(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'Y'
// pgoutput v1: no XID in Type.
let type_oid = Self::read_u32(buf)?;
let namespace = Self::read_string(buf)?;
let type_name = Self::read_string(buf)?;
self._types.insert(type_oid, (namespace.clone(), type_name.clone()));
Ok(Some(LogicalMessage::Type { type_oid, namespace, type_name }))
}
fn parse_message_msg(&mut self, buf: &mut BytesMut) -> Result<Option<LogicalMessage>, String> {
buf.get_u8(); // 'M'
// pgoutput v1: no XID in Message.
let flags = Self::read_u8(buf)?;
let lsn = Self::read_u64(buf)?;
let prefix = Self::read_string(buf)?;
let content_len = Self::read_i32(buf)? as usize;
if buf.len() < content_len {
return Err(format!(
"Message: need {} bytes for content, have {}",
content_len,
buf.len()
));
}
let content = buf.split_to(content_len).to_vec();
Ok(Some(LogicalMessage::Message { flags, lsn, prefix, content }))
}
// -----------------------------------------------------------------------
// Tuple decoder
// -----------------------------------------------------------------------
fn parse_tuple_data(&self, buf: &mut BytesMut, rel_oid: u32) -> Result<TupleData, String> {
let num_columns = Self::read_i16(buf)? as usize;
let relation = self
.relations
.get(&rel_oid)
.ok_or_else(|| format!("unknown relation OID {rel_oid} — missing Relation message?"))?;
if num_columns != relation.columns.len() {
return Err(format!(
"tuple has {} columns but relation '{}' has {}",
num_columns,
relation.name,
relation.columns.len()
));
}
let mut values = Vec::with_capacity(num_columns);
for _ in 0..num_columns {
let marker = Self::read_u8(buf)?;
let value = match marker {
b'n' => ColumnValue::Null,
b'u' => ColumnValue::UnchangedToasted,
b't' => {
let len = Self::read_i32(buf)? as usize;
if buf.len() < len {
return Err(format!(
"text column: need {} bytes, have {}",
len,
buf.len()
));
}
let data = buf.split_to(len).to_vec();
let text = String::from_utf8(data)
.map_err(|e| format!("non-UTF-8 text column: {e}"))?;
ColumnValue::Text(text)
}
b'b' => {
let len = Self::read_i32(buf)? as usize;
if buf.len() < len {
return Err(format!(
"binary column: need {} bytes, have {}",
len,
buf.len()
));
}
ColumnValue::Binary(buf.split_to(len).to_vec())
}
other => {
return Err(format!(
"unknown column marker '{}'",
other as char
));
}
};
values.push(value);
}
Ok(TupleData { values })
}
}
impl Default for MessageParser {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_starts_empty() {
let p = MessageParser::new();
assert!(p.relations.is_empty());
}
#[test]
fn empty_buf_returns_none() {
let mut p = MessageParser::new();
let mut buf = BytesMut::new();
assert!(p.parse_message(&mut buf).unwrap().is_none());
}
}