storage: report specific errors for incompatible Postgres schema changes - #38525
Conversation
c4a1018 to
e1e22fa
Compare
3a0a5cb to
df3811a
Compare
ed8466f to
118135d
Compare
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
118135d to
87800d3
Compare
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>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
QA LLM Review1. MEDIUM -- Recovery hint recommends
|
| message ProtoSourceError { | ||
| reserved 1; | ||
| ProtoSourceErrorDetails error = 2; | ||
| optional string hint = 3; |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
@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).
|
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. |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
patrickwwbutler
left a comment
There was a problem hiding this comment.
Stamped because none of my nits should be blocking, but a few suggestions for name improvements/error message nit
| // change this column's behavior. | ||
| // - self and other are both not nullable | ||
| && (self.nullable || self.nullable == other.nullable) | ||
| fn is_compatible( |
There was a problem hiding this comment.
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:
| 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
| Ok(()) | ||
| } | ||
|
|
||
| fn schema_change(&self, change: SchemaChange) -> SchemaChangeError { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
| /// 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
There was a problem hiding this comment.
| columns: other_cols, | ||
| keys: other_keys, | ||
| } = other; | ||
| if self.oid != other.oid || self.namespace != other.namespace || self.name != other.name { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>
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:
Description
determine_compatibilitynow steps through the differences like constraint changes, column nullability changes, or column detail changes and produces a tailored message for that. We wire up ahintfield intoSourceErrorfor 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
SourceErrortype 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:
Nonecase forhintwill serialize without any record of thehintfield in the bytes. This means the addition of thehintfield will be invisible for other errors. This is important for errors likeDefiniteError::InvalidUTF8which could potentially be retracted.Verification
Fleshed out testing here to validate hints are generated as desired
🤖 Generated with Claude Code