diff --git a/application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaSettingsService.java index c4930115f9..39fba7dd64 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaSettingsService.java @@ -23,6 +23,8 @@ public interface DatabaseSchemaSettingsService { void updateSchemaVersion(); + void updateSchemaVersion(String version); + String getPackageSchemaVersion(); String getDbSchemaVersion(); diff --git a/application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigration.java b/application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigration.java new file mode 100644 index 0000000000..4822022aab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigration.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.thingsboard.server.service.install.lts; + +/** + * One LTS migration, keyed by the version it ships in (e.g. "4.2.2.3"). + * The runner runs the version's data/upgrade/lts/<version>/schema_update.sql by convention + * (if present), then calls {@link #apply()} for any programmatic work beyond SQL. + * Most migrations are SQL-only and only override {@link #getVersion()}. + */ +public interface LtsMigration { + + String getVersion(); + + default void apply() { + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigrationService.java b/application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigrationService.java new file mode 100644 index 0000000000..bec88fd2ad --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigrationService.java @@ -0,0 +1,131 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.thingsboard.server.service.install.lts; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; +import org.thingsboard.server.service.install.InstallScripts; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +@TbCoreComponent +public class LtsMigrationService { + + private static final String SCHEMA_UPDATE_SQL = "schema_update.sql"; + + private final JdbcTemplate jdbcTemplate; + private final InstallScripts installScripts; + private final DatabaseSchemaSettingsService schemaSettingsService; + private final TransactionTemplate transactionTemplate; + private final List migrations; + + public LtsMigrationService(JdbcTemplate jdbcTemplate, + InstallScripts installScripts, + DatabaseSchemaSettingsService schemaSettingsService, + PlatformTransactionManager transactionManager, + List migrations) { + this.jdbcTemplate = jdbcTemplate; + this.installScripts = installScripts; + this.schemaSettingsService = schemaSettingsService; + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.migrations = validateAndSort(migrations); + log.info("Discovered {} LTS migration(s): {}", this.migrations.size(), + this.migrations.stream().map(LtsMigration::getVersion).toList()); + } + + private static List validateAndSort(List migrations) { + Set seen = new HashSet<>(); + for (LtsMigration m : migrations) { + LtsVersion.parse(m.getVersion()); // fail loud on unparseable version + if (!seen.add(m.getVersion())) { + throw new IllegalStateException("Duplicate LTS migration version: " + m.getVersion()); + } + } + return migrations.stream() + .sorted(Comparator.comparing(m -> LtsVersion.parse(m.getVersion()))) + .toList(); + } + + /** No-downtime path: per migration in (from, to] run SQL, then apply(), then record the version. */ + public void applyMigrations(String fromVersion, String toVersion) { + for (LtsMigration migration : select(fromVersion, toVersion)) { + String version = migration.getVersion(); + transactionTemplate.executeWithoutResult(status -> { + runSchemaUpdate(version); + migration.apply(); + schemaSettingsService.updateSchemaVersion(version); + }); + log.info("Applied LTS migration {}", version); + } + } + + /** Offline major-upgrade schema phase: per migration in (from, to] run SQL only. No version record. */ + public void runSchemaMigrations(String fromVersion, String toVersion) { + for (LtsMigration migration : select(fromVersion, toVersion)) { + String version = migration.getVersion(); + transactionTemplate.executeWithoutResult(status -> runSchemaUpdate(version)); + log.info("Applied LTS schema migration {}", version); + } + } + + /** Offline major-upgrade data phase: per migration in (from, to] run apply() only. No SQL, no version record. */ + public void runDataMigrations(String fromVersion, String toVersion) { + for (LtsMigration migration : select(fromVersion, toVersion)) { + migration.apply(); + log.info("Applied LTS data migration {}", migration.getVersion()); + } + } + + private List select(String fromVersion, String toVersion) { + LtsVersion from = LtsVersion.parse(fromVersion); + LtsVersion to = LtsVersion.parse(toVersion); + return migrations.stream() + .filter(migration -> { + LtsVersion version = LtsVersion.parse(migration.getVersion()); + return version.compareTo(from) > 0 && version.compareTo(to) <= 0; + }) + .toList(); + } + + private void runSchemaUpdate(String version) { + Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", version, SCHEMA_UPDATE_SQL); + if (!Files.exists(sqlFile)) { + log.trace("No LTS schema update file for version {} at {}", version, sqlFile); + return; + } + try { + jdbcTemplate.execute(Files.readString(sqlFile)); + log.info("Applied LTS SQL schema update from {}", sqlFile); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read LTS schema update file: " + sqlFile, e); + } + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java new file mode 100644 index 0000000000..0674bdbea5 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java @@ -0,0 +1,179 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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.thingsboard.server.service.install.lts; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; +import org.thingsboard.server.service.install.InstallScripts; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LtsMigrationServiceTest { + + private JdbcTemplate jdbcTemplate; + private InstallScripts installScripts; + private DatabaseSchemaSettingsService schemaSettingsService; + private PlatformTransactionManager txManager; + + @TempDir + Path dataDir; + + @BeforeEach + void setUp() { + jdbcTemplate = Mockito.mock(JdbcTemplate.class); + installScripts = Mockito.mock(InstallScripts.class); + schemaSettingsService = Mockito.mock(DatabaseSchemaSettingsService.class); + txManager = Mockito.mock(PlatformTransactionManager.class); + when(txManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus()); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + } + + private void writeSql(String version, String sql) throws Exception { + Path dir = dataDir.resolve("upgrade").resolve("lts").resolve(version); + Files.createDirectories(dir); + Files.writeString(dir.resolve("schema_update.sql"), sql); + } + + /** Records which apply() hooks fired, in order. */ + private LtsMigration migration(String version, List applied) { + return new LtsMigration() { + @Override public String getVersion() { return version; } + @Override public void apply() { applied.add(version); } + }; + } + + private LtsMigrationService service(List migrations) { + return new LtsMigrationService(jdbcTemplate, installScripts, schemaSettingsService, txManager, migrations); + } + + @Test + void selectsOnlyInRangeMigrationsInAscendingOrder() throws Exception { + List applied = new ArrayList<>(); + writeSql("4.2.2.3", "SELECT 1;"); + writeSql("4.2.2.4", "SELECT 2;"); + // intentionally unsorted input; service must sort ascending + LtsMigrationService service = service(List.of( + migration("4.2.2.4", applied), + migration("4.2.2.2", applied), + migration("4.2.2.3", applied))); + + service.applyMigrations("4.2.2.2", "4.2.2.3"); + + // only 4.2.2.3 is in (4.2.2.2, 4.2.2.3] + assertEquals(List.of("4.2.2.3"), applied); + verify(jdbcTemplate).execute("SELECT 1;"); + verify(jdbcTemplate, never()).execute("SELECT 2;"); + verify(schemaSettingsService).updateSchemaVersion("4.2.2.3"); + } + + @Test + void appliesAllInRangeAndRecordsEachVersion() throws Exception { + List applied = new ArrayList<>(); + writeSql("4.2.2.3", "SELECT 1;"); + writeSql("4.2.2.4", "SELECT 2;"); + LtsMigrationService service = service(List.of( + migration("4.2.2.3", applied), migration("4.2.2.4", applied))); + + service.applyMigrations("4.2.2.2", "4.2.2.4"); + + assertEquals(List.of("4.2.2.3", "4.2.2.4"), applied); + verify(jdbcTemplate).execute("SELECT 1;"); + verify(jdbcTemplate).execute("SELECT 2;"); + verify(schemaSettingsService).updateSchemaVersion("4.2.2.3"); + verify(schemaSettingsService).updateSchemaVersion("4.2.2.4"); + } + + @Test + void reRunAtCurrentVersionIsNoOp() throws Exception { + List applied = new ArrayList<>(); + writeSql("4.2.2.3", "SELECT 1;"); + LtsMigrationService service = service(List.of(migration("4.2.2.3", applied))); + + service.applyMigrations("4.2.2.3", "4.2.2.3"); + + assertEquals(List.of(), applied); + verify(jdbcTemplate, never()).execute(anyString()); + verify(schemaSettingsService, never()).updateSchemaVersion(anyString()); + } + + @Test + void runSchemaMigrationsRunsSqlButNeverAppliesOrRecords() throws Exception { + List applied = new ArrayList<>(); + writeSql("4.2.2.3", "SELECT 1;"); + LtsMigrationService service = service(List.of(migration("4.2.2.3", applied))); + + service.runSchemaMigrations("4.2.2.2", "4.2.2.3"); + + verify(jdbcTemplate).execute("SELECT 1;"); + assertEquals(List.of(), applied); + verify(schemaSettingsService, never()).updateSchemaVersion(anyString()); + } + + @Test + void runDataMigrationsAppliesButNeverRunsSqlOrRecords() throws Exception { + List applied = new ArrayList<>(); + writeSql("4.2.2.3", "SELECT 1;"); + LtsMigrationService service = service(List.of(migration("4.2.2.3", applied))); + + service.runDataMigrations("4.2.2.2", "4.2.2.3"); + + assertEquals(List.of("4.2.2.3"), applied); + verify(jdbcTemplate, never()).execute(anyString()); + verify(schemaSettingsService, never()).updateSchemaVersion(anyString()); + } + + @Test + void migrationWithoutSqlFileStillAppliesAndRecords() { + List applied = new ArrayList<>(); + LtsMigrationService service = service(List.of(migration("4.2.2.3", applied))); + + service.applyMigrations("4.2.2.2", "4.2.2.3"); + + assertEquals(List.of("4.2.2.3"), applied); + verify(jdbcTemplate, never()).execute(anyString()); + verify(schemaSettingsService).updateSchemaVersion("4.2.2.3"); + } + + @Test + void failsLoudOnDuplicateVersion() { + List applied = new ArrayList<>(); + assertThrows(IllegalStateException.class, () -> service(List.of( + migration("4.2.2.3", applied), migration("4.2.2.3", applied)))); + } + + @Test + void failsLoudOnUnparseableVersion() { + List applied = new ArrayList<>(); + assertThrows(IllegalArgumentException.class, () -> service(List.of(migration("nope", applied)))); + } +}