use std::path::Path;
use crate::config::{Config, ExportConfig};
use crate::error::{DataIntegrityError, Result};
use crate::plan::{
DiagnosticLevel, ExtractionStrategy, ResolvedRunPlan, build_plan, validate_plan,
};
use crate::state::StateStore;
use super::RunOptions;
use super::chunked::{self, run_chunked_parallel_checkpoint};
use super::single::{commit_incremental_cursor, run_with_reconnect};
use super::summary::RunSummary;
use crate::journal::RunEvent;
pub(crate) fn classify_error_message(msg: &str) -> Option<&'static str> {
let m = msg.to_ascii_lowercase();
if m.contains("keyset could not read") || m.contains("could not read the key value") {
Some("keyset_unreadable_key")
} else if m.contains("parallel checkpoint worker") {
Some("parallel_checkpoint")
} else if m.contains("deadlock") {
Some("deadlock")
} else if m.contains("lock wait timeout") || m.contains("could not obtain lock") {
Some("lock_timeout")
} else if m.contains("3024")
|| m.contains("maximum statement execution time")
|| m.contains("statement timeout")
{
Some("statement_timeout")
} else if m.contains("schema drift")
|| m.contains("schema changed")
|| m.contains("on_schema_drift")
{
Some("schema_drift")
} else if m.contains("doesn't exist")
|| m.contains("does not exist")
|| m.contains("no such table")
|| m.contains("invalid object name")
|| m.contains("unknown table")
{
Some("relation_not_found")
} else if m.contains("permission denied")
|| m.contains("command denied")
|| m.contains("privilege")
|| m.contains("view server state")
{
Some("privilege")
} else if m.contains("access denied")
|| m.contains("authentication failed")
|| m.contains("password authentication")
{
Some("source_auth")
} else if m.contains("connection reset")
|| m.contains("connection refused")
|| m.contains("connection closed")
|| m.contains("broken pipe")
|| m.contains("server has gone away")
|| m.contains("connection timed out")
{
Some("connection")
} else if m.contains("certificate") || m.contains("tls handshake") || m.contains("ssl error") {
Some("tls")
} else if m.contains("no space left") || m.contains("disk full") || m.contains("quota exceeded")
{
Some("disk_full")
} else if m.contains("out of memory") || m.contains("cannot allocate memory") {
Some("out_of_memory")
} else {
None
}
}
fn strategy_key_column(plan: &ResolvedRunPlan) -> Option<(&'static str, &str)> {
match &plan.strategy {
ExtractionStrategy::Keyset(k) => Some(("keyset", &k.key_column)),
ExtractionStrategy::Chunked(c) => Some(("chunked", &c.column)),
ExtractionStrategy::Incremental(i) => Some(("incremental", &i.primary_column)),
_ => None,
}
}
fn key_descriptor_json(plan: &ResolvedRunPlan, key_native_type: Option<&str>) -> Option<String> {
let (strategy, key) = strategy_key_column(plan)?;
let mut obj = serde_json::json!({ "strategy": strategy, "key": key });
if let Some(t) = key_native_type {
obj["db_type"] = serde_json::Value::String(t.to_string());
if t.to_ascii_lowercase().contains("unsigned") {
obj["unsigned"] = serde_json::Value::Bool(true);
}
}
Some(obj.to_string())
}
fn capture_open_forensics(plan: &ResolvedRunPlan, state: &StateStore, summary: &mut RunSummary) {
let mut src = match crate::source::create_source(&plan.source) {
Ok(s) => s,
Err(e) => {
log::debug!(
"open-forensics: source connect failed for '{}': {e}",
plan.export_name
);
return;
}
};
summary.server_context_json = src.server_context();
match src.type_mappings(&plan.base_query, &plan.column_overrides) {
Ok(mappings) => {
let cols: Vec<crate::state::SchemaColumn> = mappings
.iter()
.map(|m| crate::state::SchemaColumn {
name: m.column_name.clone(),
data_type: m
.arrow_type
.as_ref()
.map(|t| format!("{t:?}"))
.unwrap_or_else(|| m.source_native_type.clone()),
})
.collect();
if let Some((_, key)) = strategy_key_column(plan) {
summary.key_native_type = mappings
.iter()
.find(|m| m.column_name == key)
.map(|m| m.source_native_type.clone());
}
if let Err(e) = state.store_schema_if_absent(&summary.export_name, &cols) {
log::debug!(
"open-forensics: store_schema_if_absent failed for '{}': {e}",
summary.export_name
);
}
}
Err(e) => log::debug!(
"open-forensics: type_mappings failed for '{}': {e}",
plan.export_name
),
}
}
fn build_metric_row(
summary: &RunSummary,
plan: &ResolvedRunPlan,
tuning_class: &str,
) -> crate::state::MetricRow {
let (chunk_size, parallel) = match &plan.strategy {
crate::plan::ExtractionStrategy::Chunked(cp) => {
(Some(cp.chunk_size as i64), Some(cp.parallel as i64))
}
_ => (None, None),
};
crate::state::MetricRow {
export_name: summary.export_name.clone(),
run_id: summary.run_id.clone(),
duration_ms: summary.duration_ms,
total_rows: summary.total_rows,
peak_rss_mb: Some(summary.peak_rss_mb),
status: summary.status.clone(),
error_message: summary.error_message.clone(),
tuning_profile: Some(tuning_class.to_string()),
format: Some(summary.format.clone()),
mode: Some(summary.mode.clone()),
files_produced: summary.files_produced as i64,
bytes_written: summary.bytes_written as i64,
retries: summary.retries as i64,
validated: summary.validated,
schema_changed: summary.schema_changed,
files_committed: summary.files_committed as i64,
reconciled: summary.reconciled,
source_count: summary.source_count,
quality_passed: summary.quality_passed,
pg_temp_bytes_delta: summary.pg_temp_bytes_delta,
batch_size: summary.batch_size as i64,
batch_size_memory_mb: summary.batch_size_memory_mb.map(|m| m as i64),
skip_reason: summary.skip_reason.clone(),
schema_fingerprint: summary.schema_fingerprint.clone(),
chunk_size,
parallel,
source_type: Some(format!("{:?}", plan.source.source_type).to_lowercase()),
destination_type: Some(plan.destination.destination_type.label().to_string()),
rivet_version: Some(env!("CARGO_PKG_VERSION").to_string()),
longest_chunk_ms: summary.journal.longest_chunk_ms(),
chunk_key: plan.strategy.chunk_key().map(str::to_string),
error_class: summary
.error_message
.as_deref()
.and_then(classify_error_message)
.map(str::to_string),
cursor_min: summary.cursor_low.clone(),
cursor_max: summary.cursor_high.clone(),
key_descriptor_json: key_descriptor_json(plan, summary.key_native_type.as_deref()),
offending_value: summary.offending_value.clone(),
server_context_json: summary.server_context_json.clone(),
}
}
fn run_chunked_quality_gate(
result: Result<()>,
plan: &ResolvedRunPlan,
summary: &mut RunSummary,
) -> Result<()> {
result?;
if !matches!(
plan.strategy,
ExtractionStrategy::Chunked(_) | ExtractionStrategy::Keyset(_)
) {
return Ok(());
}
let qc = match &plan.quality {
Some(q) => q,
None => return Ok(()),
};
let total = summary.total_rows as usize;
let row_issues = crate::quality::check_row_count(total, qc);
let has_unsupported = !qc.null_ratio_max.is_empty() || !qc.unique_columns.is_empty();
if has_unsupported {
log::warn!(
"export '{}': quality checks null_ratio_max and unique_columns are not supported on the multi-part runners (chunked / keyset / parallel-Mongo) — each part processes independently; only row_count bounds are checked",
plan.export_name
);
}
if !row_issues.is_empty() {
for issue in &row_issues {
log::warn!("quality FAIL: {}", issue.message);
}
summary.quality_passed = Some(false);
let fails: Vec<&str> = row_issues.iter().map(|i| i.message.as_str()).collect();
return Err(DataIntegrityError::new(crate::quality::failure_message(
&plan.export_name,
Some("multi-part aggregate"),
&fails,
))
.into());
}
summary.quality_passed = Some(true);
Ok(())
}
fn pg_temp_bytes_snapshot(plan: &ResolvedRunPlan) -> Option<i64> {
if !matches!(plan.source.source_type, crate::config::SourceType::Postgres) {
return None;
}
let url = plan.source.resolve_url().ok()?;
crate::source::postgres::sample_temp_bytes(&url, plan.source.tls.as_ref())
}
fn harm_snapshot(plan: &ResolvedRunPlan) -> Option<Vec<(String, i64)>> {
let url = plan.source.resolve_url().ok()?;
let tls = plan.source.tls.as_ref();
match plan.source.source_type {
crate::config::SourceType::Postgres => {
crate::source::postgres::sample_harm_counters(&url, tls)
}
crate::config::SourceType::Mysql => crate::source::mysql::sample_harm_counters(&url, tls),
crate::config::SourceType::Mssql => crate::source::mssql::sample_harm_counters(&url, tls),
crate::config::SourceType::Mongo => crate::source::mongo::sample_harm_counters(&url, tls),
}
}
fn harm_deltas(before: &[(String, i64)], after: &[(String, i64)]) -> Vec<(String, i64)> {
let bmap: std::collections::HashMap<&str, i64> =
before.iter().map(|(k, v)| (k.as_str(), *v)).collect();
after
.iter()
.filter_map(|(k, after_v)| {
bmap.get(k.as_str())
.map(|b| (k.clone(), (after_v - b).max(0)))
})
.collect()
}
pub(super) fn run_diagnosis(summary: &RunSummary, harm_deltas: &[(String, i64)]) -> Option<String> {
let mut flags: Vec<String> = Vec::new();
if summary.reconnects > 0 {
flags.push(format!(
"{} reconnect(s) survived (flaky link)",
summary.reconnects
));
}
if summary.resumed {
flags.push("resumed a prior CRASHED run".to_string());
}
let spills: i64 = harm_deltas
.iter()
.filter(|(k, _)| k.contains("tmp_disk"))
.map(|(_, v)| *v)
.sum();
if spills >= 100 {
flags.push(format!(
"{spills} tmp-disk spills — the source spilled to disk; try `mode: chunked`/`chunk_by_key` or a smaller `tuning.batch_size`"
));
}
if flags.is_empty() {
return None;
}
Some(format!(
"export '{}': DIAGNOSIS — {} rows @ {} MB in {} ms [{}] · retries={} · {}",
summary.export_name,
summary.total_rows,
summary.peak_rss_mb,
summary.duration_ms,
summary.status,
summary.retries,
flags.join("; "),
))
}
fn resolve_final_result(
failed: bool,
run_result: crate::error::Result<()>,
reconcile_gate: crate::error::Result<()>,
) -> crate::error::Result<()> {
if failed { run_result } else { reconcile_gate }
}
fn reconcile_run_gate(
summary: &RunSummary,
could_not_verify: Option<&str>,
) -> crate::error::Result<()> {
if summary.reconciled == Some(false) {
return Err(crate::error::DataIntegrityError::new(format!(
"reconcile MISMATCH for '{}': the exported dataset disagrees with the source \
count {} — see the reconcile log above",
summary.export_name,
summary.source_count.unwrap_or(-1),
))
.into());
}
if let Some(reason) = could_not_verify {
anyhow::bail!(
"reconcile could not be verified for '{}': {reason}. The export completed and is \
durable; re-run to obtain the reconcile assurance, or drop `--reconcile`.",
summary.export_name
);
}
Ok(())
}
fn reconcile_source_count(plan: &ResolvedRunPlan, summary: &mut RunSummary) -> Option<String> {
if let Some(reason) = plan.strategy.reconcile_subset_skip() {
log::info!(
"reconcile: skipping full-count for '{}' ({reason})",
plan.export_name
);
return None;
}
let count_sql = format!(
"SELECT COUNT(*) FROM ({}) AS _rivet_reconcile",
plan.base_query
);
log::info!(
"reconcile: running source count query for '{}'",
plan.export_name
);
let mut src = match crate::source::create_source(&plan.source) {
Ok(s) => s,
Err(e) => {
log::warn!("reconcile: could not connect to source: {:#}", e);
return Some(format!(
"could not connect to the source to reconcile: {e:#}"
));
}
};
match src.query_scalar(&count_sql) {
Ok(Some(val)) => {
let Ok(count) = val.parse::<i64>() else {
log::warn!("reconcile: could not parse count result '{val}' as integer");
return Some(format!("source reconcile count '{val}' is not an integer"));
};
summary.source_count = Some(count);
let committed_rows: i64 = summary.manifest_parts.iter().map(|p| p.rows).sum();
let exported_total = if committed_rows > 0 {
committed_rows
} else {
summary.total_rows
};
summary.reconciled = Some(exported_total == count);
if exported_total != count {
log::warn!(
"reconcile MISMATCH for '{}': committed {} rows, source has {}",
plan.export_name,
exported_total,
count
);
} else {
log::info!(
"reconcile MATCH for '{}': {}/{}",
plan.export_name,
exported_total,
count
);
}
None
}
Ok(None) => {
log::warn!(
"reconcile: COUNT(*) returned NULL for '{}'",
plan.export_name
);
Some("source reconcile COUNT(*) returned NULL".to_string())
}
Err(e) => {
log::warn!(
"reconcile: count query failed for '{}': {:#}",
plan.export_name,
e
);
Some(format!("source reconcile count query failed: {e:#}"))
}
}
}
pub(crate) fn synthetic_failed_summary(export_name: &str, err: &anyhow::Error) -> RunSummary {
let run_id = format!(
"{}_{}",
export_name,
chrono::Utc::now().format("%Y%m%dT%H%M%S%3f"),
);
let journal = crate::journal::RunJournal::new(&run_id, export_name);
RunSummary {
cursor_column: None,
cursor_low: None,
cursor_high: None,
offending_value: None,
server_context_json: None,
key_native_type: None,
run_id,
export_name: export_name.to_string(),
status: "failed".into(),
total_rows: 0,
files_produced: 0,
bytes_written: 0,
files_committed: 0,
duration_ms: 0,
peak_rss_mb: 0,
retries: 0,
reconnects: 0,
resumed: false,
validated: None,
schema_changed: None,
quality_passed: None,
error_message: Some(crate::redact::redact_error(err)),
tuning_profile: "balanced (default)".into(),
batch_size: 0,
batch_size_memory_mb: None,
format: String::new(),
mode: String::new(),
compression: String::new(),
destination_uri: None,
source_count: None,
pg_temp_bytes_delta: None,
skip_reason: None,
reconciled: None,
manifest_parts: Vec::new(),
schema_fingerprint: None,
manifest_verification: None,
apply_context: None,
column_checksums: Vec::new(),
column_checksums_incomplete: false,
checksum_key_column: None,
journal,
}
}
fn finalize_keyset_anchor(
state: &StateStore,
plan: &ResolvedRunPlan,
export_name: &str,
failed: bool,
) {
if !failed && matches!(plan.strategy, ExtractionStrategy::Keyset(_)) {
let _ = state.clear_resume_run_id(export_name);
}
}
fn ledger_begin_run(state: &StateStore, plan: &ResolvedRunPlan, run_id: &str) {
let prefix = super::finalize::destination_uri_for_manifest(&plan.destination);
let started_at = chrono::Utc::now().to_rfc3339();
if let Err(e) = state.begin_run(run_id, &plan.export_name, &prefix, &started_at) {
log::warn!(
"export '{}': run-status begin failed (gc may over-defer orphan cleanup): {e:#}",
plan.export_name
);
}
super::finalize::write_running_manifest(plan, run_id, &started_at);
}
fn ledger_finish_run(state: &StateStore, export_name: &str, run_id: &str, status: &str) {
let finished_at = chrono::Utc::now().to_rfc3339();
if let Err(e) = state.finish_run(run_id, status, &finished_at) {
log::warn!("export '{export_name}': run-status finish failed: {e:#}");
}
}
pub(super) fn run_export_job(
config_path: &str,
config: &Config,
export: &ExportConfig,
state: &StateStore,
config_dir: &Path,
opts: &RunOptions<'_>,
) -> (Result<()>, RunSummary) {
if export.mode == crate::config::ExportMode::Cdc {
let pending = match super::cdc_job::initial_snapshot_pending(config, export, state) {
Ok(p) => p,
Err(e) => {
let summary = synthetic_failed_summary(&export.name, &e);
return (Err(e), summary);
}
};
for synth in &pending {
let (res, summary) =
run_export_job(config_path, config, synth, state, config_dir, opts);
if res.is_err() {
return (res, summary);
}
if let Some(table) = synth.table.as_deref()
&& let Err(e) =
state.mark_snapshot_done(&export.name, table, &summary.journal.run_id)
{
log::warn!(
"cdc: snapshot-completion persist failed for '{}' table '{}': {:#}",
export.name,
table,
e
);
}
}
return super::cdc_job::run_cdc_export(config_path, config, export, state);
}
let plan = match build_plan(
config,
export,
config_dir,
opts.validate,
opts.reconcile,
opts.resume,
opts.params,
) {
Ok(p) => p,
Err(e) => {
let summary = synthetic_failed_summary(&export.name, &e);
return (Err(e), summary);
}
};
let diags = validate_plan(&plan);
let mut rejected: Vec<String> = Vec::new();
for d in &diags {
match d.level {
DiagnosticLevel::Rejected => {
log::error!("[{}] plan validation rejected: {}", d.rule, d.message);
rejected.push(d.message.clone());
}
DiagnosticLevel::Warning => {
log::warn!("[{}] plan validation warning: {}", d.rule, d.message);
}
DiagnosticLevel::Degraded => {
log::info!("[{}] plan validation degraded: {}", d.rule, d.message);
}
}
}
if !rejected.is_empty() {
let err = anyhow::anyhow!(
"export '{}': plan validation failed:\n {}",
plan.export_name,
rejected.join("\n ")
);
let summary = synthetic_failed_summary(&export.name, &err);
return (Err(err), summary);
}
if opts.resume
&& !opts.force
&& let Err(e) = check_success_gate_for_resume(&plan)
{
let summary = synthetic_failed_summary(&export.name, &e);
return (Err(e), summary);
}
if !opts.resume && !opts.force {
warn_if_prefix_has_completed_run(&plan);
}
log::info!(
"starting export '{}' (effective tuning: {})",
plan.export_name,
plan.tuning
);
let start = std::time::Instant::now();
let rss_before = crate::resource::get_rss_mb();
let rss_sampler = crate::resource::RssPeakSampler::start(rss_before, 100);
let mut summary = RunSummary::new(&plan);
ledger_begin_run(state, &plan, &summary.run_id);
capture_open_forensics(&plan, state, &mut summary);
let pg_temp_bytes_before = pg_temp_bytes_snapshot(&plan);
let harm_before = harm_snapshot(&plan);
for d in &diags {
if matches!(
d.level,
DiagnosticLevel::Warning | DiagnosticLevel::Degraded
) {
summary.journal.record(RunEvent::PlanWarning {
rule: d.rule.to_string(),
message: d.message.clone(),
});
}
}
let result = if plan.strategy.requires_parallel_execution() {
if plan.strategy.is_resumable() {
run_chunked_parallel_checkpoint(
config_path,
state,
&plan,
&mut summary,
chunked::ChunkSource::Detect,
)
} else {
chunked::run_chunked_parallel(state, &plan, &mut summary, chunked::ChunkSource::Detect)
}
} else {
run_with_reconnect(state, &plan, &mut summary, config_path)
};
let rss_peak = rss_sampler.stop();
let rss_after = crate::resource::get_rss_mb();
summary.duration_ms = start.elapsed().as_millis() as i64;
summary.peak_rss_mb = rss_peak.max(rss_after).max(rss_before) as i64;
if let Some(before) = pg_temp_bytes_before
&& let Some(after) = pg_temp_bytes_snapshot(&plan)
{
let delta = (after - before).max(0);
summary.pg_temp_bytes_delta = Some(delta);
if delta > 100 * 1024 * 1024 {
log::warn!(
"export '{}': PG temp_bytes spill +{:.1} MB during run — cursor / sort overflow. \
Consider lowering `tuning.batch_size` or setting `tuning.batch_size_memory_mb` \
below PG's `work_mem`.",
plan.export_name,
delta as f64 / (1024.0 * 1024.0),
);
}
}
let mut harm_delta_vec: Vec<(String, i64)> = Vec::new();
if let Some(before) = &harm_before
&& let Some(after) = harm_snapshot(&plan)
{
harm_delta_vec = harm_deltas(before, &after);
if let Err(e) = state.record_harm(&summary.run_id, &summary.export_name, &harm_delta_vec) {
log::debug!(
"export '{}': harm metrics write failed (informational): {:#}",
summary.export_name,
e
);
}
}
let tuning_class = plan.tuning.profile_name().to_string();
let result = run_chunked_quality_gate(result, &plan, &mut summary);
let failed = result.is_err();
match &result {
Ok(()) => {
if summary.status == "running" {
summary.status = "success".into();
}
}
Err(e) => {
summary.status = "failed".into();
let redacted = crate::redact::redact_error(e);
summary.error_message = Some(redacted.clone());
log::error!("export '{}' failed: {}", plan.export_name, redacted);
}
}
if let Some(line) = run_diagnosis(&summary, &harm_delta_vec) {
log::warn!("{line}");
}
let mut reconcile_gate: crate::error::Result<()> = Ok(());
if plan.reconcile && !failed {
let could_not_verify = reconcile_source_count(&plan, &mut summary);
if let (Some(source_count), Some(matched)) = (summary.source_count, summary.reconciled) {
summary.journal.record(RunEvent::ReconciliationResult {
source_count,
exported_rows: summary.total_rows,
matched,
});
}
reconcile_gate = reconcile_run_gate(&summary, could_not_verify.as_deref());
}
summary.journal.record(RunEvent::RunCompleted {
status: summary.status.clone(),
error_message: summary.error_message.clone(),
duration_ms: summary.duration_ms,
});
if let Err(e) = state.store_journal(&summary.journal) {
log::warn!(
"export '{}': journal persist failed (run history not stored): {:#}",
summary.export_name,
e
);
}
summary.print();
ledger_finish_run(state, &plan.export_name, &summary.run_id, &summary.status);
finalize_manifest(&plan, state, &summary, "export");
if let Err(e) = commit_incremental_cursor(state, &plan, &summary) {
log::error!(
"export '{}': cursor advance failed AFTER the manifest was written — the next run \
re-exports from the prior cursor (at-least-once, no loss): {:#}",
summary.export_name,
e
);
}
finalize_keyset_anchor(state, &plan, &summary.export_name, failed);
if plan.validate {
finalize_validate_manifest(&plan, &mut summary, "export");
}
if let Err(e) = state.record_metric_full(&build_metric_row(&summary, &plan, &tuning_class)) {
log::warn!(
"export '{}': metrics write failed (run outcome not stored): {:#}",
summary.export_name,
e
);
}
finalize_run_report(config_path, &summary, "export");
crate::notify::maybe_send(config.notifications.as_ref(), &summary);
let final_result = resolve_final_result(failed, result, reconcile_gate);
(final_result, summary)
}
use super::finalize::{
check_success_gate_for_resume, finalize_manifest, finalize_run_report,
finalize_validate_manifest, warn_if_prefix_has_completed_run,
};
pub(crate) fn run_export_job_with_chunk_source(
plan: &ResolvedRunPlan,
state: &StateStore,
chunk_source: chunked::ChunkSource,
config_path: &str,
apply_context: Option<crate::pipeline::summary::ApplyContext>,
) -> Result<()> {
let diags = validate_plan(plan);
for d in &diags {
match d.level {
DiagnosticLevel::Rejected => {
anyhow::bail!(
"export '{}': plan validation rejected: {}",
plan.export_name,
d.message
);
}
DiagnosticLevel::Warning => {
log::warn!("[{}] plan validation warning: {}", d.rule, d.message);
}
DiagnosticLevel::Degraded => {
log::info!("[{}] plan validation degraded: {}", d.rule, d.message);
}
}
}
log::info!(
"apply: starting export '{}' (tuning: {})",
plan.export_name,
plan.tuning
);
let start = std::time::Instant::now();
let rss_before = crate::resource::get_rss_mb();
let rss_sampler = crate::resource::RssPeakSampler::start(rss_before, 100);
let mut summary = RunSummary::new(plan);
summary.apply_context = apply_context;
ledger_begin_run(state, plan, &summary.run_id);
capture_open_forensics(plan, state, &mut summary);
let result = if plan.strategy.requires_parallel_execution() {
if plan.strategy.is_resumable() {
run_chunked_parallel_checkpoint("", state, plan, &mut summary, chunk_source)
} else {
chunked::run_chunked_parallel(state, plan, &mut summary, chunk_source)
}
} else {
run_with_reconnect(state, plan, &mut summary, "")
};
let rss_peak = rss_sampler.stop();
let rss_after = crate::resource::get_rss_mb();
summary.duration_ms = start.elapsed().as_millis() as i64;
summary.peak_rss_mb = rss_peak.max(rss_after).max(rss_before) as i64;
let tuning_class = plan.tuning.profile_name().to_string();
let result = run_chunked_quality_gate(result, plan, &mut summary);
let failed = result.is_err();
match &result {
Ok(()) => {
if summary.status == "running" {
summary.status = "success".into();
}
}
Err(e) => {
summary.status = "failed".into();
let redacted = crate::redact::redact_error(e);
summary.error_message = Some(redacted.clone());
log::error!("apply '{}' failed: {}", plan.export_name, redacted);
}
}
summary.print();
ledger_finish_run(state, &plan.export_name, &summary.run_id, &summary.status);
finalize_manifest(plan, state, &summary, "apply");
if let Err(e) = commit_incremental_cursor(state, plan, &summary) {
log::error!(
"apply '{}': cursor advance failed AFTER the manifest was written — the next run \
re-exports from the prior cursor (at-least-once, no loss): {:#}",
summary.export_name,
e
);
}
finalize_keyset_anchor(state, plan, &summary.export_name, failed);
if plan.validate {
finalize_validate_manifest(plan, &mut summary, "apply");
}
if let Err(e) = state.record_metric_full(&build_metric_row(&summary, plan, &tuning_class)) {
log::warn!(
"apply '{}': metrics write failed: {:#}",
summary.export_name,
e
);
}
finalize_run_report(config_path, &summary, "apply");
if failed { result } else { Ok(()) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_reconcile_gate_fails_on_mismatch_matches_subcommand() {
let mut s = RunSummary {
export_name: "orders".into(),
source_count: Some(1033),
reconciled: Some(false),
..Default::default()
};
let err = reconcile_run_gate(&s, None).unwrap_err();
assert!(
err.downcast_ref::<crate::error::DataIntegrityError>()
.is_some(),
"a reconcile mismatch must carry the data-integrity marker"
);
assert_eq!(
crate::error::classify_exit(&err),
3,
"a reconcile mismatch must classify as exit 3"
);
s.reconciled = Some(true);
assert!(reconcile_run_gate(&s, None).is_ok());
s.reconciled = None;
assert!(reconcile_run_gate(&s, None).is_ok());
let err = reconcile_run_gate(&s, Some("could not connect to the source"))
.expect_err("a could-not-verify reconcile must gate non-zero");
assert!(
err.downcast_ref::<crate::error::DataIntegrityError>()
.is_none(),
"could-not-verify must NOT be data-integrity (exit 3)"
);
assert_eq!(
crate::error::classify_exit(&err),
1,
"a could-not-verify reconcile must classify as operational exit 1"
);
s.reconciled = Some(false);
let err = reconcile_run_gate(&s, Some("noise")).unwrap_err();
assert_eq!(crate::error::classify_exit(&err), 3);
}
#[test]
fn resolve_final_result_surfaces_reconcile_mismatch_when_export_succeeded() {
use crate::error::DataIntegrityError;
let gate: crate::error::Result<()> = Err(DataIntegrityError::new("mismatch").into());
let out = resolve_final_result(false, Ok(()), gate);
assert!(
out.is_err(),
"a reconcile mismatch on a successful export must surface as the run result"
);
assert_eq!(crate::error::classify_exit(&out.unwrap_err()), 3);
let qfail: crate::error::Result<()> = Err(DataIntegrityError::new("quality").into());
assert!(resolve_final_result(true, qfail, Ok(())).is_err());
assert!(resolve_final_result(false, Ok(()), Ok(())).is_ok());
}
#[test]
fn run_diagnosis_flags_flaky_link_and_spill_signals_only() {
let base = || RunSummary {
export_name: "orders".into(),
total_rows: 1000,
peak_rss_mb: 50,
duration_ms: 2000,
status: "success".into(),
..Default::default()
};
assert!(run_diagnosis(&base(), &[]).is_none());
let mut s = base();
s.reconnects = 2;
s.retries = 3;
let line = run_diagnosis(&s, &[]).expect("reconnects must diagnose");
assert!(line.contains("2 reconnect"), "got: {line}");
assert!(line.contains("retries=3"), "got: {line}");
assert!(
line.contains("[success]"),
"status must be the resolved one: {line}"
);
let mut s = base();
s.resumed = true;
assert!(
run_diagnosis(&s, &[])
.unwrap()
.contains("resumed a prior CRASHED")
);
let line = run_diagnosis(&base(), &[("Created_tmp_disk_tables".into(), 2782)])
.expect("spills must diagnose");
assert!(line.contains("2782 tmp-disk spills"), "got: {line}");
assert!(run_diagnosis(&base(), &[("Created_tmp_disk_tables".into(), 5)]).is_none());
}
#[test]
fn synthetic_failed_summary_fields() {
let err = anyhow::anyhow!("connection refused");
let summary = synthetic_failed_summary("my_export", &err);
assert_eq!(summary.export_name, "my_export");
assert_eq!(summary.status, "failed");
assert_eq!(summary.total_rows, 0);
assert_eq!(summary.files_produced, 0);
assert_eq!(summary.bytes_written, 0);
assert!(
summary
.error_message
.as_ref()
.unwrap()
.contains("connection refused")
);
}
#[test]
fn synthetic_failed_summary_run_id_contains_export_name() {
let err = anyhow::anyhow!("boom");
let summary = synthetic_failed_summary("orders", &err);
assert!(
summary.run_id.starts_with("orders_"),
"run_id was: {}",
summary.run_id
);
}
#[test]
fn synthetic_failed_summary_journal_is_empty() {
let err = anyhow::anyhow!("boom");
let summary = synthetic_failed_summary("orders", &err);
assert!(summary.journal.entries.is_empty());
}
#[test]
fn synthetic_failed_summary_no_quality_or_reconcile_state() {
let err = anyhow::anyhow!("boom");
let summary = synthetic_failed_summary("orders", &err);
assert!(summary.quality_passed.is_none());
assert!(summary.reconciled.is_none());
assert!(summary.validated.is_none());
}
use crate::config::QualityConfig;
use crate::config::{
CompressionType, DestinationConfig, DestinationType, FormatType, MetaColumns, SourceConfig,
SourceType,
};
use crate::plan::{ChunkedPlan, ExtractionStrategy, ResolvedRunPlan};
use crate::tuning::SourceTuning;
fn chunked_plan_with_quality(quality: Option<QualityConfig>) -> ResolvedRunPlan {
ResolvedRunPlan {
export_name: "orders".into(),
base_query: "SELECT id FROM orders".into(),
strategy: ExtractionStrategy::Chunked(ChunkedPlan {
column: "id".into(),
chunk_size: 100,
chunk_count: None,
parallel: 1,
dense: false,
by_days: None,
checkpoint: false,
max_attempts: 3,
}),
format: FormatType::Parquet,
compression: CompressionType::None,
compression_level: None,
max_file_size_bytes: None,
skip_empty: false,
meta_columns: MetaColumns::default(),
destination: DestinationConfig {
destination_type: DestinationType::Local,
path: Some("/tmp".into()),
..Default::default()
},
quality,
tuning: SourceTuning::from_config(None),
tuning_profile_label: "balanced".into(),
validate: false,
reconcile: false,
resume: false,
source: SourceConfig {
source_type: SourceType::Postgres,
url: Some("postgresql://nobody@127.0.0.1:9999/x".into()),
url_env: None,
url_file: None,
host: None,
port: None,
user: None,
password: None,
password_env: None,
database: None,
environment: None,
tuning: None,
tls: None,
mongo: None,
},
column_overrides: Default::default(),
verify: crate::config::VerifyMode::Size,
schema_drift_policy: Default::default(),
shape_drift_warn_factor: 0.0,
parquet: None,
}
}
fn fresh_summary(plan: &ResolvedRunPlan, total_rows: i64) -> RunSummary {
let mut s = RunSummary::stub_for_testing("r", plan.export_name.clone());
s.total_rows = total_rows;
s.batch_size = 10_000;
s.mode = "chunked".into();
s.compression = "none".into();
s
}
#[test]
fn chunked_quality_gate_passes_through_existing_error() {
let plan = chunked_plan_with_quality(None);
let mut summary = fresh_summary(&plan, 0);
let result = run_chunked_quality_gate(
Err(anyhow::anyhow!("chunk 3 failed to write")),
&plan,
&mut summary,
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("chunk 3 failed"),
"must propagate original error: {err}"
);
assert!(summary.quality_passed.is_none());
}
#[test]
fn chunked_quality_gate_no_quality_config_marks_no_decision() {
let plan = chunked_plan_with_quality(None);
let mut summary = fresh_summary(&plan, 5_000);
run_chunked_quality_gate(Ok(()), &plan, &mut summary).expect("must pass");
assert!(summary.quality_passed.is_none());
}
#[test]
fn chunked_quality_gate_row_count_within_bounds_passes() {
let plan = chunked_plan_with_quality(Some(QualityConfig {
row_count_min: Some(100),
row_count_max: Some(10_000),
null_ratio_max: Default::default(),
unique_columns: Vec::new(),
unique_max_entries: None,
}));
let mut summary = fresh_summary(&plan, 5_000);
run_chunked_quality_gate(Ok(()), &plan, &mut summary).expect("in bounds must pass");
assert_eq!(summary.quality_passed, Some(true));
}
#[test]
fn chunked_quality_gate_row_count_below_min_fails() {
let plan = chunked_plan_with_quality(Some(QualityConfig {
row_count_min: Some(100),
row_count_max: None,
null_ratio_max: Default::default(),
unique_columns: Vec::new(),
unique_max_entries: None,
}));
let mut summary = fresh_summary(&plan, 42);
let err =
run_chunked_quality_gate(Ok(()), &plan, &mut summary).expect_err("below min must fail");
let msg = err.to_string();
assert!(
msg.contains("quality check(s) failed") && msg.contains("multi-part aggregate"),
"error must name the failed quality gate: {err}"
);
assert!(
msg.contains(" - "),
"error must surface the specific failing check(s), not just a generic message: {err}"
);
assert!(
err.downcast_ref::<DataIntegrityError>().is_some(),
"chunked quality-gate failure must be a typed data-integrity error"
);
assert_eq!(crate::error::classify_exit(&err), 3);
assert_eq!(summary.quality_passed, Some(false));
}
#[test]
fn keyset_quality_gate_row_count_below_min_fails() {
let mut plan = chunked_plan_with_quality(Some(QualityConfig {
row_count_min: Some(100),
row_count_max: None,
null_ratio_max: Default::default(),
unique_columns: Vec::new(),
unique_max_entries: None,
}));
plan.strategy = ExtractionStrategy::Keyset(crate::plan::KeysetPlan {
key_column: "id".into(),
chunk_size: 500,
checkpoint: false,
incremental: false,
parallel: 1,
});
let mut summary = fresh_summary(&plan, 42);
let err = run_chunked_quality_gate(Ok(()), &plan, &mut summary)
.expect_err("keyset below-min must FAIL, not silently pass");
assert_eq!(crate::error::classify_exit(&err), 3);
assert_eq!(summary.quality_passed, Some(false));
}
#[test]
fn chunked_quality_gate_row_count_above_max_fails() {
let plan = chunked_plan_with_quality(Some(QualityConfig {
row_count_min: None,
row_count_max: Some(1_000),
null_ratio_max: Default::default(),
unique_columns: Vec::new(),
unique_max_entries: None,
}));
let mut summary = fresh_summary(&plan, 50_000);
let err =
run_chunked_quality_gate(Ok(()), &plan, &mut summary).expect_err("above max must fail");
assert!(err.to_string().contains("quality"), "error: {err}");
assert_eq!(summary.quality_passed, Some(false));
}
#[test]
fn chunked_quality_gate_skips_unsupported_checks_with_warning() {
let plan = chunked_plan_with_quality(Some(QualityConfig {
row_count_min: Some(10),
row_count_max: None,
null_ratio_max: [("name".into(), 0.1)].into_iter().collect(),
unique_columns: vec!["id".into()],
unique_max_entries: None,
}));
let mut summary = fresh_summary(&plan, 1_000);
run_chunked_quality_gate(Ok(()), &plan, &mut summary)
.expect("unsupported checks must not fail in chunked mode");
assert_eq!(summary.quality_passed, Some(true));
}
#[test]
fn chunked_quality_gate_inactive_on_non_chunked_strategy() {
let mut plan = chunked_plan_with_quality(Some(QualityConfig {
row_count_min: Some(99_999), row_count_max: None,
null_ratio_max: Default::default(),
unique_columns: Vec::new(),
unique_max_entries: None,
}));
plan.strategy = ExtractionStrategy::Snapshot;
let mut summary = fresh_summary(&plan, 10);
run_chunked_quality_gate(Ok(()), &plan, &mut summary)
.expect("non-chunked strategy must skip the gate");
assert!(summary.quality_passed.is_none());
}
#[test]
fn build_metric_row_maps_every_summary_and_plan_field() {
let mut summary = RunSummary::stub_for_testing("run-bmr", "orders");
summary.duration_ms = 1234;
summary.total_rows = 50_000;
summary.peak_rss_mb = 142;
summary.status = "success".into();
summary.error_message = Some("export 'x': keyset could not read the 'id' value".into());
summary.cursor_low = Some("1".into());
summary.cursor_high = Some("18446744073709551615".into()); summary.offending_value = Some("9223372036854775800".into()); summary.server_context_json =
Some(r#"{"engine":"mysql","max_execution_time_ms":"30000"}"#.into());
summary.key_native_type = Some("bigint unsigned".into()); summary.format = "parquet".into();
summary.mode = "chunked".into();
summary.files_produced = 7;
summary.bytes_written = 4096;
summary.retries = 2;
summary.validated = Some(true);
summary.schema_changed = Some(false);
summary.files_committed = 6; summary.reconciled = Some(true);
summary.source_count = Some(49_999); summary.quality_passed = Some(true);
summary.pg_temp_bytes_delta = Some(1_048_576);
summary.batch_size = 32_000;
summary.batch_size_memory_mb = Some(256);
summary.skip_reason = Some("manual".into());
summary.schema_fingerprint = Some("fp-abc".into());
summary.journal.push_test_chunk_span(0, 640);
let mut plan = chunked_plan_with_quality(None);
plan.strategy = ExtractionStrategy::Chunked(ChunkedPlan {
column: "id".into(),
chunk_size: 100_000,
chunk_count: None,
parallel: 4,
dense: false,
by_days: None,
checkpoint: false,
max_attempts: 3,
});
let crate::state::MetricRow {
export_name,
run_id,
duration_ms,
total_rows,
peak_rss_mb,
status,
error_message,
tuning_profile,
format,
mode,
files_produced,
bytes_written,
retries,
validated,
schema_changed,
files_committed,
reconciled,
source_count,
quality_passed,
pg_temp_bytes_delta,
batch_size,
batch_size_memory_mb,
skip_reason,
schema_fingerprint,
chunk_size,
parallel,
source_type,
destination_type,
rivet_version,
longest_chunk_ms,
chunk_key,
error_class,
cursor_min,
cursor_max,
key_descriptor_json,
offending_value,
server_context_json,
} = build_metric_row(&summary, &plan, "safe");
assert_eq!(export_name, "orders");
assert_eq!(run_id, "run-bmr");
assert_eq!(duration_ms, 1234);
assert_eq!(total_rows, 50_000);
assert_eq!(peak_rss_mb, Some(142));
assert_eq!(status, "success");
assert_eq!(
error_message.as_deref(),
Some("export 'x': keyset could not read the 'id' value")
);
assert_eq!(tuning_profile.as_deref(), Some("safe")); assert_eq!(format.as_deref(), Some("parquet"));
assert_eq!(mode.as_deref(), Some("chunked"));
assert_eq!(files_produced, 7);
assert_eq!(bytes_written, 4096);
assert_eq!(retries, 2);
assert_eq!(validated, Some(true));
assert_eq!(schema_changed, Some(false));
assert_eq!(files_committed, 6);
assert_eq!(reconciled, Some(true));
assert_eq!(source_count, Some(49_999));
assert_eq!(quality_passed, Some(true));
assert_eq!(pg_temp_bytes_delta, Some(1_048_576));
assert_eq!(batch_size, 32_000);
assert_eq!(batch_size_memory_mb, Some(256));
assert_eq!(skip_reason.as_deref(), Some("manual"));
assert_eq!(schema_fingerprint.as_deref(), Some("fp-abc"));
assert_eq!(chunk_size, Some(100_000));
assert_eq!(parallel, Some(4));
assert_eq!(source_type.as_deref(), Some("postgres"));
assert_eq!(destination_type.as_deref(), Some("local"));
assert_eq!(rivet_version.as_deref(), Some(env!("CARGO_PKG_VERSION")));
assert_eq!(longest_chunk_ms, Some(640));
assert_eq!(longest_chunk_ms, summary.journal.longest_chunk_ms());
assert_eq!(chunk_key.as_deref(), Some("id"));
assert_eq!(
error_class.as_deref(),
Some("keyset_unreadable_key"),
"error_class is DERIVED from error_message, not hardcoded"
);
assert_eq!(cursor_min.as_deref(), Some("1"));
assert_eq!(cursor_max.as_deref(), Some("18446744073709551615"));
let kd = key_descriptor_json.expect("chunked strategy carries a key descriptor");
assert!(kd.contains("\"strategy\":\"chunked\""), "{kd}");
assert!(kd.contains("\"key\":\"id\""), "{kd}");
assert!(kd.contains("\"db_type\":\"bigint unsigned\""), "{kd}");
assert!(kd.contains("\"unsigned\":true"), "{kd}");
assert_eq!(offending_value.as_deref(), Some("9223372036854775800"));
assert!(
server_context_json
.as_deref()
.is_some_and(|s| s.contains("max_execution_time_ms")),
"server_context flows from the summary: {server_context_json:?}"
);
}
#[test]
fn build_metric_row_non_chunked_has_no_chunk_dims() {
let mut plan = chunked_plan_with_quality(None);
plan.strategy = ExtractionStrategy::Snapshot;
let summary = RunSummary::stub_for_testing("run-snap", "orders");
let row = build_metric_row(&summary, &plan, "balanced");
assert!(row.chunk_size.is_none(), "snapshot has no chunk_size");
assert!(row.parallel.is_none(), "snapshot has no parallel");
assert_eq!(row.source_type.as_deref(), Some("postgres"));
assert_eq!(row.destination_type.as_deref(), Some("local"));
}
#[test]
fn classify_error_message_maps_the_field_run_failure_classes() {
assert_eq!(
classify_error_message(
"export 'aa_import_advcake': keyset could not read the 'id' value from the last row of page 0"
),
Some("keyset_unreadable_key")
);
assert_eq!(
classify_error_message(
"MySqlError { ERROR 3024 (HY000): maximum statement execution time exceeded }"
),
Some("statement_timeout")
);
assert_eq!(
classify_error_message(
"export 'aa_payouts_version': parallel checkpoint worker errors:\nchunk 3: MySqlError { ERROR 3024 (HY000): maximum statement execution time exceeded }"
),
Some("parallel_checkpoint"),
"the 3024-embedding wrapper must not be mis-bucketed as a bare statement_timeout"
);
assert_eq!(
classify_error_message(
"MySqlError { ERROR 1146 (42S02): Table 'rivet.ext_x' doesn't exist }"
),
Some("relation_not_found")
);
assert_eq!(
classify_error_message("Lock wait timeout exceeded; try restarting transaction"),
Some("lock_timeout"),
"a lock-wait embeds 'timeout' — must not fall through to statement_timeout"
);
assert_eq!(
classify_error_message("Deadlock found when trying to get lock"),
Some("deadlock")
);
assert_eq!(
classify_error_message("Connection reset by peer"),
Some("connection")
);
assert_eq!(
classify_error_message("SELECT command denied to user 'rivet'@'%' for table 't'"),
Some("privilege")
);
assert_eq!(
classify_error_message("No space left on device"),
Some("disk_full")
);
assert_eq!(
classify_error_message("some entirely novel failure with no known signature"),
None
);
}
#[test]
fn harm_deltas_subtracts_matched_counters() {
let before = vec![
("pg_tup_returned".to_string(), 100),
("pg_blks_read".to_string(), 5),
];
let after = vec![
("pg_tup_returned".to_string(), 150),
("pg_blks_read".to_string(), 9),
];
let mut got = harm_deltas(&before, &after);
got.sort();
assert_eq!(
got,
vec![
("pg_blks_read".to_string(), 4),
("pg_tup_returned".to_string(), 50)
]
);
}
#[test]
fn harm_deltas_floors_counter_reset_at_zero() {
let before = vec![("pg_tup_returned".to_string(), 1_000)];
let after = vec![("pg_tup_returned".to_string(), 40)];
assert_eq!(
harm_deltas(&before, &after),
vec![("pg_tup_returned".to_string(), 0)]
);
}
#[test]
fn harm_deltas_intersects_counter_names() {
let before = vec![("shared".to_string(), 10), ("only_before".to_string(), 1)];
let after = vec![("shared".to_string(), 25), ("only_after".to_string(), 7)];
assert_eq!(
harm_deltas(&before, &after),
vec![("shared".to_string(), 15)]
);
}
}