[jOOQ/jOOQ#9506] Add a way to resolve() a FAILURE

This commit is contained in:
Lukas Eder 2023-06-16 09:05:25 +02:00
parent 80ac10e1c8
commit 9e04de7cf8
7 changed files with 331 additions and 100 deletions

View File

@ -0,0 +1,64 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Other licenses:
* -----------------------------------------------------------------------------
* Commercial licenses for this work are available. These replace the above
* ASL 2.0 and offer limited warranties, support, maintenance, and commercial
* database integrations.
*
* For more information, please visit: https://www.jooq.org/legal/licensing
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*/
package org.jooq.codegen.maven;
import static org.apache.maven.plugins.annotations.LifecyclePhase.GENERATE_SOURCES;
import static org.apache.maven.plugins.annotations.ResolutionScope.TEST;
import org.jooq.Configuration;
import org.apache.maven.plugins.annotations.Mojo;
/**
* The jOOQ Migrations resolve mojo
*
* @author Lukas Eder
*/
@Mojo(
name = "resolve",
defaultPhase = GENERATE_SOURCES,
requiresDependencyResolution = TEST,
threadSafe = true
)
public class ResolveMojo extends AbstractMigrationsMojo {
@Override
final void execute0(Configuration configuration) throws Exception {
configuration.dsl().migrations().history().resolve("Resolved");
}
}

View File

@ -83,4 +83,14 @@ public interface History extends Iterable<Version> {
@NotNull
@Experimental
Version current();
/**
* Resolve any previous failures in the {@link History}.
* <p>
* This is EXPERIMENTAL functionality and subject to change in future jOOQ
* versions.
*/
@Experimental
void resolve(String message);
}

View File

@ -17,6 +17,7 @@ import org.jooq.Table;
import org.jooq.TableField;
import org.jooq.TableOptions;
import org.jooq.UniqueKey;
import org.jooq.impl.MigrationImpl.Resolution;
import org.jooq.impl.MigrationImpl.Status;
@ -94,6 +95,24 @@ class History extends TableImpl<HistoryRecord> {
*/
final TableField<HistoryRecord, Status> STATUS = createField(DSL.name("STATUS"), SQLDataType.VARCHAR(10).nullable(false), this, "The database version installation status.", new EnumConverter<String, Status>(String.class, Status.class));
/**
* The column <code>JOOQ_MIGRATION_HISTORY.STATUS_MESSAGE</code>. Any info
* or error message explaining the status.
*/
final TableField<HistoryRecord, String> STATUS_MESSAGE = createField(DSL.name("STATUS_MESSAGE"), SQLDataType.CLOB, this, "Any info or error message explaining the status.");
/**
* The column <code>JOOQ_MIGRATION_HISTORY.RESOLUTION</code>. The error
* resolution, if any.
*/
final TableField<HistoryRecord, Resolution> RESOLUTION = createField(DSL.name("RESOLUTION"), SQLDataType.VARCHAR(10), this, "The error resolution, if any.", new EnumConverter<String, Resolution>(String.class, Resolution.class));
/**
* The column <code>JOOQ_MIGRATION_HISTORY.RESOLUTION_MESSAGE</code>. Any
* info or error message explaining the resolution.
*/
final TableField<HistoryRecord, String> RESOLUTION_MESSAGE = createField(DSL.name("RESOLUTION_MESSAGE"), SQLDataType.CLOB, this, "Any info or error message explaining the resolution.");
private History(Name alias, Table<HistoryRecord> aliased) {
this(alias, aliased, (Field<?>[]) null, null);
}
@ -136,7 +155,8 @@ class History extends TableImpl<HistoryRecord> {
@Override
public List<Check<HistoryRecord>> getChecks() {
return Arrays.asList(
Internal.createCheck(this, DSL.name("JOOQ_MIGR_HIST_CHK1"), "\"STATUS\" IN('STARTING', 'REVERTING', 'MIGRATING', 'SUCCESS', 'FAILURE')", true)
Internal.createCheck(this, DSL.name("JOOQ_MIGR_HIST_CHK1"), "\"STATUS\" IN('STARTING', 'REVERTING', 'MIGRATING', 'SUCCESS', 'FAILURE')", true),
Internal.createCheck(this, DSL.name("JOOQ_MIGR_HIST_CHK2"), "\"RESOLUTION\" IN('OPEN', 'RESOLVED', 'IGNORED')", true)
);
}

View File

@ -42,8 +42,11 @@ import static java.util.Arrays.asList;
import static java.util.Collections.unmodifiableList;
import static org.jooq.impl.DSL.inline;
import static org.jooq.impl.DSL.name;
import static org.jooq.impl.DSL.noCondition;
import static org.jooq.impl.DSL.schema;
import static org.jooq.impl.History.HISTORY;
import static org.jooq.impl.MigrationImpl.Resolution.OPEN;
import static org.jooq.impl.MigrationImpl.Resolution.RESOLVED;
import static org.jooq.impl.MigrationImpl.Status.SUCCESS;
import static org.jooq.impl.Tools.isEmpty;
import static org.jooq.tools.StringUtils.isBlank;
@ -70,8 +73,10 @@ import org.jooq.conf.MigrationSchema;
import org.jooq.conf.RenderMapping;
import org.jooq.exception.DataAccessException;
import org.jooq.exception.DataMigrationVerificationException;
import org.jooq.impl.MigrationImpl.Resolution;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.ApiStatus.Experimental;
/**
* @author Lukas Eder
@ -86,6 +91,8 @@ class HistoryImpl extends AbstractScope implements History {
HistoryImpl(Configuration configuration) {
super(configuration);
// [#9506] TODO: What's the best way to spawn a new JDBC connection from an existing one?
// The history interactions should run in an autonomous transaction of the system connection provider
this.ctx = configuration.dsl();
this.historyCtx = initCtx(configuration, configuration.settings().getMigrationHistorySchema()).dsl();
this.commits = configuration.commitProvider().provide();
@ -184,12 +191,14 @@ class HistoryImpl extends AbstractScope implements History {
}
@Nullable
final HistoryRecord currentHistoryRecord() {
final HistoryRecord currentHistoryRecord(boolean successOnly) {
return existsHistory()
? historyCtx.selectFrom(HISTORY)
// TODO: How to recover from failure?
.where(HISTORY.STATUS.eq(inline(SUCCESS)))
.where(successOnly
? HISTORY.STATUS.eq(inline(SUCCESS))
: HISTORY.STATUS.eq(inline(SUCCESS)).or(HISTORY.RESOLUTION.eq(OPEN)))
.orderBy(HISTORY.MIGRATED_AT.desc(), HISTORY.ID.desc())
.limit(1)
.fetchOne()
@ -254,6 +263,18 @@ class HistoryImpl extends AbstractScope implements History {
}
}
@Override
public final void resolve(String message) {
HistoryRecord h = currentHistoryRecord(false);
if (h != null)
h.setResolution(RESOLVED)
.setResolutionMessage(message)
.update();
else
throw new DataMigrationVerificationException("No current history record found to resolve");
}
// -------------------------------------------------------------------------
// The Object API
// -------------------------------------------------------------------------

View File

@ -7,6 +7,7 @@ package org.jooq.impl;
import java.sql.Timestamp;
import org.jooq.Record1;
import org.jooq.impl.MigrationImpl.Resolution;
import org.jooq.impl.MigrationImpl.Status;
@ -169,6 +170,57 @@ class HistoryRecord extends UpdatableRecordImpl<HistoryRecord> {
return (Status) get(8);
}
/**
* Setter for <code>JOOQ_MIGRATION_HISTORY.STATUS_MESSAGE</code>. Any info
* or error message explaining the status.
*/
HistoryRecord setStatusMessage(String value) {
set(9, value);
return this;
}
/**
* Getter for <code>JOOQ_MIGRATION_HISTORY.STATUS_MESSAGE</code>. Any info
* or error message explaining the status.
*/
String getStatusMessage() {
return (String) get(9);
}
/**
* Setter for <code>JOOQ_MIGRATION_HISTORY.RESOLUTION</code>. The error
* resolution, if any.
*/
HistoryRecord setResolution(Resolution value) {
set(10, value);
return this;
}
/**
* Getter for <code>JOOQ_MIGRATION_HISTORY.RESOLUTION</code>. The error
* resolution, if any.
*/
Resolution getResolution() {
return (Resolution) get(10);
}
/**
* Setter for <code>JOOQ_MIGRATION_HISTORY.RESOLUTION_MESSAGE</code>. Any
* info or error message explaining the resolution.
*/
HistoryRecord setResolutionMessage(String value) {
set(11, value);
return this;
}
/**
* Getter for <code>JOOQ_MIGRATION_HISTORY.RESOLUTION_MESSAGE</code>. Any
* info or error message explaining the resolution.
*/
String getResolutionMessage() {
return (String) get(11);
}
// -------------------------------------------------------------------------
// Primary key information
// -------------------------------------------------------------------------
@ -192,7 +244,7 @@ class HistoryRecord extends UpdatableRecordImpl<HistoryRecord> {
/**
* Create a detached, initialised HistoryRecord
*/
HistoryRecord(Integer id, String migratedFrom, String migratedTo, Timestamp migratedAt, Long migrationTime, String jooqVersion, String sql, Integer sqlCount, Status status) {
HistoryRecord(Integer id, String migratedFrom, String migratedTo, Timestamp migratedAt, Long migrationTime, String jooqVersion, String sql, Integer sqlCount, Status status, String statusMessage, Resolution resolution, String resolutionMessage) {
super(History.HISTORY);
setId(id);
@ -204,6 +256,9 @@ class HistoryRecord extends UpdatableRecordImpl<HistoryRecord> {
setSql(sql);
setSqlCount(sqlCount);
setStatus(status);
setStatusMessage(statusMessage);
setResolution(resolution);
setResolutionMessage(resolutionMessage);
resetChangedOnNotNull();
}
}

View File

@ -44,12 +44,15 @@ import static org.jooq.impl.DSL.dropSchemaIfExists;
import static org.jooq.impl.DSL.dropTableIfExists;
import static org.jooq.impl.History.HISTORY;
import static org.jooq.impl.HistoryImpl.initCtx;
import static org.jooq.impl.MigrationImpl.Resolution.OPEN;
import static org.jooq.impl.MigrationImpl.Status.FAILURE;
import static org.jooq.impl.MigrationImpl.Status.MIGRATING;
import static org.jooq.impl.MigrationImpl.Status.REVERTING;
import static org.jooq.impl.MigrationImpl.Status.STARTING;
import static org.jooq.impl.MigrationImpl.Status.SUCCESS;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.sql.Timestamp;
import java.util.HashSet;
import java.util.Set;
@ -72,13 +75,14 @@ import org.jooq.exception.DataMigrationException;
import org.jooq.exception.DataMigrationVerificationException;
import org.jooq.tools.JooqLogger;
import org.jooq.tools.StopWatch;
import org.jooq.tools.StringUtils;
/**
* @author Lukas Eder
*/
final class MigrationImpl extends AbstractScope implements Migration {
static final JooqLogger log = JooqLogger.getLogger(Migration.class);
static final JooqLogger log = JooqLogger.getLogger(Migration.class);
final HistoryImpl history;
final Commit to;
Commit from;
@ -133,9 +137,19 @@ final class MigrationImpl extends AbstractScope implements Migration {
}
private final void verify0(DefaultMigrationContext ctx) {
HistoryRecord currentRecord = history.currentHistoryRecord();
HistoryRecord currentRecord = history.currentHistoryRecord(false);
if (currentRecord != null) {
switch (currentRecord.getStatus()) {
case FAILURE:
throw new DataMigrationVerificationException("Previous migration attempt from " + currentRecord.getMigratedFrom() + " to " + currentRecord.getMigratedTo() + " has failed. Please resolve before migrating.");
case STARTING:
case REVERTING:
case MIGRATING:
throw new DataMigrationVerificationException("Ongoing migration from " + currentRecord.getMigratedFrom() + " to " + currentRecord.getMigratedTo() + ". Please wait until it has finished.");
}
Commit currentCommit = commits().get(currentRecord.getMigratedTo());
if (currentCommit == null)
@ -212,90 +226,109 @@ final class MigrationImpl extends AbstractScope implements Migration {
// TODO: Transactions don't really make sense in most dialects. In some, they do
// e.g. PostgreSQL supports transactional DDL. Check if we're getting this right.
run(new ContextTransactionalRunnable() {
@Override
public void run() {
DefaultMigrationContext ctx = migrationContext();
MigrationListener listener = new MigrationListeners(configuration);
run(() -> {
DefaultMigrationContext ctx = migrationContext();
MigrationListener listener = new MigrationListeners(configuration);
if (!FALSE.equals(dsl().settings().isMigrationAutoVerification()))
verify0(ctx);
if (!FALSE.equals(dsl().settings().isMigrationAutoVerification()))
verify0(ctx);
try {
listener.migrationStart(ctx);
if (from().equals(to())) {
log.info("jOOQ Migrations", "Version " + to().id() + " is already installed as the current version.");
return;
}
// TODO: Implement preconditions
// TODO: Implement a listener with a variety of pro / oss features
// TODO: Implement additional out-of-the-box sanity checks
// TODO: Allow undo migrations only if enabled explicitly
// TODO: Add some migration settings, e.g. whether HISTORY.SQL should be filled
// TODO: Migrate the HISTORY table with the Migration API
// TODO: Create an Enum for HISTORY.STATUS
// TODO: Add HISTORY.USERNAME and HOSTNAME columns
// TODO: Add HISTORY.COMMENTS column
// TODO: Replace (MIGRATED_AT, MIGRATION_TIME) by (MIGRATION_START, MIGRATION_END)
log.info("jOOQ Migrations", "Version " + from().id() + " is being migrated to " + to().id());
StopWatch watch = new StopWatch();
// TODO: Make logging configurable
if (log.isDebugEnabled())
for (Query query : queries())
log.debug("jOOQ Migrations", dsl().renderInlined(query));
HistoryRecord record = createRecord(STARTING);
try {
listener.migrationStart(ctx);
if (from().equals(to())) {
log.info("jOOQ Migrations", "Version " + to().id() + " is already installed as the current version.");
return;
}
// TODO: Implement preconditions
// TODO: Implement a listener with a variety of pro / oss features
// TODO: Implement additional out-of-the-box sanity checks
// TODO: Allow undo migrations only if enabled explicitly
// TODO: Add some migration settings, e.g. whether HISTORY.SQL should be filled
// TODO: Migrate the HISTORY table with the Migration API
// TODO: Create an Enum for HISTORY.STATUS
// TODO: Add HISTORY.USERNAME and HOSTNAME columns
// TODO: Add HISTORY.COMMENTS column
// TODO: Replace (MIGRATED_AT, MIGRATION_TIME) by (MIGRATION_START, MIGRATION_END)
log.info("jOOQ Migrations", "Version " + from().id() + " is migrated to " + to().id());
StopWatch watch = new StopWatch();
// TODO: Make logging configurable
if (log.isDebugEnabled())
for (Query query : queries())
log.debug("jOOQ Migrations", dsl().renderInlined(query));
HistoryRecord record = createRecord(STARTING);
try {
log(watch, record, REVERTING);
revertUntracked(ctx, listener, record);
log(watch, record, MIGRATING);
execute(ctx, listener, queries());
log(watch, record, SUCCESS);
}
catch (DataAccessException e) {
// TODO: Make sure this is committed, given that we're re-throwing the exception.
// TODO: How can we recover from failure?
log(watch, record, FAILURE);
throw e;
}
log(watch, record, REVERTING);
revertUntracked(ctx, listener, record);
log(watch, record, MIGRATING);
execute(ctx, listener, queries());
log(watch, record, SUCCESS);
}
finally {
listener.migrationEnd(ctx);
catch (Exception e) {
StringWriter s = new StringWriter();
e.printStackTrace(new PrintWriter(s));
log.error("jOOQ Migrations", "Version " + from().id() + " migration to " + to().id() + " failed: " + e.getMessage());
log(watch, record, FAILURE, OPEN, s.toString());
throw new DataMigrationRedoLogException(record, e);
}
}
private final HistoryRecord createRecord(Status status) {
HistoryRecord record = history.historyCtx.newRecord(HISTORY);
record
.setJooqVersion(Constants.VERSION)
.setMigratedAt(new Timestamp(dsl().configuration().clock().instant().toEpochMilli()))
.setMigratedFrom(from().id())
.setMigratedTo(to().id())
.setMigrationTime(0L)
.setSql(queries().toString())
.setSqlCount(queries().queries().length)
.setStatus(status)
.insert();
return record;
}
private final void log(StopWatch watch, HistoryRecord record, Status status) {
record.setMigrationTime(watch.split() / 1000000L)
.setStatus(status)
.update();
finally {
listener.migrationEnd(ctx);
}
});
}
/**
* An internal wrapper class for exceptions that allows for re-creating the
* {@link HistoryRecord} in case it was rolled back.
*/
static final class DataMigrationRedoLogException extends DataMigrationException {
final HistoryRecord record;
public DataMigrationRedoLogException(HistoryRecord record, Exception cause) {
super("Redo log", cause);
this.record = record;
}
}
private final HistoryRecord createRecord(Status status) {
HistoryRecord record = history.historyCtx.newRecord(HISTORY);
record
.setJooqVersion(Constants.VERSION)
.setMigratedAt(new Timestamp(dsl().configuration().clock().instant().toEpochMilli()))
.setMigratedFrom(from().id())
.setMigratedTo(to().id())
.setMigrationTime(0L)
.setSql(queries().toString())
.setSqlCount(queries().queries().length)
.setStatus(status)
.insert();
return record;
}
private final void log(StopWatch watch, HistoryRecord record, Status status) {
log(watch, record, status, null, null);
}
private final void log(StopWatch watch, HistoryRecord record, Status status, Resolution resolution, String message) {
record.setMigrationTime(watch.split() / 1000000L)
.setStatus(status)
.setStatusMessage(message)
.setResolution(resolution)
.update();
}
private final void execute(DefaultMigrationContext ctx, MigrationListener listener, Queries q) {
// TODO: Can we access the individual Queries from Version, if applicable?
// TODO: Set the ctx.queriesFrom(), ctx.queriesTo(), and ctx.queries() values
@ -327,7 +360,7 @@ final class MigrationImpl extends AbstractScope implements Migration {
}
final Commit currentCommit() {
HistoryRecord currentRecord = history.currentHistoryRecord();
HistoryRecord currentRecord = history.currentHistoryRecord(true);
if (currentRecord == null) {
Commit result = TRUE.equals(settings().isMigrationAutoBaseline()) ? to() : to().root();
@ -352,6 +385,21 @@ final class MigrationImpl extends AbstractScope implements Migration {
init();
dsl().transaction(runnable);
}
catch (DataMigrationRedoLogException e) {
// [#9506] Make sure history record is re-created in case it was rolled back.
HistoryRecord record = history.currentHistoryRecord(false);
if (record == null || !StringUtils.equals(e.record.getId(), record.getId())) {
e.record.changed(true);
e.record.insert();
}
if (e.getCause() instanceof DataMigrationException r)
throw r;
else
throw new DataMigrationException("Exception during migration", e);
}
catch (DataMigrationException e) {
throw e;
}
@ -368,6 +416,12 @@ final class MigrationImpl extends AbstractScope implements Migration {
FAILURE
}
enum Resolution {
OPEN,
RESOLVED,
IGNORED
}
// -------------------------------------------------------------------------
// The Object API
// -------------------------------------------------------------------------

View File

@ -1,26 +1,33 @@
CREATE TABLE jooq_migration_history (
id BIGINT NOT NULL IDENTITY,
migrated_from VARCHAR(255) NOT NULL,
migrated_to VARCHAR(255) NOT NULL,
migrated_at TIMESTAMP NOT NULL,
migration_time BIGINT NULL,
jooq_version VARCHAR(50) NOT NULL,
sql CLOB NULL,
sql_count INT NOT NULL,
status VARCHAR(10) NOT NULL,
id BIGINT NOT NULL IDENTITY,
migrated_from VARCHAR(255) NOT NULL,
migrated_to VARCHAR(255) NOT NULL,
migrated_at TIMESTAMP NOT NULL,
migration_time BIGINT NULL,
jooq_version VARCHAR(50) NOT NULL,
sql CLOB NULL,
sql_count INT NOT NULL,
status VARCHAR(10) NOT NULL,
status_message CLOB NULL,
resolution VARCHAR(10) NULL,
resolution_message CLOB NULL,
CONSTRAINT jooq_migr_hist_pk PRIMARY KEY (id),
CONSTRAINT jooq_migr_hist_chk1 CHECK (status IN ('STARTING', 'REVERTING', 'MIGRATING', 'SUCCESS', 'FAILURE'))
CONSTRAINT jooq_migr_hist_chk1 CHECK (status IN ('STARTING', 'REVERTING', 'MIGRATING', 'SUCCESS', 'FAILURE')),
CONSTRAINT jooq_migr_hist_chk2 CHECK (resolution IN ('OPEN', 'RESOLVED', 'IGNORED'))
);
CREATE INDEX jooq_migr_hist_i1 ON jooq_migration_history (migrated_at);
COMMENT ON TABLE jooq_migration_history IS 'The migration history of jOOQ Migrations.';
COMMENT ON COLUMN jooq_migration_history.id IS 'The database version ID.';
COMMENT ON COLUMN jooq_migration_history.migrated_from IS 'The previous database version ID.';
COMMENT ON COLUMN jooq_migration_history.migrated_at IS 'The date/time when the database version was migrated to.';
COMMENT ON COLUMN jooq_migration_history.migration_time IS 'The time in milliseconds it took to migrate to this database version.';
COMMENT ON COLUMN jooq_migration_history.jooq_version IS 'The jOOQ version used to migrate to this database version.';
COMMENT ON COLUMN jooq_migration_history.sql_count IS 'The number of SQL statements that were run to install this database version.';
COMMENT ON COLUMN jooq_migration_history.sql IS 'The SQL statements that were run to install this database version.';
COMMENT ON COLUMN jooq_migration_history.status IS 'The database version installation status.';
COMMENT ON TABLE jooq_migration_history IS 'The migration history of jOOQ Migrations.';
COMMENT ON COLUMN jooq_migration_history.id IS 'The database version ID.';
COMMENT ON COLUMN jooq_migration_history.migrated_from IS 'The previous database version ID.';
COMMENT ON COLUMN jooq_migration_history.migrated_at IS 'The date/time when the database version was migrated to.';
COMMENT ON COLUMN jooq_migration_history.migration_time IS 'The time in milliseconds it took to migrate to this database version.';
COMMENT ON COLUMN jooq_migration_history.jooq_version IS 'The jOOQ version used to migrate to this database version.';
COMMENT ON COLUMN jooq_migration_history.sql_count IS 'The number of SQL statements that were run to install this database version.';
COMMENT ON COLUMN jooq_migration_history.sql IS 'The SQL statements that were run to install this database version.';
COMMENT ON COLUMN jooq_migration_history.status IS 'The database version installation status.';
COMMENT ON COLUMN jooq_migration_history.status_message IS 'Any info or error message explaining the status.';
COMMENT ON COLUMN jooq_migration_history.resolution IS 'The error resolution, if any.';
COMMENT ON COLUMN jooq_migration_history.resolution_message IS 'Any info or error message explaining the resolution.';