Skip to content

storage: report specific errors for incompatible Postgres schema changes - #38525

Merged
peterdukelarsen merged 7 commits into
mainfrom
plarsen/pg-schema-change-errors
Sep 9, 2026
Merged

storage: report specific errors for incompatible Postgres schema changes#38525
peterdukelarsen merged 7 commits into
mainfrom
plarsen/pg-schema-change-errors

Conversation

@peterdukelarsen

@peterdukelarsen peterdukelarsen commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Motivation

Part of SS-464 (adding support for exclude constraints) to Postgres.

Specifically, this we want to give end-users clean errors in the case where a table stalls.

Example from the PRD:

ERROR: incompatible schema change on public.users: UNIQUE constraint
"users_wallet_id_key" (wallet_id) was dropped upstream
HINT: To keep ingesting without this constraint, recreate the table in a new
versioned schema, excluding it, then swap your views to the new table:
  CREATE SCHEMA v2;
  CREATE TABLE v2.users
  FROM SOURCE pg_source (REFERENCE public.users)
  WITH (EXCLUDE CONSTRAINTS ('users_wallet_id_key'));

Description

determine_compatibility now steps through the differences like constraint changes, column nullability changes, or column detail changes and produces a tailored message for that. We wire up a hint field into SourceError for surfacing hints to the end-user.

Worth calling out that existing error messages (and future error messages + hints) will not be updated by this change because we're just plugging in to the SourceError type which basically just has opaque strings for error messages. There may be a better way to handle this encoding specific typed errors, but not a blocker because an error message that's months old and therefor stale is probably from a table that's not very important.

There are a couple of things I've learned wrt. upgrade safety worth calling out:

  1. The None case for hint will serialize without any record of the hint field in the bytes. This means the addition of the hint field will be invisible for other errors. This is important for errors like DefiniteError::InvalidUTF8 which could potentially be retracted.
  2. Speaking of retractions I've confirmed that IncompatibleSchema errors (which this change touches) aren't retracted. This should leave the output collections in a consistent end state.

Verification

Fleshed out testing here to validate hints are generated as desired

🤖 Generated with Claude Code

@peterdukelarsen
peterdukelarsen force-pushed the plarsen/pg-schema-change-errors branch from c4a1018 to e1e22fa Compare August 27, 2026 17:48
@peterdukelarsen
peterdukelarsen force-pushed the plarsen/pg-schema-change-errors branch 2 times, most recently from 3a0a5cb to df3811a Compare August 27, 2026 18:57
@peterdukelarsen
peterdukelarsen force-pushed the plarsen/pg-schema-change-errors branch 2 times, most recently from ed8466f to 118135d Compare September 8, 2026 15:48
@linear-code

linear-code Bot commented Sep 8, 2026

Copy link
Copy Markdown

SS-464

Base automatically changed from plarsen/exclude-constraints-option to main September 8, 2026 15:54
PostgresTableDesc::determine_compatibility computed exactly which facet
of the schema diverged but reported only "source table {name} with oid
{oid} has been altered", leaving users to guess what happened and how to
recover.

Diff the schemas instead and report the first mismatch specifically: a
dropped or altered PRIMARY KEY/UNIQUE constraint names the constraint
and its columns, and each message carries recovery guidance (recreate
the table in a new versioned schema and swap views, using EXCLUDE
CONSTRAINTS / EXCLUDE ALL CONSTRAINTS / EXCLUDE COLUMNS / TEXT COLUMNS
as the pre-drop tool where applicable). Dropped or renamed columns,
type changes, position changes, DROP NOT NULL, and table renames each
get their own message. The error text is written into the errs shard
and becomes the permanent user-visible error for the stalled table, so
it must stand on its own.

Test assertions on the old error text are updated; unit tests cover the
new diff logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yzDA9ywnCLXweNCzqBm12
@peterdukelarsen
peterdukelarsen force-pushed the plarsen/pg-schema-change-errors branch from 118135d to 87800d3 Compare September 8, 2026 15:54
Comment thread test/pg-cdc-old-syntax/alter-table-after-source.td Outdated
Comment thread src/postgres-util/src/desc.rs Outdated
Comment thread src/postgres-util/src/desc.rs Outdated
Comment thread src/postgres-util/src/desc.rs Outdated
Comment thread src/postgres-util/src/desc.rs Outdated
Comment thread src/postgres-util/src/desc.rs Outdated
Comment thread src/postgres-util/Cargo.toml Outdated
Replace the anyhow string from PostgresTableDesc::determine_compatibility
with SchemaChangeError, whose Display is the diagnosis (incompatible
schema change on <table>: <what changed>) and whose hint holds the
recovery steps, including the CREATE SCHEMA / CREATE TABLE .. FROM SOURCE
statements to run. SourceError gains an optional hint (a new proto field
in the errs shard encoding) so the hint survives persistence; the adapter
surfaces it as the HINT of the SQL error, and per-table source statuses
prefer it over the generic retraction hint. Renamed constraints are told
apart from dropped and recreated ones, and the hint names the constraint
as it now exists upstream. Testdrive asserts the message and hint for
every schema change case. The unit tests are dropped in favor of that
coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/postgres-util/src/desc.rs Outdated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/postgres-util/src/desc.rs Outdated
Comment thread doc/user/content/ingest-data/patterns/upstream-schema-changes.md Outdated
Comment thread src/postgres-util/src/desc.rs Outdated
Move SchemaChangeError and its variants into their own module, include the
table oid in the diagnosis, and only attach recovery hints to the cases the
EXCLUDE CONSTRAINTS work is about: a dropped, renamed, or recreated
PRIMARY KEY/UNIQUE constraint and a dropped NOT NULL constraint. Column
drops, type changes, position changes, and table renames keep their
specific message but carry no hint.

Testdrive asserts the exact hint text for a dropped UNIQUE constraint via
the table's status details.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@peterdukelarsen
peterdukelarsen marked this pull request as ready for review September 8, 2026 22:42
@peterdukelarsen
peterdukelarsen requested review from a team as code owners September 8, 2026 22:42
@peterdukelarsen
peterdukelarsen requested a review from a team as a code owner September 8, 2026 22:42
@def-

def- commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Recovery hint recommends EXCLUDE [ALL] CONSTRAINTS, which is rejected unless a default-off feature flag is set

src/postgres-util/src/schema_change.rs:106

The hint emitted for a dropped NOT NULL constraint offers WITH (EXCLUDE ALL CONSTRAINTS) as its only suggested statement, and the dropped/renamed-key hint offers WITH (EXCLUDE CONSTRAINTS ('...')). Both options are gated behind enable_exclude_constraints_option, which defaults to false everywhere except mzcompose, so a user who follows the hint while their table is stalled gets a "not supported" planning error instead of a recovery path.

Details

enable_exclude_constraints_option is declared default: false at src/sql/src/session/vars/definitions.rs:2269, and the only place in the tree that turns it on is misc/python/materialize/mzcompose/__init__.py:105. Purification calls scx.require_feature_flag(&ENABLE_EXCLUDE_CONSTRAINTS_OPTION)? at src/sql/src/pure.rs:1824 for both spellings, so CREATE TABLE ... WITH (EXCLUDE ALL CONSTRAINTS) fails to plan in a default-configured environment. The option is also not documented under doc/user/.

The NotNullDropped case is the worst of the two, because recreate(Some("EXCLUDE ALL CONSTRAINTS")) at line 106 produces the entire hint. There is no fallback sentence, and the plain recreate that the hint's own prose describes would actually work today: after the upstream DROP NOT NULL, a fresh CREATE TABLE v2.t FROM SOURCE <source> (REFERENCE ns.t); records nullable = true and ingests normally. So the hint substitutes a statement that fails for one that would have succeeded. For KeyDropped/KeyAltered (line 100) the first half of the hint is still runnable; only the trailing "create the table with WITH (EXCLUDE CONSTRAINTS (...))" sentence is affected.

Two things make this hard to notice: the hint is persisted into the source's error output and into mz_source_status_history.details, so text emitted while the flag is off outlives the moment it was produced; and the new testdrive assertions (test/pg-cdc/alter-table-after-source-1.td:197, :230, :291) only match the hint string, never execute the SQL it recommends, and run under mzcompose where the flag is forced on.

Suggested fix: keep the EXCLUDE ... guidance out of the hint until the flag defaults on, or phrase it as conditional. Concretely, for the NotNullDropped arm, emit recreate(None) (which works today) and, if the extra guidance is worth keeping, append it as an optional hardening step rather than as the statement to run:

SchemaChange::NotNullDropped { .. } => Some(recreate(None)),

Worth noting separately: EXCLUDE CONSTRAINTS ('x') is validated against the current upstream constraint set (src/sql/src/pure/postgres.rs:466, PgSourcePurificationError::ConstraintsNotFound), so once the constraint is gone upstream that clause can never be used. The "before the upstream drop" qualifier in the hint is doing real work and should stay prominent; for KeyAltered (a rename) the named constraint is already gone under that name by the time the user reads the hint.

2. LOW -- Hint interpolates upstream schema and table names into SQL without identifier quoting

src/postgres-util/src/schema_change.rs:90

recreate splices self.namespace and self.name straight into CREATE TABLE v2.{} and FROM SOURCE <source> (REFERENCE {}.{}). Postgres identifiers that are not all-lowercase or that contain spaces or quotes are common in ORM-generated schemas, and for those the emitted statement is either invalid or silently wrong.

Details

For an upstream table created as CREATE TABLE "Orders" (...), the hint renders FROM SOURCE <source> (REFERENCE public.Orders). The parser folds the unquoted Orders ident to orders, which does not match the upstream Orders, so the suggested statement fails with a reference-not-found error rather than recovering the table. The constraint name is handled correctly (line 104 escapes it for a string literal, and EXCLUDE CONSTRAINTS matching is case-sensitive), so the gap is only in the two identifier positions. Rendering them with the same quoting used elsewhere for unresolved item names would close it.

message ProtoSourceError {
reserved 1;
ProtoSourceErrorDetails error = 2;
optional string hint = 3;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine because the errors are terminal and we never try to retract a schema change error. Otherwise, we would leave errors that do no sum to 0 behind, which would be a violation of contract for data in persist. Please ensure that what I'm assuming is true!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antiguru I've confirmed the errors I'm changing the message of and adding hints to are not retracted (only plus ones no minus ones).

@peterdukelarsen

peterdukelarsen commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

LLM feedback is somewhat valid, but rejected for this PR. I'll handle it by flipping the flag alongside the tests PR to enable it by default (don't want to enable by default until tests are merged). I'll wait to merge this and merge both PRs at the same time.

peterdukelarsen and others added 2 commits September 9, 2026 10:46
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@patrickwwbutler patrickwwbutler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stamped because none of my nits should be blocking, but a few suggestions for name improvements/error message nit

Comment thread src/postgres-util/src/desc.rs Outdated
// change this column's behavior.
// - self and other are both not nullable
&& (self.nullable || self.nullable == other.nullable)
fn is_compatible(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth an updated name, esp given that compatibility is already used to refer to compatibility between schemas above, and the actual usage of the function is different now, as it's no longer a boolean function:

Suggested change
fn is_compatible(
fn get_incompatible_schema_changes(

this is a bit verbose, so feel free to change, but the idea is to make sure the name reflects what it's doing. a function name is_something seems like it should return a boolean

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good

Comment thread src/postgres-util/src/desc.rs Outdated
Ok(())
}

fn schema_change(&self, change: SchemaChange) -> SchemaChangeError {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't love this name, not really clear that it's constructing an Error from an existing SchemaChange. I'd almost say that it doesn't really belong in this struct, and should be an additional constructor (but rusty) in the SchemaChangeError struct

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the trick with this one is that we're accessing the internal namespace, name and oid of this object. It would be weird to me for the SchemaChangeError struct to be aware of this PostgresTableDesc type. As a compromise let's go with fn build_schema_change_error.

}

impl SchemaChangeError {
/// The recovery steps for a dropped constraint, including the statements

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// The recovery steps for a dropped constraint, including the statements
pub fn new(table_desc: &PostgresTableDesc, change: SchemaChange) -> Self {
Self {
table_desc.namespace.clone(),
table_desc.name.clone(),
table_desc.oid,
change,
}
}
/// The recovery steps for a dropped constraint, including the statements

a possible replacement for the schema_change func that I didn't like

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/postgres-util/src/desc.rs Outdated
columns: other_cols,
keys: other_keys,
} = other;
if self.oid != other.oid || self.namespace != other.namespace || self.name != other.name {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so technically a mismatched oid, but matching namespace/name would indicate that the table was dropped/recreated (I think?), so the error message could be slightly misleading? Not sure it's that important, this is still an improvement over the baseline but I figured I'd mention

@peterdukelarsen peterdukelarsen Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I'm curious what case an OID could change and we'd still be comparing the table here? I think in practice it should just show up upstream with a TableDropped error. Let's plug in a warn that it's unexpected to see a mismatched oid and return a new TableDropped option for that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@peterdukelarsen
peterdukelarsen merged commit 4c45b86 into main Sep 9, 2026
86 checks passed
@peterdukelarsen
peterdukelarsen deleted the plarsen/pg-schema-change-errors branch September 9, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants