Skip to main content

pgsync_postgres/
sink.rs

1//! PostgreSQL sink: applies decoded changes inside target transactions and stores the
2//! job checkpoint in the same transaction.
3
4use crate::client::PgClient;
5use crate::errors::from_pg;
6use crate::schema::{TargetColumn, target_columns};
7use bytes::{Bytes, BytesMut};
8use futures_util::future::try_join_all;
9use pgsync_core::checkpoint::Checkpoint;
10use pgsync_core::config::{TableName, quote_ident};
11use pgsync_core::event::{Op, Relation, ReplicaIdentity, Tuple, Value};
12use pgsync_core::state::JobState;
13use pgsync_core::traits::ReplicationSink;
14use pgsync_core::{Error, Lsn, Result};
15use postgres_types::{IsNull, PgLsn, ToSql, Type, to_sql_checked};
16use std::collections::{BTreeSet, HashMap};
17use std::sync::Arc;
18use tokio_postgres::Statement;
19use tracing::{debug, warn};
20
21pub const SCHEMA_SQL: &str = r#"
22SET client_min_messages = warning;
23CREATE SCHEMA IF NOT EXISTS pgsync;
24CREATE TABLE IF NOT EXISTS pgsync.job (
25    job_id            text PRIMARY KEY,
26    state             text NOT NULL,
27    source_system_id  text NOT NULL,
28    source_dbname     text NOT NULL,
29    slot              text NOT NULL,
30    publication       text NOT NULL,
31    slot_owned        boolean NOT NULL DEFAULT false,
32    consistent_point  pg_lsn,
33    commit_lsn        pg_lsn NOT NULL DEFAULT '0/0',
34    committed_lsn     pg_lsn NOT NULL DEFAULT '0/0',
35    acked_lsn         pg_lsn NOT NULL DEFAULT '0/0',
36    last_xid          bigint,
37    last_commit_ts    bigint,
38    txns_applied      bigint NOT NULL DEFAULT 0,
39    last_error        text,
40    created_at        timestamptz NOT NULL DEFAULT now(),
41    updated_at        timestamptz NOT NULL DEFAULT now()
42);
43CREATE TABLE IF NOT EXISTS pgsync.job_runtime (
44    job_id            text PRIMARY KEY REFERENCES pgsync.job (job_id) ON DELETE CASCADE,
45    pid               integer NOT NULL,
46    hostname          text NOT NULL,
47    pgsync_version    text NOT NULL,
48    started_at        timestamptz NOT NULL,
49    updated_at        timestamptz NOT NULL,
50    stopped_at        timestamptz,
51    state             text NOT NULL,
52    connection        text NOT NULL,
53    source_version    text,
54    received_lsn      pg_lsn NOT NULL DEFAULT '0/0',
55    server_wal_end    pg_lsn NOT NULL DEFAULT '0/0',
56    committed_lsn     pg_lsn NOT NULL DEFAULT '0/0',
57    acked_lsn         pg_lsn NOT NULL DEFAULT '0/0',
58    apply_lag_bytes   bigint NOT NULL DEFAULT 0,
59    txns_applied      bigint NOT NULL DEFAULT 0,
60    rows_applied      bigint NOT NULL DEFAULT 0,
61    rows_copied       bigint NOT NULL DEFAULT 0,
62    bytes_copied      bigint NOT NULL DEFAULT 0,
63    bytes_received    bigint NOT NULL DEFAULT 0,
64    txns_per_s        double precision NOT NULL DEFAULT 0,
65    rows_per_s        double precision NOT NULL DEFAULT 0,
66    reconnects        bigint NOT NULL DEFAULT 0,
67    rss_bytes         bigint,
68    last_apply_at     timestamptz
69);
70CREATE TABLE IF NOT EXISTS pgsync.table_sync (
71    job_id       text NOT NULL REFERENCES pgsync.job (job_id) ON DELETE CASCADE,
72    schema_name  text NOT NULL,
73    table_name   text NOT NULL,
74    state        text NOT NULL,
75    rows_copied  bigint NOT NULL DEFAULT 0,
76    bytes_copied bigint NOT NULL DEFAULT 0,
77    copied_at    timestamptz,
78    PRIMARY KEY (job_id, schema_name, table_name)
79);
80"#;
81
82/// A text-format parameter. Statements are prepared with all parameters typed `text` and
83/// cast server-side to the target column type.
84#[derive(Debug)]
85struct TextParam(Option<Bytes>);
86
87impl ToSql for TextParam {
88    fn to_sql(
89        &self,
90        _ty: &Type,
91        out: &mut BytesMut,
92    ) -> std::result::Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
93        match &self.0 {
94            None => Ok(IsNull::Yes),
95            Some(b) => {
96                out.extend_from_slice(b);
97                Ok(IsNull::No)
98            }
99        }
100    }
101    fn accepts(_ty: &Type) -> bool {
102        true
103    }
104    to_sql_checked!();
105}
106
107struct ResolvedRelation {
108    relation: Arc<Relation>,
109    quoted: String,
110    /// Target type name per source column index.
111    types: Vec<String>,
112    /// Columns that UPDATE must never assign (GENERATED ALWAYS AS IDENTITY).
113    identity_always: Vec<bool>,
114    insert_sql: String,
115}
116
117struct Pending {
118    stmt: Statement,
119    params: Vec<TextParam>,
120    /// Expected affected rows for UPDATE/DELETE.
121    expect_one: bool,
122    describe: &'static str,
123    table: String,
124}
125
126pub struct SinkLimits {
127    pub max_batch_rows: usize,
128    pub max_batch_bytes: usize,
129}
130
131pub struct PgSink {
132    client: PgClient,
133    relations: HashMap<u32, ResolvedRelation>,
134    statements: HashMap<String, Statement>,
135    pending: Vec<Pending>,
136    pending_bytes: usize,
137    in_txn: bool,
138    limits: SinkLimits,
139    /// `schema.table` names this job is configured to replicate. A relation outside this
140    /// set (publication changed after bootstrap, `FOR ALL TABLES`) is refused rather than
141    /// applied to a same-named target table that was never snapshotted.
142    allowed_tables: Option<BTreeSet<String>>,
143}
144
145impl PgSink {
146    /// Prepare a connected target client: create the metadata schema and configure the
147    /// session for replica apply.
148    pub async fn new(client: PgClient, limits: SinkLimits) -> Result<Self> {
149        client
150            .batch_execute(SCHEMA_SQL)
151            .await
152            .map_err(|e| from_pg("create pgsync schema on target", e))?;
153        match client
154            .batch_execute("SET session_replication_role = replica")
155            .await
156        {
157            Ok(()) => debug!(
158                "session_replication_role = replica (triggers and FK checks deferred to the source)"
159            ),
160            Err(e) => {
161                warn!(error = %e, "cannot set session_replication_role = replica; target triggers and FK checks stay active")
162            }
163        }
164        configure_session(&client).await?;
165        Ok(PgSink {
166            client,
167            relations: HashMap::new(),
168            statements: HashMap::new(),
169            pending: Vec::new(),
170            pending_bytes: 0,
171            in_txn: false,
172            limits,
173            allowed_tables: None,
174        })
175    }
176
177    /// Restrict apply to the configured tables (`run` path). Without this, every
178    /// published relation that exists on the target is accepted (`status`/`cleanup` never
179    /// apply anything, so they do not need it).
180    pub fn restrict_to(mut self, tables: &[TableName]) -> Self {
181        self.allowed_tables = Some(tables.iter().map(|t| t.to_string()).collect());
182        self
183    }
184
185    pub fn client(&self) -> &PgClient {
186        &self.client
187    }
188
189    async fn prepare(&mut self, sql: &str, nparams: usize) -> Result<Statement> {
190        if let Some(s) = self.statements.get(sql) {
191            return Ok(s.clone());
192        }
193        if self.statements.len() > 1024 {
194            self.statements.clear();
195        }
196        let types = vec![Type::TEXT; nparams];
197        let stmt = self
198            .client
199            .prepare_typed(sql, &types)
200            .await
201            .map_err(|e| from_pg("prepare", e))?;
202        self.statements.insert(sql.to_owned(), stmt.clone());
203        Ok(stmt)
204    }
205
206    async fn flush(&mut self) -> Result<()> {
207        if self.pending.is_empty() {
208            return Ok(());
209        }
210        let batch = std::mem::take(&mut self.pending);
211        self.pending_bytes = 0;
212        let client = &self.client;
213        // Issued concurrently on one connection: tokio-postgres pipelines them in order.
214        let futs = batch.iter().map(|p| {
215            let params: Vec<&(dyn ToSql + Sync)> =
216                p.params.iter().map(|x| x as &(dyn ToSql + Sync)).collect();
217            async move { client.execute(&p.stmt, &params).await }
218        });
219        let counts = try_join_all(futs)
220            .await
221            .map_err(|e| from_pg("apply batch", e))?;
222        for (p, n) in batch.iter().zip(counts) {
223            if p.expect_one && n != 1 {
224                return Err(Error::consistency(format!(
225                    "{} on {} affected {n} rows (expected 1): source and target have diverged",
226                    p.describe, p.table
227                )));
228            }
229        }
230        Ok(())
231    }
232
233    fn resolved(&self, id: u32) -> Result<&ResolvedRelation> {
234        self.relations
235            .get(&id)
236            .ok_or_else(|| Error::internal(format!("change for unknown relation id {id}")))
237    }
238
239    fn push(
240        &mut self,
241        stmt: Statement,
242        params: Vec<TextParam>,
243        expect_one: bool,
244        describe: &'static str,
245        table: String,
246    ) {
247        self.pending_bytes += params
248            .iter()
249            .map(|p| p.0.as_ref().map_or(0, |b| b.len()) + 16)
250            .sum::<usize>();
251        self.pending.push(Pending {
252            stmt,
253            params,
254            expect_one,
255            describe,
256            table,
257        });
258    }
259
260    fn needs_flush(&self) -> bool {
261        self.pending.len() >= self.limits.max_batch_rows
262            || self.pending_bytes >= self.limits.max_batch_bytes
263    }
264
265    async fn apply_insert(&mut self, rel_id: u32, new: Tuple) -> Result<()> {
266        let (sql, table, n) = {
267            let r = self.resolved(rel_id)?;
268            if new.values.len() != r.relation.columns.len() {
269                return Err(Error::unsupported(format!(
270                    "{}: insert has {} values but relation has {} columns (schema change?)",
271                    r.quoted,
272                    new.values.len(),
273                    r.relation.columns.len()
274                )));
275            }
276            (
277                r.insert_sql.clone(),
278                r.quoted.clone(),
279                r.relation.columns.len(),
280            )
281        };
282        let stmt = self.prepare(&sql, n).await?;
283        let mut params = Vec::with_capacity(n);
284        for v in new.values {
285            params.push(param(v, &table, "insert")?);
286        }
287        self.push(stmt, params, false, "INSERT", table);
288        Ok(())
289    }
290
291    async fn apply_update(
292        &mut self,
293        rel_id: u32,
294        key: Option<Tuple>,
295        old: Option<Tuple>,
296        new: Tuple,
297    ) -> Result<()> {
298        let (sql, params, table) = {
299            let r = self.resolved(rel_id)?;
300            let (sql, params) = build_update(r, key.as_ref(), old.as_ref(), &new)?;
301            (sql, params, r.quoted.clone())
302        };
303        let stmt = self.prepare(&sql, params.len()).await?;
304        self.push(stmt, params, true, "UPDATE", table);
305        Ok(())
306    }
307
308    async fn apply_delete(
309        &mut self,
310        rel_id: u32,
311        key: Option<Tuple>,
312        old: Option<Tuple>,
313    ) -> Result<()> {
314        let (sql, params, table) = {
315            let r = self.resolved(rel_id)?;
316            let mut params = Vec::new();
317            let where_clause = build_where(r, key.as_ref(), old.as_ref(), None, &mut params)?;
318            (
319                format!("DELETE FROM {} WHERE {where_clause}", r.quoted),
320                params,
321                r.quoted.clone(),
322            )
323        };
324        let stmt = self.prepare(&sql, params.len()).await?;
325        self.push(stmt, params, true, "DELETE", table);
326        Ok(())
327    }
328
329    async fn apply_truncate(
330        &mut self,
331        relations: Vec<u32>,
332        cascade: bool,
333        restart_identity: bool,
334    ) -> Result<()> {
335        self.flush().await?;
336        let mut names = Vec::new();
337        for id in relations {
338            names.push(self.resolved(id)?.quoted.clone());
339        }
340        let mut sql = format!("TRUNCATE TABLE {}", names.join(", "));
341        if restart_identity {
342            sql.push_str(" RESTART IDENTITY");
343        }
344        if cascade {
345            sql.push_str(" CASCADE");
346        }
347        self.client
348            .batch_execute(&sql)
349            .await
350            .map_err(|e| from_pg("truncate", e))?;
351        Ok(())
352    }
353
354    /// Record a table as copied during the snapshot (inside the caller's transaction).
355    pub async fn record_table_copied(
356        client: &PgClient,
357        job_id: &str,
358        schema: &str,
359        table: &str,
360        rows: u64,
361        bytes: u64,
362    ) -> Result<()> {
363        client
364            .execute(
365                "INSERT INTO pgsync.table_sync (job_id, schema_name, table_name, state, rows_copied, bytes_copied, copied_at) \
366                 VALUES ($1, $2, $3, 'COPIED', $4, $5, now()) \
367                 ON CONFLICT (job_id, schema_name, table_name) DO UPDATE SET state = 'COPIED', rows_copied = $4, bytes_copied = $5, copied_at = now()",
368                &[&job_id, &schema, &table, &(rows as i64), &(bytes as i64)],
369            )
370            .await
371            .map_err(|e| from_pg("record table copy", e))?;
372        Ok(())
373    }
374}
375
376fn param(v: Value, table: &str, op: &str) -> Result<TextParam> {
377    Ok(match v {
378        Value::Null => TextParam(None),
379        Value::Text(b) => TextParam(Some(b)),
380        Value::Binary(_) => {
381            return Err(Error::unsupported(format!(
382                "{table}: binary tuple values are not supported ({op})"
383            )));
384        }
385        Value::Unchanged => {
386            return Err(Error::unsupported(format!(
387                "{table}: unchanged TOAST value in {op} where a value is required"
388            )));
389        }
390    })
391}
392
393/// Build `UPDATE ... SET ... WHERE ...` for one change.
394fn build_update(
395    r: &ResolvedRelation,
396    key: Option<&Tuple>,
397    old: Option<&Tuple>,
398    new: &Tuple,
399) -> Result<(String, Vec<TextParam>)> {
400    let rel = &r.relation;
401    if new.values.len() != rel.columns.len() {
402        return Err(Error::unsupported(format!(
403            "{}: update tuple width mismatch (schema change?)",
404            r.quoted
405        )));
406    }
407    let mut sql = format!("UPDATE {} SET ", r.quoted);
408    let mut params = Vec::new();
409    let mut first = true;
410    // Without an old key the key did not change, so key columns need no SET.
411    let key_unchanged = key.is_none();
412    for (i, v) in new.values.iter().enumerate() {
413        if matches!(v, Value::Unchanged) || r.identity_always[i] {
414            continue;
415        }
416        if key_unchanged && rel.columns[i].is_key && rel.replica_identity != ReplicaIdentity::Full {
417            continue;
418        }
419        if !first {
420            sql.push_str(", ");
421        }
422        first = false;
423        params.push(param(v.clone(), &r.quoted, "update")?);
424        sql.push_str(&format!(
425            "{} = ${}::{}",
426            quote_ident(&rel.columns[i].name),
427            params.len(),
428            r.types[i]
429        ));
430    }
431    if first {
432        // Every assignable column is unchanged TOAST or a key: nothing to assign, but the
433        // row must still exist (affected-row check). Touch a key column with itself.
434        let (_, col) = rel.key_columns().next().ok_or_else(|| {
435            Error::unsupported(format!("{}: update with no assignable columns", r.quoted))
436        })?;
437        sql.push_str(&format!("{0} = {0}", quote_ident(&col.name)));
438    }
439    let where_clause = build_where(r, key, old, Some(new), &mut params)?;
440    sql.push_str(" WHERE ");
441    sql.push_str(&where_clause);
442    Ok((sql, params))
443}
444
445/// Build the WHERE clause identifying the old row.
446fn build_where(
447    r: &ResolvedRelation,
448    key: Option<&Tuple>,
449    old: Option<&Tuple>,
450    new: Option<&Tuple>,
451    params: &mut Vec<TextParam>,
452) -> Result<String> {
453    let rel = &r.relation;
454    let mut parts = Vec::new();
455    if let Some(old) = old {
456        // REPLICA IDENTITY FULL: match every column we know, null-safely.
457        if old.values.len() != rel.columns.len() {
458            return Err(Error::unsupported(format!(
459                "{}: old tuple width mismatch",
460                r.quoted
461            )));
462        }
463        for (i, v) in old.values.iter().enumerate() {
464            if matches!(v, Value::Unchanged) {
465                continue;
466            }
467            params.push(param(v.clone(), &r.quoted, "where")?);
468            parts.push(format!(
469                "{} IS NOT DISTINCT FROM ${}::{}",
470                quote_ident(&rel.columns[i].name),
471                params.len(),
472                r.types[i]
473            ));
474        }
475    } else {
476        let source = match (key, new) {
477            (Some(k), _) => k,
478            (None, Some(n)) => n,
479            (None, None) => {
480                return Err(Error::unsupported(format!(
481                    "{}: delete without key or old tuple",
482                    r.quoted
483                )));
484            }
485        };
486        if source.values.len() != rel.columns.len() {
487            return Err(Error::unsupported(format!(
488                "{}: key tuple width mismatch",
489                r.quoted
490            )));
491        }
492        for (i, col) in rel.key_columns() {
493            let v = &source.values[i];
494            if matches!(v, Value::Unchanged | Value::Null) {
495                return Err(Error::unsupported(format!(
496                    "{}: key column {:?} has no value",
497                    r.quoted, col.name
498                )));
499            }
500            params.push(param(v.clone(), &r.quoted, "where")?);
501            parts.push(format!(
502                "{} = ${}::{}",
503                quote_ident(&col.name),
504                params.len(),
505                r.types[i]
506            ));
507        }
508    }
509    if parts.is_empty() {
510        return Err(Error::unsupported(format!(
511            "{}: no usable key (replica identity {:?}); UPDATE/DELETE cannot be applied",
512            r.quoted, rel.replica_identity
513        )));
514    }
515    Ok(parts.join(" AND "))
516}
517
518fn build_insert_sql(quoted: &str, rel: &Relation, types: &[String]) -> String {
519    let cols: Vec<String> = rel.columns.iter().map(|c| quote_ident(&c.name)).collect();
520    let vals: Vec<String> = types
521        .iter()
522        .enumerate()
523        .map(|(i, t)| format!("${}::{t}", i + 1))
524        .collect();
525    format!(
526        "INSERT INTO {quoted} ({}) OVERRIDING SYSTEM VALUE VALUES ({})",
527        cols.join(", "),
528        vals.join(", ")
529    )
530}
531
532fn resolve(relation: Arc<Relation>, target: &[TargetColumn]) -> Result<ResolvedRelation> {
533    let quoted = format!(
534        "{}.{}",
535        quote_ident(&relation.schema),
536        quote_ident(&relation.name)
537    );
538    let mut types = Vec::with_capacity(relation.columns.len());
539    let mut identity_always = Vec::with_capacity(relation.columns.len());
540    for c in &relation.columns {
541        let t = target.iter().find(|t| t.name == c.name).ok_or_else(|| {
542            Error::unsupported(format!(
543                "{quoted}: column {:?} does not exist on the target (schema change on the source?)",
544                c.name
545            ))
546        })?;
547        if t.generated {
548            return Err(Error::unsupported(format!(
549                "{quoted}: column {:?} is generated on the target",
550                c.name
551            )));
552        }
553        types.push(t.cast_type.clone());
554        identity_always.push(t.identity_always);
555    }
556    if relation.replica_identity == ReplicaIdentity::Nothing {
557        warn!(table = %quoted, "relation has REPLICA IDENTITY NOTHING; UPDATE/DELETE will fail");
558    }
559    let insert_sql = build_insert_sql(&quoted, &relation, &types);
560    Ok(ResolvedRelation {
561        relation,
562        quoted,
563        types,
564        identity_always,
565        insert_sql,
566    })
567}
568
569impl ReplicationSink for PgSink {
570    async fn load_checkpoint(&mut self, job_id: &str) -> Result<Option<Checkpoint>> {
571        let row = self
572            .client
573            .query_opt(
574                "SELECT state, source_system_id, source_dbname, slot, publication, slot_owned, consistent_point, \
575                 commit_lsn, committed_lsn, acked_lsn, last_xid, last_commit_ts, txns_applied, last_error \
576                 FROM pgsync.job WHERE job_id = $1",
577                &[&job_id],
578            )
579            .await
580            .map_err(|e| from_pg("load checkpoint", e))?;
581        let Some(r) = row else { return Ok(None) };
582        let state: String = r.get(0);
583        let lsn = |i: usize| -> Lsn { Lsn(u64::from(r.get::<_, PgLsn>(i))) };
584        let cp = Checkpoint {
585            job_id: job_id.to_owned(),
586            state: state.parse().map_err(Error::internal)?,
587            source_system_id: r.get(1),
588            source_dbname: r.get(2),
589            slot: r.get(3),
590            publication: r.get(4),
591            slot_owned: r.get(5),
592            consistent_point: r.get::<_, Option<PgLsn>>(6).map(|l| Lsn(u64::from(l))),
593            commit_lsn: lsn(7),
594            committed_lsn: lsn(8),
595            acked_lsn: lsn(9),
596            last_xid: r.get::<_, Option<i64>>(10).map(|x| x as u32),
597            last_commit_ts: r.get(11),
598            txns_applied: r.get::<_, i64>(12) as u64,
599            last_error: r.get(13),
600        };
601        cp.check_invariants()
602            .map_err(|m| Error::internal(format!("stored checkpoint violates invariants: {m}")))?;
603        Ok(Some(cp))
604    }
605
606    async fn save_checkpoint(&mut self, cp: &Checkpoint) -> Result<()> {
607        if self.in_txn {
608            return Err(Error::internal(
609                "save_checkpoint called inside a data transaction",
610            ));
611        }
612        upsert_checkpoint(&self.client, cp).await
613    }
614
615    async fn begin(&mut self) -> Result<()> {
616        if self.in_txn {
617            return Err(Error::internal("BEGIN while a target transaction is open"));
618        }
619        self.client
620            .batch_execute("BEGIN")
621            .await
622            .map_err(|e| from_pg("BEGIN on target", e))?;
623        self.in_txn = true;
624        Ok(())
625    }
626
627    async fn relation(&mut self, rel: Arc<Relation>) -> Result<()> {
628        if let Some(allowed) = &self.allowed_tables
629            && !allowed.contains(&rel.qualified_name())
630        {
631            // pgoutput resolves publication membership from the historic catalog, so the
632            // change stays in the stream even if the table is dropped from the publication
633            // later; without a snapshot of the table the target cannot be made consistent.
634            return Err(Error::resync_required(format!(
635                "table {} is published but not in replication.tables and was never snapshotted, \
636                 so its changes cannot be applied. Add it to replication.tables and \
637                 re-bootstrap (`cleanup --yes`, then `bootstrap --restart-snapshot`)",
638                rel.qualified_name()
639            )));
640        }
641        // A relation message inside a transaction may follow pending statements for the
642        // same table; flush so ordering is preserved before metadata changes.
643        self.flush().await?;
644        let target = target_columns(&self.client, &rel.schema, &rel.name)
645            .await?
646            .ok_or_else(|| {
647                Error::unsupported(format!(
648                    "table {}.{} does not exist on the target",
649                    rel.schema, rel.name
650                ))
651            })?;
652        let resolved = resolve(rel.clone(), &target)?;
653        debug!(table = %resolved.quoted, columns = rel.columns.len(), "relation resolved");
654        self.relations.insert(rel.id, resolved);
655        Ok(())
656    }
657
658    async fn apply(&mut self, op: Op) -> Result<()> {
659        if !self.in_txn {
660            return Err(Error::internal("apply outside of a target transaction"));
661        }
662        match op {
663            Op::Insert { relation, new } => self.apply_insert(relation, new).await?,
664            Op::Update {
665                relation,
666                key,
667                old,
668                new,
669            } => self.apply_update(relation, key, old, new).await?,
670            Op::Delete { relation, key, old } => self.apply_delete(relation, key, old).await?,
671            Op::Truncate {
672                relations,
673                cascade,
674                restart_identity,
675            } => {
676                self.apply_truncate(relations, cascade, restart_identity)
677                    .await?
678            }
679        }
680        if self.needs_flush() {
681            self.flush().await?;
682        }
683        Ok(())
684    }
685
686    async fn commit(&mut self, cp: &Checkpoint) -> Result<()> {
687        if !self.in_txn {
688            return Err(Error::internal("COMMIT without an open target transaction"));
689        }
690        self.flush().await?;
691        let n = update_checkpoint(&self.client, cp).await?;
692        if n != 1 {
693            return Err(Error::internal(format!(
694                "checkpoint row for job {:?} is missing on the target",
695                cp.job_id
696            )));
697        }
698        self.client
699            .batch_execute("COMMIT")
700            .await
701            .map_err(|e| from_pg("COMMIT on target", e))?;
702        self.in_txn = false;
703        Ok(())
704    }
705
706    async fn rollback(&mut self) -> Result<()> {
707        self.pending.clear();
708        self.pending_bytes = 0;
709        if self.in_txn {
710            self.in_txn = false;
711            self.client
712                .batch_execute("ROLLBACK")
713                .await
714                .map_err(|e| from_pg("ROLLBACK on target", e))?;
715        }
716        Ok(())
717    }
718}
719
720async fn update_checkpoint(client: &PgClient, cp: &Checkpoint) -> Result<u64> {
721    client
722        .execute(
723            "UPDATE pgsync.job SET state = $2, commit_lsn = $3, committed_lsn = $4, acked_lsn = $5, last_xid = $6, \
724             last_commit_ts = $7, txns_applied = $8, last_error = $9, updated_at = now() WHERE job_id = $1",
725            &[
726                &cp.job_id,
727                &cp.state.as_str(),
728                &PgLsn::from(cp.commit_lsn.0),
729                &PgLsn::from(cp.committed_lsn.0),
730                &PgLsn::from(cp.acked_lsn.0),
731                &cp.last_xid.map(|x| x as i64),
732                &cp.last_commit_ts,
733                &(cp.txns_applied as i64),
734                &cp.last_error,
735            ],
736        )
737        .await
738        .map_err(|e| from_pg("update checkpoint", e))
739}
740
741/// Settings every pgsync session on the target needs: a fixed search path, durable commits,
742/// and no inherited timeouts. A `statement_timeout` or `idle_in_transaction_session_timeout`
743/// set on the target role or database would otherwise cancel long transactions or large
744/// copies on every attempt (SQLSTATE 57014 is retried, so the job would stall, not fail).
745pub async fn configure_session(client: &PgClient) -> Result<()> {
746    client
747        .batch_execute(
748            "SET search_path = pg_catalog; SET synchronous_commit = on; SET lock_timeout = 0; \
749             SET statement_timeout = 0; SET idle_in_transaction_session_timeout = 0; \
750             SET idle_session_timeout = 0",
751        )
752        .await
753        .map_err(|e| from_pg("configure target session", e))?;
754    // PostgreSQL 17+ only; ignore on older servers.
755    let _ = client.batch_execute("SET transaction_timeout = 0").await;
756    Ok(())
757}
758
759/// Insert or update the job row (autocommit).
760pub async fn upsert_checkpoint(client: &PgClient, cp: &Checkpoint) -> Result<()> {
761    client
762        .execute(
763            "INSERT INTO pgsync.job (job_id, state, source_system_id, source_dbname, slot, publication, slot_owned, \
764             consistent_point, commit_lsn, committed_lsn, acked_lsn, last_xid, last_commit_ts, txns_applied, last_error) \
765             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) \
766             ON CONFLICT (job_id) DO UPDATE SET state = EXCLUDED.state, source_system_id = EXCLUDED.source_system_id, \
767             source_dbname = EXCLUDED.source_dbname, slot = EXCLUDED.slot, publication = EXCLUDED.publication, \
768             slot_owned = EXCLUDED.slot_owned, consistent_point = EXCLUDED.consistent_point, commit_lsn = EXCLUDED.commit_lsn, \
769             committed_lsn = EXCLUDED.committed_lsn, acked_lsn = EXCLUDED.acked_lsn, last_xid = EXCLUDED.last_xid, \
770             last_commit_ts = EXCLUDED.last_commit_ts, txns_applied = EXCLUDED.txns_applied, last_error = EXCLUDED.last_error, \
771             updated_at = now()",
772            &[
773                &cp.job_id,
774                &cp.state.as_str(),
775                &cp.source_system_id,
776                &cp.source_dbname,
777                &cp.slot,
778                &cp.publication,
779                &cp.slot_owned,
780                &cp.consistent_point.map(|l| PgLsn::from(l.0)),
781                &PgLsn::from(cp.commit_lsn.0),
782                &PgLsn::from(cp.committed_lsn.0),
783                &PgLsn::from(cp.acked_lsn.0),
784                &cp.last_xid.map(|x| x as i64),
785                &cp.last_commit_ts,
786                &(cp.txns_applied as i64),
787                &cp.last_error,
788            ],
789        )
790        .await
791        .map_err(|e| from_pg("save checkpoint", e))?;
792    Ok(())
793}
794
795/// One heartbeat of the running process, written outside any data transaction.
796pub struct RuntimeRow<'a> {
797    pub job_id: &'a str,
798    pub started_at_unix: i64,
799    pub stopped: bool,
800    pub source_version: Option<&'a str>,
801    pub snapshot: &'a pgsync_core::metrics::MetricsSnapshot,
802    pub txns_per_s: f64,
803    pub rows_per_s: f64,
804}
805
806pub async fn upsert_runtime(client: &PgClient, r: &RuntimeRow<'_>) -> Result<()> {
807    let m = r.snapshot;
808    let hostname = std::env::var("HOSTNAME")
809        .ok()
810        .or_else(|| {
811            std::process::Command::new("hostname")
812                .output()
813                .ok()
814                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
815        })
816        .unwrap_or_default();
817    client
818        .execute(
819            "INSERT INTO pgsync.job_runtime (job_id, pid, hostname, pgsync_version, started_at, updated_at, stopped_at, \
820             state, connection, source_version, received_lsn, server_wal_end, committed_lsn, acked_lsn, apply_lag_bytes, \
821             txns_applied, rows_applied, rows_copied, bytes_copied, bytes_received, txns_per_s, rows_per_s, reconnects, \
822             rss_bytes, last_apply_at) \
823             VALUES ($1, $2, $3, $4, to_timestamp($5), now(), CASE WHEN $6 THEN now() END, $7, $8, $9, $10, $11, $12, $13, $14, \
824             $15, $16, $17, $18, $19, $20, $21, $22, $23, CASE WHEN $24::float8 > 0 THEN to_timestamp($24::float8 / 1e6) END) \
825             ON CONFLICT (job_id) DO UPDATE SET pid = EXCLUDED.pid, hostname = EXCLUDED.hostname, \
826             pgsync_version = EXCLUDED.pgsync_version, started_at = EXCLUDED.started_at, updated_at = now(), \
827             stopped_at = EXCLUDED.stopped_at, state = EXCLUDED.state, connection = EXCLUDED.connection, \
828             source_version = coalesce(EXCLUDED.source_version, pgsync.job_runtime.source_version), \
829             received_lsn = EXCLUDED.received_lsn, server_wal_end = EXCLUDED.server_wal_end, \
830             committed_lsn = EXCLUDED.committed_lsn, acked_lsn = EXCLUDED.acked_lsn, apply_lag_bytes = EXCLUDED.apply_lag_bytes, \
831             txns_applied = EXCLUDED.txns_applied, rows_applied = EXCLUDED.rows_applied, rows_copied = EXCLUDED.rows_copied, \
832             bytes_copied = EXCLUDED.bytes_copied, bytes_received = EXCLUDED.bytes_received, txns_per_s = EXCLUDED.txns_per_s, \
833             rows_per_s = EXCLUDED.rows_per_s, reconnects = EXCLUDED.reconnects, rss_bytes = EXCLUDED.rss_bytes, \
834             last_apply_at = coalesce(EXCLUDED.last_apply_at, pgsync.job_runtime.last_apply_at)",
835            &[
836                &r.job_id,
837                &(std::process::id() as i32),
838                &hostname,
839                &env!("CARGO_PKG_VERSION"),
840                &(r.started_at_unix as f64),
841                &r.stopped,
842                &m.state.as_str(),
843                &format!("{:?}", m.connection).to_lowercase(),
844                &r.source_version,
845                &PgLsn::from(m.last_received_lsn.0),
846                &PgLsn::from(m.server_wal_end.0),
847                &PgLsn::from(m.committed_lsn.0),
848                &PgLsn::from(m.acked_lsn.0),
849                &(m.apply_lag_bytes as i64),
850                &(m.txns_applied as i64),
851                &(m.rows_applied as i64),
852                &(m.rows_copied as i64),
853                &(m.bytes_copied as i64),
854                &(m.bytes_received as i64),
855                &r.txns_per_s,
856                &r.rows_per_s,
857                &(m.reconnects as i64),
858                &m.rss_bytes.map(|b| b as i64),
859                &(m.last_apply_unix_micros.unwrap_or(0) as f64),
860            ],
861        )
862        .await
863        .map_err(|e| from_pg("write runtime row", e))?;
864    Ok(())
865}
866
867/// Delete the job's metadata rows (used by `cleanup`).
868pub async fn delete_job(client: &PgClient, job_id: &str) -> Result<u64> {
869    client
870        .execute("DELETE FROM pgsync.job WHERE job_id = $1", &[&job_id])
871        .await
872        .map_err(|e| from_pg("delete job", e))
873}
874
875pub async fn set_job_state(
876    client: &PgClient,
877    job_id: &str,
878    state: JobState,
879    error: Option<&str>,
880) -> Result<()> {
881    client
882        .execute(
883            "UPDATE pgsync.job SET state = $2, last_error = $3, updated_at = now() WHERE job_id = $1",
884            &[&job_id, &state.as_str(), &error],
885        )
886        .await
887        .map_err(|e| from_pg("set job state", e))?;
888    Ok(())
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use pgsync_core::event::Column;
895
896    fn rel(identity: ReplicaIdentity) -> ResolvedRelation {
897        let relation = Arc::new(Relation {
898            id: 1,
899            schema: "public".into(),
900            name: "t".into(),
901            replica_identity: identity,
902            columns: vec![
903                Column {
904                    name: "id".into(),
905                    type_oid: 20,
906                    type_modifier: -1,
907                    is_key: true,
908                },
909                Column {
910                    name: "v".into(),
911                    type_oid: 25,
912                    type_modifier: -1,
913                    is_key: false,
914                },
915                Column {
916                    name: "big".into(),
917                    type_oid: 25,
918                    type_modifier: -1,
919                    is_key: false,
920                },
921            ],
922        });
923        let target = vec![
924            TargetColumn {
925                name: "id".into(),
926                type_name: "bigint".into(),
927                cast_type: "bigint".into(),
928                generated: false,
929                identity_always: true,
930            },
931            TargetColumn {
932                name: "v".into(),
933                type_name: "character varying(80)".into(),
934                cast_type: "character varying".into(),
935                generated: false,
936                identity_always: false,
937            },
938            TargetColumn {
939                name: "big".into(),
940                type_name: "text".into(),
941                cast_type: "text".into(),
942                generated: false,
943                identity_always: false,
944            },
945        ];
946        resolve(relation, &target).unwrap()
947    }
948
949    #[test]
950    fn insert_sql_overrides_identity_and_casts() {
951        let r = rel(ReplicaIdentity::Default);
952        // Casts use the base type: `$2::character varying(80)` would silently truncate.
953        assert_eq!(
954            r.insert_sql,
955            "INSERT INTO \"public\".\"t\" (\"id\", \"v\", \"big\") OVERRIDING SYSTEM VALUE VALUES ($1::bigint, $2::character varying, $3::text)"
956        );
957    }
958
959    #[test]
960    fn where_uses_key_from_new_tuple_when_no_old_key() {
961        let r = rel(ReplicaIdentity::Default);
962        let new = Tuple {
963            values: vec![Value::text("7"), Value::text("x"), Value::Unchanged],
964        };
965        let mut params = vec![];
966        let w = build_where(&r, None, None, Some(&new), &mut params).unwrap();
967        assert_eq!(w, "\"id\" = $1::bigint");
968        assert_eq!(params.len(), 1);
969    }
970
971    #[test]
972    fn where_prefers_old_key_and_handles_full_identity() {
973        let r = rel(ReplicaIdentity::Default);
974        let key = Tuple {
975            values: vec![Value::text("5"), Value::Null, Value::Null],
976        };
977        let new = Tuple {
978            values: vec![Value::text("7"), Value::text("x"), Value::text("y")],
979        };
980        let mut params = vec![];
981        let w = build_where(&r, Some(&key), None, Some(&new), &mut params).unwrap();
982        assert_eq!(w, "\"id\" = $1::bigint");
983        assert_eq!(params[0].0.as_deref(), Some(&b"5"[..]));
984
985        let r = rel(ReplicaIdentity::Full);
986        let old = Tuple {
987            values: vec![Value::text("5"), Value::Null, Value::Unchanged],
988        };
989        let mut params = vec![];
990        let w = build_where(&r, None, Some(&old), None, &mut params).unwrap();
991        assert_eq!(
992            w,
993            "\"id\" IS NOT DISTINCT FROM $1::bigint AND \"v\" IS NOT DISTINCT FROM $2::character varying"
994        );
995        assert!(params[1].0.is_none());
996    }
997
998    #[test]
999    fn missing_key_is_rejected() {
1000        let r = rel(ReplicaIdentity::Default);
1001        let new = Tuple {
1002            values: vec![Value::Null, Value::text("x"), Value::text("y")],
1003        };
1004        let mut params = vec![];
1005        let err = build_where(&r, None, None, Some(&new), &mut params).unwrap_err();
1006        assert!(err.message().contains("key column"));
1007        assert!(build_where(&r, None, None, None, &mut params).is_err());
1008    }
1009
1010    #[test]
1011    fn update_set_skips_identity_and_unchanged_key_columns() {
1012        let r = rel(ReplicaIdentity::Default);
1013        let new = Tuple {
1014            values: vec![Value::text("7"), Value::text("x"), Value::Unchanged],
1015        };
1016        let (sql, params) = build_update(&r, None, None, &new).unwrap();
1017        assert_eq!(
1018            sql,
1019            "UPDATE \"public\".\"t\" SET \"v\" = $1::character varying WHERE \"id\" = $2::bigint"
1020        );
1021        assert_eq!(params.len(), 2);
1022        // Only TOAST-unchanged non-key columns: still a valid statement.
1023        let new = Tuple {
1024            values: vec![Value::text("7"), Value::Unchanged, Value::Unchanged],
1025        };
1026        let (sql, _) = build_update(&r, None, None, &new).unwrap();
1027        assert_eq!(
1028            sql,
1029            "UPDATE \"public\".\"t\" SET \"id\" = \"id\" WHERE \"id\" = $1::bigint"
1030        );
1031    }
1032
1033    #[test]
1034    fn resolve_rejects_missing_or_generated_columns() {
1035        let relation = Arc::new(Relation {
1036            id: 1,
1037            schema: "public".into(),
1038            name: "t".into(),
1039            replica_identity: ReplicaIdentity::Default,
1040            columns: vec![Column {
1041                name: "id".into(),
1042                type_oid: 20,
1043                type_modifier: -1,
1044                is_key: true,
1045            }],
1046        });
1047        assert!(resolve(relation.clone(), &[]).is_err());
1048        let generated = vec![TargetColumn {
1049            name: "id".into(),
1050            type_name: "bigint".into(),
1051            cast_type: "bigint".into(),
1052            generated: true,
1053            identity_always: false,
1054        }];
1055        assert!(resolve(relation, &generated).is_err());
1056    }
1057}