Repository navigation
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrWZDcjDrqsHdkVGSrhuUj
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrWZDcjDrqsHdkVGSrhuUj
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrWZDcjDrqsHdkVGSrhuUj
frankmcsherry
left a comment
There was a problem hiding this comment.
Some thoughts. Like the idea generally, but would like more eyes on it!
|
|
||
| ## Success Criteria | ||
|
|
||
| * A user can state the full schema of a materialized view: column names, types, nullability, and keys. |
There was a problem hiding this comment.
Feels like a moment to potentially put nullability and keys in a second tier of "type", as long as these are established through semantic rather than syntactic analysis. Don't have to, but if we lock them in there's the problem of the optimizer "regressing" and failing to uphold those properties, or at least failing to infer them completely, leaving us confused about whether there is a violation or not (e.g. fix to query plan, or miss in query analysis).
There was a problem hiding this comment.
Added this framing in 3b7b80f: names and scalar types are structural (the query is cast to them), nullability and keys are a second tier of semantic properties. To avoid the ambiguity you describe, analysis never decides after creation whether a declared property holds. NOT NULL is checked against the data at runtime, and keys are the user's assertion, not compared against later optimizers' analysis.
Leaving the second tier inferred entirely is now an alternative in the doc. The catch is that nullability and keys are part of the RelationDesc persist stores as the shard schema, so leaving them inferred keeps the drift this sets out to fix, unless we also separate them from the persist schema identity.
Generated by Claude Code
| This mirrors the existing upsert sink `KEY` validation. | ||
|
|
||
| The check runs when the statement is first optimized, for `CREATE` and `EXPLAIN CREATE` alike, but not when `create_sql` is re-planned on boot or by `EXPLAIN REPLAN`. | ||
| This relies on MIR key inference being correct in the version that runs the proof: a key it proved once is a property of the query and its inputs' schemas, not of the optimizer version. |
There was a problem hiding this comment.
This statement seems false, in that it certainly is a property of the optimizer version. I agree with the text "a key it proved once is taken to be a property of the query and its inputs' schemas", but worth being clear. I don't believe we are as certain of the key inference analysis as the text hopes.
There was a problem hiding this comment.
Agreed, reworded to "taken to be a property of the query and its inputs' schemas", and the doc now says the inference is an analysis of the optimizer version that runs it and can be wrong (3b7b80f).
Generated by Claude Code
| A later optimizer that fails to re-derive the key does not make the key false. | ||
| The inputs' keys are themselves either declared (and proven), inferred (and sound), or source keys we already trust today. | ||
| The flip side is that a key-inference bug at creation time is frozen into `create_sql`, where today a fixed optimizer would stop inferring the bad key on the next upgrade. | ||
| Re-running the proof on boot as a soft check that only logs is a cheap way to detect that. |
There was a problem hiding this comment.
The language in the paragraph above using concepts like "proof" and "sound" are .. optimistic at best. I recommend we aim for language that admits the possibility of error, and frames it that way. For example, this matches the "unenforced" language SQL uses elsewhere. We could choose to filter nulls into a DLQ for example, making it "enforced" but .. still a worry. The primary keys are unenforced and unvalidated, both.
There was a problem hiding this comment.
Agreed. Keys are now described as unenforced and unvalidated against the data. The creation-time check is a guard against obvious mistakes, not a proof, and the soft re-check on boot is gone, since a miss there is exactly the violation-or-analysis-gap ambiguity. NOT NULL stays enforced (an error today), and erroring vs. a dead-letter queue is an open question now (3b7b80f).
Generated by Claude Code
Describe nullability and keys as a second tier of the declared schema: `NOT NULL` is enforced at runtime, keys are unenforced and unvalidated, and the creation-time key check is a guard against mistakes rather than a proof. Add keeping nullability and keys inferred as an alternative, and dead-letter routing of NOT NULL violations as an open question. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key confirmed by one version's analysis can stop holding in a later version without any optimizer bug, through changed function or query semantics or changed input keys. Freeze only names, types and the enforced nullability. Treat declared keys as expected keys that every plan checks against the running version's inferred keys: creation fails on an unconfirmed key, and a re-plan drops it and records it in mz_internal.mz_materialized_view_unconfirmed_keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Updated the design in 2e957bb after looking harder at whether a key confirmed in version i still holds in version i'. It does not, even when key inference is correct in both versions. Inference is relative to the query's semantics and the inputs' keys in the version that runs it, and both change across upgrades: uniqueness-preserving functions ( The design now freezes only names, scalar types and nullability. Declared keys are expected keys that every plan checks against the running version's inferred keys. Creation still fails on a key the analysis does not confirm. On boot or Generated by Claude Code |
A re-plan error at catalog load panics, so an upsert sink keyed on a key that a new version drops would crash-loop boot. Treat such a key as NOT ENFORCED when re-planning persisted SQL, and keep the error for new CREATE SINK statements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mgree
left a comment
There was a problem hiding this comment.
The core plan makes sense to me. Would be good to think through how this touches durable LIR, but we have a moment to figure it out.
| A column that cannot be assignment-cast is a planning error naming the column and both types. | ||
| A cast that fails at runtime (e.g. overflow on `int8` to `int2`) produces an error in the materialized view, like any other evaluation error. |
There was a problem hiding this comment.
If we're failing on bad keys, we might want to fail (or at least warn) on fallible casts. If I write a query that can produce int8s and I cast to int2... it seems very likely that I will have a bad time (or we've somehow overwidened a computation).
There was a problem hiding this comment.
Agreed. The design now uses implicit casts only (e10f6f8), so int8 to int2, float8 to numeric, and numeric to numeric(p, s) have to be written in the query. Two implicit casts can still fail or round, text to varchar(n)/char(n) (length) and int8/numeric to float8, and the doc says so.
Posted by Claude Code.
| Purification copies the target's column definitions into the replacement's statement, so inheriting is shorthand for writing them out: the query is cast to them with assignment casts, `NOT NULL` becomes an assertion, keys must be provable, and the feature flag applies. | ||
| The replacement's `create_sql` then states its schema. | ||
| Re-planning it on boot does not depend on the target, custom types in the definitions are recorded as dependencies, and `MaterializedView::apply_replacement` keeps taking the column list from the replacement's statement. | ||
|
|
||
| Requiring the query's types to match the target's exactly, without casts, would keep accidental casts out of a drop-in replacement. | ||
| It needs to know at planning time whether the definitions were inherited, which the stored statement cannot say, and checking exact types when re-planning would make an upgrade that changes a query's inferred type fail on boot. |
There was a problem hiding this comment.
This seems like we can end up with some weird round-trip casts: the first schema definition says int2 (which induces a cast from int8), but then the user realizes that they actually need int8... which we'll cast up from int2. Is that what we want?
(At a certain point, you need to just blue/green the thing, but I don't know exactly where that point is.)
There was a problem hiding this comment.
With implicit-only casts, a replacement whose query produces int8 for an inherited int2 column now fails to plan instead of narrowing. Going from int2 to int8 changes the declared type, which is a schema change. Replacements must match the target schema, so that case is out of scope and is where blue/green applies.
Posted by Claude Code.
| A replacement *with* column definitions for a target *without* one is allowed if the declared desc equals the target's current desc. | ||
| This is the migration path from an implicit to a declared schema for an existing materialized view. |
There was a problem hiding this comment.
Why make them name the schema at all, if there's only one thing they can name?
There was a problem hiding this comment.
The explicit definitions are the opt-in. If purification filled them in from an implicit target, every replacement of an implicit MV without a column list would silently get a declared schema. That would turn inferred NOT NULL into assertions and inferred keys into expected keys, which is the Phase 2 decision the doc defers. To avoid retyping, EXPLAIN ... WITH (schema) on the target prints the definitions to paste. Added both points to the doc.
Posted by Claude Code.
| This is the single point that makes the schema optimizer-independent on boot. | ||
| 5. **Sequencer** (`src/adapter/src/coord/sequencer/inner/create_materialized_view.rs`). | ||
| Run the key proof on the keys of the local MIR plan's type, in the optimize stage so that `EXPLAIN CREATE` rejects what `CREATE` rejects. | ||
| Reject a replacement without a declared schema for a target with one. |
There was a problem hiding this comment.
This isn't the behavior described above... don't replacements without a schema inherit the schema?
My understanding of that section was:
- if new has no schema (column list or no), inherit any pre-existing schema
- if new has a schema, either (a) it must be castable from the pre-existing schema or (b) there must not be a pre-existing schema.
Or am I misunderstanding? (I don't know exactly what "purification" is...)
There was a problem hiding this comment.
You read the replacements section correctly. The sequencer step was stale, and I've fixed it. Purification is the step before planning that rewrites the statement using catalog state. It copies the target's column definitions into a replacement that has no column list, so the stored create_sql spells out the schema. The sequencer then validates the result against the target. Its rejection of a replacement without a schema for a target with one is only a backstop for statements that skipped purification. One correction to (2): for an implicit target, the declared schema must equal the target's current desc. Castable is not enough.
Posted by Claude Code.
| Checking uniqueness in the dataflow means maintaining a count per key, i.e. an arrangement the size of the materialized view. | ||
| It removes the incompleteness of the key proof but adds a large and surprising cost. | ||
| It can be added later as an opt-in for keys the optimizer cannot prove. |
There was a problem hiding this comment.
If we want this kind of enforcement, well... well, we can expect that there will likely be an index on the fields that are keys, so perhaps we can check assertions when building those arrangements (rather than building a custom arrangement just for the assertion)?
There was a problem hiding this comment.
Good idea, added under runtime-enforced keys. The catch is that an index runs in its own dataflow, possibly on another cluster. Dependents that read the MV from persist rely on the key whether or not that index exists. So an index-based check could report a violation but not prevent wrong results downstream.
Posted by Claude Code.
| ## Open questions | ||
|
|
||
| * Assignment casts, or implicit casts only? | ||
| Implicit-only is stricter and avoids silent truncation-style surprises, but forces users to write casts in the query for common cases such as `numeric` scale. |
There was a problem hiding this comment.
I might be in favor of implicit-only---if we're introducing casts with lots of computational content/failure modes, then the user should be opting in more explicitly than writing a type declaration elsewhere.
There was a problem hiding this comment.
Adopted implicit-only. Assignment casts moved to Alternatives. Switching to them later would accept strictly more statements, so we can relax this without breaking existing ones.
Posted by Claude Code.
| Implicit-only is stricter and avoids silent truncation-style surprises, but forces users to write casts in the query for common cases such as `numeric` scale. | ||
| The prototype uses assignment casts. | ||
| * Should replacement schema inheritance reject casts? | ||
| The prototype allows them, see [Replacements](#replacements). |
There was a problem hiding this comment.
Again, my gut says: allow implicit casts, everything else should be made explicit.
There was a problem hiding this comment.
Done, see the reply above. The remaining open question is narrower: whether inherited schemas should also reject widening casts.
Posted by Claude Code.
| The assertion is a per-row datum scan in the sink, and skipping it would make performance, though not correctness, depend on the optimizer. | ||
| The prototype never skips it. |
There was a problem hiding this comment.
Performance already depends on the optimizer? We should skip it if we can.
There was a problem hiding this comment.
I'd keep the assertion. Skipping it makes correctness depend on nullability inference, not just performance. A null that reaches a column persist thinks is non-nullable panics the encoder (DatumEncoder::push) instead of raising a MustNotBeNull error. That is exactly the risk implicit MVs carry today. The check only looks at the asserted datums of rows the sink encodes anyway, though I have not measured its cost. Moved this to Alternatives with that reasoning.
Posted by Claude Code.
| The assertion is a per-row datum scan in the sink, and skipping it would make performance, though not correctness, depend on the optimizer. | ||
| The prototype never skips it. | ||
| * Should `mz_materialized_views` expose whether the schema is declared? | ||
| The prototype does not. |
There was a problem hiding this comment.
Reflecting this somehow might be good for tracking---ours (how many people are using this?) and users (how far are we converting our MVs to schemas).
There was a problem hiding this comment.
Agreed, and the doc now records both uses. The proposal is an mz_internal relation while the feature is behind the flag, and a column on mz_materialized_views once it is stable. The prototype doesn't do this yet.
Posted by Claude Code.
Cast query output to declared types with implicit casts only, so narrowing casts are written in the query. Note how declared schemas relate to pinned LIR, how index arrangements could check keys, why the NOT NULL assertion is never skipped, and fix the stale sequencer step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@mgree thanks. e10f6f8 addresses the review:
Posted by Claude Code. |
|
On handling a mismatch after an upgrade, where the new optimizer derives a different type than the declared or stored schema: rendering has to continue and objects can't be dropped. A notice is easy to miss. Pinning LIR avoids the question, and it would also keep correctness fixes such as #39779 from reaching existing objects. An alternative is to freeze the interface, re-optimize the implementation, and adapt the new plan to the stored desc at the boundary. Then no mismatch fails at boot.
One gap: the Kafka sink ignores its input's error collection ( The same boundary applies to sinks on undeclared MVs and views, which this design doesn't cover. SQL-805 tracks that. Posted by Claude Code. |
|
A correction to my comment above, after a Slack discussion. Frank pointed out that keys and nullability are semantic properties: they are beliefs the optimizer holds and that can change between versions. Column types are syntactic and can be a hard contract. My comment proposed freezing inferred nullability into the stored schema, which turns a belief into a contract. That is the same mistake behind the incidents.
Reframed, the stored schema holds only declared or syntactic information. Inferred keys and nullability are per-version beliefs that the optimizer uses but nothing downstream treats as a contract. Only declared contracts are enforced:
With nothing inferred written down, an upgrade has nothing to mismatch. The open part is still runtime enforcement on sinks that ignore their error collection, such as Kafka. Posted by Claude Code. |
Motivation
This design document outlines a solution to a long-standing problem where materialized view schemas drift across optimizer versions. Currently, schemas are re-derived from the query on every boot, making them dependent on optimizer behavior for nullability and key inference. This causes cascading issues through dependent views, sinks, and replacements.
This design is a prerequisite for the replacement materialized views feature and enables users to have schema contracts on materialized views.
Description
The document proposes extending
CREATE MATERIALIZED VIEWsyntax to accept optional column definitions with types, nullability, and key constraints, similar toCREATE TABLE:CREATE MATERIALIZED VIEW order_totals ( customer_id int8 NOT NULL, region text, total numeric(38, 2) NOT NULL, PRIMARY KEY (customer_id) ) IN CLUSTER c AS SELECT customer_id, any_value(region), sum(amount) FROM orders GROUP BY customer_id;Key design decisions:
RelationDesccomes directly from the column definitions, not from inference.INSERT INTO.NOT NULLcolumns are added to non-null assertions checked per row, matchingASSERT NOT NULLbehavior.PRIMARY KEYandUNIQUEconstraints are validated at creation time against keys the optimizer can infer from the query. This prevents silent correctness issues downstream.The document includes implementation sketches for parser, planner, optimizer, catalog, and sequencer changes, all within the SQL layer. No changes to the durable catalog format, persist, or compute protocol are needed.
A Phase 2 follow-up could freeze inferred schemas at creation time for all materialized views, removing the implicit schema entirely.
Verification
This is a design document only. No code changes or tests are included. The document is self-contained and ready for review and discussion before implementation begins.
https://claude.ai/code/session_01NrWZDcjDrqsHdkVGSrhuUj