Skip to content

test(datastore): check that migrating produces the schema SpiceDB expects - #3336

Closed
vroldanbet wants to merge 4 commits into
mainfrom
test/schema-drift
Closed

vroldanbet wants to merge 4 commits into
mainfrom
test/schema-drift

Conversation

@vroldanbet

@vroldanbet vroldanbet commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds TestSchemaDrift to the shared datastore test suite, so every engine gets it: migrate an empty database to head, record the schema, build the datastore exactly as the server does, record the schema again, and require the two to be identical.

It found one difference, on CockroachDB, which this PR also fixes: the expiration migration now sets ttl_disable_changefeed_replication on the two relationship tables, instead of leaving it to be set by an ALTER TABLE on every datastore construction.

Why

We check that migrations run. We never check what they produce.

TestMigration applies each migration in order, confirms the recorded version advances, and confirms data written before a migration survives it. It never compares the resulting schema against anything. So a migration that is simply incomplete passes.

When that happens, the usual repair is to fix the schema up at startup: the datastore issues the missing statement itself, every time it is constructed. Nothing then fails, because by the time any other test looks, the schema is correct. That is exactly how the CockroachDB TTL parameter ended up where it is - a human noticed it much later by reasoning about Watch, not because a test said so.

TestSchemaDrift turns that from something you have to notice into something that fails. It asserts a rule rather than a snapshot - migrations own the schema, startup does not change it - so there is no golden file, nothing to update as migrations are added, and nothing that breaks when a CI image moves to a new engine version.

How

SchemaDriftTest lives in pkg/datastore/test under the existing Migration category, alongside MigrationTest. memdb is the only engine excluded, and only because it has no migrations at all.

Each engine registers how to read its own schema, via test.RegisterSchemaSnapshotter. Both snapshots come from the same database on the same server seconds apart, so the text needs no normalising across engine versions:

  • CockroachDB: SHOW CREATE ALL TABLES, which carries the table storage parameters where the drift actually was.
  • MySQL: SHOW CREATE TABLE per table. The auto-increment counter is stripped, because it moves when a row is written rather than when the schema changes.
  • Spanner: the database's own DDL, from the admin API.
  • PostgreSQL: it has no SHOW CREATE, so columns, indexes, constraints and storage parameters are read out of the system catalogs.

Run against real containers, the only difference found anywhere was on CockroachDB, on both tested versions:

- ) WITH (ttl = 'on', ttl_expiration_expression = 'expires_at', ttl_job_cron = '@daily');
- ) WITH (ttl = 'on', ttl_expiration_expression = 'expires_at', ttl_job_cron = '@daily');
+ ) WITH (ttl = 'on', ttl_expiration_expression = 'expires_at', ttl_job_cron = '@daily', ttl_disable_changefeed_replication = true);
+ ) WITH (ttl = 'on', ttl_expiration_expression = 'expires_at', ttl_job_cron = '@daily', ttl_disable_changefeed_replication = true);

The second commit closes it: the expiration migration sets the parameter as part of the TTL policy, on clusters at v24.1 or later, which is the same gate the startup check already uses. Older clusters get the policy without it, as before.

The startup check stays, permanently. A database migrated before this change has that migration recorded as applied and will never run it again, so the check is the only thing that ever sets the parameter there. It also leaves alone an operator who has deliberately configured it. The comment in ttl_changefeed.go now says so.

References

Verified against containers for every engine: CockroachDB 25.2.0 and 26.2.5, PostgreSQL 14 and 18, MySQL 8, the Spanner emulator, and memdb (skipped, no migrations). Shown failing on CockroachDB before the migration change and passing after; TestTTLChangefeedSuppressionParam and TestTTLChangefeedSuppressionWatch still pass.

Considered and skipped: a committed per-engine schema snapshot. CI covers CockroachDB 25.2 and 26.2, PostgreSQL 14 through 18, MySQL and Spanner, and the catalogs differ between those versions in ways that have nothing to do with SpiceDB - so a golden file would need hand-editing on every image bump, and would go stale rather than catch anything. The invariant test needs no maintenance and finds the same class of bug.

@github-actions github-actions Bot added area/datastore Affects the storage system area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools) labels Sep 21, 2026
@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@vroldanbet
vroldanbet force-pushed the test/schema-drift branch 3 times, most recently from a2866db to 6efc7d0 Compare September 21, 2026 18:24
@vroldanbet vroldanbet changed the title Test that migrating a database produces the schema SpiceDB expects test(datastore): check that migrating produces the schema SpiceDB expects Sep 21, 2026
@vroldanbet
vroldanbet force-pushed the perf/parallel-consistency-fixtures branch from cc5df29 to c8820fe Compare September 21, 2026 20:11
@vroldanbet
vroldanbet force-pushed the perf/parallel-consistency-fixtures branch 3 times, most recently from c6edd0d to 97d73a3 Compare September 22, 2026 05:49
Base automatically changed from perf/parallel-consistency-fixtures to main September 22, 2026 06:04
@vroldanbet
vroldanbet force-pushed the test/schema-drift branch 3 times, most recently from 2fcac38 to 1cbf41e Compare September 22, 2026 13:07
MigrationTest checks that every migration applies cleanly and that data
written before one survives it. Nothing checks the other half: that the
schema a migration leaves behind is the one the running code expects.

A gap there does not fail anything. It gets papered over at startup - the
datastore issues the DDL it wants every time it is constructed, and every
other test in the suite then runs against a correct schema and passes. The
CockroachDB TTL changefeed parameter is exactly that: missing from the
migration, added by ALTER TABLE on every datastore construction.

SchemaDriftTest states the invariant instead of the expectation: migrate an
empty database to head, snapshot the schema, construct the datastore the way
production does, snapshot again, and require the two to match. No golden
file, nothing to maintain as migrations are added, and it holds for every
engine version, because both snapshots come from the same server.

Each engine registers how to capture its own schema. CockroachDB and MySQL
report CREATE statements, Spanner reports its DDL, and PostgreSQL, which has
no such statement, is read out of the system catalogs. MySQL's output
carries the auto-increment counter, which moves when a row is written rather
than when the schema changes, so it is stripped.

Claude-Session: https://claude.ai/code/session_017DF2mdm5e2RGbjPWtmYetd
The expiration migration puts a row-level TTL policy on both relationship
tables but leaves out ttl_disable_changefeed_replication, which keeps the TTL
job's deletes out of the changefeed behind the Watch API. Datastore startup
notices it is missing and issues two ALTER TABLEs to add it - on every single
connection to every database, forever, because the migration never learns.

Setting it as part of the policy means a freshly migrated database already
has it and the startup check finds nothing to do.

The check stays, permanently. A database migrated before this change has the
migration recorded as applied and will never run it again, so the check is
the only thing that ever sets the parameter there. It also leaves alone an
operator who has deliberately configured the parameter.

The parameter arrived in CockroachDB v24.1, so the gate matches the one the
startup check already uses. Older clusters get the policy without it and are
fixed up at startup exactly as before.

Claude-Session: https://claude.ai/code/session_017DF2mdm5e2RGbjPWtmYetd
…registry

The engine's suite has already built a datastore in this process, and the
datastores register their collectors globally, so the second one failed
with "duplicate metrics collector registration attempted". This
datastore only exists to let the engine apply whatever schema changes it
makes on construction, so it has no use for metrics.
@vroldanbet vroldanbet closed this Sep 29, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area/datastore Affects the storage system area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant