Skip to content

Create the lock table in the manual migration export for an empty database - #53096

Open
Alnyli07 wants to merge 1 commit into
keycloak:mainfrom
Alnyli07:fix/manual-export-changelog-lock-table-v2
Open

Alnyli07 wants to merge 1 commit into
keycloak:mainfrom
Alnyli07:fix/manual-export-changelog-lock-table-v2

Conversation

@Alnyli07

Copy link
Copy Markdown
Contributor

Closes #53088

What

With migration-strategy=manual against an empty database, the generated script creates the schema and, since
#51730, the DATABASECHANGELOG table, but not DATABASECHANGELOGLOCK. The server creates that one itself on
startup, in CustomLockService.init(), before the migration runs.

That was a deliberate choice when the changelog DDL was first added (3d47ab3, KEYCLOAK-3698): on the database
the script was generated from the lock table always exists by then, so emitting it would duplicate. It holds as
long as the script is applied to the same database. It does not hold for the flow the manual strategy exists for:
generate the script on one database, have an administrator apply it to a really empty one, and run Keycloak there
under a user without DDL rights. That database ends up without the lock table, and the first startup fails trying
to create it (#10149, #32535).

How

outputChangeLogTableCreationScript emits CreateDatabaseChangeLogLockTableStatement under the same rule as the
changelog table:

  • only when the lock table did not exist before this server started. An administrator may have created the
    bookkeeping tables up front so that a user without DDL rights can produce the script at all; the target then
    already has the table and the DDL would break the script with already exists;
  • only with the master changelog. Custom JpaEntityProvider changelogs share the same lock table, so emitting it
    per changelog would duplicate.

Whether it pre-existed is recorded in recordWhetherMasterChangelogTablePreExisted, i.e. during validation,
which runs before the database lock is taken and the table gets created. The same change is applied to the
legacy LiquibaseJpaUpdaterProvider.

Testing

  • ManualMigrationEmptyDatabaseDistTest (H2, empty database): the script contains the lock table DDL exactly once.
  • ManualMigrationCustomProviderDistTest (database already initialized, custom changelog outdated): the script
    contains no lock table DDL, since the table existed before the server started.
  • BasicDatabaseTest.assertManualDbInitialization: the lock table is created exactly once, so
    PostgreSQLDistTest, MySQLDistTest and MariaDBDistTest guard it through the test-database profile
    (PostgreSQLDistTest run locally through Testcontainers, 7/7).

Verified end to end on PostgreSQL 17 with two roles, an owner with DDL and an application user with DML only
(has_schema_privilege(app, schema, 'CREATE') = false):

Scenario Result
Export on an empty schema CREATE TABLE ...databasechangelog and ...databasechangeloglock once each, in the header
Apply the script to an empty database as the owner, then start as the DML-only user with validate started in 4.6 s, no permission denied
Same, but with the lock table line removed from the script (previous behaviour) startup fails in CustomLockService: permission denied for schema keycloak on CREATE TABLE ...databasechangeloglock
Both bookkeeping tables pre-created, export again no CREATE TABLE for either table
Apply that script to the database it was generated on no already exists for the bookkeeping tables

Unrelated, noticed while applying the script to PostgreSQL: the 26.8.0-51851-drop-old-expiration-idx-*
changesets are exported as MARK_RAN because their indexExists precondition is evaluated against the live
(empty) database, so the script later fails on CREATE INDEX ... already exists for the re-created indexes.
I will open a separate issue for that.

Copilot AI balanced review requested due to automatic review settings September 23, 2026 09:39
@Alnyli07
Alnyli07 requested review from a team as code owners September 23, 2026 09:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Both providers must record lock-table state during direct export, and the guard needs targeted test coverage.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity

Open (2)
What changed in this PR

Adds DATABASECHANGELOGLOCK creation to manual migration exports while avoiding duplicate DDL.

Changes:

  • Tracks lock-table pre-existence in both Liquibase providers.
  • Emits lock-table DDL only for master changelogs.
  • Extends integration coverage for empty and initialized databases.
File Review
quarkus/​tests/​integration/​src/​test/​java/​org/​keycloak/​it/​storage/​database/​BasicDatabaseTest.java Verifies lock-table DDL count across databases.
quarkus/​tests/​integration/​src/​test/​java/​org/​keycloak/​it/​cli/​dist/​ManualMigrationEmptyDatabaseDistTest.java Tests empty-database lock-table export.
quarkus/​tests/​integration/​src/​test/​java/​org/​keycloak/​it/​cli/​dist/​ManualMigrationCustomProviderDistTest.java Nit: Does not exercise the pre-existence guard with an exported master changelog.
quarkus/​runtime/​src/​main/​java/​org/​keycloak/​quarkus/​runtime/​storage/​database/​liquibase/​QuarkusJpaUpdaterProvider.java Critical: Direct exports do not initialize the lock-table pre-existence flag, suppressing required DDL.
model/​jpa/​src/​main/​java/​org/​keycloak/​connections/​jpa/​updater/​liquibase/​LiquibaseJpaUpdaterProvider.java Critical: Direct exports do not initialize the lock-table pre-existence flag, suppressing required DDL.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +344 to +346
if (lockTablePreExisted == null && isMasterChangelogTable(database)) {
lockTablePreExisted = SnapshotGeneratorFactory.getInstance().hasDatabaseChangeLogLockTable(database);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both callers of export() run validate() first (QuarkusJpaConnectionProviderFactory#createOrUpdateSchema and DefaultJpaConnectionProviderFactory#migration), so the flag is always recorded before the export. Recording it in updateChangeSet() as well would not work: by then the database lock is held and CustomLockService has already created the table, so it would always read as pre-existing and the DDL would never be emitted. Validation is the only point before the lock. This is now spelled out in a comment next to the changelog backfill, and a null (not observed) deliberately emits nothing rather than guessing.

Copilot AI review requested due to automatic review settings September 23, 2026 09:54
@Alnyli07
Alnyli07 force-pushed the fix/manual-export-changelog-lock-table-v2 branch from faefa3f to d2e7f46 Compare September 23, 2026 09:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

It changes cross-provider database migration behavior, and the corresponding migration documentation remains outdated.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (1)

Comment on lines +257 to +259
if (isMasterChangelogTable(database) && Boolean.FALSE.equals(lockTablePreExisted)) {
loggingExecutor.comment("Create Database Lock Table");
loggingExecutor.execute(new CreateDatabaseChangeLogLockTableStatement());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that paragraph was written for the state before this PR. Updated migrate_db.adoc: the script now creates both bookkeeping tables for an empty database, so a database set up purely by the script can be started against by a user without DDL rights. The remaining requirement is only on the database the script is generated against, where the server still creates the tables itself.

…abase

The generated script creates the DATABASECHANGELOG table but not
DATABASECHANGELOGLOCK, which the server creates itself on startup. That was
a deliberate choice when the changelog table DDL was first added, on the
grounds that the lock table is always created before the update runs, so
emitting it would duplicate.

That holds while the script is applied to the database it was generated
from. It does not hold for the flow a database user without DDL privileges
is left with: generate the script against one database, hand it to an
administrator to apply to an empty one. The target then ends up without the
lock table and the first startup still has to create it, which that user is
not allowed to do.

Emit it under the same rule as the changelog table, only when it did not
exist before this server started, and only once, with the master changelog.
A database populated purely by the generated script can then be started
under a user with no DDL privileges at all.

Closes keycloak#53088

Signed-off-by: Ali Tugrul Pinar <ali@keymate.io>
Copilot AI review requested due to automatic review settings September 23, 2026 10:02
@Alnyli07
Alnyli07 force-pushed the fix/manual-export-changelog-lock-table-v2 branch from d2e7f46 to 20e3617 Compare September 23, 2026 10:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No blocking issues remain; only a minor documentation clarification was identified.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Manual migration export for an empty database does not create the DATABASECHANGELOGLOCK table

2 participants