4 changed files with 342 additions and 0 deletions
@ -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() { |
||||
|
} |
||||
|
} |
||||
@ -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<LtsMigration> migrations; |
||||
|
|
||||
|
public LtsMigrationService(JdbcTemplate jdbcTemplate, |
||||
|
InstallScripts installScripts, |
||||
|
DatabaseSchemaSettingsService schemaSettingsService, |
||||
|
PlatformTransactionManager transactionManager, |
||||
|
List<LtsMigration> 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<LtsMigration> validateAndSort(List<LtsMigration> migrations) { |
||||
|
Set<String> 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<LtsMigration> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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<String> applied) { |
||||
|
return new LtsMigration() { |
||||
|
@Override public String getVersion() { return version; } |
||||
|
@Override public void apply() { applied.add(version); } |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private LtsMigrationService service(List<LtsMigration> migrations) { |
||||
|
return new LtsMigrationService(jdbcTemplate, installScripts, schemaSettingsService, txManager, migrations); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void selectsOnlyInRangeMigrationsInAscendingOrder() throws Exception { |
||||
|
List<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> applied = new ArrayList<>(); |
||||
|
assertThrows(IllegalArgumentException.class, () -> service(List.of(migration("nope", applied)))); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue