Skip to content

DeployedIndexes slim: deferred-only tracking (A/B alternative to #375)#376

Open
michal-morzywolek wants to merge 131 commits intomainfrom
experimental/deferred-indexes-slim
Open

DeployedIndexes slim: deferred-only tracking (A/B alternative to #375)#376
michal-morzywolek wants to merge 131 commits intomainfrom
experimental/deferred-indexes-slim

Conversation

@michal-morzywolek
Copy link
Copy Markdown
Contributor

Summary

Slim redesign of the DeployedIndexes tracking table: rows exist only for deferred indexes. Non-deferred indexes live only in the physical DB and are policed by the framework's existing SchemaHomology.schemasMatch comparison (same machinery that validates upgrade paths).

The full-tracking alternative is #375. Same adopter API, same per-database semantics, same configuration — just a narrower scope and a lot less code.

Why this is simpler

  1. Scope drop eliminates redundant work. Morf already compares declared vs. physical schema before every upgrade — that's how paths are validated. The full model re-implemented variants of that check inside its own enricher; slim just uses the existing authoritative machinery (~200 lines gone).
  2. No prepopulation → no new upgrade-step API. The full model had to seed a tracking row for every pre-existing index, which forced a new 3-arg UpgradeStep.execute overload plus a supporting context interface so the seeding step could read pre-upgrade schema. Slim has nothing to seed → the whole side-API is deleted.
  3. The build lifecycle — the table's real job — is fully preserved. PENDINGIN_PROGRESSCOMPLETED/FAILED is the one state schema comparison can't see (a declared-deferred index legitimately isn't physical yet). Slim keeps all of it, including cross-upgrade carry-forward of unbuilt deferred indexes.
  4. Adopter API unchanged. Same call to fetch deferred SQL, same tracker to report status, same per-database semantics, same kill-switch / force-immediate / force-deferred config. Only visible difference: one fewer column in an internal table adopters don't query directly.

Size

−574 net lines across 35 files vs. #375 (1 130 deletions, 556 insertions).

  • Enricher: ~295 → ~170 lines
  • CreateDeployedIndexes step: ~140 → ~85 lines
  • UpgradeContext interface: deleted
  • ~10 enricher unit tests removed (they tested the redundant checks)

Phase breakdown

SP Scope
SP1 Visitor gates tracking on isDeferred(); adds service.prime() for cross-session state
SP2 Enricher rewritten: primes service + virtualizes unbuilt-deferred only; drops consistency checks
SP3 Deletes UpgradeContext, 3-arg execute, prepopulation loop
SP4 Documents why collectDeferredIndexJobs stays schema-scan (DAO approach has a timing bug)
SP5 Drops indexDeferred column as redundant under slim invariant
SP6 New slim-branch documentation

Test plan

  • Review TestDeployedIndexesIntegration (25 tests) — deferred lifecycle on H2.
  • Review slim enricher tests — priming + virtualization coverage.
  • Compare DeployedIndexesModelEnricherImpl against the full-model version (DeployedIndexes: unified tracking table for all indexes (full model) #375) side-by-side; verify every deleted check is something SchemaHomology already catches.
  • Confirm cross-upgrade carry-forward: upgrade A declares a deferred index; upgrade B renames its column; getDeferredIndexStatements() after B returns SQL with the renamed column.

🤖 Generated with Claude Code

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 29 commits April 16, 2026 10:24
ChangeColumn.applyChange() rebuilds indexes that reference the renamed
column but previously only preserved isUnique(). isDeferred() was reset
to false, causing getDeferredIndexStatements() to miss the renamed index
after a cross-step column rename.

Also add two integration tests:
- testPrepopulationPopulatesExistingIndexes: verifies CreateDeployedIndexes
  populates pre-existing physical indexes.
- testCrossStepColumnRenameOnNonDeferredIndex: verifies column rename on
  a non-deferred index updates indexColumns in DeployedIndexes.

Strengthen testCrossStepColumnRename to assert the renamed column is now
properly reflected in getDeferredIndexStatements().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app runs deferred-index SQL itself on this branch — Morf no longer
owns a thread pool. The field, getter, and setter had no callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- SqlDialect.buildCreateIndexStatement: drop unused afterIndexKeyword
  parameter (no caller ever passed a non-empty value).
- PostgreSQLDialect.buildPostgreSqlCreateIndex: replace the String
  "afterIndexKeyword" with a clearer `boolean concurrent` flag.
- Rename EnrichedIndex -> ObservedIndex and expand the class javadoc
  with a concise reason-for-existence (declarative vs observed state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the ObservedIndex decorator pattern with a Data/Context split.
The Index interface goes back to being purely declarative (name, columns,
unique, deferred) and the runtime-observed fact "is this index physically
present?" moves to a new DeployedIndexState object produced by the
enricher alongside the schema.

Why: the decorator + default-method combo was incoherent — Index had a
runtime-only property (isPhysicallyPresent) as a leaky default, and the
ObservedIndex class existed only to override that one method. With the
split, data is data and operational state is state, and no type needs
to straddle both.

Changes:
- Add DeployedIndexState with two explicit queries — isKnownPhysicallyPresent
  (for the getDeferredIndexStatements scan) and isKnownPhysicallyAbsent
  (for the visitor's DDL decisions). Different defaults for unknown reflect
  what each caller actually wants.
- Refactor DeployedIndexesModelEnricher: enrichSchema(Schema)->Schema
  becomes enrich(Schema)->Result (schema + state). Physical indexes are
  rebuilt via SchemaUtils.index() to carry the declarative deferred flag
  from the tracking table; deferred-not-yet-built entries appear as
  virtual indexes in the schema; presence is recorded in the state.
- Remove Index.isPhysicallyPresent() default method and delete
  ObservedIndex + TestObservedIndex.
- Thread DeployedIndexState through AbstractSchemaChangeVisitor and
  InlineTableUpgrader (new overload; empty-state default preserved).
- Upgrade.findPath wires the state from enricher to upgrader and to the
  final deferred-SQL scan.

Tests: TestDeployedIndexesModelEnricher rewritten against the new API
with more precise assertions (schema fact + state fact). Stale
isPhysicallyPresent mocks removed from visitor/upgrader tests; those
mocks were load-bearing under the old code but redundant now because
the visitor gets the same answer from the state's default. All 27
deployed-index integration tests + full module build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, inline parsing

- Drop the unused upgradeUUID column from the DeployedIndexes table and
  from every plumbing layer (DAO, change service, entity, visitor call
  sites, Editor). The column was never populated anywhere; wiring it up
  properly was out of scope for now.
- Widen indexColumns from 2000 to 4000 chars, matching the comfortable
  headroom we want for composite indexes.
- Rename DeployedIndexEntry -> DeployedIndex. The POJO is the row type;
  the "Entry" suffix was noise (mirroring the DeployedViews convention,
  which has no separate entry class at all).
- Remove the static DeployedIndex.parseColumns / joinColumns helpers.
  parseColumns had one caller (the DAO); inline it there as
  Arrays.asList(s.split(",")). joinColumns was dead. Deletes the two
  matching tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two-method boolean API (isKnownPhysicallyPresent, isKnownPhysicallyAbsent)
encoded a three-state value (present, absent, unknown) via a nullable Boolean
in the internal map. Readers had to mentally decode what false meant in each
method, and !isKnownPhysicallyPresent was not the complement of
isKnownPhysicallyAbsent (both false when UNKNOWN).

Replace with an explicit enum: IndexPresence { PRESENT, ABSENT, UNKNOWN }.
Single getPresence(tableName, indexName) method. Call sites use explicit
!= PRESENT / != ABSENT comparisons — the tri-state is now visible in every
read and write.

- New IndexPresence enum.
- DeployedIndexState: Map<String, IndexPresence> internally.
- Drop both boolean wrapper methods — don't reintroduce the ambiguity.
- Update AbstractSchemaChangeVisitor and Upgrade.findPath call sites.
- Enricher records PRESENT/ABSENT explicitly.
- TestDeployedIndexesModelEnricher collapses two-asserts-per-case into
  single assertEquals on the enum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract Result to its own top-level class EnrichedModel. Returned type
  is now a first-class public API surface rather than a nested-and-generic
  inner class.
- Split DeployedIndexesModelEnricher.enrich() into five private methods:
  fastPathEmpty, enrichTable, processPhysicalIndexes,
  processRemainingTrackingEntries, logOrphanTrackingRows. enrich() is now
  a ~20-line orchestrator.
- Add WHY comments for the non-obvious invariants: _PRF exclusion, the
  "consumed tracking entry is removed" invariant that makes the leftover
  loop correct, why missing non-deferred is a hard error, why orphan
  tracking rows are a warn.

Callers updated: Upgrade.findPath, TestDeployedIndexesModelEnricher,
TestUpgrade.mockEnricher. No behavioural change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tatements

UpgradePath.getDeferredIndexStatements() used to return a List<String> of
raw CREATE INDEX SQL. The adopter then had no reliable way to recover the
(tableName, indexName) needed for DeployedIndexTracker calls — parsing
the SQL with regex, maintaining a side-map, or querying pending entries
separately and regenerating SQL were the only workarounds.

Change the return type to List<DeferredIndexJob>. Each job carries:
- tableName
- indexName
- sql: the statements (usually one, sometimes more e.g. PostgreSQL's
  COMMENT ON INDEX) to build that one index

The adopter loop becomes:
  for (DeferredIndexJob job : path.getDeferredIndexStatements()) {
      tracker.markStarted(job.getTableName(), job.getIndexName());
      try {
          for (String sql : job.getSql()) executeSql(sql);
          tracker.markCompleted(job.getTableName(), job.getIndexName());
      } catch (Exception e) {
          tracker.markFailed(job.getTableName(), job.getIndexName(), e.getMessage());
      }
  }

Also update TestDeployedIndexesIntegration — assertions that previously
did .anyMatch(s -> s.toUpperCase().contains("INDEX_NAME")) now target
job.getIndexName() / job.getTableName() directly; assertions that really
needed to peek at the SQL content (UNIQUE keyword, renamed-column name
in CREATE INDEX) flatMap into job.getSql().

Breaking change but no adopter consumed this API on this branch yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gate all DSL

Replace the scattered DSL construction in DAOImpl and ChangeServiceImpl
with a single source of truth. The two classes now have one responsibility
each, distinct column constants are consolidated, and there's one place
to look for "how does Morf talk to the DeployedIndexes table".

DeployedIndexesStatementFactory (new):
- Owns every DSL statement targeting the DeployedIndexes table — reads,
  status updates executed by the DAO, and tracking DML used by the visitor.
- Column-name constants live here; the other classes no longer have their
  own copies.
- Methods grouped: read queries, status updates, tracking DML.

DeployedIndexesDAOImpl:
- Now a thin executor. Each public method asks the factory for DSL,
  converts via the dialect, executes. Still owns the ResultSet mapper.
- No DSL construction inline.

DeployedIndexesChangeServiceImpl:
- In-memory session state + orchestration only.
- All returned Statement lists come from factory calls; no insert/update/
  delete DSL built here.

Both the DAO and the ChangeService accept an explicit factory constructor
for tests; default constructors create their own.

No behavioural change — all 4,700+ tests across 11 modules pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… on tracker

The DAO and StatementFactory are internal machinery — no external adopter
should need to construct them. Tighten the API surface:

- DeployedIndexesDAO interface, DeployedIndexesDAOImpl, and
  DeployedIndexesStatementFactory go from public to package-private.
  Constructors too.
- Add DeployedIndexesModelEnricher.create(ConnectionResources,
  UpgradeConfigAndContext) factory method. Upgrade.java used to
  hand-wire the DAO with a cross-package `new DeployedIndexesDAOImpl(...)`;
  now it calls the factory, keeping the DAO invisible.
- Add resetInProgress() to DeployedIndexTracker. TestDeployedIndexesIntegration
  now uses tracker.resetInProgress() for the crash-recovery test instead of
  constructing a DAO directly — matches how adopters would use the API.

The integration tests happen to still compile because they live in the
same Java package name as the classes (even though they're in a separate
Maven module), so package-private access still works. For actual external
adopters these types are now invisible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new unit-test classes covering previously-untested machinery, and
negative/edge-path additions to three existing test files.

New test classes:
- TestDeployedIndexState — tri-state enum behaviour (empty/PRESENT/ABSENT/
  UNKNOWN) and case-insensitive keys. 5 tests.
- TestDeployedIndexesStatementFactory — asserts DSL shape for every
  statement method (read, status update, tracking DML), plus multi-column
  CSV join and deferred/non-deferred status mapping. 17 tests.
- TestDeployedIndexTrackerImpl — mocks the DAO and verifies pure
  delegation for every tracker method including resetInProgress. 6 tests.
- TestDeployedIndexesDAOImpl — mocks the factory + dialect + executor;
  verifies each DAO method calls the correct factory method, converts via
  the dialect, and executes; plus ResultSet mapping including null
  startedTime/completedTime via wasNull. 9 tests.

Negative/edge additions to existing tests:
- TestDeployedIndexesChangeServiceImpl — no-op assertions for removeIndex /
  removeAllForTable / removeIndexesReferencingColumn / updateTableName /
  updateColumnName / updateIndexName on untracked tables; updateIndexName
  on unknown index; case-insensitive column matching in updateColumnName;
  multi-column INSERT shape verification.
- TestDeployedIndexesModelEnricher — isUnique + multi-column order
  preservation in rebuildIndex; non-deferred + physical path directly
  asserted; mixed physical + virtual on one table; multiple tables;
  orphan-row tolerance (log.warn, no throw).
- TestDeployedIndex — composite column order preservation in toIndex().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real bug caught by the tightened test assertions:
- DeployedIndexesStatementFactory.statementToMark{Started,Completed,Failed}
  and statementToBumpRetryCount used .where(X.eq(a).and(Y.eq(b))). Because
  Criterion.and is statically imported and Criterion has no instance .and(),
  the compiler resolved .and(Y.eq(b)) as a static call and silently discarded
  the LHS X.eq(a). Bytecode confirmed with a `pop`. Result: the UPDATE
  filtered by indexName only, ignoring tableName. Two indexes with the same
  name on different tables would cross-update. Fixed to use the explicit
  and(c1, c2) form.

Review fixes:
- Remove dead deferredIndexes field + getDeferredIndexes() in
  AbstractSchemaChangeVisitor (populated but never read).
- Replace the unreachable-but-misleading else-branch in
  visitDeployedIndexesStatement with an IllegalStateException. Aligns with
  the class's own documented "no schema validation" contract.
- Delete speculative static DeployedIndexesStatementFactory.allColumns() and
  its self-referential test.

New tests + stronger assertions:
- TestDeferredIndexJob (new): constructor null-safety via Objects.requireNonNull
  on all three args; multi-statement SQL order; unmodifiable and decoupled
  sql list; per-arg NPE messages.
- TestDeployedIndexesStatementFactory: factory tests now verify SET-field
  aliases and literal values, WHERE AND structure and leaf values — not
  just counts. This is what caught the tracker WHERE bug.
- TestDeployedIndexesChangeServiceImpl.testUpdateColumnName: now verifies
  UPDATE sets the right indexColumns value and targets the correct index.
- TestDeployedIndexTrackerImpl: markStarted/markCompleted now assert a
  [before, after] bounds check on the captured timestamp; no more anyLong().
- TestDeployedIndexesIntegration.testDisabledFeatureBuildsDeferredImmediately:
  captures UpgradePath and asserts jobs list is empty.
- TestDeployedIndexesIntegration new tests:
  testAppSideAdopterFlowBuildsAndMarksCompleted — exercises the full
  documented adopter loop (markStarted → execute job SQL → markCompleted);
  testAppSideAdopterFlowMarksFailed — verifies markFailed flips status and
  persists errorMessage.
- Shared newTracker() helper across integration tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s handling into buildUpgradePath

Upgrade.java:
- Replace fully-qualified org.alfasoftware.morf.upgrade.deployedindexes.* references with imports.
- Fold deferredIndexJobs setup into buildUpgradePath (now takes List<DeferredIndexJob>)
  rather than setting it at the call site; findPath simplifies to a single return.
- Revert unnecessary hoisting of the InlineTableUpgrader local variable.

DeployedIndexesChangeServiceImpl.java:
- Remove accidental "jjv" typo between field declarations.

SchemaChangeAdaptor.java, SchemaChangeSequence.java:
- Trim trailing blank lines (no behaviour change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… into graph-based visitor

Removes silent-empty-state / silent-null-schema / silent-new-factory footguns across
the branch and fixes a latent production bug where GraphBasedUpgradeSchemaChangeVisitor
was constructed via its 4-arg super call, silently substituting DeployedIndexState.empty()
and degrading the visitor's model-based DDL decisions to default behaviour.

Constructors deleted (shorter-arg convenience forms that silently default a dependency):
- AbstractSchemaChangeVisitor (4-arg → gone; 5-arg required)
- InlineTableUpgrader (5-arg → gone; 6-arg required)
- SchemaChangeSequence (1-arg and 2-arg → gone; 3-arg required)
- UpgradePathFinder.getSchemaChangeSequence() no-arg → gone; 1-arg required
- DeployedIndexesDAOImpl 2-arg → gone; Guice wires factory via @Inject on 3-arg
- DeployedIndexesChangeServiceImpl no-arg → gone; 1-arg required

Editor.getSourceSchema() silent SchemaUtils.schema() fallback is removed: sourceSchema
is now non-null via Objects.requireNonNull in the surviving constructor.

Real DeployedIndexState threaded through the graph-based chain:
- GraphBasedUpgradeSchemaChangeVisitor constructor + factory
- GraphBasedUpgradeBuilder field + constructor + factory
- Upgrade.findPath passes deployedIndexState to graphBasedUpgradeBuilderFactory.create(...)

DeployedIndexesStatementFactory is now public (needed by AbstractSchemaChangeVisitor
to construct DeployedIndexesChangeServiceImpl without reflection). P1.6 will split
this into interface + impl.

SchemaEditor.getSourceSchema() Javadoc now honestly documents the retrofit: a read
method on an otherwise-pure-write interface, added for infrastructure steps like
CreateDeployedIndexes that need the pre-upgrade schema.

DeployedIndexState gains two public test-friendly factories (of/with) so tests outside
the package can construct populated states. Verified by a new regression test
testRemoveIndexVisitRespectsAbsentStateForGraphBasedPath in
TestGraphBasedUpgradeSchemaChangeVisitor: when state reports an index as ABSENT,
the graph-based visitor no longer emits spurious DROP INDEX DDL.

Updated callers (tests):
- UpgradeTestHelper, TestInlineTableUpgrader, TestGraphBasedUpgradeSchemaChangeVisitor,
  TestGraphBasedUpgradeBuilder, TestSchemaChangeSequence (6 sites), TestUpdateToCtasAdaptor,
  TestUpgradePathFinder, TestDeployedIndexesChangeServiceImpl, TestDeployedIndexTracker,
  TestDeployedIndexesIntegration — all pass DeployedIndexState.empty() / SchemaUtils.schema()
  / DeployedIndexesStatementFactory explicitly where relevant.

Verification: morf-core unit tests (2737, +1 new), deployed-indexes integration tests
(29), checkstyle, spotbugs, javadoc all clean on morf-core verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment, hard-fail on orphans

DeployedIndexesModelEnricher:
- Replace Optional-returning fastPathEmpty(...) with boolean shouldSkipEnrichment(...).
  Eliminates the triple-return and the double dao.findAll() call (one call in enrich, the
  "table empty" check becomes an inline early-return against the fetched list).
- Add missing debug log on the feature-disabled branch.
- Delete the isMorfInfrastructureTable skip in enrich() and in the orphan check. Morf
  tables (UpgradeAudit, DeployedViews, DeployedIndexes) are now enriched like any other
  table; consistency validation applies. _PRF indexes remain the only carve-out.
- Rename local entryMap → trackingRowsByTable (and buildEntryMap → buildTrackingRowsByTable).
  Rename presence → observedPresence across enrich and helper method parameters.
- Collapse the enrichTable result block using orElse + |=.
- Replace soft-warn logOrphanTrackingRows with hard-fail validateNoOrphanTrackingRows:
  throws IllegalStateException on first orphan. Orphans cannot be produced by correct
  Morf operation (RemoveTable/RenameTable always emit matching tracking DML), so the
  same severity class as non-deferred-missing-from-DB (already a hard error) applies.
- processRemainingTrackingEntries now clears the inner map after consuming entries —
  the new hard-fail requires all consumed entries to be cleared so only truly-orphan
  tables remain for validation.
- Delete isMorfInfrastructureTable helper (no remaining callers).

CreateDeployedIndexes:
- Delete the isMorfTable skip in the prepopulation loop and the helper method.
  Morf-table indexes are now prepopulated alongside user-table indexes. Since this
  branch is pre-release, no migration step is needed for existing deployments —
  fresh CreateDeployedIndexes handles all tables on first run.

Tests:
- TestDeployedIndexesModelEnricher: testOrphanRowForMissingTableDoesNotThrow flipped to
  testOrphanRowForMissingTableThrows with @test(expected = IllegalStateException.class).
  New tests confirm Morf infrastructure tables ARE enriched: indexes on UpgradeAudit
  recorded PRESENT in state, and an untracked physical index on DeployedViews triggers
  consistency validation.
- TestDeployedIndexesIntegration: updated testPrepopulationPopulatesExistingIndexes
  Javadoc to reflect that Morf tables are no longer excluded.

Verification: morf-core 2739 tests pass, checkstyle + spotbugs + javadoc clean, all 26
TestDeployedIndexesIntegration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ployedIndexState

Adds a package-private IndexKey(tableName, indexName) value type that upper-cases
both names on construction. Replaces the Map<String, IndexPresence> keyed by a
string-concat convention (TABLE:INDEX) with Map<IndexKey, IndexPresence>.

Benefits:
- Type-safe: callers can't accidentally pass a pre-formatted string where a key
  was expected.
- Canonicalisation (upper-casing) is encapsulated in the key type; consumers no
  longer need to know the key format.
- Collisions are impossible (no TABLE:INDEX ambiguity if a name contains ':').
- Eliminates the last static helper on DeployedIndexState (the package-private
  key() method).

Changes:
- New IndexKey.java (with equals/hashCode/toString, null-rejection).
- DeployedIndexState internal map is now Map<IndexKey, IndexPresence>; the static
  key() helper is deleted. Public API (getPresence/empty/of/with) unchanged.
- DeployedIndexesModelEnricher uses new IndexKey(...) instead of
  DeployedIndexState.key(...).

Tests:
- New TestIndexKey covers equals/hashCode/case-insensitivity/toString/null-rejection.
- TestDeployedIndexState updated to construct Map<IndexKey, IndexPresence> via
  new IndexKey(...) directly — existing case-insensitivity tests still pass.

Verification: morf-core 2747 tests pass (+8 new TestIndexKey), checkstyle +
spotbugs + javadoc clean, TestDeployedIndexesIntegration 26/26 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle of related refactors in the DDL-emission path.

CreateDeployedIndexes:
- @sequence(2) → @sequence(1). Explicit about "runs first"; Morf-internal steps all
  use Unix-epoch-seconds values (billions), so they naturally sort after it.

AbstractSchemaChangeVisitor:
- Replace the visitDeployedIndexesStatement(Statement) instanceof chain with three
  typed overloads — writeDeployedIndexesDml(Insert/Update/Delete). New DML types
  force a missing-overload compile error rather than a runtime IllegalStateException.
- Rename isPhysicallyPresent → willBePhysicallyPresentAtThisEmission. Nothing has
  hit the DB yet at visitor time — the method is a projection ("will the index
  exist at this emission point"), not a query. New name reflects that.
- Delete the defensive currentSchema.tableExists(tableName) branch; all callers
  invoke this before SchemaChange.apply() which would fail loudly on a missing
  table anyway.
- Add capture-before-mutate comments at each local-variable capture (RemoveIndex,
  ChangeIndex, RenameIndex): both isTrackedDeferred and the enricher state would
  be out of sync by DDL-emission time otherwise, so inlining would silently produce
  wrong DDL.
- Split visit(AddIndex) into four named helpers: shouldEmitPhysicalIndexDdl,
  emitAddIndexOrRename, findMatchingIgnoredIndex, trackInDeployedIndexes. The
  top-level method collapses to apply+DDL+track — reads as prose.
- Collapse visit(ChangeIndex) to reuse the same helpers. Tracking call that
  previously appeared twice (deferred vs immediate branches) is now one call.

DeployedIndexesChangeService(Impl):
- Tighten return types: List<Statement> → List<InsertStatement> / List<DeleteStatement>
  / List<UpdateStatement> on each method. DeployedIndexesStatementFactory already
  returned concrete types; the ChangeService's List<Statement> was leaking
  implementation laxity into the contract.

SchemaChangeSequence.Editor:
- Simplify resolveDeferred: split into resolveTargetDeferred(index) → boolean pure
  predicate + two-line rebuild-on-mismatch call. The triple-repeated
  index.isDeferred() ? X : Y ternary collapses into one equality check.
- Add private isForcedImmediate/isForcedDeferred helpers reading the config's
  case-insensitive stored sets directly. These replace calls to the config's
  now-deleted predicate methods.

UpgradeConfigAndContext:
- Delete isForceImmediateIndex(String) and isForceDeferredIndex(String) —
  behavioural predicates don't belong on a data/setter class. Setter-side
  case-folding stays as pure normalisation. Public getForceImmediate/DeferredIndexes
  getters stay.

Tests:
- TestSchemaChangeSequence: testIsForceImmediateIndex / testIsForceDeferredIndex
  replaced with testForceImmediate/DeferredIndexesStoredCaseInsensitively —
  they now assert only the storage contract (normalised, deduped). Behavioural
  coverage is already provided by the existing
  testAddIndexDeferredWithForceImmediateProducesAddIndex /
  testAddIndexWithForceDeferredProducesDeferredAddIndex / case-insensitive
  variants, which exercise resolveDeferred end-to-end.
- TestDeployedIndexesChangeServiceImpl: local List<Statement> declarations widen
  to List<? extends Statement> to match the tightened return types.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc clean,
TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Javadoc

Upgrade.java:
- Extract collectDeferredIndexJobs(schemaChangeSequence, sourceSchema,
  deployedIndexState, dialect) → List<DeferredIndexJob>. The ~20-line scan
  that used to live inline in findPath becomes a named, individually-testable
  helper with a documented early-return on isDeferredIndexCreationEnabled().
- Extract enrichSourceSchema(sourceSchema) → EnrichedModel. The pair-mutation
  of sourceSchema + deployedIndexState now happens at one named boundary.
  Null-guard on deployedIndexesModelEnricher stays (legacy test paths).
- findPath's two ~20-line blocks collapse to two one-liners.
- buildUpgradePath passes deferredIndexJobs through the factory (see below);
  the if(!isEmpty) guard + setDeferredIndexStatements setter call disappear.

Upgrade.performUpgrade Javadoc:
- Document the void → UpgradePath return-type change: callers access
  UpgradePath.getDeferredIndexStatements() for the list of DeferredIndexJob
  entries the application must execute asynchronously via
  DeployedIndexTracker.
- Note these static entry points are simplified and primarily used by
  tests/examples; production code typically goes through Upgrade.Factory.

UpgradePath.java:
- Add import for DeferredIndexJob; replace three inline fully-qualified
  references with the imported name.
- Make deferredIndexJobs field final — initialised only in the constructor.
- Delete setDeferredIndexStatements(...) setter.
- Widen the 6-arg UpgradePath constructor to 7-arg with List<DeferredIndexJob>.
- Keep the 4-arg and 5-arg public constructors as explicit "no-deferred-jobs"
  shapes (empty-path sentinel and simpler test/build paths, per the plan).
  Their Javadoc documents the no-jobs contract.
- Widen UpgradePathFactory.create 4-arg → 5-arg (+ List<DeferredIndexJob>).
  The 1-arg and 2-arg overloads stay unchanged — they're for paths that
  genuinely can't have deferred jobs; not convenience defaults.

CreateDeployedIndexes:
- Build the DeployedIndexes table as a local variable so the prepopulation
  loop can iterate its OWN indexes too. Without this, the table's physical
  indexes (DeployedIdx_1, DeployedIdx_2) would be untracked, and the P1.2
  enricher hard-fail on untracked physical indexes would trigger on any
  subsequent upgrade run. Caught by
  TestDeployedIndexesIntegration.testReUpgradeIsIdempotent +
  testSequentialUpgradeIncludesPreviousDeferred.
- Extract a small sourceSchema(SchemaEditor) helper to keep the main loop
  one-liner-shaped.

Tests:
- TestUpgrade.upgradePathFactory() mock signature updated: create(...,
  anyList(), anyList(), nullable(builder), anyList(), anyList()).
- TestUpgradePath.testFactoryCreateUpgradeWithInitialisationSql: the
  factory call grows an ImmutableList.of() for deferredIndexJobs.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3
pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…exes package

Renames:
- DeployedIndexesChangeService → DeployedIndexesService. The word "Change"
  was misleading: Morf uses "Change" only as a noun for SchemaChange objects
  (ChangeColumn, ChangeIndex, SchemaChangeVisitor), never as a service verb.
  Service classes use noun-based names (UpgradeStatusTableService,
  DatabaseUpgradePathValidationService). git mv applied to both production
  files and the test file; sed applied class + field-name references
  (deployedIndexesChangeService → deployedIndexesService).

Interface + impl splits:
- DeployedIndexesStatementFactory promoted to interface with @ImplementedBy.
  New DeployedIndexesStatementFactoryImpl carries the bodies. Column-name
  constants moved to the interface as static final (allowed in Java 8+
  interfaces). Callers updated to instantiate Impl for non-Guice contexts
  (visitor, tests, static create factories).
- DeployedIndexesModelEnricher promoted to interface with @ImplementedBy.
  New DeployedIndexesModelEnricherImpl carries the bodies. The static
  create(...) factory moves to the interface (Java 8+). LogFactory.getLog()
  switches to the Impl class. Test file renamed to TestDeployedIndexesModelEnricherImpl.

Integration test fixture fix:
- TestDeployedIndexesIntegration.INITIAL_SCHEMA pre-creates the DeployedIndexes
  table via schemaManager, bypassing the CreateDeployedIndexes step. Since P1.2
  removed the Morf-table skip and made the enricher hard-fail on untracked
  physical indexes, tests that trigger enrichment on the pre-created
  DeployedIndexes table (testReUpgradeIsIdempotent, testSequentialUpgradeIncludesPreviousDeferred)
  would fail. Added seedDeployedIndexesOwnTrackingRows() in setUp that
  inserts tracking rows for DeployedIdx_1 and DeployedIdx_2 — mirrors what
  CreateDeployedIndexes would have done in production.

CreateDeployedIndexes:
- Removed the redundant "dedicated loop" added in P1.5 that inserted tracking
  rows for DeployedIdx_1/DeployedIdx_2. The visitor's visit(AddTable) path
  already tracks these via deployedIndexesChangeService.trackIndex(...) when
  the step calls schema.addTable(deployedIndexesTable). The P1.5 dedicated
  loop was double-inserting and triggering unique-constraint violations.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3
pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IndexPresence.java:
- Rewrite UNKNOWN Javadoc to distinguish happy path from bug path.
- Happy path: UNKNOWN is the normal result for indexes that first appear
  during the current upgrade session — the enricher runs once against the
  source schema + tracking table and does not see subsequent in-memory
  mutations. Callers decide meaning in context
  (AbstractSchemaChangeVisitor.willBePhysicallyPresentAtThisEmission treats
  UNKNOWN as "present"; Upgrade.collectDeferredIndexJobs treats UNKNOWN as
  "needs building").
- Bug path: UNKNOWN should NOT appear for an index that was live in the
  source schema or had a tracking row when the enricher ran — that would
  indicate the enricher skipped work (a bug).
- Class-level Javadoc lifts the distinction so readers don't conflate the
  two meanings.

DeployedIndexState.getPresence Javadoc:
- Added a one-line pointer to IndexPresence.UNKNOWN for the full
  happy-path-vs-bug distinction and caller interpretations.

mvn javadoc:javadoc clean (no warnings anywhere in morf-core). The earlier
commits (P1.1–P1.6) already added Javadoc to every new method in the
deployedindexes package and every modified method across the branch —
this commit completes the pass by addressing the one remaining ambiguity
(UNKNOWN semantics) and cross-referencing it from the sole consumer of
its return value.

Verification: morf-core 2747 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker 3/3
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ulation assertions

Caught during self-audit after the Phase 1 push:

TestSchemaChangeSequence:
- Add testAddIndexDeferredWithKillSwitchOffProducesImmediate — covers the
  resolveDeferred kill-switch-off branch (setDeferredIndexCreationEnabled(false)
  rebuilds a declared-deferred index as non-deferred). Missing from the
  existing coverage which only hit the force-immediate / force-deferred /
  default-passthrough branches.

TestDeployedIndexesIntegration.testPrepopulationPopulatesExistingIndexes:
- Add assertions that DeployedIdx_1 and DeployedIdx_2 (the DeployedIndexes
  table's own indexes) are recorded in the tracking table after
  CreateDeployedIndexes runs. Exercises the P1.2 removal of the
  Morf-infrastructure-table skip + the visitor's visit(AddTable)
  auto-tracking behaviour end-to-end.

Verification: morf-core 2748 tests pass (+1), checkstyle + spotbugs +
javadoc clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker
3/3 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third parameter to UpgradeStep.execute — an UpgradeContext providing
read-only upgrade-time state — and deletes the SchemaEditor.getSourceSchema()
retrofit added in Phase 1.

Rationale: SchemaEditor is a command interface ("tell me what to change").
Adding a read method to it was a retrofit for one consumer (CreateDeployedIndexes).
UpgradeContext is the principled home for read-only state steps may need;
future needs (dialect, UpgradeConfigAndContext, step metadata) grow the
context rather than polluting SchemaEditor further.

Contract:
- Framework always invokes the 3-arg execute(schema, data, context).
- The 2-arg execute stays abstract — adopters must implement it.
- The new 3-arg execute has a default implementation that bridges to the
  2-arg form, so existing adopter steps that only override 2-arg keep
  working unchanged.
- Steps needing context override 3-arg and provide a throwing stub for
  2-arg (never called when 3-arg is overridden).
- Override ONE of the two, not both — documented in Javadoc on both
  overloads. Overriding both is legal but not useful: only 3-arg runs;
  the 2-arg body is dormant unless the 3-arg override invokes it
  explicitly via execute(schema, data).

Backwards compatibility:
- Source- and binary-compatible for the common case (existing adopter
  steps overriding only 2-arg) — the new 3-arg method is a default,
  invisible unless opted into.
- One adopter in the repo (CreateDeployedIndexes) migrates to the 3-arg
  form; all other steps untouched.
- HumanReadableStatementProducer's anonymous SchemaEditor never called
  getSourceSchema, so no change needed there.

Files changed:
- New: morf-core/.../UpgradeContext.java — interface with getSourceSchema().
- UpgradeStep.java: add default 3-arg execute + explicit "override one"
  Javadoc on both overloads.
- SchemaEditor.java: delete the getSourceSchema() default method (retrofit
  removed) + unused Schema import.
- SchemaChangeSequence.java: Editor drops its sourceSchema field and
  getSourceSchema override; constructor loop builds UpgradeContext as a
  lambda (() -> sourceSchema) and invokes the 3-arg execute.
- CreateDeployedIndexes.java: 2-arg execute becomes a throwing stub;
  3-arg execute overrides and reads context.getSourceSchema().
- UpgradePathFinder.java: update the stale {@link SchemaEditor#getSourceSchema}
  Javadoc reference to point at the 3-arg execute.

Verification: morf-core 2748 tests pass, checkstyle + spotbugs + javadoc
clean, TestDeployedIndexesIntegration 26/26 + TestDeployedIndexTracker
3/3 pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…port

On dialects where SqlDialect.supportsDeferredIndexCreation() returns false
(MySQL, SQL Server, and the base SqlDialect default), a declared-deferred
index was being handled inconsistently:
- Visitor emitted CREATE INDEX immediately (correct — shouldEmitPhysicalIndexDdl
  returns true when dialect doesn't support deferred).
- But trackInDeployedIndexes passed the original declared flag to
  statementToTrackIndex, which unconditionally wrote indexDeferred=true /
  status=PENDING based on the declared flag.
- Upgrade.collectDeferredIndexJobs then saw the deferred flag in the final
  schema and (state = UNKNOWN because the new index didn't exist at
  enrichment time) added it to the app-side executor's jobs list.
- The app would then issue a second CREATE INDEX → duplicate-index error.

This was not caught by integration tests because morf-h2v2 explicitly
overrides supportsDeferredIndexCreation() to return true (so the test
harness exercises the full deferred pipeline even on H2).

Fix — two parts:

AbstractSchemaChangeVisitor:
- New effectiveIndex(Index) helper: on dialects without deferred support,
  rebuild a declared-deferred index without the deferred flag. The returned
  index flows into shouldEmitPhysicalIndexDdl (still emits CREATE) and
  trackInDeployedIndexes (now tracks as COMPLETED / indexDeferred=false).
- Applied in visit(AddIndex), visit(ChangeIndex) (for toIndex), and
  visit(AddTable) (per-index in the loop).

Upgrade.collectDeferredIndexJobs:
- Early-return empty list when dialect.supportsDeferredIndexCreation() is
  false. The app-side executor would otherwise receive jobs for indexes
  already built at upgrade time, causing duplicate-CREATE errors.

Tests:
- TestInlineTableUpgrader.testVisitAddIndexDeferredOnDialectWithoutDeferredSupport:
  mock dialect with supportsDeferredIndexCreation=false, verify CREATE INDEX
  emitted AND tracking INSERT carries indexDeferred=false / status=COMPLETED
  (not PENDING).
- TestInlineTableUpgrader.testVisitChangeIndexToDeferredOnDialectWithoutDeferredSupport:
  same shape, ChangeIndex immediate→declared-deferred path.
- Two small helpers findBooleanLiteralByAlias / findStringLiteralByAlias
  inspect the captured InsertStatement's values without SQL parsing.

Verification: morf-core 2750 tests pass (+2), checkstyle + spotbugs +
javadoc clean, TestDeployedIndexesIntegration 26/26 +
TestDeployedIndexTracker 3/3 pass end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part 1 of the deferred-tracking-only redesign on experimental/deferred-indexes-slim.

AbstractSchemaChangeVisitor:
- visit(AddTable): gate trackInDeployedIndexes on effectiveIndex(idx).isDeferred().
  Non-deferred indexes on a newly-added table produce no tracking row.
- visit(AddIndex): gate trackInDeployedIndexes on effectiveIndex(newIndex).isDeferred().
- visit(ChangeIndex): track toIndex only if its effective form is deferred.
  removeIndex call for fromIndex stays unconditional — the DELETE WHERE is a
  no-op for non-existent tracking rows.

DeployedIndexesService / Impl:
- New prime(DeployedIndex) method. Populates the in-session tracking map
  from a persisted row WITHOUT emitting DML. Called by the enricher (SP2)
  at upgrade start for every persisted deferred row, so subsequent
  remove/rename/column operations correctly produce DML against rows from
  prior upgrades. Rebuilds the Index with isDeferred()=true (slim
  invariant: every tracking row is a deferred index).

Tests:
- TestInlineTableUpgrader: the two dialect-support tests added in 1e8f9e5
  (testVisitAddIndexDeferredOnDialectWithoutDeferredSupport,
  testVisitChangeIndexToDeferredOnDialectWithoutDeferredSupport) flip from
  "assert tracking row carries indexDeferred=false / status=COMPLETED" to
  "assert no tracking INSERT is emitted at all". Slim makes the previously-
  latent bug structurally impossible — no tracking row → app-side executor
  has nothing to double-CREATE. Helper methods findBooleanLiteralByAlias /
  findStringLiteralByAlias are no longer used; deleted.
- TestDeployedIndexesServiceImpl: new testPrimeSeedsInSessionStateWithoutEmittingDml
  verifies prime seeds the map and subsequent removeIndex produces DML.

Integration tests (TestDeployedIndexesIntegration):
- testCrossStepColumnRenameOnNonDeferredIndex renamed to
  testCrossStepColumnRenameOnNonDeferredIndexDoesNotTrack; assertions flipped
  to verify no tracking row exists (slim: non-deferred not tracked).
- testNonDeferredIndexBuiltImmediately: assertions flipped — physical index
  exists, no tracking row.
- testForceImmediateBypassesDeferral: assertions flipped — when force-immediate
  ends up non-deferred, no tracking row is created.
- testPrepopulationPopulatesExistingIndexes: DELETED (SP3 work pulled
  forward to keep the branch green; prepopulation doesn't exist in slim).

Verification: morf-core 2751 tests pass (+1), TestDeployedIndexesIntegration
25/25 + TestDeployedIndexTracker 3/3 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…only

Drops the 295-line consistency-validation-heavy enricher in favour of two
responsibilities:
  1. Prime the per-session DeployedIndexesService with every persisted row
     so visitor remove/rename/column operations against prior-upgrade
     tracking rows emit correct DML.
  2. Virtualize unbuilt deferred indexes (status != COMPLETED) into the
     schema so SchemaHomology.schemasMatch treats them as declared.

Physical-vs-declared drift for non-deferred indexes is no longer this
class's concern — SchemaHomology handles it at upgrade-path-finding time.

Enricher's enrich() grows a DeployedIndexesService parameter so the
enricher and visitor share one primed service instance. Service is now
constructed once per run in Upgrade.findPath and threaded through the
InlineTableUpgrader / GraphBasedUpgradeBuilder / AbstractSchemaChangeVisitor
chain (replacing the inline `new DeployedIndexesServiceImpl(...)` that
lived in the abstract visitor).

TestDeployedIndexesModelEnricherImpl rewritten: dropped 11 tests for
deleted consistency-validation behaviours, kept 4, added coverage for
priming (verify-via-mock + primed-state-is-tracked) and
COMPLETED-row-not-virtualized.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The slim invariant ($DeployedIndexes$ = deferred-only) eliminates the
rationale for the Phase-2 UpgradeContext mechanism: there's nothing to
prepopulate — non-deferred indexes live only in the physical DB, where
SchemaHomology handles them.

Consequently:
  - Delete UpgradeContext.java entirely.
  - Revert UpgradeStep to the single 2-arg execute(schema, data). Drop the
    default 3-arg bridge and the "override one not both" Javadoc.
  - Revert SchemaChangeSequence: ctor drops the sourceSchema param; loop
    reverts to step.execute(editor, editor).
  - Revert UpgradePathFinder: getSchemaChangeSequence drops its Schema
    param; determinePath callsite reverts.
  - Strip CreateDeployedIndexes down to a CREATE TABLE: deletes the 3-arg
    override, the prepopulation loop, and all DataEditor / UUID / Index /
    DatabaseMetaDataProviderUtils imports.
  - Drop the seedDeployedIndexesOwnTrackingRows fixture from the
    integration-test setUp — slim has no non-deferred tracking rows for
    DeployedIdx_1 / DeployedIdx_2 to seed.
  - Update test call sites (SchemaChangeSequence / getSchemaChangeSequence)
    to the simpler signatures.

Verified: 2743 core tests + 25 integration tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t DAO

The plan called for replacing the schema-scan with a DAO-driven approach,
but that has a timing bug: dao.findAll() queries the DB *before* the
visitor's tracking-row INSERT DML has executed, so any row tracked in
this session would be invisible to the DAO call.

The existing schema-scan + DeployedIndexState approach is already
slim-correct:
  - Prior-session unbuilt deferred rows are virtualized into sourceSchema
    by the enricher and marked ABSENT in state → final schema sees them,
    `!= PRESENT` guard picks them up.
  - This-session newly-declared deferred indexes land in the final schema
    with UNKNOWN state → `!= PRESENT` picks them up.
  - Visitor renames / column updates mutate the schema in place, so jobs
    carry the current shape.

Rewritten the method's Javadoc to explain this explicitly (and why DAO
doesn't work), corrected the dialect-fallback comment to match slim
semantics ("tracks nothing" rather than "tracks as COMPLETED"), and
updated the DAO Javadoc to reflect the slim deferred-only invariant.

No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under the slim invariant every DeployedIndexes row is a deferred index, so
the indexDeferred boolean is always true and adds no information. Drop:

  - Column from `deployedIndexesTable()` and `CreateDeployedIndexes`.
  - `indexDeferred` field + getter + setter on DeployedIndex POJO.
  - `COL_INDEX_DEFERRED` constant on the statement factory interface.
  - The literal value from `statementToTrackIndex` + the column from the
    `selectAllColumns()` SELECT.
  - `setIndexDeferred` call in DeployedIndexesDAOImpl.mapEntries.
  - Stale "indexDeferred=false" Javadoc in AbstractSchemaChangeVisitor's
    effectiveIndex — replaced with the slim semantics (no tracking row
    under dialect fallback, so nothing for the app-side executor).
  - `statementToTrackIndex` no longer branches on isDeferred() — always
    emits PENDING (visitor only calls it for deferred indexes in slim).

Test impact: dropped non-deferred-expectations in TestDeployedIndex /
TestDeployedIndexesDAOImpl / TestDeployedIndexesModelEnricherImpl /
TestDeployedIndexesServiceImpl / TestDeployedIndexesStatementFactoryImpl;
dropped the `indexDeferred` column-field assertion from the
`testGetDeferredIndexStatementsReturnsSQL` integration test.

Verified: 2742 core tests + 25 integration tests green; full
mvn verify -DskipTests pipeline clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under the slim invariant every tracked row is deferred, so the two service
methods were equivalent. isTracked() had no production caller (only tests);
the visitor uses only isTrackedDeferred().

Collapsed into the single isTrackedDeferred() method with updated Javadoc
explaining the equivalence. Tests migrated; one obsolete test
(testTrackNonDeferredIndex, which exercised a path the slim visitor
never takes) deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

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