diff --git a/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql b/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql
deleted file mode 100644
index a0aa6a82d8..0000000000
--- a/hertzbeat-startup/src/main/resources/db/migration/h2/V182__scope_sop_schedule_owners.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to You 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
---
--- http://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.
-
-UPDATE hzb_sop_schedule
-SET enabled = 0
-WHERE creator IS NULL
- OR TRIM(creator) = '';
-
-CREATE INDEX IF NOT EXISTS idx_schedule_creator_conversation
- ON hzb_sop_schedule(creator, conversation_id);
diff --git a/hertzbeat-startup/src/main/resources/db/migration/h2/V183__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/h2/V183__update_column.sql
deleted file mode 100644
index 936fbf6547..0000000000
--- a/hertzbeat-startup/src/main/resources/db/migration/h2/V183__update_column.sql
+++ /dev/null
@@ -1,19 +0,0 @@
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to you 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
---
--- http://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.
-
--- Enlarge alert define expr to fit rules binding many monitors (#4171)
-ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr CLOB;
diff --git a/hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/h2/V190__update_column.sql
similarity index 68%
rename from hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql
rename to hertzbeat-startup/src/main/resources/db/migration/h2/V190__update_column.sql
index aa700f83f4..e018f8151c 100644
--- a/hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql
+++ b/hertzbeat-startup/src/main/resources/db/migration/h2/V190__update_column.sql
@@ -14,23 +14,8 @@
-- KIND, either express or implied. See the License for the
-- specific language governing permissions and limitations
-- under the License.
-
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to you 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
---
--- http://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.
+-- Schema changes for release 1.9.0.
+-- Every statement below is safe to re-run.
-- Scheduled SOP execution configurations
CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
@@ -39,7 +24,7 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
sop_name VARCHAR(64) NOT NULL COMMENT 'Name of the SOP skill to execute',
sop_params VARCHAR(1024) COMMENT 'SOP execution parameters in JSON format',
cron_expression VARCHAR(64) NOT NULL COMMENT 'Cron expression for scheduling',
- enabled TINYINT DEFAULT 1 COMMENT 'Whether the schedule is enabled',
+ enabled BOOLEAN DEFAULT TRUE COMMENT 'Whether the schedule is enabled',
last_run_time DATETIME COMMENT 'Last execution time',
next_run_time DATETIME COMMENT 'Next scheduled execution time',
creator VARCHAR(64) COMMENT 'Creator of this record',
@@ -50,3 +35,13 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
CREATE INDEX IF NOT EXISTS idx_schedule_conversation_id ON hzb_sop_schedule(conversation_id);
CREATE INDEX IF NOT EXISTS idx_schedule_enabled_next ON hzb_sop_schedule(enabled, next_run_time);
+-- idx_schedule_creator_conversation is declared by @Index on SopSchedule and created by Hibernate.
+
+-- Disable SOP schedules that have no owner to scope them to
+UPDATE hzb_sop_schedule
+SET enabled = FALSE
+WHERE creator IS NULL
+ OR TRIM(creator) = '';
+
+-- Enlarge alert define expr to fit rules binding many monitors
+ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr CLOB;
diff --git a/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql b/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql
deleted file mode 100644
index 3d0a986ea1..0000000000
--- a/hertzbeat-startup/src/main/resources/db/migration/mysql/V182__scope_sop_schedule_owners.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to You 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
---
--- http://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.
-
-UPDATE hzb_sop_schedule
-SET enabled = 0
-WHERE creator IS NULL
- OR TRIM(creator) = '';
-
-CREATE INDEX idx_schedule_creator_conversation
- ON hzb_sop_schedule(creator, conversation_id);
diff --git a/hertzbeat-startup/src/main/resources/db/migration/mysql/V183__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/mysql/V183__update_column.sql
deleted file mode 100644
index aeb22f273a..0000000000
--- a/hertzbeat-startup/src/main/resources/db/migration/mysql/V183__update_column.sql
+++ /dev/null
@@ -1,50 +0,0 @@
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to you 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
---
--- http://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.
-
--- Enlarge alert define expr to fit rules binding many monitors (#4171)
-DELIMITER //
-
-CREATE PROCEDURE ModifyAlertDefineExprColumn()
-BEGIN
- DECLARE table_exists INT;
- DECLARE col_exists INT;
-
- SELECT COUNT(*) INTO table_exists
- FROM INFORMATION_SCHEMA.TABLES
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hzb_alert_define';
-
- IF table_exists = 1 THEN
- SELECT COUNT(*) INTO col_exists
- FROM INFORMATION_SCHEMA.COLUMNS
- WHERE TABLE_SCHEMA = DATABASE()
- AND TABLE_NAME = 'hzb_alert_define'
- AND COLUMN_NAME = 'expr'
- AND DATA_TYPE != 'longtext';
-
- IF col_exists = 1 THEN
- ALTER TABLE hzb_alert_define MODIFY COLUMN expr LONGTEXT;
- END IF;
- END IF;
-END //
-
-DELIMITER ;
-
-CALL ModifyAlertDefineExprColumn();
-
-DROP PROCEDURE IF EXISTS ModifyAlertDefineExprColumn;
-
-COMMIT;
diff --git a/hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/mysql/V190__update_column.sql
similarity index 56%
rename from hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql
rename to hertzbeat-startup/src/main/resources/db/migration/mysql/V190__update_column.sql
index d45a51231d..b90363a648 100644
--- a/hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql
+++ b/hertzbeat-startup/src/main/resources/db/migration/mysql/V190__update_column.sql
@@ -14,23 +14,8 @@
-- KIND, either express or implied. See the License for the
-- specific language governing permissions and limitations
-- under the License.
-
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to you 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
---
--- http://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.
+-- Schema changes for release 1.9.0.
+-- Every statement below is safe to re-run.
-- Scheduled SOP execution configurations
CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
@@ -39,7 +24,7 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
sop_name VARCHAR(64) NOT NULL COMMENT 'Name of the SOP skill to execute',
sop_params VARCHAR(1024) COMMENT 'SOP execution parameters in JSON format',
cron_expression VARCHAR(64) NOT NULL COMMENT 'Cron expression for scheduling',
- enabled TINYINT DEFAULT 1 COMMENT 'Whether the schedule is enabled',
+ enabled BOOLEAN DEFAULT TRUE COMMENT 'Whether the schedule is enabled',
last_run_time DATETIME COMMENT 'Last execution time',
next_run_time DATETIME COMMENT 'Next scheduled execution time',
creator VARCHAR(64) COMMENT 'Creator of this record',
@@ -49,3 +34,46 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
INDEX idx_schedule_conversation_id (conversation_id),
INDEX idx_schedule_enabled_next (enabled, next_run_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+-- idx_schedule_creator_conversation is declared by @Index on SopSchedule and created by Hibernate.
+
+-- Disable SOP schedules that have no owner to scope them to
+UPDATE hzb_sop_schedule
+SET enabled = FALSE
+WHERE creator IS NULL
+ OR TRIM(creator) = '';
+
+-- Enlarge alert define expr to fit rules binding many monitors.
+-- Keep the DELIMITER block last so the routine body cannot swallow the plain
+-- statements above it.
+DROP PROCEDURE IF EXISTS ModifyAlertDefineExprColumn;
+
+DELIMITER //
+
+CREATE PROCEDURE ModifyAlertDefineExprColumn()
+BEGIN
+ DECLARE table_exists INT;
+ DECLARE col_exists INT;
+
+ SELECT COUNT(*) INTO table_exists
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hzb_alert_define';
+
+ IF table_exists = 1 THEN
+ SELECT COUNT(*) INTO col_exists
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'hzb_alert_define'
+ AND COLUMN_NAME = 'expr'
+ AND DATA_TYPE != 'longtext';
+
+ IF col_exists = 1 THEN
+ ALTER TABLE hzb_alert_define MODIFY COLUMN expr LONGTEXT;
+ END IF;
+ END IF;
+END //
+
+DELIMITER ;
+
+CALL ModifyAlertDefineExprColumn();
+
+DROP PROCEDURE IF EXISTS ModifyAlertDefineExprColumn;
diff --git a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql
deleted file mode 100644
index 44aaf71220..0000000000
--- a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V182__scope_sop_schedule_owners.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to You 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
---
--- http://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.
-
-UPDATE hzb_sop_schedule
-SET enabled = 0
-WHERE creator IS NULL
- OR BTRIM(creator) = '';
-
-CREATE INDEX idx_schedule_creator_conversation
- ON hzb_sop_schedule(creator, conversation_id);
diff --git a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V183__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V183__update_column.sql
deleted file mode 100644
index 08c429718e..0000000000
--- a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V183__update_column.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to you 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
---
--- http://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.
-
--- Enlarge alert define expr to fit rules binding many monitors (#4171)
-ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr TYPE TEXT;
-commit;
diff --git a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V190__update_column.sql
similarity index 72%
rename from hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql
rename to hertzbeat-startup/src/main/resources/db/migration/postgresql/V190__update_column.sql
index 8604cb9e75..2c8334205e 100644
--- a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql
+++ b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V190__update_column.sql
@@ -14,23 +14,8 @@
-- KIND, either express or implied. See the License for the
-- specific language governing permissions and limitations
-- under the License.
-
--- Licensed to the Apache Software Foundation (ASF) under one
--- or more contributor license agreements. See the NOTICE file
--- distributed with this work for additional information
--- regarding copyright ownership. The ASF licenses this file
--- to you 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
---
--- http://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.
+-- Schema changes for release 1.9.0.
+-- Every statement below is safe to re-run.
-- Scheduled SOP execution configurations
CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
@@ -39,7 +24,7 @@ CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
sop_name VARCHAR(64) NOT NULL,
sop_params VARCHAR(1024),
cron_expression VARCHAR(64) NOT NULL,
- enabled SMALLINT DEFAULT 1,
+ enabled BOOLEAN DEFAULT TRUE,
last_run_time TIMESTAMP,
next_run_time TIMESTAMP,
creator VARCHAR(64),
@@ -59,3 +44,13 @@ COMMENT ON COLUMN hzb_sop_schedule.next_run_time IS 'Next scheduled execution ti
CREATE INDEX IF NOT EXISTS idx_schedule_conversation_id ON hzb_sop_schedule(conversation_id);
CREATE INDEX IF NOT EXISTS idx_schedule_enabled_next ON hzb_sop_schedule(enabled, next_run_time);
+-- idx_schedule_creator_conversation is declared by @Index on SopSchedule and created by Hibernate.
+
+-- Disable SOP schedules that have no owner to scope them to
+UPDATE hzb_sop_schedule
+SET enabled = FALSE
+WHERE creator IS NULL
+ OR BTRIM(creator) = '';
+
+-- Enlarge alert define expr to fit rules binding many monitors
+ALTER TABLE HZB_ALERT_DEFINE ALTER COLUMN expr TYPE TEXT;
diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/AbstractMigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/AbstractMigrationUpgradeTest.java
new file mode 100644
index 0000000000..870f383f3e
--- /dev/null
+++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/AbstractMigrationUpgradeTest.java
@@ -0,0 +1,552 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.
+ */
+
+package org.apache.hertzbeat.startup.migration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import javax.sql.DataSource;
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.MigrationInfo;
+import org.flywaydb.core.api.MigrationVersion;
+import org.flywaydb.core.api.configuration.FluentConfiguration;
+import org.flywaydb.core.api.output.MigrateResult;
+import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.hibernate.SpringImplicitNamingStrategy;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
+import org.yaml.snakeyaml.Yaml;
+
+/**
+ * Upgrade regression for the 1.9.0 migration chain, run once per supported dialect.
+ *
+ * The scenario mirrors a production start: Hibernate {@code ddl-auto} builds the schema
+ * from the entity model first, then Flyway runs, exactly as {@code FlywayConfiguration}
+ * orders them with {@code @DependsOn("entityManagerFactory")}. The database is first taken
+ * to the last officially released state (V180) and only then is V190 applied, so the test
+ * covers the upgrade path a 1.8.0 user actually takes.
+ */
+abstract class AbstractMigrationUpgradeTest {
+
+ protected static final String SOP_SCHEDULE_TABLE = "hzb_sop_schedule";
+
+ protected static final String ALERT_DEFINE_TABLE = "hzb_alert_define";
+
+ private static final String LAST_RELEASED_VERSION = "180";
+
+ private static final String CURRENT_VERSION = "190";
+
+ private static final List WITHDRAWN_VERSIONS = List.of("181", "182", "183");
+
+ private static final List SCHEDULE_INDEX_CONVERSATION = List.of("conversation_id");
+
+ private static final List SCHEDULE_INDEX_ENABLED_NEXT = List.of("enabled", "next_run_time");
+
+ private static final List SCHEDULE_INDEX_CREATOR_CONVERSATION = List.of("creator", "conversation_id");
+
+ private static final String SEEDED_EXPR = "usage>90";
+
+ private static final Map SHIPPED_FLYWAY_SETTINGS = loadShippedFlywaySettings();
+
+ /**
+ * The data source of the freshly created database under test.
+ *
+ * @return data source
+ */
+ protected abstract DataSource dataSource();
+
+ /**
+ * Flyway vendor directory holding the dialect specific migrations.
+ *
+ * @return vendor directory name
+ */
+ protected abstract String vendor();
+
+ /**
+ * Reads the type the database itself reports for a column.
+ *
+ * @param table table name
+ * @param column column name
+ * @return the declared type, normalised to lower case
+ * @throws SQLException on query failure
+ */
+ protected abstract String declaredType(String table, String column) throws SQLException;
+
+ /**
+ * The types this dialect uses for a boolean column. More than one spelling is legitimate:
+ * on MySQL Hibernate declares {@code bit(1)} while the migration's own {@code BOOLEAN}
+ * lands as {@code tinyint(1)}, and both hold the same values.
+ *
+ * @return accepted types of {@code hzb_sop_schedule.enabled}
+ */
+ protected abstract Set expectedBooleanTypes();
+
+ /**
+ * The type {@code hzb_alert_define.expr} must have once V190 has enlarged it.
+ *
+ * @return expected type of {@code hzb_alert_define.expr}
+ */
+ protected abstract String expectedEnlargedTextType();
+
+ /**
+ * Stored routines left behind in the schema under test.
+ *
+ * @return routine names, empty when the migrations cleaned up after themselves
+ * @throws SQLException on query failure
+ */
+ protected abstract List storedRoutines() throws SQLException;
+
+ @Test
+ @DisplayName("V190 upgrades a V180 database whose schedule table Hibernate owns")
+ void upgradesDatabaseWhoseScheduleTableHibernateOwns() throws SQLException {
+ startFromLastReleasedState();
+ seedSchedules();
+ seedAlertDefine();
+
+ Flyway flyway = flyway();
+ assertPendingVersions(flyway, List.of(CURRENT_VERSION));
+
+ MigrateResult result = flyway.migrate();
+ assertEquals(1, result.migrationsExecuted, "V190 must be the only migration left to apply");
+ assertEquals(CURRENT_VERSION, result.targetSchemaVersion);
+
+ assertScheduleRowsScopedToOwner();
+ assertBooleanColumn();
+ assertEquals(expectedEnlargedTextType(), declaredType(ALERT_DEFINE_TABLE, "expr"));
+ assertSeededExprSurvived();
+ assertOversizedExprRoundTrips();
+ assertScheduleIndexes(List.of(
+ SCHEDULE_INDEX_CONVERSATION,
+ SCHEDULE_INDEX_ENABLED_NEXT,
+ SCHEDULE_INDEX_CREATOR_CONVERSATION));
+ assertNoStoredRoutinesLeftBehind();
+ assertHistoryChain();
+
+ assertSecondStartupChangesNothing();
+ assertScheduleRowsScopedToOwner();
+ }
+
+ @Test
+ @DisplayName("V190 upgrades a V180 database whose schedule table the migration has to create")
+ void upgradesDatabaseWhoseScheduleTableTheMigrationCreates() throws SQLException {
+ startFromLastReleasedState();
+ // Model the ordering where Flyway sees the table first: the migration, not Hibernate,
+ // has to create hzb_sop_schedule. This is the path that made PostgreSQL declare the
+ // column SMALLINT, after which the script's own "SET enabled = FALSE" failed with 42804.
+ execute("DROP TABLE " + SOP_SCHEDULE_TABLE);
+
+ Flyway flyway = flyway();
+ MigrateResult result = flyway.migrate();
+ assertEquals(1, result.migrationsExecuted, "V190 must be the only migration left to apply");
+
+ assertBooleanColumn();
+ assertEquals(expectedEnlargedTextType(), declaredType(ALERT_DEFINE_TABLE, "expr"));
+ assertScheduleIndexes(List.of(SCHEDULE_INDEX_CONVERSATION, SCHEDULE_INDEX_ENABLED_NEXT));
+ assertNoStoredRoutinesLeftBehind();
+ assertHistoryChain();
+
+ // The migration ran against an empty table, so replay its own update statement to prove
+ // the column it created accepts the FALSE literal the script uses.
+ seedSchedules();
+ execute("UPDATE " + SOP_SCHEDULE_TABLE + " SET enabled = FALSE WHERE creator IS NULL OR TRIM(creator) = ''");
+ assertScheduleRowsScopedToOwner();
+
+ assertSecondStartupChangesNothing();
+ }
+
+ /**
+ * Brings the database to the state a 1.8.0 installation is in: schema built by Hibernate,
+ * migration history applied up to and including the last released version.
+ */
+ private void startFromLastReleasedState() {
+ createSchemaWithHibernate();
+ MigrateResult result = flyway(LAST_RELEASED_VERSION).migrate();
+ assertEquals(LAST_RELEASED_VERSION, result.targetSchemaVersion,
+ "the released baseline must stop at V" + LAST_RELEASED_VERSION);
+ }
+
+ /**
+ * Builds the schema the way the application does at startup, from the scanned entity model.
+ */
+ private void createSchemaWithHibernate() {
+ LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
+ factory.setDataSource(dataSource());
+ factory.setPackagesToScan("org.apache.hertzbeat");
+ factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
+ Properties properties = new Properties();
+ properties.setProperty("hibernate.hbm2ddl.auto", "update");
+ // The naming strategies Spring Boot applies by default, so the generated column names
+ // are the ones the running application has.
+ properties.setProperty("hibernate.implicit_naming_strategy", SpringImplicitNamingStrategy.class.getName());
+ properties.setProperty("hibernate.physical_naming_strategy",
+ CamelCaseToUnderscoresNamingStrategy.class.getName());
+ factory.setJpaProperties(properties);
+ factory.afterPropertiesSet();
+ factory.destroy();
+ }
+
+ private Flyway flyway() {
+ return flyway(null);
+ }
+
+ /**
+ * Configures Flyway from the shipped {@code application.yml} rather than from a copy of it,
+ * so the regression cannot drift away from what the application actually runs. The
+ * {@code {vendor}} placeholder is resolved the way Spring Boot resolves it at startup.
+ *
+ * @param target version to stop at, {@code null} to migrate to the latest
+ * @return configured Flyway instance
+ */
+ private Flyway flyway(String target) {
+ List locations = shippedSetting("locations", List.class).stream()
+ .map(location -> String.valueOf(location).replace("{vendor}", vendor()))
+ .toList();
+ FluentConfiguration configuration = Flyway.configure()
+ .dataSource(dataSource())
+ .locations(locations.toArray(new String[0]))
+ .baselineOnMigrate(shippedSetting("baseline-on-migrate", Boolean.class))
+ .baselineVersion(String.valueOf(shippedSetting("baseline-version", Object.class)))
+ .cleanDisabled(shippedSetting("clean-disabled", Boolean.class));
+ if (target != null) {
+ configuration.target(MigrationVersion.fromVersion(target));
+ }
+ return configuration.load();
+ }
+
+ private static T shippedSetting(String key, Class type) {
+ Object value = SHIPPED_FLYWAY_SETTINGS.get(key);
+ assertNotNull(value, () -> "spring.flyway." + key + " is not declared in application.yml");
+ return type.cast(value);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map loadShippedFlywaySettings() {
+ try (InputStream stream = AbstractMigrationUpgradeTest.class.getClassLoader()
+ .getResourceAsStream("application.yml")) {
+ assertNotNull(stream, "application.yml must be on the test classpath");
+ for (Object document : new Yaml().loadAll(stream)) {
+ if (!(document instanceof Map, ?> root)) {
+ continue;
+ }
+ Object spring = root.get("spring");
+ if (spring instanceof Map, ?> springSection
+ && springSection.get("flyway") instanceof Map, ?> flyway) {
+ return (Map) flyway;
+ }
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException("cannot read the shipped application.yml", e);
+ }
+ throw new IllegalStateException("application.yml declares no spring.flyway section");
+ }
+
+ private void assertPendingVersions(Flyway flyway, List expected) {
+ List pending = new ArrayList<>();
+ for (MigrationInfo info : flyway.info().pending()) {
+ pending.add(info.getVersion().getVersion());
+ }
+ assertEquals(expected, pending);
+ }
+
+ private void assertSecondStartupChangesNothing() {
+ Flyway restart = flyway();
+ assertEquals(0, restart.info().pending().length, "a restart must have nothing left to apply");
+ MigrateResult result = restart.migrate();
+ assertEquals(0, result.migrationsExecuted, "a restart must not re-run any migration");
+ assertTrue(restart.validateWithResult().validationSuccessful,
+ "a restart must validate cleanly against the recorded history");
+ }
+
+ private void assertHistoryChain() {
+ List applied = new ArrayList<>();
+ for (MigrationInfo info : flyway().info().applied()) {
+ assertFalse(info.getState().isFailed(),
+ () -> "migration " + info.getVersion() + " is recorded as failed");
+ if (info.getVersion() != null) {
+ applied.add(info.getVersion().getVersion());
+ }
+ }
+ assertEquals(1, applied.stream().filter(CURRENT_VERSION::equals).count(),
+ () -> "V" + CURRENT_VERSION + " must be recorded exactly once, history is " + applied);
+ for (String withdrawn : WITHDRAWN_VERSIONS) {
+ assertTrue(applied.stream().noneMatch(withdrawn::equals),
+ () -> "withdrawn version V" + withdrawn + " must not appear in the history, history is " + applied);
+ }
+ assertEquals(CURRENT_VERSION, applied.get(applied.size() - 1),
+ () -> "the chain must end at V" + CURRENT_VERSION + ", history is " + applied);
+ }
+
+ private void seedSchedules() throws SQLException {
+ try (Connection connection = dataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement("INSERT INTO " + SOP_SCHEDULE_TABLE
+ + " (conversation_id, sop_name, cron_expression, enabled, creator) VALUES (?, ?, ?, ?, ?)")) {
+ addSchedule(statement, 1L, "owned", "alice");
+ addSchedule(statement, 2L, "null-owner", null);
+ addSchedule(statement, 3L, "blank-owner", " ");
+ statement.executeBatch();
+ }
+ }
+
+ private void addSchedule(PreparedStatement statement, long conversationId, String name, String creator)
+ throws SQLException {
+ statement.setLong(1, conversationId);
+ statement.setString(2, name);
+ statement.setString(3, "0 0 * * * ?");
+ statement.setBoolean(4, true);
+ statement.setString(5, creator);
+ statement.addBatch();
+ }
+
+ private void assertScheduleRowsScopedToOwner() throws SQLException {
+ Map enabledByName = new LinkedHashMap<>();
+ try (Connection connection = dataSource().getConnection();
+ Statement statement = connection.createStatement();
+ ResultSet rs = statement.executeQuery("SELECT sop_name, enabled FROM " + SOP_SCHEDULE_TABLE)) {
+ while (rs.next()) {
+ enabledByName.put(rs.getString("sop_name"), rs.getBoolean("enabled"));
+ }
+ }
+ assertEquals(Boolean.TRUE, enabledByName.get("owned"), "a schedule with an owner must stay enabled");
+ assertEquals(Boolean.FALSE, enabledByName.get("null-owner"), "a schedule without an owner must be disabled");
+ assertEquals(Boolean.FALSE, enabledByName.get("blank-owner"), "a blank owner must count as no owner");
+ }
+
+ private void seedAlertDefine() throws SQLException {
+ try (Connection connection = dataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement("INSERT INTO " + ALERT_DEFINE_TABLE
+ + " (name, expr, enable) VALUES (?, ?, ?)")) {
+ statement.setString(1, "seeded");
+ statement.setString(2, SEEDED_EXPR);
+ statement.setBoolean(3, true);
+ statement.executeUpdate();
+ }
+ }
+
+ private void assertSeededExprSurvived() throws SQLException {
+ try (Connection connection = dataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement(
+ "SELECT expr FROM " + ALERT_DEFINE_TABLE + " WHERE name = ?")) {
+ statement.setString(1, "seeded");
+ try (ResultSet rs = statement.executeQuery()) {
+ assertTrue(rs.next(), "the alert define seeded before the upgrade must still be there");
+ assertEquals(SEEDED_EXPR, rs.getString("expr"), "widening expr must not rewrite existing rows");
+ }
+ }
+ }
+
+ private void assertOversizedExprRoundTrips() throws SQLException {
+ String expr = "a".repeat(100_000);
+ try (Connection connection = dataSource().getConnection();
+ PreparedStatement insert = connection.prepareStatement("INSERT INTO " + ALERT_DEFINE_TABLE
+ + " (name, expr, enable) VALUES (?, ?, ?)")) {
+ insert.setString(1, "oversized");
+ insert.setString(2, expr);
+ insert.setBoolean(3, true);
+ insert.executeUpdate();
+ }
+ try (Connection connection = dataSource().getConnection();
+ PreparedStatement select = connection.prepareStatement(
+ "SELECT expr FROM " + ALERT_DEFINE_TABLE + " WHERE name = ?")) {
+ select.setString(1, "oversized");
+ try (ResultSet rs = select.executeQuery()) {
+ assertTrue(rs.next(), "the oversized alert define must have been stored");
+ assertEquals(expr.length(), rs.getString("expr").length(),
+ "expr must hold an expression far beyond the pre-1.9.0 limit");
+ }
+ }
+ }
+
+ private void assertBooleanColumn() throws SQLException {
+ String actual = declaredType(SOP_SCHEDULE_TABLE, "enabled");
+ assertTrue(expectedBooleanTypes().contains(actual),
+ () -> "enabled must be a boolean column, but is declared " + actual
+ + "; expected one of " + expectedBooleanTypes());
+ }
+
+ private void assertNoStoredRoutinesLeftBehind() throws SQLException {
+ List routines = storedRoutines();
+ assertTrue(routines.isEmpty(), () -> "migrations left stored routines behind: " + routines);
+ }
+
+ private void assertScheduleIndexes(List> expected) throws SQLException {
+ Map> indexes = indexColumns(SOP_SCHEDULE_TABLE);
+ List> actual = indexes.values().stream()
+ .filter(columns -> !columns.equals(List.of("id")))
+ .toList();
+ for (List signature : expected) {
+ assertEquals(1, actual.stream().filter(signature::equals).count(),
+ () -> "index on " + signature + " must exist exactly once, found " + indexes);
+ }
+ assertEquals(expected.size(), actual.size(), () -> "unexpected indexes on " + SOP_SCHEDULE_TABLE + ": " + indexes);
+ }
+
+ private Map> indexColumns(String table) throws SQLException {
+ Map> indexes = new LinkedHashMap<>();
+ try (Connection connection = dataSource().getConnection()) {
+ String[] identity = resolveTable(connection, table);
+ assertNotNull(identity, () -> "table " + table + " does not exist");
+ try (ResultSet rs = connection.getMetaData()
+ .getIndexInfo(identity[0], identity[1], identity[2], false, false)) {
+ while (rs.next()) {
+ String name = rs.getString("INDEX_NAME");
+ String column = rs.getString("COLUMN_NAME");
+ if (name == null || column == null) {
+ continue;
+ }
+ indexes.computeIfAbsent(name.toLowerCase(Locale.ROOT), key -> new ArrayList<>())
+ .add(column.toLowerCase(Locale.ROOT));
+ }
+ }
+ }
+ return indexes;
+ }
+
+ /**
+ * Resolves the catalog, schema and stored spelling of a table. The lookup is pinned to the
+ * catalog and schema of the connection: MySQL Connector/J defaults {@code nullCatalogMeansCurrent}
+ * to false, so an unqualified lookup would happily match a table of the same name in another
+ * database on the same server.
+ *
+ * @param connection connection to the database under test
+ * @param table table name, in any case
+ * @return catalog, schema and table name, or {@code null} when the table does not exist
+ * @throws SQLException on metadata failure
+ */
+ private String[] resolveTable(Connection connection, String table) throws SQLException {
+ String catalog = connection.getCatalog();
+ String schema = connection.getSchema();
+ try (ResultSet rs = connection.getMetaData().getTables(catalog, schema, "%", new String[]{"TABLE"})) {
+ while (rs.next()) {
+ if (table.equalsIgnoreCase(rs.getString("TABLE_NAME"))) {
+ return new String[]{rs.getString("TABLE_CAT"), rs.getString("TABLE_SCHEM"),
+ rs.getString("TABLE_NAME")};
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Runs a statement against the database under test.
+ *
+ * @param sql statement to run
+ * @throws SQLException on execution failure
+ */
+ protected void execute(String sql) throws SQLException {
+ try (Connection connection = dataSource().getConnection();
+ Statement statement = connection.createStatement()) {
+ statement.execute(sql);
+ }
+ }
+
+ /**
+ * Runs a single value query against the database under test.
+ *
+ * @param sql query returning one column
+ * @param parameters query parameters
+ * @return the first column of the first row, lower cased, or {@code null} when there is no row
+ * @throws SQLException on execution failure
+ */
+ protected String queryString(String sql, Object... parameters) throws SQLException {
+ try (Connection connection = dataSource().getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ for (int index = 0; index < parameters.length; index++) {
+ statement.setObject(index + 1, parameters[index]);
+ }
+ try (ResultSet rs = statement.executeQuery()) {
+ if (!rs.next()) {
+ return null;
+ }
+ String value = rs.getString(1);
+ return value == null ? null : value.toLowerCase(Locale.ROOT);
+ }
+ }
+ }
+
+ /**
+ * Runs a query collecting a single string column.
+ *
+ * @param sql query returning one column
+ * @return every value of the first column
+ * @throws SQLException on execution failure
+ */
+ protected List queryStrings(String sql) throws SQLException {
+ List values = new ArrayList<>();
+ try (Connection connection = dataSource().getConnection();
+ Statement statement = connection.createStatement();
+ ResultSet rs = statement.executeQuery(sql)) {
+ while (rs.next()) {
+ values.add(rs.getString(1));
+ }
+ }
+ return values;
+ }
+
+ /**
+ * Waits until the container database accepts JDBC connections. MySQL briefly listens while
+ * its entrypoint is still bootstrapping, so a single successful connect is not enough.
+ *
+ * @param url jdbc url
+ * @param username user name
+ * @param password password
+ */
+ protected static void awaitDatabaseReady(String url, String username, String password) {
+ long deadline = System.currentTimeMillis() + Duration.ofMinutes(5).toMillis();
+ int consecutiveSuccesses = 0;
+ SQLException last = null;
+ while (System.currentTimeMillis() < deadline) {
+ try (Connection connection = DriverManager.getConnection(url, username, password);
+ Statement statement = connection.createStatement()) {
+ statement.execute("SELECT 1");
+ if (++consecutiveSuccesses == 3) {
+ return;
+ }
+ } catch (SQLException e) {
+ last = e;
+ consecutiveSuccesses = 0;
+ }
+ try {
+ Thread.sleep(1000L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("interrupted while waiting for the database", e);
+ }
+ }
+ throw new IllegalStateException("timed out waiting for " + url, last);
+ }
+}
diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/H2MigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/H2MigrationUpgradeTest.java
new file mode 100644
index 0000000000..45ac3b19b7
--- /dev/null
+++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/H2MigrationUpgradeTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.
+ */
+
+package org.apache.hertzbeat.startup.migration;
+
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Set;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+
+/**
+ * Runs the 1.9.0 migration upgrade regression on H2, the default embedded database.
+ */
+@DisplayName("1.9.0 migration upgrade on H2")
+class H2MigrationUpgradeTest extends AbstractMigrationUpgradeTest {
+
+ private static final AtomicInteger DATABASE_SEQUENCE = new AtomicInteger();
+
+ private DriverManagerDataSource dataSource;
+
+ @BeforeEach
+ void createDatabase() {
+ // MODE=MYSQL matches the URL the shipped application.yml uses.
+ dataSource = new DriverManagerDataSource(
+ "jdbc:h2:mem:migration_" + DATABASE_SEQUENCE.incrementAndGet() + ";MODE=MYSQL;DB_CLOSE_DELAY=-1",
+ "sa", "");
+ dataSource.setDriverClassName("org.h2.Driver");
+ }
+
+ @AfterEach
+ void dropDatabase() throws SQLException {
+ execute("SHUTDOWN");
+ }
+
+ @Override
+ protected DataSource dataSource() {
+ return dataSource;
+ }
+
+ @Override
+ protected String vendor() {
+ return "h2";
+ }
+
+ @Override
+ protected String declaredType(String table, String column) throws SQLException {
+ return queryString("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
+ + "WHERE TABLE_NAME = ? AND COLUMN_NAME = ?",
+ table.toUpperCase(Locale.ROOT), column.toUpperCase(Locale.ROOT));
+ }
+
+ @Override
+ protected Set expectedBooleanTypes() {
+ return Set.of("boolean");
+ }
+
+ @Override
+ protected String expectedEnlargedTextType() {
+ return "character large object";
+ }
+
+ @Override
+ protected List storedRoutines() throws SQLException {
+ return queryStrings("SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = 'PUBLIC'");
+ }
+}
diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MigrationInventoryTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MigrationInventoryTest.java
new file mode 100644
index 0000000000..9fd93b494f
--- /dev/null
+++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MigrationInventoryTest.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.
+ */
+
+package org.apache.hertzbeat.startup.migration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.File;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Guards the shape of the Flyway migration chain shipped by the startup module.
+ * The chain is part of the release contract: 1.8.0 ended at V180 and 1.9.0 adds
+ * exactly one migration per dialect, V190.
+ */
+class MigrationInventoryTest {
+
+ private static final List VENDORS = List.of("h2", "mysql", "postgresql");
+
+ private static final Pattern VERSIONED_SCRIPT = Pattern.compile("^V(\\d+)__.+\\.sql$");
+
+ private static final int LAST_RELEASED_VERSION = 180;
+
+ private static final int CURRENT_VERSION = 190;
+
+ private static final List WITHDRAWN_VERSIONS = List.of(181, 182, 183);
+
+ @Test
+ @DisplayName("every dialect ends the chain with V180 followed by V190")
+ void chainEndsWithLastReleasedVersionThenCurrentVersion() throws URISyntaxException {
+ for (String vendor : VENDORS) {
+ List versions = versionsOf(vendor);
+
+ assertTrue(versions.size() >= 2, () -> vendor + " must ship at least V180 and V190");
+ assertEquals(CURRENT_VERSION, versions.get(versions.size() - 1),
+ () -> vendor + " must end at V" + CURRENT_VERSION + " but ends at " + versions);
+ assertEquals(LAST_RELEASED_VERSION, versions.get(versions.size() - 2),
+ () -> vendor + " must apply V" + CURRENT_VERSION + " directly after the last released V"
+ + LAST_RELEASED_VERSION + " but has " + versions);
+ }
+ }
+
+ @Test
+ @DisplayName("the withdrawn pre-release versions V181, V182 and V183 are gone")
+ void withdrawnVersionsAreAbsent() throws URISyntaxException {
+ for (String vendor : VENDORS) {
+ List versions = versionsOf(vendor);
+ for (Integer withdrawn : WITHDRAWN_VERSIONS) {
+ assertFalse(versions.contains(withdrawn),
+ () -> vendor + " still ships the withdrawn pre-release migration V" + withdrawn);
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("all dialects ship the same versions, each exactly once")
+ void allDialectsShipTheSameVersions() throws URISyntaxException {
+ Map> byVendor = new LinkedHashMap<>();
+ for (String vendor : VENDORS) {
+ List versions = versionsOf(vendor);
+ assertEquals(versions.size(), versions.stream().distinct().count(),
+ () -> vendor + " declares a version more than once: " + versions);
+ byVendor.put(vendor, versions);
+ }
+
+ List reference = byVendor.get(VENDORS.get(0));
+ for (Map.Entry> entry : byVendor.entrySet()) {
+ assertEquals(reference, entry.getValue(),
+ () -> entry.getKey() + " diverges from " + VENDORS.get(0) + ": "
+ + entry.getValue() + " vs " + reference);
+ }
+ }
+
+ /**
+ * Reads the migration versions of one dialect from the packaged classpath location,
+ * which is exactly what {@code spring.flyway.locations} resolves at runtime.
+ *
+ * @param vendor flyway vendor directory name
+ * @return the versions in ascending order
+ * @throws URISyntaxException when the classpath location cannot be resolved to a directory
+ */
+ private static List versionsOf(String vendor) throws URISyntaxException {
+ URL location = MigrationInventoryTest.class.getClassLoader().getResource("db/migration/" + vendor);
+ assertNotNull(location, () -> "missing migration location db/migration/" + vendor);
+
+ File[] files = new File(location.toURI()).listFiles();
+ assertNotNull(files, () -> "db/migration/" + vendor + " is not a readable directory");
+
+ List versions = new ArrayList<>();
+ for (File file : files) {
+ Matcher matcher = VERSIONED_SCRIPT.matcher(file.getName());
+ if (matcher.matches()) {
+ versions.add(Integer.valueOf(matcher.group(1)));
+ }
+ }
+ versions.sort(Integer::compareTo);
+ return List.copyOf(versions);
+ }
+
+ @Test
+ @DisplayName("no dialect directory holds an unversioned or misnamed script")
+ void everyScriptFollowsTheVersionedNaming() throws URISyntaxException {
+ for (String vendor : VENDORS) {
+ URL location = MigrationInventoryTest.class.getClassLoader().getResource("db/migration/" + vendor);
+ assertNotNull(location, () -> "missing migration location db/migration/" + vendor);
+ File[] files = new File(location.toURI()).listFiles();
+ assertNotNull(files, () -> "db/migration/" + vendor + " is not a readable directory");
+
+ List unexpected = Arrays.stream(files)
+ .map(File::getName)
+ .filter(name -> !VERSIONED_SCRIPT.matcher(name).matches())
+ .toList();
+ assertTrue(unexpected.isEmpty(),
+ () -> "db/migration/" + vendor + " holds non-migration files: " + unexpected);
+ }
+ }
+}
diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MysqlMigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MysqlMigrationUpgradeTest.java
new file mode 100644
index 0000000000..793fb08ca6
--- /dev/null
+++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/MysqlMigrationUpgradeTest.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.
+ */
+
+package org.apache.hertzbeat.startup.migration;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Runs the 1.9.0 migration upgrade regression on MySQL, where the pre-release scripts
+ * failed with {@code 1061 Duplicate key name}. Requires a Docker daemon, which the
+ * backend CI runner provides.
+ */
+@DisplayName("1.9.0 migration upgrade on MySQL")
+class MysqlMigrationUpgradeTest extends AbstractMigrationUpgradeTest {
+
+ private static final DockerImageName IMAGE = DockerImageName.parse("mysql:8.4");
+
+ private static final String ROOT_USER = "root";
+
+ private static final String ROOT_PASSWORD = "root123";
+
+ private static final int MYSQL_PORT = 3306;
+
+ private static final AtomicInteger DATABASE_SEQUENCE = new AtomicInteger();
+
+ private static GenericContainer> container;
+
+ private DriverManagerDataSource dataSource;
+
+ @BeforeAll
+ static void startContainer() {
+ assumeTrue(DockerClientFactory.instance().isDockerAvailable(),
+ "a Docker daemon is required to verify the MySQL migrations");
+ container = new GenericContainer<>(IMAGE)
+ .withExposedPorts(MYSQL_PORT)
+ .withEnv("MYSQL_ROOT_PASSWORD", ROOT_PASSWORD)
+ .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(5)));
+ container.start();
+ awaitDatabaseReady(jdbcUrl("mysql"), ROOT_USER, ROOT_PASSWORD);
+ }
+
+ @AfterAll
+ static void stopContainer() {
+ if (container != null) {
+ container.stop();
+ container = null;
+ }
+ }
+
+ @BeforeEach
+ void createDatabase() throws SQLException {
+ String database = "hertzbeat_migration_" + DATABASE_SEQUENCE.incrementAndGet();
+ try (Connection connection = DriverManager.getConnection(jdbcUrl("mysql"), ROOT_USER, ROOT_PASSWORD);
+ Statement statement = connection.createStatement()) {
+ statement.execute("CREATE DATABASE " + database);
+ }
+ dataSource = new DriverManagerDataSource(jdbcUrl(database), ROOT_USER, ROOT_PASSWORD);
+ dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
+ }
+
+ private static String jdbcUrl(String database) {
+ return "jdbc:mysql://" + container.getHost() + ":" + container.getMappedPort(MYSQL_PORT) + "/" + database
+ + "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
+ }
+
+ @Override
+ protected DataSource dataSource() {
+ return dataSource;
+ }
+
+ @Override
+ protected String vendor() {
+ return "mysql";
+ }
+
+ @Override
+ protected String declaredType(String table, String column) throws SQLException {
+ return queryString("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS "
+ + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", table, column);
+ }
+
+ @Override
+ protected Set expectedBooleanTypes() {
+ // Hibernate declares bit(1); the migration's BOOLEAN lands as tinyint(1).
+ return Set.of("tinyint(1)", "bit(1)");
+ }
+
+ @Override
+ protected String expectedEnlargedTextType() {
+ return "longtext";
+ }
+
+ @Override
+ protected List storedRoutines() throws SQLException {
+ return queryStrings("SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()");
+ }
+}
diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/PostgresqlMigrationUpgradeTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/PostgresqlMigrationUpgradeTest.java
new file mode 100644
index 0000000000..1430a9ae77
--- /dev/null
+++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/migration/PostgresqlMigrationUpgradeTest.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.
+ */
+
+package org.apache.hertzbeat.startup.migration;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Runs the 1.9.0 migration upgrade regression on PostgreSQL, where the pre-release scripts
+ * failed with {@code 42804 column "enabled" is of type boolean but expression is of type
+ * integer}. Requires a Docker daemon, which the backend CI runner provides.
+ */
+@DisplayName("1.9.0 migration upgrade on PostgreSQL")
+class PostgresqlMigrationUpgradeTest extends AbstractMigrationUpgradeTest {
+
+ private static final DockerImageName IMAGE = DockerImageName.parse("postgres:17-alpine");
+
+ private static final String USERNAME = "postgres";
+
+ private static final String PASSWORD = "postgres123";
+
+ private static final int POSTGRESQL_PORT = 5432;
+
+ private static final AtomicInteger DATABASE_SEQUENCE = new AtomicInteger();
+
+ private static GenericContainer> container;
+
+ private DriverManagerDataSource dataSource;
+
+ @BeforeAll
+ static void startContainer() {
+ assumeTrue(DockerClientFactory.instance().isDockerAvailable(),
+ "a Docker daemon is required to verify the PostgreSQL migrations");
+ container = new GenericContainer<>(IMAGE)
+ .withExposedPorts(POSTGRESQL_PORT)
+ .withEnv("POSTGRES_PASSWORD", PASSWORD)
+ .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(5)));
+ container.start();
+ awaitDatabaseReady(jdbcUrl("postgres"), USERNAME, PASSWORD);
+ }
+
+ @AfterAll
+ static void stopContainer() {
+ if (container != null) {
+ container.stop();
+ container = null;
+ }
+ }
+
+ @BeforeEach
+ void createDatabase() throws SQLException {
+ String database = "hertzbeat_migration_" + DATABASE_SEQUENCE.incrementAndGet();
+ try (Connection connection = DriverManager.getConnection(jdbcUrl("postgres"), USERNAME, PASSWORD);
+ Statement statement = connection.createStatement()) {
+ statement.execute("CREATE DATABASE " + database);
+ }
+ dataSource = new DriverManagerDataSource(jdbcUrl(database), USERNAME, PASSWORD);
+ dataSource.setDriverClassName("org.postgresql.Driver");
+ }
+
+ private static String jdbcUrl(String database) {
+ return "jdbc:postgresql://" + container.getHost() + ":" + container.getMappedPort(POSTGRESQL_PORT)
+ + "/" + database;
+ }
+
+ @Override
+ protected DataSource dataSource() {
+ return dataSource;
+ }
+
+ @Override
+ protected String vendor() {
+ return "postgresql";
+ }
+
+ @Override
+ protected String declaredType(String table, String column) throws SQLException {
+ return queryString("SELECT udt_name FROM information_schema.columns "
+ + "WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", table, column);
+ }
+
+ @Override
+ protected Set expectedBooleanTypes() {
+ return Set.of("bool");
+ }
+
+ @Override
+ protected String expectedEnlargedTextType() {
+ return "text";
+ }
+
+ @Override
+ protected List storedRoutines() throws SQLException {
+ return queryStrings("SELECT routine_name FROM information_schema.routines "
+ + "WHERE routine_schema = current_schema()");
+ }
+}