Repository navigation
sql-server: Record only the key the capture instance uses - #40131
peterdukelarsen merged 2 commits into
Conversation
Purification recorded every PRIMARY KEY and UNIQUE constraint of an upstream table as a key of the Materialize relation. SQL Server only protects the index its capture instance uses to identify rows; any other constraint can be dropped at any time, and the drop is not recorded in cdc.ddl_history, so duplicate rows reached a relation that still declared the key and the optimizer planned DISTINCT away. Read cdc.change_tables.index_name alongside the table metadata and keep only the constraint of that name. Co-Authored-By: Claude Fable 5.1 <[email protected]>
martykulma
left a comment
There was a problem hiding this comment.
Nice, thanks @peterdukelarsen! A couple minor comments, nothing blocking.
| /// table's newest one, so it is the only key the upstream table keeps for | ||
| /// as long as the source replicates from it. Any other PRIMARY KEY or |
There was a problem hiding this comment.
partially true? Not as long as the source replication from it, as long as the capture instance MZ runs against is the only one.
| $ sql-server-execute name=sql-server | ||
| EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', @source_name = 'superseded', @role_name = 'SA', @supports_net_changes = 0, @capture_instance = 'superseded_two', @index_name = 'superseded_code'; | ||
| ALTER TABLE superseded DROP CONSTRAINT superseded_pk; | ||
| INSERT INTO superseded VALUES (1, 30, 300); |
There was a problem hiding this comment.
Should this add a verification step before enabling the second capture instance to ensure that the PK drop is in fact blocked?
# While superseded_one is the only capture instance, SQL Server rejects dropping its index ...
$ sql-server-set-from-sql name=sql-server var=drop-sole-pk-outcome
BEGIN TRY
ALTER TABLE superseded DROP CONSTRAINT superseded_pk;
SELECT 'dropped';
END TRY
BEGIN CATCH
SELECT CAST(ERROR_NUMBER() AS VARCHAR(10));
END CATCH
> SELECT '${drop-sole-pk-outcome}';
22979
# ... but a newer capture instance with a different index lifts that protection.
...
Co-Authored-By: Claude Fable 5.1 <[email protected]>
QA LLM Review1. MEDIUM -- A nonclustered CDC index can still be disabled with
|
…ey (#40201) Closes: [SS-468](https://linear.app/materializeinc/issue/SS-468) Documents the behavior from #40131: a SQL Server table records at most one key, the `PRIMARY KEY` or `UNIQUE` constraint behind the capture instance's index. Replaces the "Dropping a `UNIQUE` constraint puts the table in an error state" row, which did not match the replication path even before that change. Also adds the v26.49.0 release notes for the change: an Improvements bullet, and an Advisory that re-creating a SQL Server table (blue/green deployment, source versioning) can change the plans of dependent objects, for example a `DISTINCT` that used to be removed is now kept. Results do not change. Merge after v26.49 has rolled out. Pages for review: - https://preview.materialize.com/materialize/40201/ingest-data/sql-server/#unique-keys - https://preview.materialize.com/materialize/40201/ingest-data/sql-server/#changing-constraints - https://preview.materialize.com/materialize/40201/ingest-data/sql-server/source-versioning/ - https://preview.materialize.com/materialize/40201/releases/#v26.49-advisory 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <[email protected]>
Dates in RC snapshots are provisional until the release ships. One commit is added per snapshot; the final snapshot sets the real dates. ##⚠️ Needs the release owner before this can merge **1. This PR conflicts with `main`, and the conflict is substantive.** While this PR sat open, [#40201](#40201) ("docs: describe which SQL Server constraint Materialize records as a key") merged to `main` on 2026-11-05 and independently added its own `## v26.49.0` section — an `### Advisory {#v26.49-advisory}` plus an `### Improvements {#v26.49-improvements}` bullet. Both sides edit the same region of `_index.md`, so `mergeable` is `CONFLICTING`. Two problems ride along with the conflict: - **The same PR is described twice.** [#40201](#40201) documents the behavior of [#40131](#40131), which `rc.1` already published here as a Bug Fix ("Reduced the risk of new SQL Server source tables returning incorrect results after a unique constraint or primary key is dropped upstream…"). *Recommendation: keep `#40201`'s Advisory and Improvement and drop the `rc.1` bug-fix bullet* — the hand-written pair is more complete, carries the upgrade-impact guidance, and links `/ingest-data/sql-server/#unique-keys`. - **Anchor collision.** `main`'s `## v26.49.0` carries `{#v26.49-advisory}` and `{#v26.49-improvements}`; this branch's combined section carries `{#v26.49-improvements}`, `{#v26.49-agent-skills}`, and `{#v26.49-bug-fixes}`. Merging both defines `#v26.49-improvements` twice on one page. *Recommendation: rename this branch's three anchors to `{#v26.49.1-improvements}`, `{#v26.49.1-agent-skills}`, `{#v26.49.1-bug-fixes}`* — which is what the `v26.49.1` snapshot's review notes intended — *and leave `{#v26.49-advisory}` and `{#v26.49-improvements}` to `#40201`'s section.* `#40201`'s review links already point at `/releases/#v26.49-advisory`, and the compatibility YAML's `/releases/#v26491` target is unaffected. **2. Wording drift against the merged `v26.49.1` snapshot.** Commit `39e6b854a` landed here on 2026-11-05, before [MaterializeInc/mz-skills#355](MaterializeInc/mz-skills#355) merged on 2026-11-06. Two entries read differently on the branch than in the merged snapshot — the hydration improvement is "Investigate hydration on the cluster details page" here versus "See how a cluster hydrated, and why it did not" there, and the Freshness and Disk-column bullets differ likewise. Resolving the conflict above is the natural moment to decide which wording ships. ## Note on the section heading The `v26.49.1` patch train reused this branch (commit `39e6b854a`) rather than opening its own, so this PR now publishes a single combined `## v26.49.1` section — this train's `rc.1`–`rc.3` entries plus the three `v26.49.1` entries, with the Cloud version in parentheses. That is the house convention for a patch that ships in its parent's rollout week; compare the published `## v26.48.2` (`*Released to Materialize Cloud: 2026-10-29 (v26.48.0)*`). The compatibility YAML accordingly carries one `v26.49.1` row dated 2026-11-06, which is what actually shipped to Self-Managed that day. The `final` commit for this train is therefore **empty**: its increment carries no entries ([MaterializeInc/release#635](MaterializeInc/release#635) states "No PRs in `v26.49.0` require review", `v26.49.0-rc.3...v26.49.0` is the lone version-bump commit, and the agent-skills window is empty), and the real dates — Cloud **2026-11-05**, Self-Managed **2026-11-06** — were already set by `39e6b854a`. It exists so the branch's commit-subject ledger records that the snapshot was applied. ## Decisions required (carried over from `rc.1`) - **`DROP INDEX`** ([#39098](#39098)): it now rejects drops that used to succeed. Call it out as a breaking change rather than an Improvement? - **Console on large clusters** ([#40306](#40306)): the Cloud Console deploys separately, so this may already be live. Keep it in v26.49? - **Maintained Objects page** ([#40105](#40105)): omitted because it is behind a LaunchDarkly flag. Is it generally available? - **`mz profile init` OAuth** ([#40319](#40319)): omitted because the PR lists open items. Which `mz` release carries it? - **Upsert v2** ([#40189](#40189)): still omitted because `enable_upsert_v2` is `false`. Publish it? ## Snapshot drafts (with PR links) in mz-skills - rc.1: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/rc.1/release_notes.md), [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/rc.1/omitted.md), [MaterializeInc/mz-skills#351](MaterializeInc/mz-skills#351) - rc.2: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/rc.2/release_notes.md), [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/rc.2/omitted.md), [MaterializeInc/mz-skills#352](MaterializeInc/mz-skills#352) - rc.3: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/rc.3/release_notes.md), [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/rc.3/omitted.md), [MaterializeInc/mz-skills#353](MaterializeInc/mz-skills#353) - final: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/final/release_notes.md), [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/final/omitted.md) **Borderline PRs omitted:** rc.1: 10 — [rc.1/omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.49.0/rc.1/omitted.md#borderline), rc.2: 0, rc.3: 0, final: 0 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Valerie Chiang <[email protected]> Co-authored-by: Pranshu Maheshwari <[email protected]>
Motivation
Primary motivation is SS-371, where we can serve incorrect data if a unique constraint has been dropped upstream and we use the recorded unique constraint in planning.
This is also related to SS-466, which is for EXCLUDE CONSTRAINTS syntax. The main goal with supporting exclude constraints is making it possible to safely drop a constraint. I dug the original issue (SS-371) up because I noticed we weren't evaluating constraints during replication.
The idea here is to limit the constraints we pull in to only the constraint fixed in SQL Server's upstream capture instance.
Details
What this does is, during purification of new sources we only retain the constraints that are referenced by the capture instance. SQL Server prevents dropping these unique constraints (except when there's a second capture instance, the old capture instance loses the enforcement).
This will significantly reduce the risk of a customer seeing this in production and effectively get us the benefits from EXCLUDE CONSTRAINTS in terms of flexibility.
The main downside of this is the loss of the optimization. This is not mutually exclusive with actually detecting or partially detecting key drops -- I think we'll ideally add reliable constraint drop detection and reintroduce the current behavior.
A couple more details on behavioral specifics:
UNIQUEconstraint is recorded only when the capture instance was created with@index_namepointing at it. The primary key is then not recorded, since SQL Server lets it be dropped.@index_name) records no key.UNIQUEconstraint over nullable columns is never recorded, because SQL Server refuses it as a CDC index (error 22838).Verification
Had Claude play around with SQL Server to verify behavior. Local unit tests.