Repository navigation
feat(TC-1274): seek KV list ranges on a COLLATE "C" index; hold the legacy-artifact fence scan for 60 s - #374
Conversation
…egacy-artifact fence scan for 60 s - m20261020_000000_current_kv_key_c_index: idx_current_kv_space_deleted_key_c ON current_kv (space, deleted, (key COLLATE "C")). The operator builds it with CREATE INDEX CONCURRENTLY first; the migration is a no-op for a valid index of that shape, fails with DROP/CREATE INDEX CONCURRENTLY instructions for an INVALID or differently shaped one, and otherwise builds it inside the boot transaction. - EffectiveFenceCache: LEGACY_SCAN_TTL 1 s -> 60 s, with the correctness argument.
The migration no longer builds idx_current_kv_space_deleted_key_c on a non-empty current_kv and never fails boot over the index. Missing on a non-empty table, invalid, or wrong-shaped under its name now log a typed event (current_kv_c_index_missing / _invalid / _wrong_shape) and boot continues. It still builds the index on an empty table. Tests that run CREATE INDEX CONCURRENTLY use a database of their own.
…ture names; check leading-column collations The boot timeouts in the index migration tests now start when the capture lock is held, not before. Fixture schema/database names carry a process-wide counter, since two tests can read the same clock instant. The shape check also requires space and deleted to use their own columns' collations.
|
APPROVE: see TC-1274 (reviewed exact head a353d20) |
|
APPROVE: see TC-1274 (main-merge with TWO listed differences, waived by Naomi; subset of reviewed lines; vs a353d20; head faf5e3a) Detail: merged origin/main d1854fe (1.21.3 so far: #163 pin, #371, #372, #370) into the approved head a353d20 with a merge commit. Conflicts: CHANGELOG.md and tinycloud-core/src/test_support.rs. The PR diff against main has the same 11 files. Nine files have byte-identical added/removed lines vs a353d20 (Cargo.lock, docs/releasing.md, tinycloud-core/Cargo.toml, database_migration.rs, db.rs, epoch_head.rs, migrations/m20261017_000000_epoch_order_space_index.rs, migrations/m20261020_000000_current_kv_key_c_index.rs, migrations/mod.rs; compared with the corrected +/- filter). The two differences, both a SUBSET of the reviewed lines (nothing added that was not reviewed): |
TC-1274 / TC-1317. Ships in the release after 1.21.2. Two changes from the 2026-10-19 CPU analysis (§3 range select, §4 fence scan).
Operator step before deploy (recommended; the node does not depend on it)
The index is performance-only. The node boots and serves without it; KV lists are slower until it exists. When it is missing on a non-empty
current_kv, the node logscurrent_kv_c_index_missingand keeps serving. Build it before the deploy so lists are fast from the first request. Order: this step first, then the node deploy. Run it on the PlanetScale primary outside any transaction (console, orpsqlwithoutBEGIN):Verify; all three columns must be
t:If the build was cancelled or failed, the index stays INVALID and
IF NOT EXISTSskips it. Drop it, then redo the first statement:current_kv(a role that cannot create indexes makes the build fail), and in the same session runSET statement_timeout = 0;first so a console or role statement timeout does not cancel the build (a cancelled build leaves an INVALID index: recover with the DROP above). Do not run a non-concurrentDROP INDEXorALTER TABLEoncurrent_kvwhile a node boots (it can fail that boot after the 30 s lock wait, like any migration).current_kvcontinue during the build (CONCURRENTLY). It waits for transactions open when it starts, without blocking them.DROP INDEX CONCURRENTLY IF EXISTS idx_current_kv_space_deleted_key_c;is safe at any time; only the range select's plan changes. A binary rollback to a release before this migration also needsDELETE FROM seaql_migrations WHERE version = 'm20261020_000000_current_kv_key_c_index';first (a node refuses to start with a recorded migration it does not ship, as for feat(TC-1274): add epoch_order (space, parent, child) index migration #341/feat(TC-1274): maintain epoch_head so writes stop scanning epoch history #343). The migration'sdownkeeps the index.docs/releasing.md("Operator steps before a node deploy").1.
idx_current_kv_space_deleted_key_ckv_prefix_conditioncompareskey COLLATE "C".idx_current_kv_space_deleted_keyis built in the column's own collation, so under a non-Cdatabase collation (production:en_US.UTF-8) the key bounds could not be index conditions: the scan read every live key of the space and filtered.Migration
m20261020_000000_current_kv_key_c_index. Performance-only (Naomi's ruling): it never lockscurrent_kvfor a build in the boot transaction and never fails the boot because the index is missing or broken. PostgreSQL stores(key COLLATE "C")on a bare column as the columnkeywith thepg_catalog.Ccollation inindcollation, not as anindexprsexpression, so the shape check reads exactly that. Every row below records the migration as applied (normal seaql behaviour), so an index an operator builds later is simply picked up by the planner, with no restart.current_kv, columnsspace, deleted, key, third column collationpg_catalog.C,spaceanddeletedin their own columns' collations (a different collation onspacecannot servespace = $1: checked with EXPLAIN), default opclasses, ascending, no predicate, no expression, no INCLUDEcurrent_kvempty (SELECT EXISTS (SELECT 1 FROM current_kv))CREATE INDEX(instant on an empty table), in a savepoint with a 2 slock_timeout; if that build fails (a writer appears, or the role may not build), the transaction stays usable and it is the row below withreason=build_failedcurrent_kvnot emptyCREATE INDEXon a non-empty table. One WARN eventcurrent_kv_c_index_missing(reason=table_not_empty); the node boots and serves with slower KV listsCREATE INDEX CONCURRENTLY)current_kv_c_index_invalid, pointing atDROP INDEX CONCURRENTLY+ the operatorCREATE. Index left alone; boot continues(space, deleted, key)shape,COLLATE "POSIX", another expression, DESC, other opclass, INCLUDE, partial, another table), or a non-index relationcurrent_kv_c_index_wrong_shape, pointing atDROP INDEX CONCURRENTLY idx_current_kv_space_deleted_key_c;then the operatorCREATE. Object left alone; boot continuesLogging (TC-961). The three events are
tracingrecords with a constanteventfield and a constant message that names the index and points atdocs/releasing.md, sectionidx_current_kv_space_deleted_key_c; they carry no key, space, DID or value (a test inserts a key and asserts it is absent from the event). Tracing is initialised (tracing_try_init,lib.rs) beforeTinyCloud::newruns the migrations, so they are emitted at boot. The log shipper (log-shipper/sanitize.vrl) ignores theeventfield: it maps a WARN/ERROR record tonode_warning/node_errorby exact message and otherwise toevent_code=unclassified. Nothing needs registering for the records to ship (no CI check requires it), so none was added; the named event is visible in the node's own container log.No query change is needed (confirmed): the planner matches the
COLLATE "C"bounds to the new index.postgres_kv_list_range_seeks_on_the_c_collation_indexmakescurrent_kv.keyICU-encollated and explainskv_list_statementon 320 rows, with the new index dropped and present (prefixdocs/and baredocs):No
Rows Removed by Filterwith the index. The bare prefix is aBitmapOrof two arms on the same index. On this 320-row fixture the planner picks a bitmap scan plus a sort (ORDER BY keyis in the column's collation, as before); the RCA measured an Index Only Scan on a larger table. Both areIndex Condon the key bounds. SQLite: with a trailing-/prefix the existing index already seeks (SEARCH ... (space=? AND deleted=? AND key>? AND key<?), tested), so no change there. A bare prefix on SQLite is anORwith the exact key and is not seeked; that is unchanged and out of scope.2. Fence-scan TTL 1 s → 60 s (
EffectiveFenceCache)LEGACY_SCAN_TTLis now a named constant; the argument is in its doc comment. Summary:true(a SQL/DuckDB artifact with a non-digest name and nodatabase_legacy_artifactrow) makes every SQL/DuckDB/invoke, and the share-email SQL store, answer 503 before any database is touched.falselets the request on toresolve, which readsdatabase_legacy_artifactanddatabase_aliaslive on every request and quarantines or routes a registered legacy name.database_identity_fencerow are read on every call before the cache, sofence onis immediate.apply,set_aliasandclear_aliasall require the durable fence, andset_fence(false)runs its own uncached scan.false(the unsafe direction). This binary has no writer of an unregistered legacy artifact: names come fromlogical_name(an N2 digest) or from an alias, andresolvereturns an alias only when its legacy row is registered; the legacy selector is "for N3's inventory only". The answer can turnfalse→trueonly if an older release or a restore writes one. For up to 60 s, requests for that path then resolve to the digest name (an empty database) instead of getting a 503. That is the existing 1 s window made longer; it overwrites nothing, and a digest artifact created in the window makes the laterapplyfail withCollisionfor the operator to resolve.true. Fail-closed: after the last legacy artifact is registered, requests keep getting a retryable 503 for at most 60 s, and only while the durable fence is off.tinycloud-sql-identityCLI is a different process and does not invalidate it; nothing above needs that. Nodes sharing a database each hold their own entry, each at most 60 s stale. Entries age from the start of their scan.Note on the RCA's option 1 ("turn the durable fence on"). Do not do this. A durable fence that is on makes
EffectiveFenceCache::checkreturntrue, which is the 503 for all SQL/DuckDB requests (step 9 of the TC-780 runbook isfence offto resume SQL). The TTL change is the right fix while the fence stays off.Test:
effective_fence_reuses_a_legacy_scan_for_the_ttl_and_rescans_after_it(moves the entry's timestamp; no sleeping): reused atTTL - 5 s, re-run atTTL + 1 s.Tests
cargo test -p tinycloud-core --lib: 505 passed against a private PostgreSQL 16.15 (ICU,en_US.utf8cluster) and with no PostgreSQL (SQLite only, the PostgreSQL tests skip); the lib suite ran 5 times in a row plus at 96 and 200 test threads without a hang. fmt and clippy-D warningsclean.lock_timeoutback; missing + empty with a blocking writer warns (build_failed), boots, records the migration; missing + non-empty builds nothing (no lock: a writer holding ROW EXCLUSIVE does not delay the boot), warns once with the constant message and no key, boots and records; an operator build afterwards is picked up quietly; invalid / not ready / not live index warns once and is left alone; aCREATE INDEX CONCURRENTLYstill running warnsinvalidwithout waiting for it; 14 wrong-shape variants under our name (including another table's index and a table of that name) log one ERROR event; different shapes (and the exact shape) under other names are ignored. I checked the tests catch bugs: a version that always builds, one that never builds, and one that treats an invalid index as valid each fail them.CREATE INDEX CONCURRENTLYwaits for every open transaction of its database, whatever schema it works in, so a build in the shared test database can queue behind other tests' migration transactions (and they behind it). Per-test schemas do not help. Tests that run it, or hold a transaction open while it runs, now use a database of their own (PostgresTestDatabase): this PR's migration tests, and the twom20261017_000000_epoch_order_space_indextests and theepoch_headupgrade test that also buildCONCURRENTLY. I did not reproduce the hang locally on the previous head (3 runs); the isolation removes the cause, not a symptom.tracingsubscriber installed on first use (a thread-scoped one can miss events while other threads of the parallel lib tests register the same callsites; the repo's ownattestation_logging.rsnote says the same).Conflicts with open PRs
Checked with
git merge-treeagainst each open PR's head, minus what already conflicts withmain:/admin/stats):CHANGELOG.mdonly. Itsdb.rshunk nearlist_space_idsdoes not overlap: this PR adds only tests todb.rs. Keep both changelog entries.tinycloud-core/src/migrations/mod.rs, the adjacentpub modandBox::new(...)lines of two new migrations; keep both.CHANGELOG.md: keep both.mainon the same files.