Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
08598df
Add comprehensive test suite for UpgradeGraph class
Feb 4, 2026
031546d
Add DeferredIndexConfig with defaults for deferred index execution
Feb 20, 2026
2ce4961
Add DeferredIndexOperation system tables and bootstrap upgrade step
Feb 20, 2026
c0ca5d9
Add DeferredIndexOperation domain class, enums, and DAO
Feb 21, 2026
ac910c3
Add DeferredAddIndex SchemaChange with visitor wiring and DAO interfa…
Feb 21, 2026
92257b1
Add SchemaEditor.addIndexDeferred() and visitor wiring for Stage 5
Feb 22, 2026
b92d7dc
Add auto-cancel and dependency tracking for deferred index operations
Feb 28, 2026
045d336
Add DeferredIndexExecutor, RecoveryService, and Validator (Stages 7-10)
Mar 1, 2026
5b87bbf
Add cross-platform deferred index dialect support (Stage 11)
Mar 2, 2026
67625fd
Add end-to-end integration tests for deferred index lifecycle (Stage 12)
Mar 2, 2026
a6c1e4d
Fix review findings: timestamp format, boolean column, and ChangeInde…
Mar 2, 2026
9738338
Cap retry backoff delay and fail upgrade on unresolved deferred indexes
Mar 2, 2026
6342d68
Fix review findings #4, #5, #7: backoff cap, validator throw, in-memo…
Mar 2, 2026
a50f492
Use BIG_INTEGER primary keys for both deferred index tables
Mar 3, 2026
fcb61a9
Replace N+1 queries with JOIN in DeferredIndexOperationDAO
Mar 3, 2026
7a691a9
Fix case-sensitivity inconsistencies in deferred index handling
Mar 3, 2026
286d7dc
Widen deferred index table name columns to SchemaValidator.MAX_LENGTH
Mar 3, 2026
47b00bc
Remove dead code and fix stale comment
Mar 3, 2026
66a3c52
Remove @ImplementedBy and @Inject from DAO since it is always constru…
Mar 3, 2026
f2c4248
Distinguish deferred index in human-readable upgrade output
Mar 3, 2026
6405207
Add config validation to deferred index services
Mar 3, 2026
d06bcbb
Add DeferredIndexService facade and make internal classes package-pri…
Mar 3, 2026
b68ffbe
Fix stale assertions in TestUpgradeSteps for deferred index tables
Mar 3, 2026
c801e00
Add unit tests to fill coverage gaps in deferred index feature
Mar 3, 2026
805bd6c
Improve test coverage for deferred index feature
Mar 3, 2026
1b7df21
Refactor deferred index services to use Guice constructor injection
Mar 3, 2026
594f2e3
Add force-immediate config to bypass deferred index creation
Mar 3, 2026
0ec04b8
Add force-deferred config to override immediate index creation
Mar 3, 2026
47591b2
Add coverage for forceImmediateIndexes and forceDeferredIndexes getters
Mar 3, 2026
84941aa
Merge origin/main into experimental/deferred-index-creation
Mar 3, 2026
253301f
Fix review findings: stale rename, negative IDs, SKIPPED status, javadoc
Mar 3, 2026
2b4fa36
Add DEBUG logging to deferred index services
Mar 3, 2026
0723c35
Code review fixes: remove dead code, simplify timestamps, decouple DAO
Mar 3, 2026
babf682
Refactor DeferredIndexChangeServiceImpl: extract SQL builders, add ja…
Mar 3, 2026
3f42844
Rename operationTimeoutSeconds to executionTimeoutSeconds, default 8h
Mar 3, 2026
d5984b1
Extract interfaces for DeferredIndexExecutor, DeferredIndexRecoverySe…
Mar 4, 2026
aa12945
Extract ExecutionResult/ExecutionStatus from DeferredIndexExecutor, r…
Mar 4, 2026
b885f8c
Replace polling with CompletableFuture, validate config in execute()
Mar 4, 2026
8533866
Ensure deferred index tables exist before parallel upgrade steps
Mar 4, 2026
4aa85ea
Rename DeferredIndexValidator to DeferredIndexReadinessCheck, wire in…
Mar 4, 2026
b792b48
Add DeferredIndexExecutorServiceFactory for pluggable thread pool cre…
Mar 4, 2026
cbf4147
Remove test-only constructor from DeferredIndexExecutorImpl
Mar 4, 2026
bfbcd84
Replace ScheduledExecutorService with per-operation progress logging
Mar 5, 2026
b15f6ea
Add getProgress() to DeferredIndexService facade
Mar 5, 2026
b607831
Executor cleanup: INFO/ERROR logging with elapsed time, autocommit re…
Mar 5, 2026
564bb99
Add Javadoc to all non-public methods across deferred index package
Mar 5, 2026
5ae9af7
Code review fixes: remove dead DAO methods, drop operationType column…
Mar 5, 2026
85056d9
Remove shutdown() from DeferredIndexExecutor interface
Mar 5, 2026
874c8b0
Add TDD integration tests for deferred index lifecycle (expect compil…
Mar 5, 2026
a5e7d41
Rename DeferredIndexConfig to DeferredIndexExecutionConfig, add force…
Mar 5, 2026
3ff3a90
Fix Mode 1: move readiness check before sourceSchema capture, add sch…
Mar 5, 2026
106f086
Executor crash recovery + remove recovery service from DeferredIndexS…
Mar 5, 2026
3894fe8
Remove recovery service, dead DAO method, fix lifecycle test index va…
Mar 5, 2026
4764e56
Code review fixes: dedup reconstructIndex, remove dead DAO methods, a…
Mar 5, 2026
ebb04e7
Remove redundant fields from executor, remove dead DAO insertOperatio…
Mar 6, 2026
b198c46
Simplify resetAllInProgressToPending, remove noOp(), fix javadoc wording
Mar 6, 2026
998e11c
Rename run/augment methods, extract awaitCompletion, add stale-index log
Mar 6, 2026
e3c3722
Fix DeferredIndexReadinessCheck Javadoc to describe both modes
Mar 9, 2026
824ae21
Code review fixes: inline upgrade tables, re-defer on ChangeIndex, re…
Mar 9, 2026
d50fd08
Remove unnecessary volatile from executionFuture field
Mar 9, 2026
af1dd91
Code review fixes: harden executor, DAO, config validation, fix flaky…
Mar 18, 2026
7ee6dda
Remove Mode 1/Mode 2, simplify to unified deferred index behavior
Mar 19, 2026
1624541
Add @see Javadoc to all @Override methods, split POJO test into per-f…
Mar 19, 2026
03bbf9a
Remove plan files from repo, add PLAN-*.md to .gitignore
Mar 19, 2026
b1c2148
Add CLAUDE.md to .gitignore
Mar 19, 2026
76b2d7f
Fix stray characters in readiness check Javadoc
Mar 19, 2026
e44d503
Add dialect-level deferred index support, fall back to immediate on u…
Mar 19, 2026
28a7eb1
Add supportsDeferredIndexCreation() override to H2v2 dialect, fix cha…
Mar 20, 2026
66c3f37
Remove DeferredIndexOperationColumn table, store columns as comma-sep…
Mar 24, 2026
0eb027f
Move deferred index config into UpgradeConfigAndContext, validate at …
Mar 24, 2026
3cb8049
Add deferredIndexCreationEnabled kill switch, disabled by default
Mar 24, 2026
31d43cb
Fix SonarCloud code smells: extract constants, clean up imports, refa…
Mar 24, 2026
3161ecf
Code review fixes: constants, Javadoc, private visibility, redundant …
Mar 24, 2026
6b18236
Add isDeferred() property to Index model
Apr 1, 2026
2295c3e
Add deferred index comment parsing utilities and SqlDialect hook
Apr 1, 2026
f13644c
Add IF EXISTS variants for index drop and rename DDL
Apr 1, 2026
6fd5e25
Add deferred index comment support to all dialect MetaDataProviders
Apr 1, 2026
7ead1ed
Remove addIndexDeferred API, handle deferral via index.isDeferred() flag
Apr 2, 2026
f467226
Rewrite executor to scan comments, remove tracking table infrastructure
Apr 2, 2026
c9ec255
Add documentation for comments-based deferred index model
Apr 2, 2026
7805ca4
Fix integration tests for comments-based model, remove stale references
Apr 2, 2026
475595c
Remove unused assertIndexDoesNotExist helpers and stale imports
Apr 2, 2026
5606779
Add integration tests for remove/change/rename on deferred indexes
Apr 2, 2026
e649e0e
Update documentation with MetaDataProvider merge behavior and test co…
Apr 2, 2026
1f7efc6
Add post-failure index-exists check to executor, update JIRA description
Apr 2, 2026
29f4f6f
Add targeted comment scan and PostgreSQL invalid index detection
Apr 2, 2026
d2a0357
Code review fixes: DDL refactor, schema sync bugs, test improvements
Apr 3, 2026
5743e13
Remove dead DeferredIndexReadinessCheck (no-op in comments-based model)
Apr 3, 2026
24e2cdb
Extract buildPostgreSqlCreateIndex to eliminate DDL duplication
Apr 4, 2026
9bdbae3
Add given/when/then structure to all deferred index tests
Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ target
*.iml
.idea
**/ivy-ide-settings.properties
PLAN-*.md
CLAUDE.md
27 changes: 27 additions & 0 deletions PLAN-deferred-index-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Deferred Index Creation — Comments-Based Model

See integration guide at: `~/deferred-index-comments-integration-guide.md`
See dev description at: `~/deferred-index-comments-dev.txt`

## Summary

Replace the tracking-table approach (`DeferredIndexOperation` table + DAO + state machine)
with a comments-based declarative model (~3600 net lines removed):

- `Index.isDeferred()` — deferred is a property on the index itself
- Table comments store permanent `DEFERRED:[name|cols|unique]` segments
- MetaDataProvider merges comments with physical catalog: declared indexes always have `isDeferred()=true`
- Executor scans for indexes that are `isDeferred()=true` but not physically present
- `getMissingDeferredIndexStatements()` exposes raw SQL for custom execution
- IF EXISTS DDL for safe operations on deferred indexes (built or not)
- "Change the plan" — unbuilt deferred indexes modified in later upgrades without force-build

## Branch

`experimental/deferred-index-comments` (branched from `experimental/deferred-index-creation`)

## Key Design Rule

DEFERRED comment segments are **permanent** — never removed when the index is physically built.
They are only modified during upgrade steps (removeIndex, changeIndex, renameIndex).
The executor never writes comments.
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@

package org.alfasoftware.morf.jdbc;

import static org.alfasoftware.morf.metadata.SchemaUtils.index;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.alfasoftware.morf.metadata.Index;
import org.alfasoftware.morf.metadata.SchemaUtils.IndexBuilder;
import org.apache.commons.lang3.StringUtils;

/**
Expand Down Expand Up @@ -65,6 +75,123 @@ public static Optional<String> getDataTypeFromColumnComment(String columnComment
}


/**
* Label used in table comments to declare deferred indexes.
*/
public static final String DEFERRED_COMMENT_LABEL = "DEFERRED";

/**
* Regex for extracting DEFERRED segments from table comments.
* Matches: /DEFERRED:[indexName|col1,col2|unique] or /DEFERRED:[indexName|col1,col2]
*/
private static final Pattern DEFERRED_INDEX_REGEX = Pattern.compile("DEFERRED:\\[([^\\]]+)\\]");


/**
* Parses deferred index declarations from a table comment string.
* Each DEFERRED segment has the format: {@code DEFERRED:[indexName|col1,col2|unique]}
* where the {@code |unique} suffix is optional.
*
* @param tableComment the full table comment string, may be null or empty.
* @return a list of deferred indexes parsed from the comment, each with {@code isDeferred()=true}.
*/
public static List<Index> parseDeferredIndexesFromComment(String tableComment) {
if (StringUtils.isEmpty(tableComment)) {
return Collections.emptyList();
}

List<Index> result = new ArrayList<>();
Matcher matcher = DEFERRED_INDEX_REGEX.matcher(tableComment);
while (matcher.find()) {
String content = matcher.group(1);
String[] parts = content.split("\\|");
if (parts.length < 2) {
continue;
}

String indexName = parts[0];
List<String> columns = Arrays.asList(parts[1].split(","));
boolean unique = parts.length >= 3 && "unique".equalsIgnoreCase(parts[2]);

IndexBuilder builder = index(indexName).columns(columns).deferred();
if (unique) {
builder = builder.unique();
}
result.add(builder);
}
return result;
}


/**
* Builds the DEFERRED comment segments for the given deferred indexes.
* Each index produces a segment like {@code /DEFERRED:[indexName|col1,col2|unique]}.
*
* @param deferredIndexes the deferred indexes to serialize.
* @return the concatenated DEFERRED segments, or an empty string if none.
*/
public static String buildDeferredIndexCommentSegments(List<Index> deferredIndexes) {
if (deferredIndexes == null || deferredIndexes.isEmpty()) {
return "";
}

StringBuilder sb = new StringBuilder();
for (Index idx : deferredIndexes) {
sb.append("/").append(DEFERRED_COMMENT_LABEL).append(":[");
sb.append(idx.getName());
sb.append("|");
sb.append(idx.columnNames().stream().collect(Collectors.joining(",")));
if (idx.isUnique()) {
sb.append("|unique");
}
sb.append("]");
}
return sb.toString();
}


/**
* Merges deferred index declarations from a table comment with physical indexes
* loaded from the database catalog.
*
* <p>Physical indexes whose names match a deferred declaration are returned with
* {@code isDeferred()=true}. Deferred declarations with no physical counterpart
* are returned as virtual indexes (also with {@code isDeferred()=true}).</p>
*
* @param physicalIndexes the indexes loaded from the database catalog.
* @param tableComment the raw table comment string, may be null or empty.
* @return the merged index list, or {@code physicalIndexes} unchanged if
* no deferred declarations are found.
*/
public static List<Index> mergeDeferredIndexes(List<Index> physicalIndexes, String tableComment) {
List<Index> deferredFromComment = parseDeferredIndexesFromComment(tableComment);
if (deferredFromComment.isEmpty()) {
return physicalIndexes;
}

Set<String> deferredNames = deferredFromComment.stream()
.map(i -> i.getName().toUpperCase())
.collect(Collectors.toCollection(java.util.HashSet::new));

List<Index> merged = new ArrayList<>();
for (Index physical : physicalIndexes) {
if (deferredNames.contains(physical.getName().toUpperCase())) {
IndexBuilder builder = index(physical.getName()).columns(physical.columnNames()).deferred();
merged.add(physical.isUnique() ? builder.unique() : builder);
deferredNames.remove(physical.getName().toUpperCase());
} else {
merged.add(physical);
}
}
for (Index deferred : deferredFromComment) {
if (deferredNames.contains(deferred.getName().toUpperCase())) {
merged.add(deferred);
}
}
return merged;
}


/**
* Indexes which contain the suffix _PRF and a digit are to be ignored:
* this allows performance testing of new index to verify their effect,
Expand Down
148 changes: 146 additions & 2 deletions morf-core/src/main/java/org/alfasoftware/morf/jdbc/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
Expand Down Expand Up @@ -307,7 +308,7 @@
* @return SQL statements required to change a table name.
*/
public Collection<String> renameTableStatements(Table from, Table to) {
return ImmutableList.of("ALTER TABLE " + schemaNamePrefix(from) + from.getName() + " RENAME TO " + to.getName());

Check failure on line 311 in morf-core/src/main/java/org/alfasoftware/morf/jdbc/SqlDialect.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal " RENAME TO " 3 times.

See more on https://sonarcloud.io/project/issues?id=org.alfasoftware%3Amorf-parent&issues=AZ1Vb9Xa8MsCUjGK0urM&open=AZ1Vb9Xa8MsCUjGK0urM&pullRequest=374
}


Expand Down Expand Up @@ -3898,6 +3899,33 @@
}


/**
* Generate the SQL to drop an index if it exists. Used for deferred indexes
* which may or may not have been physically built yet.
*
* @param table The table to drop the index from.
* @param indexToBeRemoved The index to be dropped.
* @return The SQL to drop the specified index if it exists.
*/
public Collection<String> indexDropStatementsIfExists(@SuppressWarnings("unused") Table table, Index indexToBeRemoved) {
return ImmutableList.of("DROP INDEX IF EXISTS " + indexToBeRemoved.getName());
}


/**
* Generate the SQL to rename an index if it exists. Used for deferred indexes
* which may or may not have been physically built yet.
*
* @param table The table on which the index exists.
* @param fromIndexName The index to rename.
* @param toIndexName The new index name.
* @return The SQL to rename the index if it exists.
*/
public Collection<String> renameIndexStatementsIfExists(Table table, String fromIndexName, String toIndexName) {
return ImmutableList.of("ALTER INDEX IF EXISTS " + schemaNamePrefix(table) + fromIndexName + " RENAME TO " + toIndexName);
}


/**
* Generates the SQL to create a table and insert the data specified in the {@link SelectStatement}.
*
Expand Down Expand Up @@ -4047,6 +4075,104 @@
}


/**
* Whether this dialect supports deferred index creation. When {@code true},
* indexes marked as deferred are queued for background creation via table
* comments. When {@code false}, deferred requests are silently converted to
* immediate index creation, because the platform's {@code CREATE INDEX} blocks
* DML and deferring would move the lock from the upgrade window (when no
* traffic is flowing) to post-startup (when it is).
*
* <p>The default returns {@code false}. Dialects that support non-blocking
* DDL (e.g. PostgreSQL {@code CONCURRENTLY}, Oracle {@code ONLINE}) should
* override this to return {@code true}.</p>
*
* @return {@code true} if deferred index creation is beneficial on this platform.
*/
public boolean supportsDeferredIndexCreation() {
return false;
}


/**
* Generates the SQL to build a deferred index on an existing table. By default this
* delegates to {@link #addIndexStatements(Table, Index)}, which issues a standard
* {@code CREATE INDEX} statement. Platform-specific dialects may override this method
* to emit non-blocking variants (e.g. {@code CREATE INDEX CONCURRENTLY} on PostgreSQL).
*
* @param table The existing table.
* @param index The new index to build in the background.
* @return A collection of SQL statements.
*/
public Collection<String> deferredIndexDeploymentStatements(Table table, Index index) {
return addIndexStatements(table, index);
}


/**
* Returns SQL to find table names that have DEFERRED index declarations in their
* table comments. Used by the executor to avoid a full schema scan on large databases.
*
* <p>The default implementation returns {@code null}, meaning the caller should
* fall back to a full schema scan. Dialects that use table comments override this
* to produce an efficient query against the database catalog.</p>
*
* @return SQL that returns a single-column result set of table names, or {@code null}
* if the dialect does not support this optimization.
*/
public String findTablesWithDeferredIndexesSql() {
return null;
}


/**
* Returns SQL to check whether a named index exists but is invalid (e.g. PostgreSQL's
* {@code indisvalid=false} after a crashed {@code CREATE INDEX CONCURRENTLY}). The SQL
* should return at least one row if the index is invalid, and no rows otherwise.
*
* <p>The default implementation returns {@code null}, meaning no invalid-index checking
* is performed. PostgreSQL overrides this.</p>
*
* @param indexName the index name to check.
* @return SQL that returns rows if the index is invalid, or {@code null} if not supported.
*/
public String checkInvalidIndexSql(String indexName) {
return null;
}


/**
* Returns SQL to drop an invalid index before rebuilding it. Only called when
* {@link #checkInvalidIndexSql(String)} indicated the index is invalid.
*
* <p>The default implementation returns an empty list. PostgreSQL overrides this
* with {@code DROP INDEX IF EXISTS}.</p>
*
* @param indexName the index to drop.
* @return SQL statements to drop the invalid index.
*/
public Collection<String> dropInvalidIndexStatements(String indexName) {
return Collections.emptyList();
}


/**
* Generates a {@code COMMENT ON TABLE} SQL statement that includes both the standard
* table metadata (e.g. REALNAME) and any deferred index declarations. The deferred index
* declarations are appended as {@code /DEFERRED:[indexName|col1,col2|unique]} segments.
*
* <p>The default implementation returns an empty list. Dialects that use table comments
* (PostgreSQL, Oracle, H2) override this to produce the appropriate SQL.</p>
*
* @param table The table to generate the comment for.
* @param deferredIndexes The deferred indexes to declare in the comment.
* @return A collection of SQL statements, or an empty list if the dialect does not support comments.
*/
public Collection<String> generateTableCommentStatements(Table table, List<Index> deferredIndexes) {
return Collections.emptyList();
}


/**
* Helper method to create all index statements defined for a table
*
Expand All @@ -4070,13 +4196,31 @@
* @return The SQL to deploy the index on the table.
*/
protected Collection<String> indexDeploymentStatements(Table table, Index index) {
return ImmutableList.of(buildCreateIndexStatement(table, index, ""));
}


/**
* Builds a {@code CREATE [UNIQUE] INDEX} statement with an optional keyword
* inserted between {@code INDEX} and the index name (e.g. {@code "CONCURRENTLY"}).
*
* @param table The table to create the index on.
* @param index The index to create.
* @param afterIndexKeyword keyword to insert after {@code INDEX}, or empty string for none.
* @return the complete CREATE INDEX SQL string.
*/
protected String buildCreateIndexStatement(Table table, Index index, String afterIndexKeyword) {
StringBuilder statement = new StringBuilder();

statement.append("CREATE ");
if (index.isUnique()) {
statement.append("UNIQUE ");
}
statement.append("INDEX ")
statement.append("INDEX ");
if (!afterIndexKeyword.isEmpty()) {
statement.append(afterIndexKeyword).append(' ');
}
statement
.append(schemaNamePrefix(table))
.append(index.getName())
.append(" ON ")
Expand All @@ -4086,7 +4230,7 @@
.append(Joiner.on(", ").join(index.columnNames()))
.append(')');

return ImmutableList.of(statement.toString());
return statement.toString();
}


Expand Down
Loading