//! 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 — `"" (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), } pub type MirrorSender = SyncSender; /// 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, 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, 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) -> Option> { 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, dropped: &mut u64, ) -> Option { 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(); } }