270 lines
11 KiB
Rust
270 lines
11 KiB
Rust
//! PostgreSQL backup mirror — plan 16b (`docs/plan/16-postgres-mirror.md`).
|
|
//!
|
|
//! RAM is authoritative; this module is the **backup mechanism**: every
|
|
//! committed mutation is cloned onto an mpsc channel by the shard engines
|
|
//! (after the WAL made it durable — never before, never gating the ack) and
|
|
//! a single dedicated `wo-pg` thread drains the channel into Postgres as
|
|
//! JSONB upserts. Reads never touch Postgres.
|
|
//!
|
|
//! Failure doctrine (16b): Postgres being down costs clients nothing — the
|
|
//! thread reconnects with capped backoff while the bounded channel absorbs
|
|
//! the burst; if the channel fills, records are dropped **loudly** (counted
|
|
//! and logged). Lossless catch-up (dirty-flag full resync) is plan 16d;
|
|
//! restore-from-Postgres at boot is 16e.
|
|
//!
|
|
//! Schema (16b): one table per type — `"<storage_name>" (id BIGINT PRIMARY
|
|
//! KEY, row JSONB NOT NULL)` — the `@table(name: "prices")` annotation
|
|
//! names the table. Typed-column projection is 16c.
|
|
|
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::Value;
|
|
|
|
use crate::engine::Row;
|
|
use crate::pg::{escape_ident, escape_literal, Conn, PgConfig};
|
|
|
|
/// Mirror channel capacity. At ~200 bytes/record this bounds the buffered
|
|
/// backlog around a few tens of MB — enough to ride out a Postgres restart
|
|
/// under load without threatening the RAM budget.
|
|
pub const QUEUE_CAP: usize = 65_536;
|
|
|
|
/// One committed mutation, as the mirror needs it. Unlike `WalRec::Update`
|
|
/// (which carries the merge body), `Upsert` always carries the FULL
|
|
/// post-merge row — the mirror's `ON CONFLICT ... DO UPDATE` replaces the
|
|
/// whole JSONB value.
|
|
#[derive(Debug, Clone)]
|
|
pub enum MirrorRec {
|
|
Upsert { ty: String, id: i64, row: Row },
|
|
Delete { ty: String, id: i64 },
|
|
/// One method transaction (plan 13b) — applied inside one Postgres
|
|
/// transaction, mirroring the WAL's atomic `WalRec::Txn` frame.
|
|
Txn(Vec<MirrorRec>),
|
|
}
|
|
|
|
pub type MirrorSender = SyncSender<MirrorRec>;
|
|
|
|
/// Spawn the `wo-pg` mirror thread. `tables` maps type name → storage
|
|
/// (table) name for every catalog type; DDL is bootstrapped on every
|
|
/// (re)connect so a fresh database works out of the box.
|
|
pub fn spawn(
|
|
cfg: PgConfig,
|
|
rx: Receiver<MirrorRec>,
|
|
tables: Vec<(String, String)>,
|
|
) -> std::thread::JoinHandle<()> {
|
|
std::thread::Builder::new()
|
|
.name("wo-pg".into())
|
|
.spawn(move || run(cfg, rx, tables))
|
|
.expect("spawn wo-pg mirror thread")
|
|
}
|
|
|
|
fn run(cfg: PgConfig, rx: Receiver<MirrorRec>, tables: Vec<(String, String)>) {
|
|
let mut dropped: u64 = 0;
|
|
loop {
|
|
// (Re)connect with capped backoff, bootstrapping DDL each time.
|
|
let Some(mut c) = connect_with_backoff(&cfg, &tables, &rx, &mut dropped) else {
|
|
return; // channel closed — shutdown
|
|
};
|
|
|
|
eprintln!("[wo] pg mirror: connected to {}:{}/{} ({} tables)",
|
|
cfg.host, cfg.port, cfg.database, tables.len());
|
|
if dropped > 0 {
|
|
eprintln!("[wo] pg mirror: WARNING — {dropped} records were dropped while \
|
|
disconnected; Postgres is behind RAM until a resync (plan 16d)");
|
|
}
|
|
|
|
// Drain loop: batch what's queued, one round-trip per batch.
|
|
loop {
|
|
let batch = match next_batch(&rx) {
|
|
Some(b) => b,
|
|
None => return, // senders gone — shutdown
|
|
};
|
|
if let Err(e) = apply_batch(&mut c, &tables, &batch) {
|
|
eprintln!("[wo] pg mirror: connection lost ({e}) — reconnecting");
|
|
break; // outer loop reconnects
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Block for the next record, then opportunistically drain up to a batch.
|
|
/// `None` = all senders dropped (process shutting down).
|
|
fn next_batch(rx: &Receiver<MirrorRec>) -> Option<Vec<MirrorRec>> {
|
|
const BATCH: usize = 512;
|
|
let first = rx.recv().ok()?;
|
|
let mut batch = vec![first];
|
|
while batch.len() < BATCH {
|
|
match rx.try_recv() {
|
|
Ok(rec) => batch.push(rec),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
Some(batch)
|
|
}
|
|
|
|
/// `None` = every sender is gone (process shutting down).
|
|
fn connect_with_backoff(
|
|
cfg: &PgConfig,
|
|
tables: &[(String, String)],
|
|
rx: &Receiver<MirrorRec>,
|
|
dropped: &mut u64,
|
|
) -> Option<Conn> {
|
|
let mut delay = Duration::from_millis(200);
|
|
loop {
|
|
match Conn::connect(cfg) {
|
|
Ok(mut c) => match bootstrap_ddl(&mut c, tables) {
|
|
Ok(()) => return Some(c),
|
|
Err(e) => eprintln!("[wo] pg mirror: DDL bootstrap failed ({e}) — retrying"),
|
|
},
|
|
Err(e) => eprintln!("[wo] pg mirror: connect failed ({e}) — retrying in {delay:?}"),
|
|
}
|
|
// While waiting, keep the channel from silently backing up forever:
|
|
// absorb what we can into the void, counting the loss (16b policy —
|
|
// 16d replaces this with dirty-flag resync).
|
|
let wait_until = std::time::Instant::now() + delay;
|
|
loop {
|
|
let left = wait_until.saturating_duration_since(std::time::Instant::now());
|
|
if left.is_zero() { break; }
|
|
match rx.recv_timeout(left.min(Duration::from_millis(100))) {
|
|
Ok(_) => { *dropped += 1; }
|
|
Err(RecvTimeoutError::Timeout) => {}
|
|
Err(RecvTimeoutError::Disconnected) => return None,
|
|
}
|
|
}
|
|
delay = (delay * 2).min(Duration::from_secs(5));
|
|
}
|
|
}
|
|
|
|
fn bootstrap_ddl(c: &mut Conn, tables: &[(String, String)]) -> Result<(), crate::pg::PgError> {
|
|
for (_, storage) in tables {
|
|
c.simple_query(&format!(
|
|
"CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, row JSONB NOT NULL)",
|
|
escape_ident(storage)))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply one batch. Statement/data errors are isolated per record and
|
|
/// logged (the batch continues); only I/O errors propagate (→ reconnect).
|
|
fn apply_batch(
|
|
c: &mut Conn,
|
|
tables: &[(String, String)],
|
|
batch: &[MirrorRec],
|
|
) -> Result<(), crate::pg::PgError> {
|
|
for rec in batch {
|
|
let sql = rec_sql(tables, rec);
|
|
match c.simple_query(&sql) {
|
|
Ok(_) => {}
|
|
Err(e) if e.severity == "CLIENT" => return Err(e), // socket-level: reconnect
|
|
Err(e) => eprintln!("[wo] pg mirror: statement rejected ({e}) — record skipped"),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Render one record as SQL. A `Txn` becomes BEGIN; …; COMMIT in a single
|
|
/// simple-query message — atomic on the Postgres side like its WAL frame.
|
|
fn rec_sql(tables: &[(String, String)], rec: &MirrorRec) -> String {
|
|
match rec {
|
|
MirrorRec::Upsert { ty, id, row } => {
|
|
let table = storage_for(tables, ty);
|
|
let json = serde_json::to_string(&Value::Object(row.clone()))
|
|
.unwrap_or_else(|_| "{}".into());
|
|
format!(
|
|
"INSERT INTO {} (id, row) VALUES ({}, {}::jsonb) \
|
|
ON CONFLICT (id) DO UPDATE SET row = EXCLUDED.row",
|
|
escape_ident(table), id, escape_literal(&json))
|
|
}
|
|
MirrorRec::Delete { ty, id } => {
|
|
format!("DELETE FROM {} WHERE id = {}", escape_ident(storage_for(tables, ty)), id)
|
|
}
|
|
MirrorRec::Txn(recs) => {
|
|
let mut sql = String::from("BEGIN");
|
|
for r in recs {
|
|
sql.push_str("; ");
|
|
sql.push_str(&rec_sql(tables, r));
|
|
}
|
|
sql.push_str("; COMMIT");
|
|
sql
|
|
}
|
|
}
|
|
}
|
|
|
|
fn storage_for<'a>(tables: &'a [(String, String)], ty: &'a str) -> &'a str {
|
|
tables.iter()
|
|
.find(|(t, _)| t == ty)
|
|
.map(|(_, s)| s.as_str())
|
|
.unwrap_or(ty)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn row(v: Value) -> Row {
|
|
match v { Value::Object(m) => m, _ => panic!() }
|
|
}
|
|
|
|
#[test]
|
|
fn sql_rendering_upsert_delete_txn() {
|
|
let tables = vec![("Price".to_string(), "prices".to_string())];
|
|
let up = MirrorRec::Upsert {
|
|
ty: "Price".into(), id: 2,
|
|
row: row(json!({"amount": 4999, "note": "it's"})),
|
|
};
|
|
let sql = rec_sql(&tables, &up);
|
|
assert!(sql.starts_with(r#"INSERT INTO "prices" (id, row) VALUES (2, '{"#), "{sql}");
|
|
assert!(sql.contains("''s"), "quote must be doubled: {sql}");
|
|
assert!(sql.ends_with("ON CONFLICT (id) DO UPDATE SET row = EXCLUDED.row"));
|
|
|
|
let del = MirrorRec::Delete { ty: "Price".into(), id: 7 };
|
|
assert_eq!(rec_sql(&tables, &del), r#"DELETE FROM "prices" WHERE id = 7"#);
|
|
|
|
// Unmapped type falls back to the type name.
|
|
let other = MirrorRec::Delete { ty: "Ghost".into(), id: 1 };
|
|
assert_eq!(rec_sql(&tables, &other), r#"DELETE FROM "Ghost" WHERE id = 1"#);
|
|
|
|
let txn = MirrorRec::Txn(vec![up, del]);
|
|
let sql = rec_sql(&tables, &txn);
|
|
assert!(sql.starts_with("BEGIN; "));
|
|
assert!(sql.ends_with("; COMMIT"));
|
|
}
|
|
|
|
/// Integration: full pipeline against a live server (WO_PG_TEST gated).
|
|
#[test]
|
|
fn mirror_pipeline_against_live_server() {
|
|
let Ok(url) = std::env::var("WO_PG_TEST") else {
|
|
eprintln!("mirror_pipeline: skipped (set WO_PG_TEST=postgres://... to run)");
|
|
return;
|
|
};
|
|
let cfg = PgConfig::from_url(&url).unwrap();
|
|
{
|
|
let mut c = Conn::connect(&cfg).unwrap();
|
|
c.simple_query(r#"DROP TABLE IF EXISTS "mirror_prices""#).unwrap();
|
|
}
|
|
|
|
let tables = vec![("Price".to_string(), "mirror_prices".to_string())];
|
|
let (tx, rx) = std::sync::mpsc::sync_channel(QUEUE_CAP);
|
|
let handle = spawn(cfg.clone(), rx, tables);
|
|
|
|
tx.send(MirrorRec::Upsert {
|
|
ty: "Price".into(), id: 1, row: row(json!({"amount": 100})),
|
|
}).unwrap();
|
|
tx.send(MirrorRec::Txn(vec![
|
|
MirrorRec::Upsert { ty: "Price".into(), id: 3, row: row(json!({"amount": 300})) },
|
|
MirrorRec::Upsert { ty: "Price".into(), id: 1, row: row(json!({"amount": 150})) },
|
|
])).unwrap();
|
|
tx.send(MirrorRec::Delete { ty: "Price".into(), id: 3 }).unwrap();
|
|
drop(tx); // close channel → thread drains and exits
|
|
handle.join().unwrap();
|
|
|
|
let mut c = Conn::connect(&cfg).unwrap();
|
|
let r = c.simple_query(
|
|
r#"SELECT id, row->>'amount' FROM "mirror_prices" ORDER BY id"#).unwrap();
|
|
assert_eq!(r.rows.len(), 1, "id 3 deleted, id 1 remains: {:?}", r.rows);
|
|
assert_eq!(r.rows[0][0].as_deref(), Some("1"));
|
|
assert_eq!(r.rows[0][1].as_deref(), Some("150"), "txn upsert must have applied");
|
|
c.simple_query(r#"DROP TABLE "mirror_prices""#).unwrap();
|
|
}
|
|
}
|