Skip to content

Convert mz_columns to a builtin materialized view - #40001

Merged
SangJunBak merged 13 commits into
jun/name-from-oidfrom
jun/mz-column-migration
Oct 28, 2026
Merged

SangJunBak merged 13 commits into
jun/name-from-oidfrom
jun/mz-column-migration

Conversation

@SangJunBak

@SangJunBak SangJunBak commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

From the durable column information we recorded in #39818, we derive the column information mz_columns and convert it to a builtin mv.

See commit messages for more details

Motivation

Part of sql-494

Description

We report the column defaults from a create_sql statement, generate the mz_builtin_columns for our builtin collections since they don't exist in our catalog, then we convert mz_columns to a builtin mv using a combination of mz_builtin_columns and mz_catalog_raw.

Verification

  • Expanded the existing SLT with a more thorough test suite that asserts against a column of each type in our system.
  • Diffed this relation using the prod analytics workload replay against main and this branch.

@SangJunBak
SangJunBak changed the base branch from main to jun/name-from-oid October 19, 2026 22:12
@SangJunBak
SangJunBak added this pull request to stack #40002 October 19, 2026 22:15
@SangJunBak SangJunBak added the ci-nightly PR CI control: also trigger Nightly label Oct 19, 2026
@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch from adf85f8 to 07eab27 Compare October 19, 2026 22:19
@SangJunBak SangJunBak changed the title catalog: add ItemColumn and IndexColumn durable collections sql-494: Convert mz_columns to a builtin materialized view Oct 19, 2026
@linear-code

linear-code Bot commented Oct 19, 2026

Copy link
Copy Markdown

SQL-494

@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch from 07eab27 to 1bb5e56 Compare October 19, 2026 22:33
@SangJunBak
SangJunBak marked this pull request as ready for review October 19, 2026 22:36
@SangJunBak
SangJunBak requested review from a team as code owners October 19, 2026 22:36
@SangJunBak SangJunBak changed the title sql-494: Convert mz_columns to a builtin materialized view Convert mz_columns to a builtin materialized view Oct 19, 2026
@def-

def- commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. HIGH -- A column named $serde_json::private::Number panics every catalog MV on mz_catalog_server

src/catalog-decode/src/create_sql.rs:264

item_details now uses user-chosen column names as JSON object keys in column_defaults, and Jsonb's packer treats a map whose first key is $serde_json::private::Number as an arbitrary-precision number. With that column name, Jsonb::from_serde_json returns Err and to_jsonb's .expect("valid JSONB") (src/expr/src/scalar/func/impls/catalog.rs:26) panics inside the mz_catalog_server dataflows. Any authenticated user can trigger this with a temporary table, which needs no grants, and it crash-loops the catalog server replicas.

Details

Trigger: CREATE TEMPORARY TABLE t ("$serde_json::private::Number" int);. item_details returns {"column_defaults": {"$serde_json::private::Number": null}, ...}. The Collector in src/repr/src/adt/jsonb.rs (KeyClassifier → KeyClass::Number → NumberParser) rejects it with invalid type: null, expected a valid number. I ran the verbatim Collector/KeyClassifier code against serde_json 1.0.151 (arbitrary_precision + preserve_order) and confirmed it. A DEFAULT 5 on that column, followed by a second column, fails with invalid length 2. With a single column, the whole map is silently replaced by the number 5.

Impact: parse_catalog_create_sql runs on every Item row in mz_columns, mz_tables, mz_views, mz_materialized_views, mz_history_retention_strategies and others, all IN CLUSTER mz_catalog_server. The clusterd panic hook aborts the process, so every replica of that cluster crash-loops and catalog introspection stalls (SHOW, pg_catalog, console, dbt). Dropping the table probably does not recover it. A restarted dataflow resumes at the MV's stuck write frontier, so it re-evaluates the row inserted at the creation time. Before this change, item_details emitted only fixed keys.

Fix: do not key JSON by user identifiers. Emit column_defaults as an array in column order (create_sql column order matches the latest desc, VERSION ADDED columns included) and read it with i.column_defaults->>((c.data->'key'->>'position')::int). Separately, have to_jsonb map the error to EvalError::InvalidCatalogJson rather than .expect, so a future packing failure fails one MV instead of aborting the replica. A regression test that creates the column above and then reads mz_columns would cover both.

@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch from 6363439 to 5eaa4e4 Compare October 20, 2026 20:35
@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch from 5eaa4e4 to 48e3b69 Compare October 20, 2026 20:42
@SangJunBak
SangJunBak removed this pull request from stack #40002 October 20, 2026 20:51
@SangJunBak
SangJunBak added this pull request to stack #40040 October 20, 2026 20:51
@bosconi

bosconi commented Oct 21, 2026

Copy link
Copy Markdown
Member

I've re-run three jobs from nightly 18550 on this head (889c34a), because DDL looked much slower there than at the previous head or on main:

  • testdrive with SIZE 8 1 timed out at 68 min after 62 of 148 files. It passed in 38 min at the previous head (18541) and 34 min on main (18553). Over the files both runs reached, statements took about 6x longer: CREATE TABLE averaged 0.258 s against 0.056 s, and COMMIT 0.720 s against 0.080 s.
  • SLT (2 replicas) 7 timed out at 67 min, against 27 min on main.
  • Workload Replay (1% initial data) 2: object creation on workload_ddnet.yml regressed 29.1%, against a 20% threshold.

The code change between the two heads is small, so this may be the agents, and the re-run on fresh agents should tell us: testdrive, SLT, workload replay. I'll follow up with the results in my review.

@aljoscha aljoscha 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.

Did a first pass, looks good, but would be good to have @ggevay take a look.

I did have some inline questions. Also, that change from a string "NULL" to SQL NULL, have to think about if we want to do that. It feels correct, but it's a change, so ... 🤷‍♂️

def validate(self) -> Testdrive:
return Testdrive(dedent("""
# On releases before 26.48 mz_columns is a table the adapter packs,
# which reports the string NULL for a table column without a default.

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.

weird that we had this ...

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.

Yeah I thought so too! Re:

Also, that change from a string "NULL" to SQL NULL, have to think about if we want to do that. It feels correct, but it's a change, so ...

Gabor suggested changing it to a SQL NULL here https://linear.app/materializeinc/issue/SQL-494/convert-mz-columns-and-mz-index-columns-to-catalog-views#comment-839801c2, mainly to keep pg parity

..
} => {
let entry = self.get_entry(custom_id);
// NOTE(benesch): the `mz_columns.type text` field is

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.

did we change anything about this issue here?

// A user relation's columns are the `ItemColumn` rows its catalog item
// records.
//
// NOTE(benesch): the `mz_columns.type text` field is

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.

ah okay, that resolves my earlier question ... 😅

// we replace `mz_type_name` with a join on `mz_builtin_types`.
let sql = format!(
"
IN CLUSTER mz_catalog_server

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.

now I'm out of my depth, might be good to have @ggevay take a look at the SQL at least, to make sure it's good, I'm still reviewing the rest of the PR

query I
SELECT count(DISTINCT(1)) FROM pg_attrdef
----
1

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.

what's up with these changes?

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.

Ah sorry, should've been more clear in the commit message. This is a result of replacing the string 'NULL' with a SQL NULL. The motivation was brought up by Gabor https://linear.app/materializeinc/issue/SQL-494/convert-mz-columns-and-mz-index-columns-to-catalog-views#comment-839801c2

order by
attr.attnum;
----
c_int4 NULL false true false true 0 base int4 false

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.

what's up with these change?

@SangJunBak SangJunBak Oct 21, 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.

Same motivation #40001 (comment). Here the "hasDefault" column turns to false which makes sense since if you look at the table definition, they have no column defaults.

@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch 2 times, most recently from 3babd8d to 7a28b4a Compare October 23, 2026 11:43
@def-

def- commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. HIGH -- One wide temporary view OOM-loops mz_catalog_server, because mz_columns copies each relation's whole ItemColumns row onto every one of its columns

src/catalog/src/builtin/mz_catalog.rs:673

user_columns unnests c.data->'value'->'columns' while each output row still carries the full data JSON. The kind = 'ItemColumns' filter only runs after the unnest, so an N-column relation produces N rows that each hold an O(N) payload. Any user without privileges can run CREATE TEMPORARY VIEW with 2000 columns, and the mz_catalog_server replica is OOM-killed on every restart. Dropping the view does not stop it, and catalog introspection (SHOW, pg_catalog, console, dbt) stalls for the whole environment.

Details

In the plan in catalog_server_explain.slt, l0 is Table Function jsonb_array_elements(((#0{data} -> "value") -> "columns")) with Project: #0..=#5, so #0{data} is kept. The kind filter sits in the consumers of l0 because l1 (the custom-type join) reads l0 without it. l1's Arrange (#5{custom_type}) then keeps data resident for every custom-typed column. column_defaults is carried per column the same way (Project: #1..=#4 and both custom-type arrangements), so a table with long default expressions also costs O(N²).

Repro: local bin/environmentd at this head, where replicas are limited to 4 GiB. I connected as a new non-superuser role with no CREATE privilege and ran CREATE TEMPORARY VIEW tv AS SELECT NULL::int4 AS "c00000xxx…", … with 2000 columns and 206-character names (448 KB of SQL). Its ItemColumns row is 562 KB, so the unnest produces about 1.1 GB per copy, and l0 has three consumers. The mz_catalog_server replica was OOM-killed 3 s later and every ~10 s after that. SHOW TABLES hung while SELECT 1 worked. It kept crash-looping after DROP VIEW, and also after the temporary view's session ended, until I stopped the environment (41 OOM kills over 8 minutes in the temporary-view run). The likely reason is that each restart re-hydrates from the MV's stalled write frontier, which still includes the row's insertion. Control: the same steps on the base branch, which records the same 562 KB ItemColumns row but keeps mz_columns as a table, caused no OOM, and mz_columns returned 3923 rows. Memory grows with columns² × name length, so a larger replica only raises the number of columns needed. Legitimate wide relations pay the quadratic cost on every hydration and on every change to the item.

Fix: filter and project before unnesting, and unnest the defaults separately so no per-column row carries an O(N) value:

item_columns AS (
    SELECT
        mz_internal.parse_catalog_id(data->'key'->'id') AS id,
        data->'value'->'columns' AS columns
    FROM mz_internal.mz_catalog_raw
    WHERE data->>'kind' = 'ItemColumns'
),
defaults AS (
    SELECT i.id, d.ordinality, d.value AS "default"
    FROM items AS i,
        jsonb_array_elements_text(i.column_defaults) WITH ORDINALITY AS d(value, ordinality)
),

user_columns then reads FROM item_columns AS c JOIN items AS i ON i.id = c.id CROSS JOIN LATERAL jsonb_array_elements(c.columns) WITH ORDINALITY AS col(value, ordinality) … LEFT JOIN defaults AS d ON d.id = c.id AND d.ordinality = col.ordinality. With this change the plan drops columns right after the table function. On the same replica where the PR's user_columns SQL was OOM-killed at 4 GiB, the rewrite returned the 2000 rows in under a second. On a table, view and materialized view with list, map and record columns, it returned the same rows as the PR's SQL.

2. LOW -- mz_columns.default reports the first DEFAULT of a column, but the planner applies the last

src/catalog-decode/src/create_sql.rs:271

When a column has more than one DEFAULT, item_details takes the first non-NULL one, but plan_create_table overwrites the default with each option, so the last one wins (src/sql/src/plan/statement/ddl.rs:343). CREATE TABLE dd (a int DEFAULT 1 DEFAULT 2, b int DEFAULT 5 DEFAULT NULL) is accepted, and INSERT INTO dd DEFAULT VALUES stores a = 2 and b = NULL. mz_columns, and therefore pg_attrdef and information_schema.columns, report 1 and 5 instead. The in-memory path this PR removes reported the planner's value. Fix: take the last ColumnOption::Default and then apply is_null_literal to it.

@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch from 7a28b4a to e42ed13 Compare October 23, 2026 15:59
@SangJunBak
SangJunBak requested a review from aljoscha October 26, 2026 20:46
@SangJunBak
SangJunBak requested a review from a team as a code owner October 26, 2026 21:40
@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch 2 times, most recently from 35e5c82 to 9b17d9d Compare October 26, 2026 21:56
@SangJunBak
SangJunBak requested a review from ggevay October 27, 2026 17:06

@ggevay ggevay 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.

LGTM, minor things

Builtin::MaterializedView(&MZ_DATABASES),
Builtin::MaterializedView(&MZ_SCHEMAS),
Builtin::Table(&MZ_COLUMNS),
Builtin::MaterializedView(&MZ_COLUMNS),

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.

mz_columns is in mz_catalog, so misc/python/materialize/checks/all_checks/builtin_version_pin.py should record the conversion: "mz_columns": MzVersion.parse_mz("v26.49.0"), or the release this ends up in. It's None there today. The same goes for mz_index_columns in #39427.

Comment thread src/catalog/src/builtin/mz_catalog.rs Outdated
Comment on lines +672 to +686
user_columns AS (
SELECT
c.id,
col.value->>'name' AS name,
-- WITH ORDINALITY numbers the list from one, which is what position reports
col.ordinality::uint8 AS position,
(col.value->>'nullable')::bool AS nullable,
coalesce(custom.name, mz_internal.mz_type_name((col.value->>'type_oid')::oid), 'unknown') AS type,
d.default,
coalesce(custom.oid, (col.value->>'type_oid')::oid) AS type_oid,
(col.value->>'type_mod')::int4 AS type_mod
FROM
item_columns AS c
JOIN items AS i ON i.id = c.id
CROSS JOIN LATERAL jsonb_array_elements(c.columns) WITH ORDINALITY AS col(value, ordinality)

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.

On a synthetic catalog with 14k items and 324k columns, the mz_columns dataflow holds 165 MB of arrangements on a 1-worker mz_catalog_server. The old table's index was 12 MB, and it stays as mz_columns_ind. Most of the 165 MB is per-column state: the JSON value of every column is carried through the delta join's arrangements, and the defaults join adds more.

Extracting the scalar fields right after the unnest halves it. I ran this as a user MV over mz_catalog_raw next to an exact copy of the user branch here. The rows are identical and the size is 166 → 86 MB:

    cols AS (
        SELECT
            c.id,
            col.ordinality,
            col.value->>'name' AS name,
            (col.value->>'nullable')::bool AS nullable,
            (col.value->>'type_oid')::oid AS type_oid,
            (col.value->>'type_mod')::int4 AS type_mod,
            CASE
                WHEN jsonb_typeof(col.value->'custom_type') = 'object'
                THEN mz_internal.parse_catalog_id(col.value->'custom_type')
            END AS custom_type
        FROM
            item_columns AS c
            CROSS JOIN LATERAL jsonb_array_elements(c.columns) WITH ORDINALITY AS col(value, ordinality)
    )

The rest of user_columns then joins cols to items, custom and defaults as it does today.

parse_catalog_create_sql reports a table's DEFAULT clauses by column name. If it doesn't have a default, we provide None which gets replaced with a SQL null. 

The behavior before was a string 'NULL' for tables and a SQL NULL for any other type of relation. This is actually different from postgres, thus we correct it now.
The generated view lists the columns of every builtin relation, so that
catalog views can reference the column information of builtin items in
SQL.
We use the durable catalog and mz_builtin_columns to derive mz_columns. We also remove the builtin table code.
We add SLTs surrounding mz_columns to ensure:
- The columns report what we expect them to
- We test a column of each different type
-
The ItemColumns platform check creates relations with column and expression keys in every phase, adds columns
with ALTER TABLE, and comments on columns. It then checks what mz_columns
and mz_index_columns report and that the reported positions line up with the positions in mz_comments.
A table column without a default now reads as SQL NULL in mz_columns, so
pg_attrdef has no rows in cockroach/select_search_path.slt. This is consistent with Postgresql
For column defaults, we were originally exposing it as a JSON map keyed by the column's name. However, this could cause an error if a user had a column named after the magic internal key `$serde_json::private::Number`. The magic internal key is what turns a string into a number. For example, {"numField": {"$serde_json::private::Number": "5"}} would say that numField has a numeric value of 5. However if we had { "column_defaults": {"$serde_json::private::Number": "5", "b": "'x'", "c": null}}, then we'd get an error "invalid length 3" and cause the catalog server to go into a panic loop.

We now make `column_defaults` an array and index on position to avoid the map entirely.

Addresses the QA review on PR #40001:
#40001 (comment)
The `ItemColumns` row of a relation holds its column list, so the view
unnests it with `WITH ORDINALITY`, whose one-based numbering is the
reported position, and the column defaults are read by that position.
- By doing all the JSONB extraction in a CTE, we join on the scalars from the json rather than the JSON values, halving the memory necessary for maintenance. This is because otherwise, the `value` JSON of every column was carried through the delta join's arrangement. 
- Also records the mz_columns conversion in the builtin version pin check.
@SangJunBak
SangJunBak force-pushed the jun/mz-column-migration branch from 9b17d9d to ee15d8a Compare October 27, 2026 21:25
@SangJunBak
SangJunBak merged commit 8636905 into main Oct 28, 2026
344 of 350 checks passed
@SangJunBak
SangJunBak deleted the jun/mz-column-migration branch October 28, 2026 01:31
SangJunBak added a commit that referenced this pull request Oct 28, 2026
mz_columns.default reads as the string NULL for a table column without a
default, as it did in every release through v26.48. #40001 made it SQL
NULL, which the Terraform provider (every release through v0.11.12)
compares against its declared default of "NULL": it then replaces the
table on every apply and drops its rows (SQL-839).

Builtin tables read the string too, so the contract holds for every table
and not only for user tables.

The PostgreSQL-compatible views keep the behavior #40001 gave them by
mapping the string to no default: pg_attribute.atthasdef is false,
pg_attrdef has no row and information_schema.columns.column_default is
NULL for such a column.

Closes: SQL-839

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SangJunBak added a commit that referenced this pull request Oct 29, 2026
Before, tables whose columns' defaults were undefined we'd store their
`mz_columns` default value as the SQL string 'NULL' but would store it
as NULL for other relation objects. In #40001 , we decided to unify the
behavior and store it as NULL for tables too. However, this created a
regression in our terraform provider where it'd drop and create tables
with no defaults on their columns because of a schema mismatch. Thus we
change it back to the SQL string `NULL`.

### Motivation

Closes: [SQL-839](https://linear.app/materializeinc/issue/SQL-839)

### Verification

We still get pg compatibility by just changing the definition of the
information_schema / pg views. This is why we don't need to update any
of the SLT tests related to those views.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-nightly PR CI control: also trigger Nightly

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants