Skip to content

Deferred indexes -- background-build - #383

Open
michal-morzywolek wants to merge 211 commits into
mainfrom
experimental/deferred-indexes-background-build
Open

Deferred indexes -- background-build#383
michal-morzywolek wants to merge 211 commits into
mainfrom
experimental/deferred-indexes-background-build

Conversation

@michal-morzywolek

@michal-morzywolek michal-morzywolek commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new opt-in mechanism for building long-running indexes outside the upgrade window. Indexes declared with .deferred() are queued in a DeferredIndexes infrastructure table during the upgrade step; the adopter later drives them to completion by running the Runnables returned from DeferredIndexService.getBuildTasks().

The build task is self-healing: it observes physical state via a new SqlDialect.isIndexValid on every invocation, so a routine JVM restart mid-CREATE INDEX does not boot-loop the application. Only operator-caused corruption of a COMPLETED row (missing or INVALID physical) surfaces as fatal drift, and even then every anomaly across the schema is collected and reported in a single IllegalStateException per boot cycle.

API additions (all additive)

  • New public interfaces in org.alfasoftware.morf.upgrade.deferredindexes:
    • DeferredIndexService -- getBuildTasks(): List<DeferredIndexBuildTask> + getProgress(): Map<DeferredIndexStatus, Integer>
    • DeferredIndexBuildTask extends Runnable -- adds getTableName()/getIndexName() identity + getStatus()/getAttemptsCount()/getErrorMessage() snapshot getters for adopter-side filtering.
  • New default methods on SqlDialect:
    • Optional<Boolean> isIndexValid(Connection, String, String) -- implemented for PostgreSQL (pg_index.indisvalid), Oracle (USER_INDEXES.STATUS), both H2s (existence in INFORMATION_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 the DROP INDEX wait when reconciling an in-flight leftover.
    • boolean deferredIndexBuildRequiresAutoCommit() -- PostgreSQL returns true (CREATE INDEX CONCURRENTLY can't run in a transaction block); the build task flips and restores autocommit around the work.
  • New infrastructure upgrade step CreateDeferredIndexes, new DeferredIndexes table (11 columns, PK on id, unique index on (tableName, indexName), non-unique on status for 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 returning supportsDeferredIndexCreation()=false silently strip the .deferred() flag and build immediately.

Interaction with _PRF (ignored) indexes

When the visitor's emitPhysicalIndexIfNeeded sees a target index whose columns + isUnique match a _PRF* entry in UpgradeConfigAndContext.setIgnoredIndexes, it emits RENAME INDEX PRF -> newName instead of CREATE 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 as PENDING and self-heals to COMPLETED on the adopter's next build pass via isIndexValid. See integration guide Section 11.1 for the full behaviour matrix.

Docs

Two standalone documents ship with the feature:

  • Adopter integration guide -- covers the getBuildTasks() recommended loop pattern, CommonJ Work wrapper, single-node requirement, state matrix (12-cell self-heal table), per-dialect notes, _PRF rename interaction, and known limitations.
  • Design / testing / risks doc for the JIRA ticket -- glossary, requirements, solution overview, per-task algorithm, self-heal state matrix, INT-R1..INT-R3 regression scenarios + INT-01..INT-70 positive/edge scenarios (each row lists the specific test method backing it), technical approach, risks, assumptions & decisions.

Test plan

  • mvn clean verify green across all 10 modules
  • 38 integration tests in TestDeferredIndexesIntegration (real H2, end-to-end from Upgrade.performUpgrade through adopter's getBuildTasks() loop)
  • Unit test suites for every new production class (builder, service, session, statements, enricher, registration policy, deferred-index POJO, build-task holder)
  • Per-dialect surface tests: TestPostgreSQLDeferredIndexSupport (13), TestOracleDeferredIndexSupport (8), TestH2DeferredIndexSupport (7 tests x both H2 modules)
  • Latest merge from origin/main (16 commits, incl. the PRF-rename optimisation) integrated; three-way conflict in AbstractSchemaChangeVisitor.visit(ChangeIndex) resolved by lifting the PRF-rename optimisation into the shared emitPhysicalIndexIfNeeded helper (used by both AddIndex and ChangeIndex for symmetric behaviour). PRF x deferred intersection tests added (INT-66..INT-70) to cover the full-flow scenarios neither side had integration coverage for.
  • Checkstyle + SpotBugs pass

PRF-rename correctness fix

Reviewing the merge surfaced a defect in the first cut of the _PRF interaction, 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 state DeferredIndexSession's central invariant excludes — registered, non-terminal, and physically present — so isAwaitingBuild returned 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:

  • removeIndexDROP suppressed, physical orphaned
  • renameIndexRENAME suppressed, row records a name the database does not have
  • changeIndexDROP of the from-index suppressed, so the replacement CREATE hit Index "PRODUCT_NAME_1" already exists and failed the upgrade outright

Fixed by registering such rows as COMPLETED with completedTime set (DeferredIndexSession.registerCompletedIndex), which both restores the invariant and means a PRF-materialised index never enters the build queue — getBuildTasks() and getProgress() 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.

Your Name and others added 30 commits February 3, 2026 23:24
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>
Your Name and others added 30 commits April 30, 2026 20:28
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant