Deferred indexes -- background-build - #383
Open
michal-morzywolek wants to merge 211 commits into
Open
Conversation
Implements 14 test methods covering: - Valid steps with @Version and package-based versioning - Sequence ordering and validation - Error detection for missing/duplicate sequences - Invalid version format detection - Package name validation - Multiple validation error accumulation Increases coverage from 56% to 94% instruction coverage. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DeferredIndexStatus enum: PENDING, IN_PROGRESS, COMPLETED, FAILED - DeferredIndexOperationType enum: ADD - DeferredIndexOperation domain class representing a row from the DeferredIndexOperation table plus ordered column names - DeferredIndexOperationDAO for all CRUD operations on the deferred index queue, following the UpgradeStatusTableServiceImpl pattern (SqlScriptExecutorProvider + SqlDialect, enum values stored via .name()) - 10 unit tests using ArgumentCaptor to verify DSL statement structure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ce split - Add DeferredAddIndex: SchemaChange for deferred index creation; apply() updates metadata only, reverse() removes index from metadata, isApplied() checks actual DB schema first then DeferredIndexOperation PENDING queue via DAO - Split DeferredIndexOperationDAO into interface (@ImplementedBy) + DAOImpl class, following UpgradeStatusTableService/Impl convention - Wire DeferredAddIndex into SchemaChangeVisitor (visit method), AbstractSchemaChangeVisitor (updates schema, no DDL), SchemaChangeSequence.InternalVisitor, and SchemaChangeAdaptor (default + Combining) - Add 11 tests for DeferredAddIndex covering apply, reverse, isApplied, and accept - Rename TestDeferredIndexOperationDAO -> TestDeferredIndexOperationDAOImpl Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add addIndexDeferred() to SchemaEditor interface and SchemaChangeSequence.Editor: creates a DeferredAddIndex carrying the step's @uuid, calls visitor.visit() only (no schemaAndDataChangeVisitor — no DDL runs on the target table during upgrade) - AbstractSchemaChangeVisitor.visit(DeferredAddIndex): write INSERT SQL into DeferredIndexOperation and DeferredIndexOperationColumn as part of the upgrade script; upgradeUUID read from DeferredAddIndex rather than stored visitor state - DeferredAddIndex: add upgradeUUID field + getter; updated toString() to include it - HumanReadableStatementProducer: implement addIndexDeferred() via generateAddIndexString - Tests: TestInlineTableUpgrader, TestSchemaChangeSequence, TestDeferredAddIndex updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces DeferredIndexChangeService (interface + DeferredIndexChangeServiceImpl) to track pending deferred ADD INDEX operations within an upgrade session and emit the compensating SQL when subsequent schema changes interact with them: - RemoveIndex on a pending deferred ADD → cancel (DELETE) instead of DROP INDEX - RemoveTable → cancel all pending deferred indexes on that table - RemoveColumn → cancel pending deferred indexes referencing that column - RenameTable → UPDATE tableName in PENDING rows and update in-memory tracking - ChangeColumn (rename) → UPDATE columnName in PENDING column rows AbstractSchemaChangeVisitor delegates entirely to DeferredIndexChangeService, keeping SQL construction out of the visitor. The service is independently tested with 19 unit tests covering edge cases: multiple indexes on the same table, partial column matches, case-insensitivity, and single-UPDATE coverage for multi-index column renames. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stage 7: DeferredIndexExecutor picks up PENDING operations, builds indexes via SqlDialect.deferredIndexDeploymentStatements(), and manages retry with exponential backoff. Progress logged at 30s intervals. Stage 8: awaitCompletion() polls the queue for multi-instance deployments where non-executor nodes must block until index builds finish. Stage 9: DeferredIndexRecoveryService detects stale IN_PROGRESS operations (exceeded staleThresholdSeconds) and resets or completes them based on whether the index exists in the schema. Stage 10: DeferredIndexValidator force-executes any PENDING operations before a new upgrade runs, ensuring no missing indexes. Supporting changes: DeferredIndexTimestamps utility, retryBaseDelayMs config field, DAO.hasNonTerminalOperations(), SqlDialect base method for deferred index DDL. 28 tests (10 executor, 6 recovery, 4 validator, 8 unit) all passing. Coverage: Executor 87%/81%, Recovery 100%, Validator 100%, Timestamps 100%. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Override deferredIndexDeploymentStatements() in PostgreSQLDialect (CREATE INDEX CONCURRENTLY) and OracleDialect (ONLINE PARALLEL NOLOGGING) so deferred index builds avoid table-level write locks. DeferredIndexExecutor.buildIndex() now uses a dedicated autocommit connection because PostgreSQL's CONCURRENTLY cannot run inside a transaction block. This is harmless for other platforms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 H2 integration tests covering: pending row creation, executor completion, auto-cancel, unique/multi-column/new-table indexes, populated table builds, multiple indexes per step, executor idempotency, and recovery-to-execution pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…x/RenameIndex deferred handling - Fix DeferredIndexChangeServiceImpl to use DeferredIndexTimestamps.currentTimestamp() instead of System.currentTimeMillis() for createdTime (yyyyMMddHHmmss format) - Fix DeferredIndexOperationDAOImpl to use literal(boolean)/getBoolean() for the indexUnique column instead of literal(int)/getInt() - Add ChangeIndex/RenameIndex handling for pending deferred indexes in AbstractSchemaChangeVisitor: ChangeIndex cancels the deferred op and adds the new index immediately; RenameIndex updates the queued index name - Add updatePendingIndexName() to DeferredIndexChangeService/Impl - Add unit tests for deferred branches in both visitor test classes - Add integration tests for deferred-add-then-change and deferred-add-then-rename Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add retryMaxDelayMs config (default 5 min) to DeferredIndexConfig to cap exponential backoff and prevent overflow at high retry counts - DeferredIndexValidator now throws IllegalStateException when forced execution fails, blocking the upgrade until the issue is resolved - Update integration test to expect the exception on failed forced execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ry tracking - Add retryMaxDelayMs config (default 5 min) to cap exponential backoff and prevent overflow at high retry counts (#4) - DeferredIndexValidator throws IllegalStateException when forced execution fails, blocking the upgrade until resolved (#5) - updatePendingColumnName/updatePendingTableName now rebuild in-memory DeferredAddIndex entries so cancelPendingReferencingColumn finds indexes by renamed column/table names (#7) - Add unit and integration tests for all three fixes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace STRING(100) operationId PK on DeferredIndexOperation with BIG_INTEGER id column. Change DeferredIndexOperationColumn FK from STRING(100) to BIG_INTEGER to match. Update domain class, DAO interface/impl, services, executor, recovery service, and all tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
findOperationsByStatus and findStaleInProgressOperations now use a single LEFT OUTER JOIN query to fetch operations with their column names, eliminating per-operation column lookups. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DeferredAddIndex.apply() now uses equalsIgnoreCase() for index name comparison, consistent with reverse() and other SchemaChange classes. DeferredIndexChangeServiceImpl SQL statements now use the original casing from stored DeferredAddIndex entries rather than the caller's casing, ensuring SQL WHERE clauses match rows on case-sensitive databases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tableName, indexName, and columnName were STRING(30) which is too narrow for the validated maximum identifier length of 60 characters. Made SchemaValidator.MAX_LENGTH public and referenced it directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove existsByUpgradeUUIDAndIndexName from DAO interface and implementation — no production code calls it. Update stale comment in SchemaChangeSequence that referenced a future stage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…cted directly The DAO is never injected via Guice — all consumers construct it directly with ConnectionResources. Remove the misleading annotations to match the actual usage pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
addIndexDeferred now prefixes "Deferred: " so the output is distinguishable from a regular addIndex operation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each service now validates the config fields it consumes at construction time: threadPoolSize, maxRetries, retry delays, staleThresholdSeconds, and operationTimeoutSeconds. Also fixes stale comment in SchemaChangeSequence and distinguishes deferred index in human-readable output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…vate Introduce DeferredIndexService as the single public entry point for adopters. The facade orchestrates recovery, execution, and failure detection in one execute() call, and provides awaitCompletion() for passive nodes. Internal classes (Executor, RecoveryService, Validator, Operation, OperationType, Status) are now package-private. Config validation is consolidated in DeferredIndexServiceImpl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update column and index name assertions to match actual table structure after previous refactoring (operationId → id, updated index names). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- TestDeferredIndexValidatorUnit (5 tests): validates empty queue shortcut, successful execution, failure exception with count - TestDeferredIndexRecoveryServiceUnit (6 tests): stale recovery for index-exists, index-absent, table-missing, multiple ops, and case-insensitive index name matching - TestDeferredIndexExecutorUnit additions (8 tests): empty queue, single success, retry-then-success, permanent failure, getStatus before/after execution, awaitCompletion true/false paths - Added test constructors to DeferredIndexValidator and DeferredIndexRecoveryService for mock injection - Made DeferredIndexValidator.createExecutor() overridable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- TestDeferredIndexOperation (2 tests): full POJO getter/setter coverage including nullable fields → 100% line coverage - TestDeferredAddIndex additions (4 tests): toString(), apply/reverse with existing other indexes, isApplied with non-matching index → 100% line coverage - TestDeferredIndexExecutorUnit additions (3 tests): unique index reconstruction, SQLException from getConnection, zero-timeout awaitCompletion → 83% line coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace internal factory methods with proper Guice @Inject/@singleton wiring so that DeferredIndexService can be injected by adopters. All services now share a single DAO instance instead of each creating its own. Bind DeferredIndexConfig in MorfModule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Deployers can now configure a set of index names that should be built immediately during upgrade even when the upgrade step uses addIndexDeferred(). This allows overriding deferral for critical indexes without modifying upgrade steps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Deployers can now configure a set of index names that should be deferred even when the upgrade step uses addIndex(). This enables retroactive deferred index creation on old upgrade steps without modifying them. Includes conflict validation that throws if an index name appears in both forceImmediateIndexes and forceDeferredIndexes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Final pass for Javadoc coverage on production code added or significantly changed vs main. Most files were already well-covered through the earlier review-pass commits; this commit fills in the two remaining gaps: - DeferredIndexServiceImpl.java: the @Inject constructor lacked a Javadoc. Added a one-line description for the (builder, dao) pair. - SchemaChangeSequence.java: the inner Editor's resolveDeferred and rebuildIndex helpers lacked Javadoc -- both private but newly added for the deferred-index force-immediate / force-deferred resolution path. Added one-line descriptions. Verified via a per-file pass that every other added or significantly changed method already carries Javadoc: - All deferred-indexes/ package classes (interfaces + impls + POJOs). - AbstractSchemaChangeVisitor's new helpers (writeDeferredIndexesDml, emitAddIndexOrRename, findMatchingIgnoredIndex, registerInDeferredIndexes, withoutDeferredOnSupportingDialect, willBePhysicallyPresentAtThisEmission). - SqlDialect's added defaults (supportsDeferredIndexCreation, deferredIndexDeploymentStatements, deferredIndexBuildRequiresAutoCommit, setLockTimeoutSql, resetLockTimeoutSql, isIndexValid). - Per-dialect overrides on PostgreSQLDialect, OracleDialect, H2Dialect (v1 and v2). - Index/IndexBean/SchemaUtils additions for the deferred() flag. - UpgradeConfigAndContext additions for kill-switch and force lists. - Upgrade.performUpgrade overloads (5-arg + deprecated 4-arg). - AbstractSqlDialectTest hooks for per-dialect deferred coverage. @OverRide methods inherit Javadoc from their interface declarations per Javadoc tool semantics, so impl classes don't repeat them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups missed in the earlier commits: 1. The Builder/Task split in commit e5b100d changed the DeferredIndexServiceImpl constructor from (ConnectionResources, DAO) to (DeferredIndexBuilder, DAO). morf-core tests passed because they use the renamed signature, but TestDeferredIndexesIntegration.java in the integration-test module had four call sites still passing the old (connectionResources, dao) pair -- not exercised by the morf-core test run, but failing on integration-test compile. Added a newService() helper that constructs the service paired with a freshly-built builder + DAO, mirroring the existing newDao() helper. All four sites now go through it. 2. The mass `Track` -> `Register` sweep in commit 1327189 turned the test-method substring "Tracking" into "Registering" via replace_all, which produced two grammatically-clumsy artifacts: - testDeferredIndexProducesPendingRegisteringRow - "Registering row for Product_Name_1 should be deleted ..." Both represent registration-the-noun, not registering-the-verb. Renamed to "Registration". Verified: mvn -pl morf-integration-test -am test-compile clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Commit 1327189 renamed the public API (track→register, removeXxx→ unregisterXxx, effectiveIndex→normalize), but several test method names, local-variable names, and assertion-message phrases still carried the old vocabulary. TestDeferredIndexSessionImpl: 7 testRemoveXxx → testUnregisterXxx renames covering the positive paths and the no-op-on-unregistered-table paths. TestDeferredIndexesStatements: testRemoveIndex / testRemoveAllForTable follow the same pattern. TestDeferredIndexRegistrationPolicy: testIdempotencyUnderEffectiveIndex → testIdempotencyUnderNormalize, two `Index effective` locals → `Index normalized`, and the "effective form ..." assertion messages / Javadoc phrases switched to "normalized form ...". Cleaned up an "normalize normalization" Javadoc duplication while there. No production-code or behavioural change; tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test Javadoc and inline comments still carried "slim invariant", "Slim
used to ...", and references to renamed/removed members. These add
nothing for a future reader: comments should describe what the code IS,
not what it replaced.
Slim-branch language scrubbed from:
- TestDeferredIndex
- TestDeferredIndexSessionImpl
- TestDeferredIndexesStatements
- TestDeferredIndexesModelEnricherImpl
- TestInlineTableUpgrader
- TestDeferredIndexesIntegration
Stale-member references repointed to current API:
- TestDeferredIndexSessionImpl: "removeIndex" Javadoc / inline phrasing
→ "unregisterIndex"
- TestDeferredIndexesStatements: same; "deferred track should emit
PENDING" → "registered deferred index should emit status=PENDING"
- TestDeferredIndexBuilder: "executeOne/executeAll" reference dropped
(those names ceased to exist in the #131 split)
- TestDeferredIndexesModelEnricherImpl: "sharpened message hints at
manual recovery" Javadoc reset to describe current behaviour
(manual-recovery hints were stripped in #133)
Evolution-history comment in TestIndexNameDecorator (per
feedback_no_evolution_comments) reworded to describe what the test
verifies rather than the bug it was added for.
99 affected tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…path LHS The buildDeferredIndexesViaAdopter helper (TestDeferredIndexesIntegration L1374-1391) had three formal parameters none of which were referenced — @SuppressWarnings("unused") admitted as much — and a stale "TODO (Phase 5): rewrite the surrounding tests" Javadoc. Phase 5 is long done. Body identical to runBuildTasks(). - Replaced the 4 call sites with runBuildTasks() and deleted the helper. - With the helper gone, every `UpgradePath path = performUpgrade(...)` LHS in the file (18 sites) was unused; dropped them all (path, path1, path2 variants across performUpgrade / performUpgradeSteps / Upgrade.performUpgrade). - performUpgrade and performUpgradeSteps wrappers' UpgradePath return type became unused too — narrowed to void. - Dropped the now-unused org.alfasoftware.morf.upgrade.UpgradePath import. 39 integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestDeferredIndexBuilder: testRowMissingNoOp and testRowCompletedNoOp had
identical 4-line "no DAO writes / no SQL" verification blocks; only the
stubbed DAO return differed. Factored a verifyNoBuildSideEffects()
helper so each test now ends with a single call. Both branches still
documented separately.
TestDeferredIndexSessionImpl: testRegisterIndexReturnsInsert (non-deferred
input) and testRegisterDeferredIndex (deferred input) asserted the same
contract (one INSERT against DeferredIndexes, isRegistered=true). The
session itself does not differentiate -- the visitor's policy is what
filters non-deferred -- so the non-deferred case was redundant. Merged
into a single test using the realistic .deferred() input.
TestSchemaChangeSequence: StepWithAddIndex and StepWithDeferredAddIndex
inner classes had identical execute() bodies (schema.addIndex(
"TestTable", index)). The "deferred-ness" was always determined by the
test's stubbing of {@code index.isDeferred()}, never by the step. Kept
StepWithAddIndex (the more accurate name) and replaced the four
references to StepWithDeferredAddIndex.
The three remaining items in the TODO (mockIndex setup helper across
TestInlineTableUpgrader, the register-then-clearInvocations pattern, and
DeferredUser/DeferredUser2 in TestGraphBasedUpgradeBuilder) are left for
a future opportunistic pass: the first two would touch many pre-existing
tests for modest savings, and DeferredUser2 needs to coexist with
DeferredUser in the same SchemaChangeSequence to test parallelism, so
they cannot be merged.
44 affected tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestDeferredIndexesStatements: testUnregisterIndex,
testUnregisterAllForTable, testUpdateTableName, testUpdateIndexColumns
and testUpdateIndexName previously asserted only
assertNotNull(stmt.getWhereCriterion()) and a field-list size of 1.
Replaced with assertWhereOnTableAndIndex / a new
assertWhereOnSingleField helper, plus explicit alias-and-value checks
on the SET expression. The shared helpers now unwrap FieldLiteral for
consistent comparison since the unregister/update paths wrap their
right-hand sides in literal(...) (markStarted/markCompleted/markFailed
do not — pre-existing inconsistency in DeferredIndexesStatements).
TestDeferredIndexSessionImpl: the merged testRegisterDeferredIndex
asserted on stmts.get(0).toString().contains("DeferredIndexes") -- a
brittle reliance on Statement.toString. Switched to the typed pattern
already used in TestDeferredIndexesStatements:
((InsertStatement) stmts.get(0)).getTable().getName().
TestInlineTableUpgrader: 10 sites used verify(..., atLeast(1)).writeSql,
which would silently let a regression that emits the registration INSERT
twice through. Tightened to verify(...) (= times(1)) where the test
exercises a single visit, and to verify(..., times(2)) for the five
"register-then-cancel" tests where the visit emits two writes (DELETE +
DDL or INSERT + DDL). Also dropped the FQNs at L91-93 / L1006 / L1053:
ArgumentMatchers.any(org.alfasoftware.morf.sql.InsertStatement.class)
became ArgumentMatchers.any(InsertStatement.class) since the imports
already exist.
TestGraphBasedUpgradeSchemaChangeVisitor: removed an awkward
((java.util.Collection<?>) c).containsAll(STATEMENTS) cast at L393 to
match the surrounding pattern of c -> c.containsAll(STATEMENTS).
testFactory in TestGraphBasedUpgradeSchemaChangeVisitor (TODO item 6)
left as-is: assertNotNull is the meaningful contract of a factory
smoke test.
90 affected tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
testAbsentHappyPath already used InOrder to pin
markStarted → execute(CREATE) → markCompleted, but the failing-path
tests did not, so a regression that flipped any of those calls would
slip through.
- testAbsentCreateFailsMarksFailed: wrap the verifications in an
InOrder on (dao, statement) — markStarted, then execute(CREATE),
then markFailed.
- testInvalidNoLockTimeoutSkipsSet: add InOrder on
(dao, stmtDrop, stmtCreate) — markStarted, DROP, CREATE,
markCompleted.
- testInvalidCreateAfterDropFailsMarksFailedWithRawMessage: add
InOrder on (dao, stmtDrop, stmtCreate) — markStarted, DROP, CREATE,
markFailed; also pinned a "markCompleted is never called" check
that was previously implicit.
14 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production typo fix: UpgradeGraph#L71 builds the duplicate-@sequence error message with "sh are" (two spaces, no first space) instead of "share". Pre-existing on main; surfaced only by this branch's new TestUpgradeGraph (08598df) which copy-pasted the typo'd substring into its assertions. Fixed both production source and the two test assertions (testDuplicateSequenceNumbers, testMultipleValidationErrors). Style nits: - assertEquals(false, ...) → assertFalse(...) in TestSchemaChangeSequence#L138 and the three sibling dialect tests (Oracle, H2, H2v2 — TestPostgreSQLDeferredIndexSupport already used assertFalse). - TestDeferredIndexesIntegration: instance-initialiser block `{ config.setDeferredIndexCreationEnabled(true); }` moved into the existing @before setUp(); schemaWithIndex() made static to match its sibling helper schemaWith(). - TestPostgreSQLDeferredIndexSupport: factored the repeated `new PostgreSQLDialect("schemaA")` into a `private final dialect` field, matching the Oracle/H2 sibling tests. Tests passing null / "MySchema" / "" left inline since they exercise distinct schema configs. - TestSchemaChangeSequence: two @test(expected = ...) replaced with `assertThrows(IllegalStateException.class, () -> ...)` to match the modern style. - TestGraphBasedUpgradeBuilder: Javadoc on DeferredUser{,2} read "addIndex (deferred)()" -- replaced with the actual call form `schema.addIndex(table, index().deferred())`. - TestInlineTableUpgrader: 26 `ArgumentMatchers.any(...)` / `ArgumentMatchers.eq(...)` qualified calls switched to the static-imported `any(...)` / `eq(...)` form already used elsewhere in the file. Added `import static ...any` and dropped the now-unused `import org.mockito.ArgumentMatchers`. - TestDeferredIndexServiceImpl: dropped testGetBuildTasksReturnsBuildTaskImpl -- the `instanceof DeferredIndexBuildTaskImpl` check leaked the package-private impl class while the surviving tests already cover the public-API contract (identity, unmodifiable list, row snapshot, progress). setUp leakage in TestInlineTableUpgrader / TestGraphBasedUpgradeSchemaChangeVisitor (TODO item 9) left as-is: the convertStatementToSQL stubs serve enough tests to be worth the central setUp. 39 integration tests + 2744 morf-core tests pass. Checkstyle clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestDeferredIndexesModelEnricherImpl: add testPhysicalIndexWithNoRowKeptAsNonDeferred documenting the trivial-pass branch -- a physical index with no matching DeferredIndexes row passes through unchanged with isDeferred()=false. Other tests' setups exercised this implicitly via their primary-key index (no row), but a focused test pins the contract. TestSchemaChangeSequence: add testDeprecatedSingleArgCtorBehavesAsDefaultConfig covering the @deprecated SchemaChangeSequence(List) overload. The 1-arg ctor was restored for backwards compatibility but no test exercised it. The new test asserts construction succeeds and produces an equivalent change list to the 2-arg form invoked with a default UpgradeConfigAndContext. TestGraphBasedUpgradeSchemaChangeVisitor: split the previous testRemoveIndexVisitRespectsAwaitingBuildSession (which only exercised PENDING) into PENDING / IN_PROGRESS / FAILED siblings driven by a shared assertNoDropIndexEmittedForAwaitingBuildRow helper. The Javadoc already claimed all three statuses behave identically; now they're all verified. Added testChangeIndexVisitRespectsAwaitingBuildSession (no DROP for the awaiting-build from-index, and no immediate CREATE because the to-index is also deferred) and testRenameIndexVisitRespectsAwaitingBuildSession (no physical RENAME). Both reuse the new primedSessionWithStatus helper. TestPostgreSQLDeferredIndexSupport: replaced what would have been a "whitespace-only schema name" isIndexValid test with testWhitespaceSchemaNameRejectedAtConstruction. The StringUtils.isNotBlank guard inside isIndexValid is dead code given that SchemaValidatorUtil.validateSchemaName (called from the SqlDialect ctor) rejects anything outside [A-Za-z0-9_]* at construction time -- so the test now pins the upstream guard. 55 morf-core tests pass + 13 postgresql dialect tests. Checkstyle clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test only asserted findNonTerminal().isEmpty() after running an upgrade with a non-deferred index. Both testNonDeferredIndexBuiltImmediately and testForceImmediateBypassesDeferral already assert that contract plus the physical-index existence / absence — strict supersets. The "ReturnsEmptyStatements" name also referred to the long-removed getDeferredIndexStatements() API. 38 integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Javadoc claimed "no duplicate rows" but the assertions only verified status before and after. A regression that wrote a duplicate PENDING row on the second upgrade would have slipped through. Added findNonTerminal().size() == 1 checks both before and after the second upgrade so the duplicate-row contract is actually verified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously asserted only "GhostTable" appeared in the drift message.
Sibling testEnricherHardFailsOnCompletedRowWithoutPhysicalIndex pins
two substrings ("Phantom_Idx", "COMPLETED"). Match that style by also
requiring the index name "GhostIdx" so a regression that drops either
the table or the index from the drift message can't pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously asserted only the registration row's tableName updated to "Item"; never verified the physical Product table was renamed. Added assertPhysicalTableExists / assertPhysicalTableDoesNotExist helpers (mirroring the existing index helpers) and pinned the three post-rename invariants: - Item table exists physically - Product table no longer exists - No physical Product_Name_1 index on Item (still deferred, not built) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test method name and its Javadoc both said "Indexes" / "all its indexes", but the test exercises a single inline index (Category_Label_1). Renamed to testAddTableRegistersIndexInDeferredTable (singular) and rephrased the Javadoc to match — "register the index in the DeferredIndexes table". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renamed every `List<DeferredIndex> deferredJobs` local to `rows` (~10
sites) — the rows are registration rows, not jobs. Updated the
companion assertion messages ("Should have a deferred job" →
"Should have a registration row" / "build task pending"), and
rephrased four inline comments / one Javadoc that still spoke of
"deferred index job" / "execute the job" / "renamed deferred index in
jobs". The old "jobs" vocabulary was a leftover from a removed earlier
API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Javadoc fragment "Inline-deferred index on AddTable: the actually-defer fix" read as half-sentence shorthand and was meaningless without prior context. Replaced with a description of what the test actually verifies: declaring a deferred index inline on addTable must not emit CREATE INDEX at upgrade time — it travels through the same registration pipeline as a stand-alone .deferred() addIndex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two assertions covered the same fact about the renamed column:
- queryDeferredIndexField(..., "indexColumns") -> "label"
- .findNonTerminal() stream check that .getIndexColumns() contains
"label"
Kept the first (precise, reads more naturally) and replaced the second
with a simpler "row still exists after rename" check, which adds
distinct survival information rather than restating the columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ithCustomConfig Three tests (testDisabledFeatureBuildsDeferredImmediately, testForceImmediateBypassesDeferral, testForceDeferredOverridesImmediate) each open-coded a fresh UpgradeConfigAndContext, set one or two fields on it, and called Upgrade.performUpgrade(...) directly. Extracted a performUpgradeWithCustomConfig(Schema, step, Consumer<config>) helper that creates the config, applies the customizer, and runs the upgrade through the same connectionResources / viewDeploymentValidator the other helpers use. Each call site goes from ~5 lines to 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file was new on this branch but missed the // given / // when / // then convention used elsewhere in the deferred-index test cluster (TestDeferredIndexesIntegration, TestDeferredIndexesStatements, TestDeferredIndexSessionImpl, etc.). Each test now lays out construction (given), the decorator wrap (when), and the assertion block (then) explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven test files added on this branch (TestUpgradeGraph, TestDeferredIndexBuilder, TestUpgradeSteps, the four dialect-side Test*DeferredIndexSupport classes) lacked the // given / // when / // then convention used by the higher-level deferred-index cluster (Integration / Statements / Session / Enricher / Service / Policy / IndexNameDecorator). Brought them in line: 66 test methods now each expose the three sections explicitly. No logic change — pure annotation pass. Pre-existing morf tests left alone (morf-core only uses GWT in ~13% of its test files, so retrofit is out of scope). 49 affected tests pass across morf-core / h2 / h2v2 / oracle / postgresql; checkstyle clean on all five modules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-indexes-background-build # Conflicts: # morf-core/src/main/java/org/alfasoftware/morf/upgrade/AbstractSchemaChangeVisitor.java # morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestGraphBasedUpgradeSchemaChangeVisitor.java # morf-core/src/test/java/org/alfasoftware/morf/upgrade/TestInlineTableUpgrader.java
Adds the tests that live at the intersection of main's PRF-rename optimisation (5dbd73c) and this branch's deferred-index feature. Both sides shipped without integration coverage for the combined path. Integration (TestDeferredIndexesIntegration): - testAddDeferredIndexWithMatchingPRFRenamesInsteadOfCreating -- an AddIndex with .deferred() and a matching PRF renames the PRF, no CREATE, no duplicate physical; self-heals to COMPLETED on the next build pass. - testChangeImmediateToDeferredWithMatchingPRFRenamesInsteadOfCreating -- a ChangeIndex whose to-index is .deferred() with a matching PRF drops the from-index, renames the PRF, self-heals to COMPLETED. - testDeferredIndexBuiltViaPRFRenameCanBeRemovedInLaterUpgrade -- the PRF-materialised deferred index round-trips cleanly (add, build, remove) across two upgrades. - testForceImmediateWithMatchingPRFRenamesInsteadOfCreating -- the forceImmediateIndexes override still hits the PRF-rename path since the optimisation is orthogonal to the deferred flag. Unit (TestDeferredIndexBuilder): - testPendingWithValidPhysicalMarksCompleted -- the PENDING variant of testValidMarksCompleted; documents the self-heal path Path C relies on for PRF-rename origin rows. Fixtures added: - v2_0_0.ChangeImmediateNameIndexToDeferredIdName -- ChangeIndex from immediate on name to deferred on id+name. - v2_0_0.RemoveDeferredProductNameIndex -- standalone RemoveIndex for Product_Name_1. Helpers added: - performUpgradeStepsWithCustomConfig(varargs) -- multi-step config variant matching the existing single-step helper. - physicalIndexExistsRaw / assertPhysicalIndexExistsRaw / assertPhysicalIndexDoesNotExistRaw -- INFORMATION_SCHEMA-level checks that bypass DatabaseMetaDataProviderUtils.shouldIgnoreIndex (which filters PRF-named indexes out of the SchemaResource view). Coverage gaps deliberately not filled here: - Item 4 (enricher re-scan after PRF rename) is a subset of the flow covered by testDeferredIndexBuiltViaPRFRenameCanBeRemovedInLaterUpgrade and by the pre-existing testNonCompletedRowWithPhysicalMatchRebuiltAsDeferred. - Item 5 (same-step add-deferred + remove with matching PRF) exposes a pre-existing session-state gap: after a PRF rename, DeferredIndexSession still marks the index as awaitingBuild=true (status PENDING), so a same-step RemoveIndex sees fromWillBePresent=false and skips the DROP -- leaving the renamed physical orphaned. Fixing this needs a new session API (register-as-completed) and a status-parameterised INSERT; scoped as a separate follow-up rather than dragged into this coverage pass. Full mvn clean verify: BUILD SUCCESS across all 10 modules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PRF-rename path satisfied a declared-deferred index by renaming a
shape-matching ignored _PRF index, then registered the row as PENDING.
That produced a state the session's central invariant excludes --
registered + non-terminal + physically present -- so
DeferredIndexSession.isAwaitingBuild returned a false positive for an
index that was already in the database.
Every consumer of that invariant was affected, not just the same-step
remove noted in the previous commit. Reproduced across upgrades, in the
supported case where the adopter has not drained the build queue before
running the next upgrade:
- RemoveIndex -> DROP suppressed; physical orphaned.
- RenameIndex -> RENAME suppressed; row records a name the database
does not have.
- ChangeIndex -> DROP of the from-index suppressed, so the replacement
CREATE hit "Index PRODUCT_NAME_1 already exists" and
failed the upgrade outright.
Fix: when the emitted DDL has already materialised the index, register
the row as COMPLETED with completedTime set rather than PENDING.
- DeferredIndexesStatements.registerCompletedIndex -- INSERT with
status COMPLETED; the existing registerIndex now delegates to a
shared status-parameterised private builder.
- DeferredIndexSession.registerCompletedIndex -- caches the record as
COMPLETED so isAwaitingBuild reports false.
- AbstractSchemaChangeVisitor.emitPhysicalIndexIfNeeded now returns
whether it materialised the index; visit(AddIndex) and
visit(ChangeIndex) thread that into registerInDeferredIndexes, which
picks the COMPLETED or PENDING variant. AddTable / AddTableFrom pass
false -- the table is being created, so nothing can pre-exist.
Beyond fixing the DDL suppression this removes the transient
"PENDING that is not actually pending" state: a PRF-materialised index
never enters the build queue, so getBuildTasks() and getProgress() no
longer report phantom outstanding work.
Tests (written first, all three failed against the previous commit):
- testRemoveOfPRFMaterialisedDeferredIndexDropsPhysicalWhenQueueNotDrained
- testRenameOfPRFMaterialisedDeferredIndexRenamesPhysicalWhenQueueNotDrained
- testChangeOfPRFMaterialisedDeferredIndexDropsFromPhysicalWhenQueueNotDrained
- TestDeferredIndexSessionImpl.testRegisterCompletedIndexIsNotAwaitingBuild
plus testRegisterIndexIsAwaitingBuild as the contrasting case.
The two PRF tests added in ba3da38 asserted the old PENDING status;
updated to assert COMPLETED and to pin that the index never enters the
build queue. New fixture RenameDeferredProductNameIndex.
Full mvn clean verify: BUILD SUCCESS across all 10 modules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
registerCompletedIndex was covered at the session level (isAwaitingBuild behaviour) and end-to-end, but not at the DSL level -- unlike its sibling registerIndex, which has had a dedicated test since it was written. Pins the two things that distinguish it: 9 values rather than 8 (the extra one being completedTime), and status=COMPLETED with no PENDING literal anywhere in the INSERT. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sweeps the four scenarios left untested after the registerCompletedIndex fix. All four already behaved correctly -- these pin the behaviour rather than change it. - RemoveColumn against a PRF-materialised deferred index. This path never consults isAwaitingBuild (it deletes the row via unregisterByColumn and lets the column drop cascade), so it was unaffected by the COMPLETED fix -- now verified rather than assumed. - A non-unique PRF is not consumed by a declared UNIQUE deferred index. Renaming it would produce an index without the uniqueness constraint the schema asks for; the matcher's isUnique() comparison prevents it. - A multi-column PRF matching a multi-column deferred index on the same columns in the same order is consumed and registered COMPLETED. - A PRF whose columns are in a different order is not consumed -- column order is part of an index's identity. Full mvn clean verify: BUILD SUCCESS across all 10 modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CreateDeferredIndexes was added to UpgradeSteps.LIST on this branch, so Morf creates the DeferredIndexes table on every upgrade, unconditionally and regardless of whether the adopter enables the feature. But DatabaseUpgradeTableContribution.tables() still returned only [DeployedViews, UpgradeAudit]. tables() is bound into a Multibinder<TableContribution> in MorfModule, so it is how an infrastructure table reaches the target schema an adopter assembles from its binaries. With the table created but not declared, UpgradePathFinder.determinePath compared a trial-upgraded schema containing DeferredIndexes against a target schema without it and threw NoUpgradePathExistsException. Effect: any existing deployment picking up this build could not start -- with the feature switched off and no .deferred() index anywhere. The mirror case was a fresh deployment, where Deployment.deploy writes only the target schema's tables while recording every step UUID as applied, leaving CreateDeferredIndexes marked done and the table absent; a later upgrade would then INSERT into a table that does not exist. This changes no design decision. The table was always going to exist for every adopter -- that was settled when the step joined UpgradeSteps.LIST. Only the declaration was missing. Tests (written first, both failed): - testTablesIncludesEveryTableCreatedByAMorfUpgradeStep -- direct. - testPendingMorfUpgradeStepsReachTheContributedSchema -- reproduces the adopter path: current schema as it stands before the newest step, target assembled from the contribution, every step but the newest marked applied, and determinePath must find a path. It derives the applied UUIDs by reflecting @uuid off UpgradeSteps.LIST rather than hardcoding them, so the next infrastructure step that forgets tables() fails here too. The existing suite could not catch this: TestDeferredIndexesIntegration's schemaWith() helper hand-builds its schema with deferredIndexesTable() added explicitly, compensating for the exact omission the product had. Verified the two declarations of the table -- deferredIndexesTable() and CreateDeferredIndexes.execute() -- agree column for column; a divergence there would have traded one path-finder failure for another. Full mvn clean verify: 4869 tests, 0 failures, 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two pairs of integration fixtures collided: @sequence(90002) AddDeferredIndexThenRemove, AddSecondDeferredIndex @sequence(90008) AddTableWithInlineDeferredIndex, AddTwoDeferredIndexes @uuid(...0008) AddTableWithInlineDeferredIndex, AddTwoDeferredIndexes Nothing failed today because no existing test combines a clashing pair in one performUpgradeSteps(...) call. The next one to try would have got an IllegalStateException from UpgradeGraph -- "share the same @sequence annotation value of [900xx]" -- in a test unrelated to either step, and worded as though the product were at fault rather than the fixtures. Moved the two v1_0_0 fixtures into free slots (90003 and 90011) with matching UUIDs, leaving their v2_0_0 counterparts untouched. Tests (written first, both failed): - testAllFixturesCanBeCombinedInOneUpgradeGraph -- builds an UpgradeGraph over every fixture. Using Morf's own validator means the guard checks exactly what a real upgrade checks, rather than reimplementing it. - testAllFixtureUuidsAreUnique -- UpgradeGraph does not police UUIDs, so this covers the half it misses. Both scan the fixture package with Guava's ClassPath instead of holding a hardcoded list, so a fixture added later is covered without anyone remembering to register it. Full mvn clean verify: 4871 tests, 0 failures, 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upgrade.findPath walks the schema change sequence twice: once with the
InlineTableUpgrader and once, later, via the graph-based upgrade builder.
Two alternative scripts are produced and only one is executed. Both walks
were handed the same DeferredIndexSession instance.
The session is mutable and is what the visitor consults to decide whether
an index is physically present -- isAwaitingBuild backs
willBePhysicallyPresentAtThisEmission. So the second walk saw whatever the
first had left behind. For a deferred index registered by an earlier
upgrade and not yet built, removeIndex behaved like this:
pass 1 (inline) isAwaitingBuild -> true suppresses DROP, emits DELETE,
and evicts the entry
pass 2 (graph) isAwaitingBuild -> false emits DROP INDEX for an index
that was never created, and no
DELETE, so the row survives
Under a graph-based upgrade only pass 2's script runs: the DROP fails at
runtime, and if that is tolerated the orphaned PENDING row is virtualised
back into the source schema on the next boot, no longer matches the target
schema, and the application cannot start. removeTable and renameTable
diverge the same way.
Fix: DeferredIndexSession.copy() returns an independent session holding the
same state. Upgrade takes the copy immediately after the enricher primes,
before the inline walk can mutate anything, and gives it to the graph
builder. Both walks now start from identical state and cannot observe each
other. IndexRecord is immutable, so copying the two map levels suffices.
Tests (written first, failed):
- TestUpgrade.testGraphBasedBuilderGetsASessionUnaffectedByTheInlineUpgrader
drives the real findPath wiring, captures the session handed to the graph
builder, and asserts it still reports the index as awaiting build. This
also opens up the graph path in tests, which had no coverage at all --
the static Upgrade.performUpgrade entry point passes a null builder
factory, so every existing deferred-index integration test exercises only
the inline walk.
- TestDeferredIndexSessionImpl gains coverage of copy(): that it carries
primed state, and that mutations do not leak in either direction.
Full mvn clean verify: 4874 tests, 0 failures, 0 errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The visitor asked the session "is this awaiting build?" and used the answer
to mean "is this physically absent?". Those diverge whenever a build creates
the index and then dies before writing COMPLETED, and whenever a PostgreSQL
CREATE INDEX CONCURRENTLY fails and leaves the index behind. In both cases a
non-terminal row sits over an index that genuinely exists.
A later upgrade touching that index then suppressed its DDL:
removeIndex DELETEs the row, skips the DROP -- the index outlives every
record of itself and the next enrichment pass reports it as an
unexplained difference
renameIndex renames the row, skips the RENAME -- the row records a name the
database doesn't have, and the next build pass creates a second
index alongside the stranded original
changeIndex skips the DROP but still emits the CREATE for the replacement,
so the script aborts on "index already exists"
The enricher already knew the answer: it walks the physical schema and matches
each row against it. It just never passed that on. prime() now carries the
observation, IndexRecord stores it in place of the row status -- so the wrong
input is no longer available to consult -- and isAwaitingBuild becomes
willBePhysicallyPresent, which is the only question any caller ever asked.
Its sole consumer was a private wrapper defined as its negation; that wrapper
is gone and the three call sites now read the session directly.
Presence is a name-existence scan of a schema already in memory, not a validity
check: DROP and RENAME are correct, and necessary, against an index that exists
but is INVALID. No extra database work.
Does not address an index becoming present between enrichment and script
execution -- a build task racing an upgrade. That remains open.
Tests: three integration tests covering remove, rename and change over a
crashed build, each verified to fail beforehand; a graph-visitor test for a
non-terminal row with a physical index; and the existing status-parameterised
visitor tests reworked to assert that status does not enter the decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USjLVCYoZA9dpVUeJcu8pK
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
Adds a new opt-in mechanism for building long-running indexes outside the upgrade window. Indexes declared with
.deferred()are queued in aDeferredIndexesinfrastructure table during the upgrade step; the adopter later drives them to completion by running theRunnables returned fromDeferredIndexService.getBuildTasks().The build task is self-healing: it observes physical state via a new
SqlDialect.isIndexValidon every invocation, so a routine JVM restart mid-CREATE INDEXdoes not boot-loop the application. Only operator-caused corruption of aCOMPLETEDrow (missing orINVALIDphysical) surfaces as fatal drift, and even then every anomaly across the schema is collected and reported in a singleIllegalStateExceptionper boot cycle.API additions (all additive)
org.alfasoftware.morf.upgrade.deferredindexes:DeferredIndexService--getBuildTasks(): List<DeferredIndexBuildTask>+getProgress(): Map<DeferredIndexStatus, Integer>DeferredIndexBuildTask extends Runnable-- addsgetTableName()/getIndexName()identity +getStatus()/getAttemptsCount()/getErrorMessage()snapshot getters for adopter-side filtering.SqlDialect:Optional<Boolean> isIndexValid(Connection, String, String)-- implemented for PostgreSQL (pg_index.indisvalid), Oracle (USER_INDEXES.STATUS), both H2s (existence inINFORMATION_SCHEMA.INDEXES). Default empty; adopters on MySQL/SQL Server get graceful fallback.Optional<String> setLockTimeoutSql(Duration)/resetLockTimeoutSql()-- PostgreSQL-only (SET lock_timeout = <ms>+RESET lock_timeout), used to bound theDROP INDEXwait when reconciling an in-flight leftover.boolean deferredIndexBuildRequiresAutoCommit()-- PostgreSQL returnstrue(CREATE INDEX CONCURRENTLYcan't run in a transaction block); the build task flips and restores autocommit around the work.CreateDeferredIndexes, newDeferredIndexestable (11 columns, PK onid, unique index on(tableName, indexName), non-unique onstatusfor fast non-terminal lookup).Adopters not opting into the feature see zero behaviour change:
UpgradeConfigAndContext.setDeferredIndexCreationEnabled(true)is required to activate the pipeline, and dialects returningsupportsDeferredIndexCreation()=falsesilently strip the.deferred()flag and build immediately.Interaction with
_PRF(ignored) indexesWhen the visitor's
emitPhysicalIndexIfNeededsees a target index whose columns +isUniquematch a_PRF*entry inUpgradeConfigAndContext.setIgnoredIndexes, it emitsRENAME INDEX PRF -> newNameinstead ofCREATE INDEX newName-- regardless of the deferred flag. For a.deferred()target this materialises the physical immediately (cheaper than a background CREATE); the row is still registered asPENDINGand self-heals toCOMPLETEDon the adopter's next build pass viaisIndexValid. See integration guide Section 11.1 for the full behaviour matrix.Docs
Two standalone documents ship with the feature:
getBuildTasks()recommended loop pattern, CommonJWorkwrapper, single-node requirement, state matrix (12-cell self-heal table), per-dialect notes,_PRFrename interaction, and known limitations.Test plan
mvn clean verifygreen across all 10 modulesTestDeferredIndexesIntegration(real H2, end-to-end fromUpgrade.performUpgradethrough adopter'sgetBuildTasks()loop)TestPostgreSQLDeferredIndexSupport(13),TestOracleDeferredIndexSupport(8),TestH2DeferredIndexSupport(7 tests x both H2 modules)origin/main(16 commits, incl. the PRF-rename optimisation) integrated; three-way conflict inAbstractSchemaChangeVisitor.visit(ChangeIndex)resolved by lifting the PRF-rename optimisation into the sharedemitPhysicalIndexIfNeededhelper (used by bothAddIndexandChangeIndexfor symmetric behaviour). PRF x deferred intersection tests added (INT-66..INT-70) to cover the full-flow scenarios neither side had integration coverage for.PRF-rename correctness fix
Reviewing the merge surfaced a defect in the first cut of the
_PRFinteraction, now fixed on the branch.Satisfying a declared-deferred index by renaming a shape-matching PRF leaves it physically present the instant the upgrade script runs, but the row was still registered
PENDING. That produced the one stateDeferredIndexSession's central invariant excludes — registered, non-terminal, and physically present — soisAwaitingBuildreturned a false positive for an index already in the database, and the visitor suppressed the DDL of any later change to it. Reproduced across upgrades, in the explicitly-supported case where the adopter has not drained the build queue before the next upgrade:removeIndex—DROPsuppressed, physical orphanedrenameIndex—RENAMEsuppressed, row records a name the database does not havechangeIndex—DROPof the from-index suppressed, so the replacementCREATEhitIndex "PRODUCT_NAME_1" already existsand failed the upgrade outrightFixed by registering such rows as
COMPLETEDwithcompletedTimeset (DeferredIndexSession.registerCompletedIndex), which both restores the invariant and means a PRF-materialised index never enters the build queue —getBuildTasks()andgetProgress()no longer report phantom outstanding work. Three integration tests were written first and confirmed failing (INT-71..INT-73), plus session-level tests pinning the invariant directly (INT-74).There is no known outstanding limitation in this area.