enhancement
major
minor
defect
critical
major
minor
critical
#30146
Data migration is not protected against parallel execution when multiple cluster nodes are started simultaneously
== Problem
If multiple nodes in a cluster are started simultaneously (e.g., by a deployment that rolls out all nodes at once), each node can execute the automatic data migration (MigrationService). There is no lock to prevent the migration runs from overlapping. As a result, the nodes may interfere with one another or perform the same migrations twice.
The ClusterManager’s startup token does not protect the migration because the migration is already running before the token is retrieved.
== Analysis
=== Startup Order
AbstractStartStopListener.boot():
- ClusterManager.startUpClusterManager() — starts the ClusterManager with its dependencies, including ConnectionPoolRegistry.
- startTokenSystem(...) — retrieves the startup token in cluster mode (usingacquireStartupContext() via the LockService).
- startupModuleSystem(...) — starts the remaining modules.
- releaseTokenSystem(...) — releases the token.
=== The migration is already underway in Step 1
- InitialTableSetup is @ServiceExtensionPoint(ConnectionPoolRegistry.Module.class).
- MigrationService is @ServiceExtensionPoint(InitialTableSetup.Module.class).
- ModuleUtil.addDependencies() starts a module’s extensions after the module itself. Thus, InitialTableSetup and MigrationService are started via ConnectionPoolRegistry (a dependency of ClusterManager).
- MigrationService.startUp() directly calls migrate(log).
The migration thus runs within startUpClusterManager() and before startTokenSystem().
=== No dedicated lock in MigrationService
- There is no lock and no cluster coordination in MigrationService or in the com.top_logic.knowledge.service.migration package.
- The list of pending migrations is determined in the constructor (fetchMigrations(), separate read connection) and processed unchanged later in startUp().
- The stored schema baseline, on the other hand, is read fresh from DBProperties each time it is accessed (MigrationContext.getPersistentSchema() → KBSchemaUtil.loadSchema()).
- Migration versions and the baseline are ordinary entries in DBProperties (DBProperties.setProperty()); row locks in the database serialize individual write operations at most, not the entire migration run.
=== Possible Scenario
- Nodes A and B start simultaneously, read the same stored versions, and determine the same pending migrations.
- A executes all migrations, then uses StoreTypeConfiguration to save the baseline from the application configuration as well as the new versions, and commits.
- B processes its outdated list and applies the migrations to the state that has already been migrated.
Consequences:
- Non-idempotent baseline migrations (XSLT) create inconsistent schemas, e.g., duplicate <key-attributes> (see #30142), causing B to fail when parsing the baseline.
- Data migrations may be applied twice to data that has already been migrated—this issue does not necessarily result in an error.
- For databases with implicit commits for DDL (Oracle, MySQL), intermediate states of an aborted run may be persisted.
== Conceptual Limitation
The migration cannot simply be placed under the existing startup token: The token requires LockService and PersistencyLayer, i.e., a schema that has already been migrated.
== Proposed Solution
- A custom, purely JDBC-based lock for the migration run, e.g., `SELECT ... FOR UPDATE ` on a dedicated row (e.g., in `DBProperties`) or a comparable DB lock, held throughout the entire run, including the saving of versions and baselines.
- After acquiring the lock, recalculate the pending migrations instead of using the list calculated in the constructor. A node that had to wait will then typically find that there is nothing left to do.
- Timeout or handling of a crashed node that holds the lock (in the case of a transaction lock, the database releases it when the connection is terminated).