Skip to content

Commit 3d6f4f6

Browse files
authored
fix(replica): tolerate per-statement errors in schema migration (#39)
2 parents 656a62f + 071fe32 commit 3d6f4f6

2 files changed

Lines changed: 99 additions & 14 deletions

File tree

src/controllers/replica.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,10 @@ pub async fn reconcile(replica: Arc<PostgresPhysicalReplica>, ctx: Arc<Context>)
298298
.status
299299
.as_ref()
300300
.and_then(|s| s.schema_migration_phase.as_deref());
301-
matches!(phase, None | Some("complete"))
301+
// "partial" counts as complete for sweep purposes: the
302+
// migration Job ran and we accepted the result; we no longer
303+
// depend on the previous restore being around.
304+
matches!(phase, None | Some("complete") | Some("partial"))
302305
} else {
303306
true
304307
};
@@ -1172,15 +1175,50 @@ async fn reconcile_schema_migration(
11721175
return Ok(false);
11731176
}
11741177
JobStatus::Succeeded => {
1175-
info!(replica = %replica_name, "migration Job succeeded");
1178+
// The migration script reports a partial-success callback body
1179+
// when psql exited cleanly but some individual statements
1180+
// failed (typical when dbt views reference renamed/dropped
1181+
// upstream columns). We treat it as completion either way —
1182+
// the replica must come up — but surface partials as a
1183+
// Warning event so operators can find them.
1184+
let callback = ctx.schema_migration_results.take(namespace, &replica_name);
1185+
let is_partial = callback
1186+
.as_deref()
1187+
.is_some_and(|b| b.starts_with("partial"));
1188+
1189+
if is_partial {
1190+
warn!(
1191+
replica = %replica_name,
1192+
result = ?callback,
1193+
"migration Job succeeded with statement errors; some persistent_schemas objects may need regenerating"
1194+
);
1195+
if let Err(e) = ctx
1196+
.recorder
1197+
.publish(
1198+
&Event {
1199+
type_: EventType::Warning,
1200+
reason: "SchemaMigrationPartial".into(),
1201+
note: callback.clone(),
1202+
action: "Restore".into(),
1203+
secondary: Some(new_restore.object_ref(&())),
1204+
},
1205+
&replica.object_ref(&()),
1206+
)
1207+
.await
1208+
{
1209+
warn!(replica = %replica_name, error = %e, "failed to publish SchemaMigrationPartial event");
1210+
}
1211+
} else {
1212+
info!(replica = %replica_name, "migration Job succeeded");
1213+
}
11761214

1177-
// Update status
1215+
let phase = if is_partial { "partial" } else { "complete" };
11781216
let replicas: Api<PostgresPhysicalReplica> =
11791217
Api::namespaced(client.clone(), namespace);
11801218
let patch = serde_json::json!({
11811219
"status": {
11821220
"schemaMigrationJob": null,
1183-
"schemaMigrationPhase": "complete",
1221+
"schemaMigrationPhase": phase,
11841222
}
11851223
});
11861224
replicas

src/controllers/replica/schema_migration.rs

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ pub fn migration_job_name(replica_name: &str) -> String {
3232
/// SCHEMAS (comma-separated list)
3333
/// MIGRATION_CALLBACK_URL
3434
static MIGRATION_SCRIPT: &str = r#"#!/bin/bash
35-
set -o pipefail
35+
# pipefail is intentionally NOT set: psql's per-statement failures don't
36+
# propagate into the script's exit code (see ON_ERROR_STOP discussion
37+
# below), and pg_dump can fail mid-stream after producing partial output
38+
# — we still want psql to apply whatever it did receive, then exit
39+
# normally so the replica can come up.
3640
3741
# Parse comma-separated schema list
3842
IFS=',' read -ra SCHEMA_ARRAY <<< "$SCHEMAS"
@@ -58,6 +62,17 @@ for schema in "${SCHEMA_ARRAY[@]}"; do
5862
echo "Migrating schema: $schema"
5963
done
6064
65+
# Capture psql's stderr for visibility on partial failures.
66+
PSQL_STDERR=$(mktemp)
67+
68+
# ON_ERROR_STOP is deliberately NOT set: persistent_schemas like dbt
69+
# contain views derived from upstream tables, and across upstream schema
70+
# changes (renamed columns, dropped tables) some view DDL in the old
71+
# replica's schema becomes invalid against the new restore's source
72+
# tables. Failing the whole migration on the first such error blocks the
73+
# replica from coming up at all. Tolerance trades schema completeness
74+
# for replica availability — clients can regenerate the broken views
75+
# afterward, but the replica must be reachable.
6176
PGPASSWORD="$SOURCE_PASSWORD" pg_dump \
6277
-h "$SOURCE_HOST" -p 5432 -U "$SOURCE_USER" -d "$SOURCE_DB" \
6378
"${SCHEMA_ARGS[@]}" \
@@ -66,21 +81,30 @@ PGPASSWORD="$SOURCE_PASSWORD" pg_dump \
6681
--verbose \
6782
| PGPASSWORD="$TARGET_PASSWORD" psql \
6883
-h "$TARGET_HOST" -p 5432 -U "$TARGET_USER" -d "$TARGET_DB" \
69-
-v ON_ERROR_STOP=1 --quiet
84+
--quiet 2> >(tee "$PSQL_STDERR" >&2)
7085
71-
EXIT_CODE=$?
86+
PSQL_EXIT=$?
87+
PSQL_ERROR_COUNT=$(grep -c '^ERROR:' "$PSQL_STDERR" 2>/dev/null || echo 0)
88+
PSQL_ERROR_COUNT=${PSQL_ERROR_COUNT:-0}
89+
rm -f "$PSQL_STDERR"
7290
73-
if [ $EXIT_CODE -eq 0 ]; then
74-
echo ""
91+
echo ""
92+
if [ "$PSQL_EXIT" -ne 0 ]; then
93+
echo "=== psql exited non-zero ($PSQL_EXIT); proceeding so the replica can come up ===" >&2
94+
fi
95+
96+
if [ "$PSQL_ERROR_COUNT" -gt 0 ]; then
97+
echo "=== Schema migration tolerated $PSQL_ERROR_COUNT statement error(s); some objects may need regenerating ===" >&2
98+
report_result "partial: $PSQL_ERROR_COUNT statement error(s)"
99+
else
75100
echo "=== Schema migration completed successfully ==="
76101
report_result 'success'
77-
else
78-
echo ""
79-
echo "=== Schema migration failed with exit code $EXIT_CODE ===" >&2
80-
report_result "Migration failed with exit code $EXIT_CODE"
81102
fi
82103
83-
exit $EXIT_CODE
104+
# Always exit 0: any non-fatal issues are reported via the callback
105+
# above. Treating partial migrations as Job failures puts the operator
106+
# into a retry loop that never converges (the same views keep failing).
107+
exit 0
84108
"#;
85109

86110
/// Build the schema migration Job spec.
@@ -266,6 +290,29 @@ mod tests {
266290
}
267291
}
268292

293+
#[test]
294+
fn migration_script_is_tolerant_to_statement_errors() {
295+
// The migration script must NOT use `ON_ERROR_STOP=1`. Persistent
296+
// schemas (e.g. dbt) contain views derived from upstream tables;
297+
// when upstream schema migrations rename or drop those columns,
298+
// some view recreations fail. Aborting the entire migration on
299+
// the first such error blocks the replica from coming up, which
300+
// is a worse outcome than a partial migration that clients can
301+
// patch up afterwards.
302+
assert!(
303+
!MIGRATION_SCRIPT.contains("ON_ERROR_STOP=1"),
304+
"migration script must not enable ON_ERROR_STOP=1 — statement errors should be tolerated so the replica can come up"
305+
);
306+
assert!(
307+
MIGRATION_SCRIPT.contains("exit 0"),
308+
"migration script must exit 0 on completion; non-fatal errors are reported via the callback body"
309+
);
310+
assert!(
311+
MIGRATION_SCRIPT.contains("partial"),
312+
"migration script must report partial migrations via the callback so the operator can surface them"
313+
);
314+
}
315+
269316
#[test]
270317
fn migration_job_name_format() {
271318
assert_eq!(

0 commit comments

Comments
 (0)