use tokio_postgres::error::SqlState;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use crate::config::MigrationConfig;
use crate::error::{MigrationError, Result};
use crate::preflight::filter_tables_by_exclusions;
use crate::tls::connect_with_sslmode;
const LIST_TABLES_SQL: &str = "\
SELECT n.nspname::text, c.relname::text \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE ((c.relkind = 'r' AND NOT c.relispartition) OR c.relkind = 'p') \
AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
AND n.nspname NOT LIKE 'pg_temp_%' \
AND n.nspname NOT LIKE 'pg_toast_temp_%'";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableCount {
pub schema: String,
pub table: String,
pub source: i64,
pub target: i64,
}
#[derive(Debug, Clone)]
pub struct VerifyReport {
rows: Vec<TableCount>,
}
impl VerifyReport {
pub fn new(rows: Vec<TableCount>) -> Self {
Self { rows }
}
pub fn rows(&self) -> &[TableCount] {
&self.rows
}
pub fn mismatches(&self) -> Vec<&TableCount> {
self.rows.iter().filter(|r| r.source != r.target).collect()
}
pub fn is_ok(&self) -> bool {
self.mismatches().is_empty()
}
pub fn summary_line(&self) -> String {
let total = self.rows.len();
let bad = self.mismatches().len();
if bad == 0 {
format!("verify: {total} table(s) matched")
} else {
format!("verify: {bad}/{total} table(s) MISMATCHED")
}
}
}
pub(crate) fn select_tables_to_verify(
candidates: &[String],
schemas: &[String],
tables: &[String],
exclude_tables: &[String],
exclude_schemas: &[String],
) -> Vec<String> {
let no_includes = schemas.is_empty() && tables.is_empty();
let schema_set: std::collections::HashSet<&str> = schemas.iter().map(|s| s.as_str()).collect();
let table_set: std::collections::HashSet<&str> = tables.iter().map(|s| s.as_str()).collect();
let kept: Vec<String> = candidates
.iter()
.filter(|qt| {
if no_includes {
return true;
}
let schema = qt.rsplit_once('.').map(|(s, _)| s).unwrap_or("");
schema_set.contains(schema) || table_set.contains(qt.as_str())
})
.cloned()
.collect();
filter_tables_by_exclusions(&kept, exclude_tables, exclude_schemas)
}
pub async fn verify_row_counts(
cfg: &MigrationConfig,
cancel: &CancellationToken,
) -> Result<VerifyReport> {
let source = tokio::select! {
_ = cancel.cancelled() => return Err(MigrationError::Cancelled),
res = connect_with_sslmode(&cfg.source.connection_string) => res?,
};
let rows = tokio::select! {
_ = cancel.cancelled() => return Err(MigrationError::Cancelled),
res = source.query(LIST_TABLES_SQL, &[]) => res?,
};
let qualified: Vec<String> = rows
.iter()
.map(|r| {
let schema: String = r.get(0);
let table: String = r.get(1);
format!("{schema}.{table}")
})
.collect();
let qualified = select_tables_to_verify(
&qualified,
&cfg.schemas,
&cfg.tables,
&cfg.exclude_tables,
&cfg.exclude_schemas,
);
drop(source);
let jobs = cfg.jobs.max(1);
let worker_count = jobs.min(qualified.len().max(1));
let chunk_size = qualified.len().div_ceil(worker_count).max(1);
let source_url = cfg.source.connection_string.clone();
let target_url = cfg.target.connection_string.clone();
let workers = qualified.chunks(chunk_size).map(|slice| {
let slice: Vec<String> = slice.to_vec();
let source_url = source_url.clone();
let target_url = target_url.clone();
let cancel = cancel.clone();
async move {
let (source, target) = tokio::select! {
_ = cancel.cancelled() => return Err(MigrationError::Cancelled),
res = async {
tokio::try_join!(
connect_with_sslmode(&source_url),
connect_with_sslmode(&target_url),
)
} => res?,
};
let mut local = Vec::with_capacity(slice.len());
for qt in slice {
if cancel.is_cancelled() {
return Err(MigrationError::Cancelled);
}
let (schema, table) = match qt.rsplit_once('.') {
Some((s, t)) => (s.to_string(), t.to_string()),
None => continue,
};
let q = format!(
"SELECT count(*) FROM {}.{}",
pg_walstream::quote_ident(&schema)?,
pg_walstream::quote_ident(&table)?
);
let (s_res, t_res) = tokio::select! {
_ = cancel.cancelled() => return Err(MigrationError::Cancelled),
res = async {
tokio::join!(source.query_one(&q, &[]), target.query_one(&q, &[]))
} => res,
};
let source_count: i64 = s_res?.get(0);
let target_count: i64 = match t_res {
Ok(row) => row.get(0),
Err(e)
if e.code() == Some(&SqlState::UNDEFINED_TABLE)
|| e.code() == Some(&SqlState::UNDEFINED_SCHEMA) =>
{
warn!(
schema = %schema, table = %table,
source = source_count,
"table missing on target"
);
-1
}
Err(e) => return Err(e.into()),
};
if source_count != target_count {
warn!(
schema = %schema, table = %table,
source = source_count, target = target_count,
"row-count mismatch"
);
}
local.push(TableCount {
schema,
table,
source: source_count,
target: target_count,
});
}
Ok::<Vec<TableCount>, MigrationError>(local)
}
});
let collected: Vec<Vec<TableCount>> = futures::future::try_join_all(workers).await?;
let out: Vec<TableCount> = collected.into_iter().flatten().collect();
let report = VerifyReport::new(out);
info!("{}", report.summary_line());
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn tc(schema: &str, table: &str, s: i64, t: i64) -> TableCount {
TableCount {
schema: schema.into(),
table: table.into(),
source: s,
target: t,
}
}
#[test]
fn mismatches_empty_when_all_equal() {
let r = VerifyReport::new(vec![tc("public", "a", 5, 5), tc("public", "b", 0, 0)]);
assert!(r.is_ok());
assert!(r.mismatches().is_empty());
assert_eq!(r.summary_line(), "verify: 2 table(s) matched");
}
#[test]
fn mismatches_lists_only_unequal_rows() {
let r = VerifyReport::new(vec![tc("public", "a", 5, 5), tc("public", "b", 7, 3)]);
assert!(!r.is_ok());
let m = r.mismatches();
assert_eq!(m.len(), 1);
assert_eq!(m[0].table, "b");
assert_eq!(r.summary_line(), "verify: 1/2 table(s) MISMATCHED");
}
#[test]
fn list_tables_sql_excludes_system_schemas_and_partitions() {
assert!(LIST_TABLES_SQL.contains("NOT c.relispartition"));
assert!(LIST_TABLES_SQL.contains("information_schema"));
assert!(LIST_TABLES_SQL.contains("relkind = 'p'"));
}
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
#[test]
fn selects_all_when_no_filter_set() {
let candidates = s(&["app.a", "public.users", "other.x"]);
let got = select_tables_to_verify(&candidates, &[], &[], &[], &[]);
assert_eq!(got, candidates);
}
#[test]
fn selects_schema_tables_when_only_schemas_set() {
let candidates = s(&["app.a", "app.b", "public.users", "other.x"]);
let got = select_tables_to_verify(&candidates, &s(&["app"]), &[], &[], &[]);
assert_eq!(got, s(&["app.a", "app.b"]));
}
#[test]
fn selects_exact_tables_when_only_tables_set() {
let candidates = s(&["app.a", "app.b", "public.users", "other.x"]);
let got = select_tables_to_verify(&candidates, &[], &s(&["public.users"]), &[], &[]);
assert_eq!(got, s(&["public.users"]));
}
#[test]
fn unions_when_both_schemas_and_tables_set() {
let candidates = s(&["app.a", "app.b", "public.users", "other.x"]);
let got =
select_tables_to_verify(&candidates, &s(&["app"]), &s(&["public.users"]), &[], &[]);
assert_eq!(got, s(&["app.a", "app.b", "public.users"]));
}
#[test]
fn applies_exclusions_after_union() {
let candidates = s(&["app.a", "app.b", "public.users", "other.x"]);
let got = select_tables_to_verify(
&candidates,
&s(&["app"]),
&s(&["public.users"]),
&s(&["app.b"]),
&[],
);
assert_eq!(got, s(&["app.a", "public.users"]));
let got_sch = select_tables_to_verify(
&candidates,
&s(&["app"]),
&s(&["public.users"]),
&[],
&s(&["public"]),
);
assert_eq!(got_sch, s(&["app.a", "app.b"]));
}
}