This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/master by this push:
     new 74c79d1ea3 [fix] consolidate unreleased 1.9.0 migrations into V190 and 
fix PG/MySQL startup (#4368)
74c79d1ea3 is described below

commit 74c79d1ea3907a38c859c5d677e2f6a78fccfd8f
Author: Duansg <[email protected]>
AuthorDate: Tue Sep 8 11:45:05 2026 +0800

    [fix] consolidate unreleased 1.9.0 migrations into V190 and fix PG/MySQL 
startup (#4368)
---
 .../manager/config/FlywayConfigurationTest.java    |   2 +-
 hertzbeat-startup/pom.xml                          |   8 +
 .../h2/V182__scope_sop_schedule_owners.sql         |  24 -
 .../db/migration/h2/V183__update_column.sql        |  19 -
 ...__update_column.sql => V190__update_column.sql} |  31 +-
 .../mysql/V182__scope_sop_schedule_owners.sql      |  24 -
 .../db/migration/mysql/V183__update_column.sql     |  50 --
 ...__update_column.sql => V190__update_column.sql} |  64 ++-
 .../postgresql/V182__scope_sop_schedule_owners.sql |  24 -
 .../migration/postgresql/V183__update_column.sql   |  20 -
 ...__update_column.sql => V190__update_column.sql} |  31 +-
 .../migration/AbstractMigrationUpgradeTest.java    | 552 +++++++++++++++++++++
 .../startup/migration/H2MigrationUpgradeTest.java  |  86 ++++
 .../startup/migration/MigrationInventoryTest.java  | 143 ++++++
 .../migration/MysqlMigrationUpgradeTest.java       | 130 +++++
 .../migration/PostgresqlMigrationUpgradeTest.java  | 130 +++++
 16 files changed, 1122 insertions(+), 216 deletions(-)

diff --git 
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java
 
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java
index b57d1a8a6f..f8a1067a80 100644
--- 
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java
+++ 
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/FlywayConfigurationTest.java
@@ -48,7 +48,7 @@ class FlywayConfigurationTest {
     @Test
     void repairsAndRetriesWhenHistoryHasFailedMigration() {
         Mockito.when(flyway.migrate())
-                .thenThrow(validateException("Detected failed migration to 
version 181 (update column)"))
+                .thenThrow(validateException("Detected failed migration to 
version 190 (update column)"))
                 .thenReturn(null);
 
         configuration.delayedFlywayInitializer(flyway, enabledProperties());
diff --git a/hertzbeat-startup/pom.xml b/hertzbeat-startup/pom.xml
index 90d3a3a663..eb512ba049 100644
--- a/hertzbeat-startup/pom.xml
+++ b/hertzbeat-startup/pom.xml
@@ -168,6 +168,14 @@
             <version>${testcontainers.version}</version>
             <scope>test</scope>
         </dependency>
+        <!-- Test-only driver for the migration regression; the postgresql 
driver is already
+             on the test classpath transitively. Version comes from the root 
dependencyManagement
+             and nothing here is packaged into the distribution. -->
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
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.
+ *
+ * <p>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<String> WITHDRAWN_VERSIONS = List.of("181", 
"182", "183");
+
+    private static final List<String> SCHEDULE_INDEX_CONVERSATION = 
List.of("conversation_id");
+
+    private static final List<String> SCHEDULE_INDEX_ENABLED_NEXT = 
List.of("enabled", "next_run_time");
+
+    private static final List<String> SCHEDULE_INDEX_CREATOR_CONVERSATION = 
List.of("creator", "conversation_id");
+
+    private static final String SEEDED_EXPR = "usage>90";
+
+    private static final Map<String, Object> 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<String> 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<String> 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<String> 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> T shippedSetting(String key, Class<T> 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<String, Object> 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<String, Object>) 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<String> expected) {
+        List<String> 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<String> 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<String, Boolean> 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<String> routines = storedRoutines();
+        assertTrue(routines.isEmpty(), () -> "migrations left stored routines 
behind: " + routines);
+    }
+
+    private void assertScheduleIndexes(List<List<String>> expected) throws 
SQLException {
+        Map<String, List<String>> indexes = indexColumns(SOP_SCHEDULE_TABLE);
+        List<List<String>> actual = indexes.values().stream()
+                .filter(columns -> !columns.equals(List.of("id")))
+                .toList();
+        for (List<String> 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<String, List<String>> indexColumns(String table) throws 
SQLException {
+        Map<String, List<String>> 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<String> queryStrings(String sql) throws SQLException {
+        List<String> 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<String> expectedBooleanTypes() {
+        return Set.of("boolean");
+    }
+
+    @Override
+    protected String expectedEnlargedTextType() {
+        return "character large object";
+    }
+
+    @Override
+    protected List<String> 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<String> 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<Integer> 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<Integer> 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<Integer> 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<String, List<Integer>> byVendor = new LinkedHashMap<>();
+        for (String vendor : VENDORS) {
+            List<Integer> versions = versionsOf(vendor);
+            assertEquals(versions.size(), versions.stream().distinct().count(),
+                    () -> vendor + " declares a version more than once: " + 
versions);
+            byVendor.put(vendor, versions);
+        }
+
+        List<Integer> reference = byVendor.get(VENDORS.get(0));
+        for (Map.Entry<String, List<Integer>> 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<Integer> 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<Integer> 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<String> 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<String> 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<String> 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<String> expectedBooleanTypes() {
+        return Set.of("bool");
+    }
+
+    @Override
+    protected String expectedEnlargedTextType() {
+        return "text";
+    }
+
+    @Override
+    protected List<String> storedRoutines() throws SQLException {
+        return queryStrings("SELECT routine_name FROM 
information_schema.routines "
+                + "WHERE routine_schema = current_schema()");
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to