Skip to main content

pgsync_postgres/
job.rs

1//! Job orchestration: bootstrap (snapshot + handoff), run, status, cleanup, and the WAL
2//! safety monitor.
3
4use 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
30/// Everything a command needs.
31pub 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    /// Source `version()` once known (preflight or stream open), for the heartbeat row.
39    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// ---- check -------------------------------------------------------------------------
114
115#[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    // Replication protocol connectivity (pg_hba, REPLICATION attribute in practice).
154    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
324// ---- bootstrap ---------------------------------------------------------------------
325
326pub 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    // Existing job?
332    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            // A previous bootstrap died between recording its intent and recording the
343            // snapshot. Nothing was copied; the only thing to undo is a slot it may have
344            // created. Target tables are left alone.
345            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    // Preflight (never modifies the source).
365    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    // Record the intent to create the slot *before* creating it, so that a crash in between
399    // leaves a job row that marks the slot as owned (and therefore droppable by `cleanup`
400    // or the next `bootstrap`) instead of an orphaned slot that retains WAL.
401    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    // Slot + exported snapshot. The replication connection must stay idle until all copy
413    // sessions have imported the snapshot.
414    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    // The exported snapshot is no longer needed once the copies have committed (or failed).
442    repl.close().await;
443    match snapshot_result {
444        Ok(()) => {}
445        Err(e) => {
446            // The copy is all-or-nothing: a slot without a usable snapshot would only retain
447            // WAL on the source until someone notices. Drop the slot this invocation created
448            // (never one that predates it) and leave the job in SNAPSHOTTING so that
449            // `bootstrap --restart-snapshot` is the only way forward.
450            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
480/// Drop the job's slot on the source if the job owns it and it exists. Refuses while a
481/// walsender holds it (another process is streaming from it).
482async 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    // Copy tables with bounded parallelism. Each worker owns one source connection (with
561    // the exported snapshot) and one target connection (one transaction per table).
562    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    // "PostgreSQL 18.6 (Debian ...) on ..." -> "PostgreSQL 18.6"
666    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
687// ---- run -----------------------------------------------------------------------------
688
689pub 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        // The monitor stopped the job: surface its reason and record the state.
739        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            // Cancelled before the loop ran (e.g. during connect). Reload the checkpoint.
756            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
868/// Periodic status line in the log plus the `pgsync.job_runtime` heartbeat row on the
869/// target (its own connection, best effort, reconnects on failure). On cancellation a final
870/// row with `stopped_at` is written.
871async 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
947/// Start the heartbeat/report task for a `bootstrap` or `run`.
948fn 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// ---- status / cleanup ----------------------------------------------------------------
962
963#[derive(Serialize)]
964pub struct StatusOutput {
965    pub job: Option<Checkpoint>,
966    /// Last heartbeat of the process running this job, if any was ever written.
967    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    /// Seconds between now and the commit time of the last applied source transaction.
974    pub seconds_since_last_commit: Option<f64>,
975    pub snapshot: SnapshotStatus,
976    pub tables: Vec<TableSyncRow>,
977    pub resync_required: bool,
978    /// Operator action, when one is needed.
979    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    /// Seconds since the heartbeat row was last written.
1006    pub age_seconds: f64,
1007    /// True when the row is older than three heartbeat intervals and the process did not
1008    /// record a clean stop: the process is probably gone.
1009    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
1185/// Drop the slot PGSync owns and delete the job metadata. Target data tables are kept.
1186pub 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}