1use 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#[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 types: Vec<String>,
112 identity_always: Vec<bool>,
114 insert_sql: String,
115}
116
117struct Pending {
118 stmt: Statement,
119 params: Vec<TextParam>,
120 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 allowed_tables: Option<BTreeSet<String>>,
143}
144
145impl PgSink {
146 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 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 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, ¶ms).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 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
393fn 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 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 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
445fn 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 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("ed, &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 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 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
741pub 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 let _ = client.batch_execute("SET transaction_timeout = 0").await;
756 Ok(())
757}
758
759pub 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
795pub 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
867pub 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 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 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}