fix(core): complete catalogue functions on startup; fix flaky covering-index fuzz op - #7259
Conversation
FuzzAddCoveringIndexOperation.executePostDrain validates that its target
column is a SYMBOL by reading CairoEngine.getTableMetadata(), which for
WAL tables is the lagging *applied* metadata. It then issues
ALTER TABLE ... ADD INDEX, which is validated against getMetadataForWrite()
-> getSequencerMetadata(), the fresh write-side metadata.
When the table metadata lags behind a drop/re-add (column-name reuse) that
changed the column from SYMBOL to a non-symbol type, the operation emits an
ALTER on a name that now resolves to a non-symbol column. The resulting
SqlException ("indexes are only supported for symbol type") was not in the
operation's swallowed-error set, so it propagated and failed fuzz tests
such as ReplicationFuzzTest.testPrimaryMigration (Azure build 241911):
io.questdb.griffin.SqlException: [48] indexes are only supported for
symbol type [column=new_col_12, type=BYTE]
at FuzzAddCoveringIndexOperation.executePostDrain(...:112)
Tolerate this transient skew like the other races the operation already
swallows (already indexed / does not exist / table busy), and add a
deterministic regression test that reproduces the metadata skew without
the fuzz seed or concurrency.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the βοΈ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
β¨ Finishing Touchesπ§ͺ Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
/azp run macwin |
|
Azure Pipelines successfully started running 1 pipeline(s). |
β¦g startup hydration tables() and all_tables() return a snapshot() of MetadataCache, which is populated lazily on a background thread by onStartupAsyncHydrator(). A catalogue query that runs before hydration completes (e.g. right after a restart or a backup restore) observed an empty/partial list and silently missed tables, racing the async hydrator. Add MetadataCache.hydrateAllTables(), which reconciles the cache against the table registry (engine.getTableTokens) and hydrates any missing tables on demand, and call it from both catalogue cursor factories before snapshotting. In steady state it is a cheap read-only scan that takes no write lock, so the snapshot version fast-path is preserved. Regression test creates tables, clears the cache to simulate the pre-hydration window, and asserts tables()/all_tables() still return the full set without invoking the async hydrator.
Record the team's PR logistics so the agent stops proposing logic-based PR splits. CI is the bottleneck (~40 min, flake-prone) and the shop runs ~20 PRs/day, so splitting one branch into several "clean" PRs multiplies CI time and flake exposure and creates PRs that fail on each other's flakes. Squash-merge collapses a branch into one master commit anyway, so bundling related fixes onto the in-flight branch costs nothing at merge time. Also capture the "send a change to PR #N" flow: push to that branch and update the PR body, do not open a new PR.
hydrateAllTables() was running its full registry-vs-cache reconcile on every tables()/all_tables() cursor open, even once the cache was fully populated -- allocating a token set, walking the registry and taking the read lock on the hot path for no reason. The cache-vs-registry gap only exists during the one-shot startup hydration window: after onStartupAsyncHydrator() completes, writers keep the cache in sync incrementally. Gate the reconcile on a volatile flag set at the end of that pass, so steady state is a single volatile read with no allocation or lock. If the startup pass aborts abnormally the flag stays unset and the reconcile keeps running as a self-healing fallback. Add a MetadataCacheTest case that pins the fast-path contract: after startup hydration completes, hydrateAllTables() is a no-op even if the cache was cleared.
The startup-hydration completeness fix originally covered only tables() and all_tables(), but MetadataCacheReader.snapshot() has four production callers. The column enumerators -- information_schema.columns() and pg_catalog.pg_attribute (plus their reuse by pg_attribute's prefixed variant and information_schema.questdb_columns) -- snapshot the same lazily-hydrated cache and so still omitted tables and their columns in the post-restart / post-backup-restore pre-hydration window. This is the higher-impact path: PostgreSQL drivers and BI tools query these automatically on connect. pg_class enumerates tables straight from the registry, so during the window pg_class is complete while pg_attribute is not -- the standard pg_class -> pg_attribute introspection join renders existing tables with zero columns, breaking driver schema views and autocomplete right after a restart. Reconcile before the snapshot in both base cursor factories, which covers all four functions via reuse. Cheap now that the reconcile short-circuits once startup hydration has completed. Add a regression test for information_schema.columns() mirroring the tables() one.
All four catalogue cursor factories repeated the same dance: call hydrateAllTables(), then open their own read lock to run MetadataCacheReader.snapshot(). That duplicated the reconcile-then-snapshot sequence at every call site and took the read lock twice during the startup window. Add MetadataCache.snapshot(localCache, priorVersion) that folds the two steps -- reconcile (a cheap no-op once startup hydration completes) followed by a single read-locked snapshot -- and switch all four factories to it. This removes the per-site boilerplate and makes the complete-catalogue guarantee the default for the convenient call, so future callers cannot forget it. The reader-level snapshot() stays for callers that already hold the read lock or deliberately want the raw cache contents (used directly by MetadataCacheTest, including the short-circuit test that still drives hydrateAllTables() on its own).
β¦p redundant reconcile The catalogue reconcile (hydrateAllTables) backing tables()/all_tables() could read a table's _meta multiple times and, in embedded mode, ran a full reconcile on every query forever. - hydrateTableStartup() now takes skipIfCached: the startup hydrator and the catalogue reconcile skip a table already present in the cache (under the write lock, so check-and-hydrate is atomic). Each table is read from disk exactly once per clearCache() epoch; the writer-driven refresh path passes skipIfCached=false and still re-reads. - Generalized the fast-path flag (startupHydrated -> cacheComplete): it is now set by any reconcile pass that observes the cache as complete, not only by onStartupAsyncHydrator() (which is only invoked by ServerMain). This spares embedded engines a per-query reconcile once warmed up. - clearCache() resets cacheComplete so a wiped cache is reconciled afresh, removing the stale-catalogue footgun after a post-startup clear. Tests: add exactly-once (object identity) and clearCache-reenables-reconcile coverage; retarget the short-circuit test to probe via dropTable.
Follows up the catalogue-startup work with three related fixes surfaced during review of the MetadataCache reconcile. - Latch honesty + bounded retry: onStartupAsyncHydrator() and hydrateAllTables() now latch cacheComplete only when every registered table is actually present. A swallowed per-table hydration failure leaves the flag unset so the reconcile keeps retrying (self-healing transient _meta faults), and a genuinely unhydratable table is bounded by MAX_INCOMPLETE_RECONCILE_PASSES so it can no longer drive perpetual reconcile + CRITICAL-log spam on every catalogue query. - Lock discipline: every cacheComplete=true is now published under the lock that observed completeness (read lock for the warm/startup checks, write lock for the give-up latch), making it mutually exclusive with clearCache(). A concurrent clear can no longer interleave between observing completeness and publishing the flag and leave an emptied cache marked complete. - Point-lookup catalogue paths: add MetadataCache.hydrateTableOnDemand() (the single-table analogue of hydrateAllTables) and route SHOW COLUMNS, SHOW CREATE TABLE, SHOW CREATE MATERIALIZED VIEW and the parquet partition-format probe through it. These resolved the token from the synchronously loaded registry but read the lazily hydrated cache, so they reported a registered table as non-existent (or skipped parquet pruning) during the startup window and indefinitely on embedded engines. Adds unit and integration coverage: failed-hydration injection, a deterministic latch/clearCache interleaving (via a test seam) plus a concurrency stress test, and SHOW COLUMNS/CREATE TABLE/CREATE MATERIALIZED VIEW regressions exercised in the pre-hydration window.
The catalogue read-path (snapshot/hydrateAllTables/hydrateTableOnDemand) hydrates tables lazily on the calling thread via hydrateTableStartup(), which acquires a thread-local Path (NATIVE_PATH_THREAD_LOCAL). Unlike onStartupAsyncHydrator(), these caller-thread paths never released it, so a short-lived (non-pooled) thread that triggered hydration leaked the native Path buffer on termination. This deterministically tripped the assertMemoryLeak check in ViewCompilerJobTest.testConcurrentEventProcessing. Wrap the allocating sections in try/finally and call Path.clearThreadLocals(), mirroring onStartupAsyncHydrator(). Cleanup only runs on the hydration slow path; warm queries short-circuit before allocating, and it runs after the write lock is released.
β¦a-cache window AbstractPartitionFrameCursorFactory.hasParquetFormatPartitions() hydrates the table on demand before probing the metadata cache, so parquet row-group pruning still applies when a query runs before the cache has hydrated the table (async startup window, or after a clearCache()). No test exercised that cross-context callsite: the existing pruning tests always run with the table already cached (CREATE/CONVERT hydrate it), so the on-demand probe was a no-op there. Add a regression test that converts a partition to parquet, evicts the table from the metadata cache, then runs a filtered query and asserts row groups are still skipped. Pruning is an optimization (it does not change results), so the assertion targets the pruning signal (rowGroupsSkipped). Without the on-demand hydrate the probe returns false, pruning is skipped, and the full parquet decode path runs (which also crashed natively on the unfixed code).
β¦he last missing table hydrateAllTables() pass 2 hydrated every missing table but never latched cacheComplete - it only bumped the retry counter and returned, forcing the next catalogue query to run a second, redundant full reconcile (getTableTokens + ObjHashSet/ObjList allocation + scan) just to observe "nothing missing" and set the flag. Latch immediately after pass 2 by re-checking the token snapshot already collected (no second getTableTokens/allocation) under the read lock - mutually exclusive with clearCache(), mirroring onStartupAsyncHydrator(). A silently failed hydration still leaves a gap, so latchCacheCompleteIfWarmed() latches only when genuinely warm; otherwise we fall through to the bounded retry budget as before. As a side benefit, a successful pass no longer consumes the incomplete-reconcile retry budget. testCompletenessLatchIsMutuallyExclusiveWithClearCache relied on 'pass 2 never latches' to reach a warm-but-unlatched state; warm it via per-table hydrateTableOnDemand (which never latches) instead. Add testReconcileLatchesCompleteAfterHydratingMissingTables to pin the new behavior; the concurrent latch/clearCache test already guards the new latch site.
β¦ round, not per invocation incompleteReconcilePasses was bumped once per hydrateAllTables() call that still found a table missing, on a single shared counter. Under a post-restart PG-introspection storm, a burst of concurrent catalogue queries could each bump the counter and exhaust the 8-pass budget in one millisecond-scale burst, latching cacheComplete=true with a transiently-unreadable table still absent - so the 'giving transient failures several passes to self-heal' contract did not hold under concurrency. (Not a regression vs master, which never retries.) Reset incompleteReconcilePasses=0 whenever a reconcile pass hydrated at least one previously-missing table, so the budget counts only consecutive zero-progress rounds. A storm that keeps making incremental progress (tables hydrating, e.g. racing the startup hydrator) no longer exhausts it; only a genuinely unhydratable table that blocks a whole round spends it, after which we give up as before. The check runs under the same write lock as the bump/latch, so it stays mutually exclusive with clearCache(). Add testReconcileBudgetCountsOnlyZeroProgressRounds (drives several progress rounds then asserts the budget survives until progress stops); update testReconcileGivesUpAfterRepeatedHydrationFailures to do an explicit progress round before the zero-progress give-up rounds.
β¦fuzz-stale-metadata
Review β
|
β¦a cache SHOW CREATE VIEW resolved the view token from the synchronously loaded table registry (SqlParserCallback.getViewToken) but then gated on the lazily hydrated metadata cache, throwing "view does not exist" on a cache miss. Plain views have no _meta file, are skipped by the startup hydrator, and hydrateTableOnDemand() no-ops on them, so only the async ViewCompilerJob ever caches them. During the startup / embedded window a registered view was therefore reported as missing -- the same class of bug fixed for SHOW CREATE TABLE / MATERIALIZED VIEW, except those rely on on-demand cache hydration that cannot apply to views. Existence and view-ness are already guaranteed upstream by getViewToken, and the definition is read straight from disk via ViewDefinition.readFrom, so the cache read is now best-effort: it only preserves the TableReferenceOutOfDate staleness guard when the view is warm and no longer fails on a miss. Adds ViewsFunctionTest.testShowCreateViewBeforeStartupHydration exercising the pre-hydration window via the clearCache() test seam.
Fixed: M2 β SHOW CREATE VIEW had the same resolve-then-read-cache race (now closed)Follow-up to the SHOW CREATE TABLE / MATERIALIZED VIEW startup-window fixes. Root cause.
They are only ever cached by the async Fix. Existence and view-ness are already guaranteed upstream by try (MetadataCacheReader metadataRO = ...readLock()) {
this.view = metadataRO.getTable(viewToken);
if (this.view != null && !viewToken.equals(view.getTableToken())) {
throw TableReferenceOutOfDateException.of(viewToken);
}
}Test. Added Validation: With this, all three of Commit: a84b6e7 |
|
/azp run macwin |
|
Azure Pipelines successfully started running 1 pipeline(s). |
[PR Coverage check]π pass : 88 / 88 (100.00%) file detail
|
This PR carries two code fixes that both surfaced from CI on this branch, plus a small documentation change. Each is in its own clearly-labeled section below.
1. fix(core): complete catalogue & metadata lookups during startup hydration
Problem
The catalogue functions return a
snapshot()ofMetadataCache. That cache is populated lazily on a background thread byMetadataCache.onStartupAsyncHydrator(). A catalogue query that runs before hydration finishes β right after a restart, or after a backup restore brings a server up β observes an empty or partial table list and silently misses tables (and their columns).This was caught by the enterprise cross-repo CI for this PR:
BackupTest.testFailedRestoreParallelTaskFailureranselect table_name from tableson a freshly-started server and got an empty result because the query beat the async hydrator by a few milliseconds (metadata hydration completedwas logged ~6 ms after the query executed).Root cause
MetadataCacheReader.snapshot()only copies tables already present in the cache'stableMap. The startup hydrator fills that map entry-by-entry on a background thread, so until it completes the snapshot is incomplete. The catalogue functions did no on-demand reconciliation against the authoritative table registry.Fix β catalogue enumerators (
tables/all_tables/columns/pg_attribute)MetadataCache.hydrateAllTables(): reconciles the cache against the table registry (engine.getTableTokens) and hydrates any missing tables on demand. Under a single read lock it collects tables that exist in the registry but are missing from the cache, then hydrates them with a per-table write lock (matchingonStartupAsyncHydrator). Views are skipped (no_meta).snapshot()callers βtables(),all_tables(),information_schema.columns(), andpg_catalog.pg_attributeβ which via cursor-factory reuse also coverspg_attribute's prefixed variant andinformation_schema.questdb_columns.Fix β point-lookup paths (
SHOW COLUMNS/SHOW CREATE TABLE/SHOW CREATE MATERIALIZED VIEW/ parquet pruning)The same root cause hits single-table lookups that resolve a
TableTokenfrom the synchronously-loaded registry but then read the lazily-hydrated cache.MetadataCache.hydrateTableOnDemand(token), the point-lookup analogue ofhydrateAllTables(): if the cache is not yet complete and the one table is missing, hydrate just that table under a write lock (best-effort; a per-table failure is swallowed so the caller reports it missing rather than failing hard).SHOW COLUMNS(ShowColumnsRecordCursorFactory),SHOW CREATE TABLE(ShowCreateTableRecordCursorFactory),SHOW CREATE MATERIALIZED VIEW(ShowCreateMatViewRecordCursorFactory), and the parquet row-group-pruning probeAbstractPartitionFrameCursorFactory.hasParquetFormatPartitions().Reconcile machinery:
cacheCompletelatch & concurrencyhydrateAllTables()/hydrateTableOnDemand()short-circuit on a single volatilecacheCompleteflag, so once the cache is known complete they add no per-query work. The flag is latched when a reconcile observes every registered table present, when the startup hydrator finishes hydrating every table, or when the give-up budget below is exhausted; it is reset byclearCache()(checkpoint/restore, tests).clearCache(): the completeness latch is published under the read lock that observed the cache as complete, so it cannot interleave withclearCache()(write lock) and leave an emptied cache marked complete. Pinned byMetadataCacheTest.testCompletenessLatchIsMutuallyExclusiveWithClearCacheand a concurrent stress test.cacheCompletein that same pass (re-checking the token snapshot it already collected, under the read lock) instead of leaving the flag off and forcing the next catalogue query to run a second, redundant full reconcile just to observe "nothing missing".Give-up budget and its tradeoff
A table whose
_metacannot be read (genuinely corrupt, or transiently unavailable at startup) never lands in the cache and so always looks "missing"; without a cap it would be re-read β and loggedCRITICALβ on every catalogue query forever.MAX_INCOMPLETE_RECONCILE_PASSES = 8: after that many consecutive zero-progress reconcile rounds,hydrateAllTables()gives up and latchescacheCompleteanyway.registerName/hydrateTablere-hydrate it) or the nextclearCache()/restart. This matchesmaster's worst case βmasternever reconciles, so such a table is hidden there too β in exchange for bounded work and log output.Native memory hygiene
The reconcile / on-demand paths hydrate on the caller's thread, acquiring a thread-local
Path(NATIVE_PATH_THREAD_LOCAL). They release it viaPath.clearThreadLocals()after hydrating β mirroringonStartupAsyncHydrator()β so a short-lived (non-pooled) caller thread does not leak the native buffer. (CI caught the leak viaViewCompilerJobTest.testConcurrentEventProcessing's memory-leak assertion, which spawns and joins worker threads that hit these paths.)Steady-state cost
The reconcile is only needed while the one-shot startup hydration is in flight. Once it completes, writers keep the cache in sync incrementally, so
hydrateAllTables()short-circuits on a single volatile flag β no allocation, no lock, thesnapshot()version fast-path is untouched. If the startup pass aborts abnormally the flag stays unset and the reconcile keeps running as a self-healing fallback.Impact (Postgres Wire)
The column enumerators are the higher-impact path: PostgreSQL drivers and BI tools query them automatically on connect.
pg_classenumerates tables straight from the registry, so during the windowpg_classis complete whilepg_attributeis not β the standardpg_class -> pg_attributeintrospection join would render existing tables with zero columns, breaking driver schema views and autocomplete right after a restart.Regression tests
Catalogue enumerators:
TablesFunctionFactoryTest.testCatalogueIsCompleteBeforeStartupHydrationβtables()/all_tables()return the complete set in the pre-hydration window.InformationSchemaColumnsFunctionFactoryTest.testColumnsCompleteBeforeStartupHydrationβ same forinformation_schema.columns()(which also backspg_attribute); verified it fails with a partial column list when the reconcile is removed.Point-lookup paths:
MetadataCacheTest.testShowColumnsBeforeStartupHydration,MetadataCacheTest.testHydrateTableOnDemandPopulatesMissingTable.ShowCreateTableTest.testShowCreateTableBeforeStartupHydration,ShowCreateTableTest.testShowCreateMatViewBeforeStartupHydration(materialized view).ParquetRowGroupPruningTest.testRowGroupPruningSurvivesEmptyMetadataCacheWindowβ evicts the table from the cache, then asserts a filtered query still skips row groups (pruning is applied via on-demand hydrate).Reconcile machinery / latch / budget:
MetadataCacheTest.testHydrateAllTablesShortCircuitsWhenCacheCompleteβ pins the steady-state fast path (no-op once complete, even if the cache was cleared).MetadataCacheTest.testReconcileLatchesCompleteAfterHydratingMissingTablesβ one reconcile both hydrates and latches (no redundant second pass).MetadataCacheTest.testReconcileBudgetCountsOnlyZeroProgressRoundsβ progress rounds do not consume the give-up budget.MetadataCacheTest.testReconcileGivesUpAfterRepeatedHydrationFailuresβ give up afterMAX_INCOMPLETE_RECONCILE_PASSESzero-progress rounds.MetadataCacheTest.testStartupHydratorDoesNotLatchCompleteWhenATableFailsToHydrate,testCompletenessLatchIsMutuallyExclusiveWithClearCache,testConcurrentReconcileAndClearCacheNeverMarksEmptyCacheCompleteβ never latch over an incomplete/emptied cache.2. test(core): fix flaky covering-index fuzz op on stale WAL metadata
Problem
ReplicationFuzzTest.testPrimaryMigration(and other fuzz tests that enableaddCoveringIndexProb) intermittently fail with:The SQL engine is behaving correctly here β you genuinely cannot index a non-symbol column. The bug is in the fuzz operation.
Root cause
FuzzAddCoveringIndexOperation.executePostDrain:CairoEngine.getTableMetadata()and validates the target column by index is aSYMBOL, then takes that column's name;ALTER TABLE ... ALTER COLUMN "<name>" ADD INDEX TYPE POSTING INCLUDE (...).For WAL tables these two reads can see different metadata versions:
getTableMetadata()is, by its own contract, the lagging applied metadata (not all WAL transactions have necessarily reached the table yet).ALTER ... ADD INDEXvalidates againstSqlExecutionContext.getMetadataForWrite()βgetLegacyMetadata()βgetSequencerMetadata(), i.e. the fresh write-side metadata.When the workload recycles a column name (drop a
SYMBOLcolumn / rename so a name is reused by a non-symbol column), the operation can observe the name asSYMBOLin the lagging metadata while the name already resolves to a non-symbol (e.g.BYTE) column in the sequencer metadata. The emittedALTERthen fails, and"indexes are only supported for symbol type"was not in the operation's swallowed-error set, so it propagated and failed the test.This was seen on a CI run where the fuzz workload heavily renamed columns, recycling the name
new_col_12onto aBYTEcolumn before the covering-index op ran.Fix
Tolerate this transient metadata skew, consistent with the other races the operation already swallows (
already indexed/does not exist/table busy/ dropped table). Skipping the operation under this race is the established pattern for the fuzz infrastructure.Regression test
Added
FuzzAddCoveringIndexOperationTestwhich reproduces the skew deterministically β no fuzz seed, no servers, no concurrency (~1s): create a WAL table with aSYMBOLcolumn, drop + re-add it asBYTEthrough the sequencer without draining, then invokeexecutePostDrain. The applied metadata still reports the column asSYMBOLwhile the sequencer metadata seesBYTE, reproducing the exact failure.executePostDrain:112βalterTableColumnAddIndex:1176).Tests run: 1, Failures: 0, Errors: 0.3. docs: PR-bundling workflow guidance (
CLAUDE.md)Documentation-only; no code impact. Adds guidance to
CLAUDE.mdto bundle related fixes onto one branch/PR (CI is the throughput bottleneck, and the squash-merge collapses the branch into onemastercommit anyway), to give each fix its own clearly-labeled section in the PR body, and how to update a PR's branch/metadata when asked to "send a change to PR #N". Kept on this branch deliberately: it is small, merges/reverts with no code coupling, and it codifies the very multi-fix workflow this PR follows.