Repository navigation
feat(migrations): catalog migrations with a ledger in every store, for Cassandra and MongoDB - #681
Open
LeeroyHannigan wants to merge 6 commits into
Open
LeeroyHannigan wants to merge 6 commits into
LeeroyHannigan wants to merge 6 commits into
Conversation
Cassandra and MongoDB now use the numbered catalog migrations and the runner that SQLite and PostgreSQL use. Each keyspace or database keeps a ledger, the status is read from the ledgers, serve refuses a catalog that does not match the binary, and every change runs under the migration lock. Cassandra: migration 1 is a CQL file for the catalog keyspace and one for each account keyspace, with every statement qualified by its keyspace. Migration 2 is the shared policy rewrite; 3 and 4 do not apply. Cassandra has no transactions: every statement of a body can be sent again without harm, and a body's ledger row is written at QUORUM after it. Migration 1's row and the catalog_version row go in one logged batch; for a later id the version row is written first, so a run stopped between the two leaves that id pending. The lock is a settings row written by lightweight transactions at SERIAL, with a 60 s TTL renewed every 15 s while the run makes progress; a holder whose renewals fail stops writing 25 s before the row can expire. Each request under the lock is bounded by 10 s and each schema statement by 20 s. After every schema statement the runner waits until every live node reports the same schema version, and a node that does not answer is not counted as agreeing (#637). init creates the catalog keyspace and its settings table, takes the lock, and runs everything else under it, the default account's migrations included. Under an authorizer other than AllowAllAuthorizer it grants the application role its permissions on the catalog keyspace and the default account's keyspace. CreateAccount runs the account keyspace's migrations through the runner. MongoDB: the bodies are code for the catalog and data databases, and a body's ledger row and the catalog_version row commit in one transaction. The lock and the server registrations are documents written with majority write concern. Commands connect with 10 s connect and server selection timeouts, and each request under the lock is bounded by 10 s. Both backends read the status under the lock as its holder (status_under_lock), so migration 2's scan over every principal keeps the lock. Migration 2's error names its first failed lookup whatever its kind, and the status names a migration pending in several stores once, with the number of stores. With every backend on the runner, the bootstrapper's migration methods, the old branches of init, migrate and verify, serve's resource-tag marker check and OperationsEngine::catalog_version are removed. Fixes #637.
docs/schema/cassandra.txt and docs/schema/mongodb.txt hold the schema a fresh install creates. The catalog check jobs dump a fresh install's schema (devtools/schema-dump on Cassandra, devtools/check-mongodb-schema on MongoDB) and fail when it differs from the committed file. Cassandra: the storage job runs tests/migrations.rs and tests/migrations_stopped.rs. New jobs run the migration tests on a two-node cluster, check the permissions init grants under CassandraAuthorizer, and run the catalog checks: the schema, the lock and the serving registration, and a catalog the last 0.1.x release wrote. MongoDB: devtools/run-mongodb-tests runs the catalog migration tests against its replica set, and the catalog job checks the schema, the lock, a user with readWrite on the two databases and no other role, the released catalogs and the last 0.1.x image. The new test steps set EXTENDDB_TEST_CASSANDRA=required or EXTENDDB_TEST_MONGODB_REQUIRED=1, so a test without its server fails rather than skips.
ADR-0023 describes every backend on the runner and gives the Cassandra and MongoDB lock and registration values. The upgrade manual gives this release's Cassandra and MongoDB procedures, the GRANT statements a Cassandra deployment created before 1.0 needs under CassandraAuthorizer, and how a stopped Cassandra run is finished. docs/cassandra-setup.md says what init grants. The design docs, the architecture, admin and storage extension manuals and CONTRIBUTING.md's procedure for adding a migration cover all four backends, and docs/build-docs.py says extenddb version prints the catalog migration on every backend.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 30, 2026 19:56
The fixture in tests/migrations_stopped.rs created its users with
create_user, 32 at a time. create_user inserts the user row with a
lightweight transaction, and every fixture user is in the same account's
partition, so the 32 transactions contend for that partition. On the
cassandra-storage runner one of them failed, and
the_status_read_under_the_lock_keeps_it_through_a_long_scan stopped in
its fixture with user: Internal("Database error"). On a node limited to
one CPU the old fixture failed in both runs. The fixture now inserts the
user row with the columns create_user writes, without the transaction,
and adds the self-service policy create_user adds, so each user holds
the same rows as before.
a_bodys_request_to_a_node_that_stops_answering_fails_at_the_request_timeout
sends its requests through a proxy that stops passing bytes. On the
two-node runner the driver also read the second node from system.peers
and connected to it directly, and the second node answered the request
sent after the proxy went silent, 7 ms later. The test now uses a
session that leaves every node but the proxy out of its query plans. On
a local two-node cluster the old session's plan held both nodes and the
second node answered the first request after the proxy went silent; the
new session's plan holds the proxy only, and both of its requests waited
for the 10 s request timeout.
… two-node test two_migrators_started_together_apply_a_legacy_catalog_once starts two migrators through sessions that reach the cluster at different nodes. The driver reads the other nodes from system.peers_v2 and sends requests to every node in turn, so each migrator sent its requests to both nodes, and the two migrators' requests did not always go through different coordinators. The test now gives each migrator a session whose query plans hold its own node only. On a local two-node cluster the plan of each such session held one node, a different one for each, while a session built as before held both. The schema agreement wait still asks every node for its version, on a connection to each, and it agreed through both sessions. pinned_session in tests/migrations_support builds that session. Like CassandraEngine::create_session, it logs in with the test credentials when both are set and gives up on the connect after 10 s, so a node that cannot be reached fails the test after 10 s instead of leaving it waiting until the job's timeout. The proxy test's session now comes from it. The proxy test now checks that the request sent before the proxy goes silent is answered by the node, which reports that the keyspace does not exist. Before, any error passed, so a proxy that passed nothing would not have been caught. With the proxy silenced before that request, the test now fails. The fixture in tests/migrations_stopped.rs binds password_hash to null, as create_user does, so each user row holds the same cells as before. Corrections to the previous commit's message, which says system.peers: on Cassandra 4.0 and later the driver reads system.peers_v2. The CI log does not say which of create_user's two statements failed, because create_user reports any driver error as Database error; the lightweight transaction is the statement the new fixture no longer sends.
The proxy test's first request must now be answered through the proxy. The error a node returns names the server the driver read it from, and the check requires the proxy's address there as well as "does not exist". A session that also sent requests straight to the second node passed the old check whenever its next request, the one after the proxy went silent, fell to the proxy. On a local two-node cluster, a session built as before the previous commit failed the new check in three runs out of three, answered by the second node, and the pinned session passed it in three out of three. pinned_session resolves its contact point first, so a host name works as it does for CassandraEngine::create_session, and the session is given the address it resolved to. The doc comments on the pinned sessions now say what they hold: query plans with one node. The schema agreement wait still reads every node. A correction to the previous commit's message: create_user's account_exists read reports the driver's text, and only its two writes, the lightweight transaction and the self-service policy insert, report Database error. The CI log ruled out the read and could not tell the two writes apart.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#666 put SQLite and PostgreSQL on one numbered list of catalog migrations with a ledger in every store. This PR moves Cassandra and MongoDB onto the same list and the same runner. All four backends now read their catalog status from their ledgers, refuse a catalog that does not match the binary when
servestarts, and take the migration lock before any change. With every backend on the runner, this PR removes the code that served a backend without catalog migrations: the bootstrapper's migration methods, the old branches ofinit,migrateandverify,serve's resource-tag marker check andOperationsEngine::catalog_version.It is stacked on #666, and its diff is against
feat/catalog-migrations. The design is ADR-0023, which now has Cassandra and MongoDB rows. The first three commits are the code and tests, then CI with the check scripts and the committed schemas, then the docs. The fourth to sixth change test code only: the fourth fixes two tests that failed on the first CI run, the fifth sends each migrator of the two-node test to its own node, and the sixth tightens the proxy test's first check. All three are described under Testing.Fixes #637 (schema agreement on every node before the next statement). Refs #636, #346, #470 (permissions under
CassandraAuthorizer).The migrations on each backend
initthat stopped part way left out.Each store records what it applied in its ledger, and the first time migration 1 is applied the runner compares the result with the expected definitions (
system_schemaon Cassandra, collection options and index keys on MongoDB) and records nothing on a difference. Migration 1 also deletes the marker row 0.1.x servers require, and writes the legacyschema_historyrows (Cassandra) or001_initialdocument (MongoDB) so a 0.1.xmigratefinds nothing to do.What changes for operators
extenddb migrate,migrate --yes,--list,--forget,--reconverge,--lock-timeoutandverifywork on Cassandra and MongoDB as they do on SQLite and PostgreSQL, with the same exit codes.serveon Cassandra and MongoDB refuses a catalog whose status is notCurrentorAheadCompatible, before it opens the engine, and prints the command that resolves it. On MongoDB,servedid not read the catalog version before.migrate --yesandinitrefuse a keyspace or database that is not a catalog, including another program's keyspace whosesettingstable has the catalog's columns, and write nothing to it. On Cassandra the refusal names what the keyspace holds.migrate --yesand--forgetread the status under the lock as its holder. On a catalog behind migration 2 that read scans every IAM principal's policies, and each request it sends counts as progress, so the lock is renewed through the scan.migrate --yesruns again. When migration 2 cannot read or write a policy, its error ends with the first failure's text, such as a lapsed lock or a node that did not answer.settingstable, written by lightweight transactions atSERIAL, with a 60 s TTL renewed every 15 s while the run makes progress. Each server registers atQUORUMand renews atLOCAL_QUORUM, andmigrate --yesreads the registrations atALL, waits up to--lock-timeoutfor running servers, then exits 6 naming them. A server that cannot renew its registration for 30 s exits 1 and removes its PID file.initcreates the catalog keyspace and itssettingstable, then takes the lock and runs the rest under it, the default account's migrations included. Under an authorizer other thanAllowAllAuthorizerit grants the application role its permissions, andmigrategrants them on an account keyspace it creates. The upgrade manual gives the statements for deployments created before 1.0.readWriteonextenddb_catalogandextenddb_dataand no other role can run every command.What changes for contributors
docs/schema/cassandra.txtanddocs/schema/mongodb.txthold the current schema, and CI fails when a fresh install differs.CONTRIBUTING.md"Adding a catalog migration" now covers all four backends.tests/migrations.rsandtests/migrations_stopped.rs, a two-node job, aCassandraAuthorizerjob and the catalog checks; for MongoDB, the catalog migration tests and the catalog checks (schema, lock, access control, released catalogs, the last 0.1.x image). Both set their*_REQUIREDvariable so the tests fail rather than skip without a server.test_table_operations_work_on_a_catalog_without_v005(fix(backup): restore sort keys, indexes, and throughput; bound memory; recover abandoned restores #384) is removed. A catalog without migration 1 reads asBehind, andserverefuses it, so no table operation runs on one.Testing
Run before the push on 30 October on one host, against Cassandra 4.1 containers (one node, two for the two-node job), MongoDB 7 replica sets, PostgreSQL and SQLite:
cargo test --workspace(2715 passed);test_export_dynamodb_json, which passed three times out of three alone), and the--comprehensivesuite on each (340);tests/migrations.rsandtests/migrations_stopped.rs;CassandraAuthorizerchecks, with a binary built before the last round, which changed migration 2's error text, test code and comments;tests/migrations.rson a two-node cluster;--comprehensivesuite (340 passed). The failure istest_racing_puts_on_an_lsi_table_leave_only_the_final_entry, a create-race test from fix(postgres): order a PutItem that loses a create race after the winner #388 that fails as storage-cassandra: a PutItem that loses the create race can leave its LSI row behind #414 describes: racing puts leave a stale index entry beside the final one. Run alone with this PR's server it failed 2 of 5 runs, and its GSI twin failed 4 of 6 runs alone with feat(migrations): one list of catalog migrations with a ledger in every store, for SQLite and PostgreSQL #666's server. This PR does not change Cassandra's item or index write path.init's default-account run, the agreement wait's progress, the status read under the lock, the version row of a stopped run, the schema statement bound, and migration 2's error text.test_ttl_enable_rejects_asynchronous_gsi, which fails the same way on feat(migrations): one list of catalog migrations with a ledger in every store, for SQLite and PostgreSQL #666's head and on main (Cassandra ttl_integration: test_ttl_enable_rejects_asynchronous_gsi asserts a refusal wording #384 replaced; masked by continue-on-error #566), in a step CI runs withcontinue-on-error(Feat/storage cassandra in tree #338), and the backup-store conformance tests, which refuse a backup root under the test host's/tmpbecause another user owns it. This PR does not touch that crate.The first CI run, on the third commit, passed 33 of its 36 checks. Two Cassandra tests failed, and the third failure is the job that requires every Cassandra job to pass. The fourth commit changes test code only:
the_status_read_under_the_lock_keeps_it_through_a_long_scanstopped in its fixture, which created 4000 users withcreate_user, 32 at a time.create_userinserts a user with a lightweight transaction, and every fixture user is in the same account's partition, so the transactions contend for it. The CI log does not name the failing statement:create_userreports a driver error from either of its writes, the transaction and the self-service policy insert, asDatabase error. The transaction is the one statement the new fixture no longer sends. The fixture now inserts the user row without the transaction, with the self-service policycreate_useradds. On a Cassandra node limited to one CPU,tests/migrations_stopped.rsfailed in both runs with the old fixture and passed in both with the new one.a_bodys_request_to_a_node_that_stops_answering_fails_at_the_request_timeout, in the two-node job: the driver read the second node fromsystem.peers_v2and sent the request that follows the proxy going silent straight to that node, which answered in 7 ms. The driver sends requests to the nodes in turn, and on the local two-node cluster that request fell to the proxy, so the old test passed there in 8 runs out of 8. The test now uses a session whose query plans hold only the proxy. On the local cluster the old session's plan held both nodes, and the new session's plan holds the proxy only.tests/migrations.rspassed on one node and twice on two nodes, andtests/migrations_stopped.rspassed on one node and twice on the node limited to one CPU. clippy (1.97.0) and rustfmt pass on the crate.The second CI run, on the fourth commit, passed all 36 checks. The fifth commit also changes test code only:
two_migrators_started_together_apply_a_legacy_catalog_oncegives each migrator a session whose query plans hold its own node only, so on two nodes the two migrators always reach the catalog through different coordinators. Before, each migrator's session sent requests to both nodes in turn. The schema agreement wait still asks every node for its version, on a connection to each. On the local two-node cluster, each such session's plan held one node, a different one for each, and the agreement wait finished through both.CassandraEngine::create_sessiondoes, so a node that cannot be reached fails a test after 10 s instead of at the job's timeout. The proxy test's session comes from it.password_hashto null, ascreate_userdoes.tests/migrations.rson two nodes and one on one node, and the two tests that use the fixture in two runs oftests/migrations_stopped.rson one node.tests/migrations.rspassed in full on one node and in one of the three runs on two nodes. The other runs had five failures in four tests this commit does not change, each on a timing bound: a schema statement past its 20 s bound three times, and a lock left by a killed holder taken again 8.9 s and 26 s after the kill, against a bound of 8.5 s. Each of the four passed twice when run alone. clippy (1.97.0) and rustfmt pass on the crate.The third CI run, on the fifth commit, passed all 36 checks after one rerun of
mongodb-pytest, whose--comprehensiverun failedtest_all_writers_succeedas #577 describes. The sixth commit also changes test code only:pinned_sessionresolves its contact point first, so a host name works, as it does forCassandraEngine::create_session, and its doc comments say what its query plans hold.tests/migrations.rspassed on one node, and on two nodes with host names as contact points. clippy (1.97.0) and rustfmt pass on the crate.Measured:
dynamodb:ResourceTaginline policy each and migration 2 pending,migrate --yeswith production lease times. A binary that read the status under the lock with no lease stopped renewing the lock three minutes in and exited 1 after 308 s, with migration 2 not recorded. This PR's binary then applied migration 2 on the same catalog in 815 s, the ledger row names all 75000 rewrites, andverifypasses.migrate --yesexited 1 after 75 s; with it, 0 after 125 s with migration 2 recorded.initran the default account's migrations:initstopped at its next schema statement's 20 s bound and recorded nothing. Before the fix, the account run went on writing 22 s past the lock's deadline.CassandraAuthorizer: the upgrade manual's GRANT statements for deployments created before 1.0 give the application role the same ten permissionsinitgrants.