1use crate::client::{self, PgClient};
5use crate::errors::from_pg;
6use crate::replication::conn::ReplicationConnection;
7use crate::replication::slot::{create_slot_with_snapshot, identify_system};
8use crate::replication::stream::PgStreamFactory;
9use crate::schema;
10use crate::sink::{self, PgSink, SinkLimits};
11use crate::snapshot;
12use crate::source::{self, PreflightMode, PreflightReport, SourcePreflight};
13use crate::url::ConnectionInfo;
14use pgsync_core::checkpoint::Checkpoint;
15use pgsync_core::config::{Config, SchemaMode, TableName, WalRetentionAction};
16use pgsync_core::engine::run_replication;
17use pgsync_core::metrics::Metrics;
18use pgsync_core::state::JobState;
19use pgsync_core::traits::SinkFactory;
20use pgsync_core::{Error, ErrorKind, Lsn, Result};
21use postgres_types::PgLsn;
22use serde::Serialize;
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25use tokio::sync::Semaphore;
26use tokio::task::JoinSet;
27use tokio_util::sync::CancellationToken;
28use tracing::{error, info, warn};
29
30pub struct JobContext {
32 pub config: Config,
33 pub source: ConnectionInfo,
34 pub target: ConnectionInfo,
35 pub metrics: Arc<Metrics>,
36 pub cancel: CancellationToken,
37 pub progress: pgsync_core::progress::ProgressHandle,
38 pub source_version: Arc<Mutex<Option<String>>>,
40}
41
42impl JobContext {
43 pub fn new(config: Config, cancel: CancellationToken) -> Result<Self> {
44 let source = ConnectionInfo::from_env(&config.source.url_env, &config.safety)?;
45 let target = ConnectionInfo::from_env(&config.target.url_env, &config.safety)?;
46 Ok(JobContext {
47 config,
48 source,
49 target,
50 metrics: Arc::new(Metrics::new()),
51 cancel,
52 progress: pgsync_core::progress::none(),
53 source_version: Arc::new(Mutex::new(None)),
54 })
55 }
56
57 fn timeout(&self) -> Duration {
58 self.config.resources.connection_timeout
59 }
60
61 async fn source_client(&self, name: &str) -> Result<PgClient> {
62 client::connect(&self.source, &self.config.safety, self.timeout(), name).await
63 }
64
65 async fn target_client(&self, name: &str) -> Result<PgClient> {
66 client::connect(&self.target, &self.config.safety, self.timeout(), name).await
67 }
68
69 fn limits(&self) -> SinkLimits {
70 SinkLimits {
71 max_batch_rows: self.config.resources.max_batch_rows,
72 max_batch_bytes: self.config.resources.max_batch_bytes as usize,
73 }
74 }
75
76 pub fn stream_factory(&self) -> PgStreamFactory {
77 PgStreamFactory {
78 info: self.source.clone(),
79 safety: self.config.safety.clone(),
80 timeout: self.timeout(),
81 slot: self.config.source.slot.clone(),
82 publication: self.config.source.publication.clone(),
83 max_message_bytes: self.config.resources.effective_max_message_bytes() as usize,
84 }
85 }
86}
87
88pub struct PgSinkFactory {
89 pub target: ConnectionInfo,
90 pub safety: pgsync_core::config::SafetyConfig,
91 pub timeout: Duration,
92 pub max_batch_rows: usize,
93 pub max_batch_bytes: usize,
94 pub tables: Vec<TableName>,
95}
96
97impl SinkFactory for PgSinkFactory {
98 type Sink = PgSink;
99 async fn connect(&mut self) -> Result<PgSink> {
100 let c = client::connect(&self.target, &self.safety, self.timeout, "pgsync-apply").await?;
101 Ok(PgSink::new(
102 c,
103 SinkLimits {
104 max_batch_rows: self.max_batch_rows,
105 max_batch_bytes: self.max_batch_bytes,
106 },
107 )
108 .await?
109 .restrict_to(&self.tables))
110 }
111}
112
113#[derive(Serialize)]
116pub struct CheckOutput {
117 pub source: PreflightReport,
118 pub target: PreflightReport,
119 pub ok: bool,
120}
121
122pub async fn check(
123 ctx: &JobContext,
124 mode: PreflightMode,
125) -> Result<(CheckOutput, Option<SourcePreflight>)> {
126 let mut target_report = PreflightReport::default();
127 let src = match ctx.source_client("pgsync-check").await {
128 Ok(c) => Some(c),
129 Err(e) => {
130 let mut r = PreflightReport::default();
131 r.fail("source_connection", e.to_string());
132 return Ok((
133 CheckOutput {
134 source: r,
135 target: target_report,
136 ok: false,
137 },
138 None,
139 ));
140 }
141 };
142 let src = src.unwrap();
143 let mut pre = source::preflight(
144 &src,
145 &ctx.config.source.publication,
146 &ctx.config.source.slot,
147 &ctx.config.replication.tables,
148 mode,
149 ctx.config.resources.feedback_interval,
150 )
151 .await?;
152 *ctx.source_version.lock().unwrap() = Some(pre.server.version.clone());
153 match ReplicationConnection::connect(&ctx.source, &ctx.config.safety, ctx.timeout()).await {
155 Ok(mut conn) => match identify_system(&mut conn).await {
156 Ok(sys) => {
157 pre.report.pass(
158 "replication_connection",
159 format!("system {} timeline {}", sys.system_id, sys.timeline),
160 );
161 conn.close().await;
162 }
163 Err(e) => pre.report.fail("replication_connection", e.to_string()),
164 },
165 Err(e) => pre.report.fail(
166 "replication_connection",
167 format!(
168 "{e} (check pg_hba.conf allows replication connections for this role/database)"
169 ),
170 ),
171 }
172 match session_tls(&src).await {
173 Ok(true) => pre
174 .report
175 .pass("source_tls", format!("encrypted ({:?})", ctx.source.tls)),
176 Ok(false) if ctx.source.is_loopback => {
177 pre.report.warn("source_tls", "plain connection (loopback)")
178 }
179 Ok(false) => pre.report.fail("source_tls", "connection is not encrypted"),
180 Err(e) => pre
181 .report
182 .warn("source_tls", format!("cannot determine: {e}")),
183 }
184
185 match ctx.target_client("pgsync-check").await {
186 Ok(tgt) => {
187 let row = tgt
188 .query_one("SELECT version(), current_setting('server_version_num')::int, (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)", &[])
189 .await
190 .map_err(|e| from_pg("target version", e))?;
191 let version: String = row.get(0);
192 let vnum: i32 = row.get(1);
193 let superuser: bool = row.get(2);
194 if vnum < 140000 {
195 target_report.fail("target_version", version);
196 } else {
197 target_report.pass("target_version", version);
198 }
199 if superuser {
200 target_report.pass(
201 "target_role",
202 "superuser (session_replication_role = replica available)",
203 );
204 } else {
205 target_report.warn("target_role", "not a superuser: target triggers and FK checks stay active during apply; tables must be ordered parent-first");
206 }
207 match tgt
208 .batch_execute("CREATE SCHEMA IF NOT EXISTS pgsync")
209 .await
210 {
211 Ok(()) => target_report.pass("target_pgsync_schema", "writable"),
212 Err(e) => target_report.fail(
213 "target_pgsync_schema",
214 from_pg("create schema", e).to_string(),
215 ),
216 }
217 if ctx.config.schema.mode == SchemaMode::PgDump {
218 let bin = ctx
219 .config
220 .schema
221 .pg_dump_path
222 .clone()
223 .unwrap_or_else(|| "pg_dump".into());
224 match tokio::process::Command::new(&bin)
225 .arg("--version")
226 .output()
227 .await
228 {
229 Ok(o) if o.status.success() => target_report.pass(
230 "pg_dump",
231 String::from_utf8_lossy(&o.stdout).trim().to_owned(),
232 ),
233 _ => target_report.fail(
234 "pg_dump",
235 format!(
236 "{bin} not runnable (set schema.pg_dump_path or schema.mode: existing)"
237 ),
238 ),
239 }
240 }
241 for t in &ctx.config.replication.tables {
242 match schema::target_columns(&tgt, &t.schema, &t.name).await? {
243 Some(cols) => {
244 if ctx.config.schema.mode == SchemaMode::PgDump
245 && mode == PreflightMode::Bootstrap
246 {
247 target_report.fail(&format!("target table {t}"), "already exists; schema.mode is pg_dump (use --restart-snapshot to drop and recreate, or schema.mode: existing)");
248 } else if let Some(info) = pre.tables.iter().find(|i| &i.table == t) {
249 match schema::verify_columns(t, &info.columns, &cols) {
250 Ok(w) if w.is_empty() => target_report
251 .pass(&format!("target table {t}"), "columns match"),
252 Ok(w) => {
253 target_report.warn(&format!("target table {t}"), w.join("; "))
254 }
255 Err(e) => target_report
256 .fail(&format!("target table {t}"), e.message().to_owned()),
257 }
258 }
259 }
260 None => {
261 if ctx.config.schema.mode == SchemaMode::Existing {
262 target_report.fail(
263 &format!("target table {t}"),
264 "does not exist and schema.mode is existing",
265 );
266 } else if mode == PreflightMode::Run {
267 target_report
268 .fail(&format!("target table {t}"), "does not exist on the target");
269 } else {
270 target_report.pass(
271 &format!("target table {t}"),
272 "will be created by pg_dump schema bootstrap",
273 );
274 }
275 }
276 }
277 }
278 match session_tls(&tgt).await {
279 Ok(true) => {
280 target_report.pass("target_tls", format!("encrypted ({:?})", ctx.target.tls))
281 }
282 Ok(false) if ctx.target.is_loopback => {
283 target_report.warn("target_tls", "plain connection (loopback)")
284 }
285 Ok(false) => target_report.fail("target_tls", "connection is not encrypted"),
286 Err(e) => target_report.warn("target_tls", format!("cannot determine: {e}")),
287 }
288 }
289 Err(e) => target_report.fail("target_connection", e.to_string()),
290 }
291 let ok = !pre.report.failed() && !target_report.failed();
292 Ok((
293 CheckOutput {
294 source: pre.report.clone(),
295 target: target_report,
296 ok,
297 },
298 Some(pre),
299 ))
300}
301
302async fn session_tls(c: &PgClient) -> Result<bool> {
303 let row = c
304 .query_one(
305 "SELECT coalesce((SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()), false)",
306 &[],
307 )
308 .await
309 .map_err(|e| from_pg("pg_stat_ssl", e))?;
310 Ok(row.get(0))
311}
312
313fn preflight_error(out: &CheckOutput) -> Error {
314 let mut lines = Vec::new();
315 for c in out.source.failures() {
316 lines.push(format!("source {}: {}", c.name, c.detail));
317 }
318 for c in out.target.failures() {
319 lines.push(format!("target {}: {}", c.name, c.detail));
320 }
321 Error::preflight(format!("preflight failed:\n {}", lines.join("\n ")))
322}
323
324pub async fn bootstrap(ctx: &JobContext, restart_snapshot: bool) -> Result<()> {
327 let cfg = &ctx.config;
328 let job_id = cfg.job.id.clone();
329 ctx.metrics.set_state(JobState::Initializing);
330
331 let tgt = ctx.target_client("pgsync-bootstrap").await?;
333 tgt.batch_execute(sink::SCHEMA_SQL)
334 .await
335 .map_err(|e| from_pg("create pgsync schema", e))?;
336 let mut probe = PgSink::new(ctx.target_client("pgsync-bootstrap").await?, ctx.limits()).await?;
337 let existing =
338 pgsync_core::traits::ReplicationSink::load_checkpoint(&mut probe, &job_id).await?;
339 drop(probe);
340 if let Some(cp) = &existing {
341 if cp.state == JobState::Initializing {
342 info!("previous bootstrap did not reach the snapshot; discarding its metadata");
346 drop_owned_slot(ctx, cp, "left by an interrupted bootstrap").await?;
347 sink::delete_job(&tgt, &job_id).await?;
348 } else if !restart_snapshot {
349 return Err(Error::config(format!(
350 "job {job_id:?} already exists on the target in state {}. Use `pgsync run` to resume, \
351 `pgsync bootstrap --restart-snapshot` to discard the incomplete copy and start over, \
352 or `pgsync cleanup` to remove it.",
353 cp.state
354 )));
355 } else {
356 info!(state = %cp.state, "restarting snapshot: discarding previous copy");
357 restart_cleanup(ctx, &tgt, Some(cp)).await?;
358 }
359 } else if restart_snapshot {
360 info!("restarting snapshot: no job metadata; resetting target tables only");
361 restart_cleanup(ctx, &tgt, None).await?;
362 }
363
364 let (out, pre) = check(ctx, PreflightMode::Bootstrap).await?;
366 if !out.ok {
367 return Err(preflight_error(&out));
368 }
369 let pre = pre.expect("preflight present when ok");
370 let src_system_id = pre.server.system_identifier.clone();
371 let warns = out
372 .source
373 .checks
374 .iter()
375 .chain(out.target.checks.iter())
376 .filter(|c| c.status == source::CheckStatus::Warn)
377 .count();
378 ctx.progress.step(&format!(
379 "connected to source {} · system {}",
380 short_version(&pre.server.version),
381 src_system_id
382 ));
383 ctx.progress.step(&format!(
384 "publication {} · {} tables",
385 cfg.source.publication,
386 pre.tables.len()
387 ));
388 ctx.progress.step(&format!(
389 "preflight: {} checks passed{}",
390 out.source.checks.len() + out.target.checks.len(),
391 if warns > 0 {
392 format!(" ({warns} warnings)")
393 } else {
394 String::new()
395 }
396 ));
397
398 let mut cp = Checkpoint::new_job(
402 &job_id,
403 &src_system_id,
404 &pre.server.dbname,
405 &cfg.source.slot,
406 &cfg.source.publication,
407 );
408 cp.state = JobState::Initializing;
409 cp.slot_owned = true;
410 sink::upsert_checkpoint(&tgt, &cp).await?;
411
412 let mut repl = ReplicationConnection::connect(&ctx.source, &cfg.safety, ctx.timeout()).await?;
415 let sys = identify_system(&mut repl).await?;
416 if sys.system_id != src_system_id {
417 return Err(Error::internal(
418 "system identifier differs between SQL and replication connections",
419 ));
420 }
421 let created = create_slot_with_snapshot(&mut repl, &cfg.source.slot).await?;
422 info!(slot = %created.slot_name, consistent_point = %created.consistent_point, snapshot = %created.snapshot_name, "replication slot created with exported snapshot");
423 ctx.progress.step(&format!(
424 "replication slot {} created (pgoutput) · snapshot at {}",
425 created.slot_name, created.consistent_point
426 ));
427
428 cp.state = JobState::Snapshotting;
429 cp.consistent_point = Some(created.consistent_point);
430 cp.commit_lsn = created.consistent_point;
431 cp.committed_lsn = created.consistent_point;
432 cp.acked_lsn = created.consistent_point;
433 sink::upsert_checkpoint(&tgt, &cp).await?;
434 ctx.metrics.set_state(JobState::Snapshotting);
435
436 let hb_cancel = ctx.cancel.child_token();
437 let reporter = spawn_reporter(ctx, hb_cancel.clone());
438 let snapshot_result = run_snapshot(ctx, &tgt, &pre, &created.snapshot_name).await;
439 hb_cancel.cancel();
440 let _ = reporter.await;
441 repl.close().await;
443 match snapshot_result {
444 Ok(()) => {}
445 Err(e) => {
446 let dropped = match drop_owned_slot(ctx, &cp, "its snapshot failed").await {
451 Ok(()) => "the slot was dropped (no WAL is retained)".to_owned(),
452 Err(de) => {
453 error!(error = %de, slot = %cp.slot, "could not drop the slot; it retains WAL until `bootstrap --restart-snapshot` or `cleanup --yes`");
454 format!(
455 "WARNING: slot {:?} could not be dropped and retains WAL: {de}",
456 cp.slot
457 )
458 }
459 };
460 error!(error = %e, "snapshot failed; job stays in SNAPSHOTTING and needs `bootstrap --restart-snapshot`");
461 let _ = sink::set_job_state(
462 &tgt,
463 &job_id,
464 JobState::Snapshotting,
465 Some(&format!("{e}; {dropped}")),
466 )
467 .await;
468 return Err(e);
469 }
470 }
471
472 cp.state = JobState::CatchingUp;
473 sink::upsert_checkpoint(&tgt, &cp).await?;
474 info!(consistent_point = %created.consistent_point, "snapshot complete; switching to CDC");
475 drop(tgt);
476
477 run(ctx).await.map(|_| ())
478}
479
480async fn drop_owned_slot(ctx: &JobContext, cp: &Checkpoint, why: &str) -> Result<()> {
483 if !cp.slot_owned {
484 return Ok(());
485 }
486 let src = ctx.source_client("pgsync-bootstrap").await?;
487 let Some(info) = source::slot_info(&src, &cp.slot).await? else {
488 return Ok(());
489 };
490 if info.active {
491 return Err(Error::preflight(format!(
492 "slot {:?} is active (pid {:?}); stop the consumer first",
493 cp.slot, info.active_pid
494 )));
495 }
496 src.execute("SELECT pg_drop_replication_slot($1)", &[&cp.slot])
497 .await
498 .map_err(|e| from_pg("drop slot", e))?;
499 info!(slot = %cp.slot, "dropped replication slot owned by this job ({why})");
500 Ok(())
501}
502
503async fn restart_cleanup(ctx: &JobContext, tgt: &PgClient, cp: Option<&Checkpoint>) -> Result<()> {
504 if let Some(cp) = cp {
505 drop_owned_slot(ctx, cp, "restarting the snapshot").await?;
506 }
507 for t in &ctx.config.replication.tables {
508 let sql = match ctx.config.schema.mode {
509 SchemaMode::PgDump => format!("DROP TABLE IF EXISTS {} CASCADE", t.quoted()),
510 SchemaMode::Existing => format!("TRUNCATE TABLE {} CASCADE", t.quoted()),
511 };
512 tgt.batch_execute(&sql)
513 .await
514 .map_err(|e| from_pg(&format!("reset target table {t}"), e))?;
515 }
516 if let Some(cp) = cp {
517 sink::delete_job(tgt, &cp.job_id).await?;
518 }
519 Ok(())
520}
521
522async fn run_snapshot(
523 ctx: &JobContext,
524 tgt: &PgClient,
525 pre: &SourcePreflight,
526 snapshot_name: &str,
527) -> Result<()> {
528 let cfg = &ctx.config;
529 let started = Instant::now();
530
531 if cfg.schema.mode == SchemaMode::PgDump {
532 let sql = schema::dump_schema(
533 cfg.schema.pg_dump_path.as_deref(),
534 &ctx.source,
535 &cfg.safety,
536 &cfg.replication.tables,
537 )
538 .await?;
539 schema::apply_schema(tgt, &cfg.replication.tables, &sql).await?;
540 info!("target schema created from pg_dump");
541 ctx.progress.step(&format!(
542 "schema: {} tables created on target (pg_dump)",
543 cfg.replication.tables.len()
544 ));
545 }
546 for info in &pre.tables {
547 let cols = schema::target_columns(tgt, &info.table.schema, &info.table.name)
548 .await?
549 .ok_or_else(|| {
550 Error::preflight(format!(
551 "{} missing on target after schema bootstrap",
552 info.table
553 ))
554 })?;
555 for w in schema::verify_columns(&info.table, &info.columns, &cols)? {
556 warn!("{w}");
557 }
558 }
559
560 let sem = Arc::new(Semaphore::new(cfg.resources.max_parallel_copies));
563 let mut set: JoinSet<Result<(TableName, snapshot::CopyStats)>> = JoinSet::new();
564 let deadline = tokio::time::Instant::now() + cfg.resources.snapshot_timeout;
565 let buffer = (cfg.resources.buffer_size_kb * 1024) as usize;
566 let job_id = cfg.job.id.clone();
567 for info in &pre.tables {
568 let table = info.table.clone();
569 let columns = info.column_names();
570 let sem = sem.clone();
571 let source = ctx.source.clone();
572 let target = ctx.target.clone();
573 let safety = cfg.safety.clone();
574 let timeout = ctx.timeout();
575 let metrics = ctx.metrics.clone();
576 let snapshot_name = snapshot_name.to_owned();
577 let cancel = ctx.cancel.clone();
578 let job_id = job_id.clone();
579 let progress = ctx.progress.clone();
580 set.spawn(async move {
581 let _permit = sem.acquire().await.map_err(|_| Error::cancelled())?;
582 if cancel.is_cancelled() {
583 return Err(Error::cancelled());
584 }
585 let src = client::connect(&source, &safety, timeout, "pgsync-copy").await?;
586 snapshot::import_snapshot(&src, &snapshot_name).await?;
587 let dst = client::connect(&target, &safety, timeout, "pgsync-copy").await?;
588 sink::configure_session(&dst).await?;
589 if let Err(e) = dst
590 .batch_execute("SET session_replication_role = replica")
591 .await
592 {
593 warn!(error = %e, %table, "copying with triggers and FK checks active");
594 }
595 dst.batch_execute("BEGIN")
596 .await
597 .map_err(|e| from_pg("BEGIN copy", e))?;
598 progress.copy_started(&table.to_string());
599 let t0 = Instant::now();
600 let stats = tokio::select! {
601 r = snapshot::copy_table(&src, &dst, &table, &columns, buffer, &metrics) => r?,
602 _ = cancel.cancelled() => return Err(Error::cancelled()),
603 };
604 PgSink::record_table_copied(
605 &dst,
606 &job_id,
607 &table.schema,
608 &table.name,
609 stats.rows,
610 stats.bytes,
611 )
612 .await?;
613 dst.batch_execute("COMMIT")
614 .await
615 .map_err(|e| from_pg("COMMIT copy", e))?;
616 src.batch_execute("COMMIT")
617 .await
618 .map_err(|e| from_pg("end snapshot txn", e))?;
619 info!(%table, rows = stats.rows, bytes = stats.bytes, "copied");
620 progress.copy_done(&table.to_string(), stats.rows, stats.bytes, t0.elapsed());
621 Ok((table, stats))
622 });
623 }
624 let mut total_rows = 0u64;
625 let mut total_bytes = 0u64;
626 while let Some(res) = tokio::select! {
627 r = set.join_next() => r,
628 _ = tokio::time::sleep_until(deadline) => {
629 set.abort_all();
630 return Err(Error::resource(format!("snapshot exceeded snapshot_timeout ({:?})", cfg.resources.snapshot_timeout)));
631 }
632 } {
633 match res {
634 Ok(Ok((_, stats))) => {
635 total_rows += stats.rows;
636 total_bytes += stats.bytes;
637 }
638 Ok(Err(e)) => {
639 set.abort_all();
640 return Err(e);
641 }
642 Err(e) => {
643 set.abort_all();
644 return Err(Error::internal(format!("copy task failed: {e}")));
645 }
646 }
647 }
648 let secs = started.elapsed().as_secs_f64();
649 info!(
650 tables = pre.tables.len(),
651 rows = total_rows,
652 bytes = total_bytes,
653 seconds = format!("{secs:.1}"),
654 mb_per_s = format!("{:.1}", total_bytes as f64 / 1e6 / secs.max(0.001)),
655 "initial copy finished"
656 );
657 ctx.progress.step(&format!(
658 "initial copy complete: {} rows in {secs:.1}s",
659 group_digits(total_rows)
660 ));
661 Ok(())
662}
663
664fn short_version(v: &str) -> String {
665 v.split(" (")
667 .next()
668 .unwrap_or(v)
669 .split(" on ")
670 .next()
671 .unwrap_or(v)
672 .to_owned()
673}
674
675pub fn group_digits(n: u64) -> String {
676 let s = n.to_string();
677 let mut out = String::new();
678 for (i, c) in s.chars().enumerate() {
679 if i > 0 && (s.len() - i) % 3 == 0 {
680 out.push(',');
681 }
682 out.push(c);
683 }
684 out
685}
686
687pub async fn run(ctx: &JobContext) -> Result<Checkpoint> {
690 let cfg = &ctx.config;
691 let monitor_cancel = ctx.cancel.child_token();
692 let monitor_reason: Arc<Mutex<Option<Error>>> = Arc::new(Mutex::new(None));
693 let monitor = tokio::spawn(monitor_task(
694 ctx.source.clone(),
695 cfg.safety.clone(),
696 ctx.timeout(),
697 cfg.source.slot.clone(),
698 cfg.safety.wal_retention_alarm_bytes,
699 cfg.resources.max_replication_lag_bytes,
700 cfg.safety.wal_retention_action,
701 cfg.safety.monitor_interval,
702 ctx.metrics.clone(),
703 monitor_cancel.clone(),
704 ctx.cancel.clone(),
705 monitor_reason.clone(),
706 ));
707 let reporter = spawn_reporter(ctx, monitor_cancel.clone());
708 if ctx.source_version.lock().unwrap().is_none()
709 && let Ok(src) = ctx.source_client("pgsync-run").await
710 && let Ok(info) = source::server_info(&src).await
711 {
712 *ctx.source_version.lock().unwrap() = Some(info.version);
713 }
714
715 let result = run_replication(
716 &cfg.job.id,
717 ctx.stream_factory(),
718 PgSinkFactory {
719 target: ctx.target.clone(),
720 safety: cfg.safety.clone(),
721 timeout: ctx.timeout(),
722 max_batch_rows: cfg.resources.max_batch_rows,
723 max_batch_bytes: cfg.resources.max_batch_bytes as usize,
724 tables: cfg.replication.tables.clone(),
725 },
726 &cfg.resources,
727 ctx.metrics.clone(),
728 ctx.cancel.clone(),
729 ctx.progress.clone(),
730 )
731 .await;
732 monitor_cancel.cancel();
733 let _ = monitor.await;
734 let _ = reporter.await;
735
736 let monitor_reason = monitor_reason.lock().unwrap().take();
737 if let Some(reason) = monitor_reason {
738 if let Ok(tgt) = ctx.target_client("pgsync-run").await {
740 let state = if reason.kind() == ErrorKind::ResyncRequired {
741 JobState::ResyncRequired
742 } else {
743 JobState::Paused
744 };
745 let _ = sink::set_job_state(&tgt, &cfg.job.id, state, Some(&reason.to_string())).await;
746 }
747 return Err(reason);
748 }
749 match result {
750 Ok(cp) => {
751 info!(committed = %cp.committed_lsn, acked = %cp.acked_lsn, "replication stopped cleanly");
752 Ok(cp)
753 }
754 Err(e) if e.kind() == ErrorKind::Cancelled => {
755 let mut s = PgSink::new(ctx.target_client("pgsync-run").await?, ctx.limits()).await?;
757 pgsync_core::traits::ReplicationSink::load_checkpoint(&mut s, &cfg.job.id)
758 .await?
759 .ok_or_else(|| Error::config("job has no checkpoint"))
760 }
761 Err(e) => Err(e),
762 }
763}
764
765#[allow(clippy::too_many_arguments)]
766async fn monitor_task(
767 source: ConnectionInfo,
768 safety: pgsync_core::config::SafetyConfig,
769 timeout: Duration,
770 slot: String,
771 retention_alarm: u64,
772 lag_alarm: u64,
773 action: WalRetentionAction,
774 interval: Duration,
775 metrics: Arc<Metrics>,
776 cancel: CancellationToken,
777 job_cancel: CancellationToken,
778 reason: Arc<Mutex<Option<Error>>>,
779) {
780 let mut client: Option<PgClient> = None;
781 loop {
782 tokio::select! {
783 _ = cancel.cancelled() => return,
784 _ = tokio::time::sleep(interval) => {}
785 }
786 if client.is_none() {
787 match client::connect(&source, &safety, timeout, "pgsync-monitor").await {
788 Ok(c) => client = Some(c),
789 Err(e) => {
790 warn!(error = %e, "monitor: cannot connect to source");
791 continue;
792 }
793 }
794 }
795 let c = client.as_ref().unwrap();
796 let (slot_info, current) = match (
797 source::slot_info(c, &slot).await,
798 source::current_wal_lsn(c).await,
799 ) {
800 (Ok(s), Ok(l)) => (s, l),
801 (Err(e), _) | (_, Err(e)) => {
802 warn!(error = %e, "monitor: source query failed");
803 client = None;
804 continue;
805 }
806 };
807 metrics.set_lsn(&metrics.server_wal_end, current);
808 let Some(s) = slot_info else {
809 let e = Error::resync_required(format!("replication slot {slot:?} disappeared"));
810 error!("{e}");
811 *reason.lock().unwrap() = Some(e);
812 job_cancel.cancel();
813 return;
814 };
815 if s.wal_status.as_deref() == Some("lost") || s.invalidation_reason.is_some() {
816 let e = Error::resync_required(format!(
817 "replication slot {slot:?} invalidated (wal_status={:?}, reason={:?})",
818 s.wal_status, s.invalidation_reason
819 ));
820 error!("{e}");
821 *reason.lock().unwrap() = Some(e);
822 job_cancel.cancel();
823 return;
824 }
825 let retained = s.restart_lsn.map(|l| current.bytes_since(l)).unwrap_or(0);
826 let lag = s
827 .confirmed_flush_lsn
828 .map(|l| current.bytes_since(l))
829 .unwrap_or(0);
830 let snap = metrics.snapshot();
831 info!(
832 slot = %slot,
833 active = s.active,
834 wal_status = ?s.wal_status,
835 retained_wal_bytes = retained,
836 slot_lag_bytes = lag,
837 apply_lag_bytes = snap.apply_lag_bytes,
838 safe_wal_size = ?s.safe_wal_size,
839 "wal monitor"
840 );
841 if lag > lag_alarm {
842 warn!(
843 slot_lag_bytes = lag,
844 threshold = lag_alarm,
845 "ALARM: replication lag exceeds max_replication_lag_bytes"
846 );
847 }
848 if retained > retention_alarm {
849 warn!(
850 retained_wal_bytes = retained,
851 threshold = retention_alarm,
852 "ALARM: WAL retained by the slot exceeds safety.wal_retention_alarm_bytes"
853 );
854 if action == WalRetentionAction::Stop {
855 let e = Error::resource(format!(
856 "WAL retention {retained} bytes exceeds safety.wal_retention_alarm_bytes ({retention_alarm}); stopping as configured. \
857 Stopping does not release WAL: resume with `pgsync run`, or `pgsync cleanup` to drop the slot."
858 ));
859 error!("{e}");
860 *reason.lock().unwrap() = Some(e);
861 job_cancel.cancel();
862 return;
863 }
864 }
865 }
866}
867
868async fn report_task(
872 target: ConnectionInfo,
873 safety: pgsync_core::config::SafetyConfig,
874 timeout: Duration,
875 job_id: String,
876 source_version: Arc<Mutex<Option<String>>>,
877 interval: Duration,
878 metrics: Arc<Metrics>,
879 cancel: CancellationToken,
880) {
881 let started_at_unix = std::time::SystemTime::now()
882 .duration_since(std::time::UNIX_EPOCH)
883 .map(|d| d.as_secs() as i64)
884 .unwrap_or(0);
885 let mut client: Option<PgClient> = None;
886 let mut last = (Instant::now(), 0u64, 0u64);
887 let mut last_log = Instant::now();
888 let mut stopping = false;
889 loop {
890 if !stopping {
891 tokio::select! {
892 _ = cancel.cancelled() => stopping = true,
893 _ = tokio::time::sleep(interval) => {}
894 }
895 }
896 let s = metrics.snapshot();
897 let elapsed = last.0.elapsed().as_secs_f64().max(0.001);
898 let txns_per_s = (s.txns_applied.saturating_sub(last.1)) as f64 / elapsed;
899 let rows_per_s = (s.rows_applied.saturating_sub(last.2)) as f64 / elapsed;
900 last = (Instant::now(), s.txns_applied, s.rows_applied);
901 if client.is_none() {
902 client = client::connect(&target, &safety, timeout, "pgsync-heartbeat")
903 .await
904 .ok();
905 }
906 if let Some(c) = &client {
907 let sv = source_version.lock().unwrap().clone();
908 let row = sink::RuntimeRow {
909 job_id: &job_id,
910 started_at_unix,
911 stopped: stopping,
912 source_version: sv.as_deref(),
913 snapshot: &s,
914 txns_per_s,
915 rows_per_s,
916 };
917 if let Err(e) = sink::upsert_runtime(c, &row).await {
918 warn!(error = %e, "heartbeat: cannot write pgsync.job_runtime");
919 client = None;
920 }
921 }
922 if stopping {
923 return;
924 }
925 if last_log.elapsed() < Duration::from_secs(30) {
926 continue;
927 }
928 last_log = Instant::now();
929 info!(
930 state = %s.state,
931 txns_applied = s.txns_applied,
932 rows_applied = s.rows_applied,
933 events = s.events_received,
934 bytes_received = s.bytes_received,
935 committed_lsn = %s.committed_lsn,
936 acked_lsn = %s.acked_lsn,
937 apply_lag_bytes = s.apply_lag_bytes,
938 inflight_items = s.inflight_items,
939 rss_mb = s.rss_bytes.map(|b| b / (1024 * 1024)),
940 reconnects = s.reconnects,
941 connection = ?s.connection,
942 "status"
943 );
944 }
945}
946
947fn spawn_reporter(ctx: &JobContext, cancel: CancellationToken) -> tokio::task::JoinHandle<()> {
949 tokio::spawn(report_task(
950 ctx.target.clone(),
951 ctx.config.safety.clone(),
952 ctx.timeout(),
953 ctx.config.job.id.clone(),
954 ctx.source_version.clone(),
955 ctx.config.safety.heartbeat_interval,
956 ctx.metrics.clone(),
957 cancel,
958 ))
959}
960
961#[derive(Serialize)]
964pub struct StatusOutput {
965 pub job: Option<Checkpoint>,
966 pub runtime: Option<RuntimeStatus>,
968 pub source: Option<SourceStatus>,
969 pub slot: Option<crate::replication::slot::SlotInfo>,
970 pub source_wal_lsn: Option<Lsn>,
971 pub slot_lag_bytes: Option<u64>,
972 pub retained_wal_bytes: Option<u64>,
973 pub seconds_since_last_commit: Option<f64>,
975 pub snapshot: SnapshotStatus,
976 pub tables: Vec<TableSyncRow>,
977 pub resync_required: bool,
978 pub action: Option<String>,
980}
981
982#[derive(Serialize)]
983pub struct SourceStatus {
984 pub version: Option<String>,
985 pub system_id: String,
986 pub dbname: String,
987}
988
989#[derive(Serialize)]
990pub struct SnapshotStatus {
991 pub tables_total: usize,
992 pub tables_copied: usize,
993 pub rows_copied: i64,
994 pub bytes_copied: i64,
995}
996
997#[derive(Serialize)]
998pub struct RuntimeStatus {
999 pub pid: i32,
1000 pub hostname: String,
1001 pub pgsync_version: String,
1002 pub started_at: String,
1003 pub updated_at: String,
1004 pub stopped_at: Option<String>,
1005 pub age_seconds: f64,
1007 pub stale: bool,
1010 pub state: String,
1011 pub connection: String,
1012 pub received_lsn: Lsn,
1013 pub server_wal_end: Lsn,
1014 pub committed_lsn: Lsn,
1015 pub acked_lsn: Lsn,
1016 pub apply_lag_bytes: i64,
1017 pub txns_applied: i64,
1018 pub rows_applied: i64,
1019 pub txns_per_s: f64,
1020 pub rows_per_s: f64,
1021 pub reconnects: i64,
1022 pub rss_bytes: Option<i64>,
1023 pub last_apply_at: Option<String>,
1024}
1025
1026#[derive(Serialize)]
1027pub struct TableSyncRow {
1028 pub table: String,
1029 pub state: String,
1030 pub rows_copied: i64,
1031 pub bytes_copied: i64,
1032}
1033
1034pub async fn status(ctx: &JobContext) -> Result<StatusOutput> {
1035 let tgt = ctx.target_client("pgsync-status").await?;
1036 let mut s = PgSink::new(tgt, ctx.limits()).await?;
1037 let job =
1038 pgsync_core::traits::ReplicationSink::load_checkpoint(&mut s, &ctx.config.job.id).await?;
1039 let rows = s
1040 .client()
1041 .query(
1042 "SELECT schema_name || '.' || table_name, state, rows_copied, bytes_copied FROM pgsync.table_sync WHERE job_id = $1 ORDER BY 1",
1043 &[&ctx.config.job.id],
1044 )
1045 .await
1046 .map_err(|e| from_pg("table_sync", e))?;
1047 let tables = rows
1048 .iter()
1049 .map(|r| TableSyncRow {
1050 table: r.get(0),
1051 state: r.get(1),
1052 rows_copied: r.get(2),
1053 bytes_copied: r.get(3),
1054 })
1055 .collect();
1056 let runtime = s
1057 .client()
1058 .query_opt(
1059 "SELECT pid, hostname, pgsync_version, started_at::text, updated_at::text, stopped_at::text, \
1060 extract(epoch from now() - updated_at)::float8, state, connection, received_lsn, server_wal_end, \
1061 committed_lsn, acked_lsn, apply_lag_bytes, txns_applied, rows_applied, txns_per_s, rows_per_s, \
1062 reconnects, rss_bytes, last_apply_at::text, source_version \
1063 FROM pgsync.job_runtime WHERE job_id = $1",
1064 &[&ctx.config.job.id],
1065 )
1066 .await
1067 .map_err(|e| from_pg("job_runtime", e))?;
1068 let stale_after = ctx.config.safety.heartbeat_interval.as_secs_f64() * 3.0;
1069 let mut source_version: Option<String> = None;
1070 let runtime = runtime.map(|r| {
1071 let age: f64 = r.get(6);
1072 let stopped: Option<String> = r.get(5);
1073 source_version = r.get(21);
1074 let lsn = |i: usize| Lsn(u64::from(r.get::<_, PgLsn>(i)));
1075 RuntimeStatus {
1076 pid: r.get(0),
1077 hostname: r.get(1),
1078 pgsync_version: r.get(2),
1079 started_at: r.get(3),
1080 updated_at: r.get(4),
1081 stale: stopped.is_none() && age > stale_after,
1082 stopped_at: stopped,
1083 age_seconds: age,
1084 state: r.get(7),
1085 connection: r.get(8),
1086 received_lsn: lsn(9),
1087 server_wal_end: lsn(10),
1088 committed_lsn: lsn(11),
1089 acked_lsn: lsn(12),
1090 apply_lag_bytes: r.get(13),
1091 txns_applied: r.get(14),
1092 rows_applied: r.get(15),
1093 txns_per_s: r.get(16),
1094 rows_per_s: r.get(17),
1095 reconnects: r.get(18),
1096 rss_bytes: r.get(19),
1097 last_apply_at: r.get(20),
1098 }
1099 });
1100 let (slot, source_wal_lsn, live_version) = match ctx.source_client("pgsync-status").await {
1101 Ok(src) => {
1102 let v = src
1103 .query_one("SELECT version()", &[])
1104 .await
1105 .ok()
1106 .map(|r| r.get::<_, String>(0));
1107 (
1108 source::slot_info(&src, &ctx.config.source.slot).await?,
1109 Some(source::current_wal_lsn(&src).await?),
1110 v,
1111 )
1112 }
1113 Err(e) => {
1114 warn!(error = %e, "source unreachable");
1115 (None, None, None)
1116 }
1117 };
1118 let source = job.as_ref().map(|j| SourceStatus {
1119 version: live_version.or(source_version),
1120 system_id: j.source_system_id.clone(),
1121 dbname: j.source_dbname.clone(),
1122 });
1123 let seconds_since_last_commit = job.as_ref().and_then(|j| j.last_commit_ts).map(|ts| {
1124 let unix_micros = pgsync_core::event::pg_micros_to_unix_micros(ts);
1125 let now = std::time::SystemTime::now()
1126 .duration_since(std::time::UNIX_EPOCH)
1127 .map(|d| d.as_micros() as i64)
1128 .unwrap_or(0);
1129 (now - unix_micros) as f64 / 1e6
1130 });
1131 let slot_lag_bytes = match (&slot, source_wal_lsn) {
1132 (Some(s), Some(cur)) => s.confirmed_flush_lsn.map(|l| cur.bytes_since(l)),
1133 _ => None,
1134 };
1135 let retained_wal_bytes = match (&slot, source_wal_lsn) {
1136 (Some(s), Some(cur)) => s.restart_lsn.map(|l| cur.bytes_since(l)),
1137 _ => None,
1138 };
1139 let tables: Vec<TableSyncRow> = tables;
1140 let snapshot = SnapshotStatus {
1141 tables_total: ctx.config.replication.tables.len(),
1142 tables_copied: tables.iter().filter(|t| t.state == "COPIED").count(),
1143 rows_copied: tables.iter().map(|t| t.rows_copied).sum(),
1144 bytes_copied: tables.iter().map(|t| t.bytes_copied).sum(),
1145 };
1146 let resync_required = job
1147 .as_ref()
1148 .map(|j| j.state == JobState::ResyncRequired)
1149 .unwrap_or(false)
1150 || slot
1151 .as_ref()
1152 .map(|s| s.wal_status.as_deref() == Some("lost") || s.invalidation_reason.is_some())
1153 .unwrap_or(false)
1154 || (job.is_some() && slot.is_none() && source_wal_lsn.is_some());
1155 let action = match (&job, resync_required) {
1156 (None, _) => Some("run `pgsync bootstrap`".to_owned()),
1157 (Some(_), true) => Some("run `pgsync cleanup --yes` then `pgsync bootstrap`".to_owned()),
1158 (Some(j), false) => match j.state {
1159 JobState::Snapshotting | JobState::Initializing => {
1160 Some("snapshot incomplete: run `pgsync bootstrap --restart-snapshot`".to_owned())
1161 }
1162 JobState::Error => Some("fix the cause in last_error, then `pgsync run`".to_owned()),
1163 JobState::Paused => {
1164 Some("resume with `pgsync run` (WAL is still retained by the slot)".to_owned())
1165 }
1166 _ => None,
1167 },
1168 };
1169 Ok(StatusOutput {
1170 job,
1171 runtime,
1172 source,
1173 slot,
1174 source_wal_lsn,
1175 slot_lag_bytes,
1176 retained_wal_bytes,
1177 seconds_since_last_commit,
1178 snapshot,
1179 tables,
1180 resync_required,
1181 action,
1182 })
1183}
1184
1185pub async fn cleanup(ctx: &JobContext) -> Result<Vec<String>> {
1187 let mut actions = Vec::new();
1188 let tgt = ctx.target_client("pgsync-cleanup").await?;
1189 let mut s = PgSink::new(tgt, ctx.limits()).await?;
1190 let job =
1191 pgsync_core::traits::ReplicationSink::load_checkpoint(&mut s, &ctx.config.job.id).await?;
1192 let slot_owned =
1193 job.as_ref().map(|j| j.slot_owned).unwrap_or(false) || ctx.config.safety.auto_drop_slot;
1194 let src = ctx.source_client("pgsync-cleanup").await?;
1195 if let Some(info) = source::slot_info(&src, &ctx.config.source.slot).await? {
1196 if slot_owned {
1197 if info.active {
1198 return Err(Error::preflight(format!(
1199 "slot {:?} is active (pid {:?}); stop the consumer first",
1200 ctx.config.source.slot, info.active_pid
1201 )));
1202 }
1203 src.execute(
1204 "SELECT pg_drop_replication_slot($1)",
1205 &[&ctx.config.source.slot],
1206 )
1207 .await
1208 .map_err(|e| from_pg("drop slot", e))?;
1209 actions.push(format!(
1210 "dropped replication slot {:?} on the source",
1211 ctx.config.source.slot
1212 ));
1213 } else {
1214 actions.push(format!(
1215 "left replication slot {:?} in place (not owned by this job; set safety.auto_drop_slot to override)",
1216 ctx.config.source.slot
1217 ));
1218 }
1219 } else {
1220 actions.push(format!(
1221 "replication slot {:?} does not exist",
1222 ctx.config.source.slot
1223 ));
1224 }
1225 if job.is_some() {
1226 sink::delete_job(s.client(), &ctx.config.job.id).await?;
1227 actions.push(format!(
1228 "deleted job {:?} metadata from the target (data tables kept)",
1229 ctx.config.job.id
1230 ));
1231 } else {
1232 actions.push(format!(
1233 "job {:?} has no metadata on the target",
1234 ctx.config.job.id
1235 ));
1236 }
1237 Ok(actions)
1238}