Browse Source

Merge branch 'feature/lts-migrations-4.3' into feature/lts-migrations-4.4

pull/15810/head
Viacheslav Klimov 1 month ago
parent
commit
fae2d334cb
Failed to extract signature
  1. 10
      application/src/main/data/upgrade/lts/4.2.2.3/schema_update.sql
  2. 21
      application/src/main/data/upgrade/lts/4.3.1.2/schema_update.sql
  3. 37
      application/src/main/data/upgrade/lts/4.3.1.3/schema_update.sql
  4. 2
      application/src/main/java/org/thingsboard/server/service/install/DatabaseSchemaSettingsService.java
  5. 21
      application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
  6. 12
      application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java
  7. 30
      application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigration.java
  8. 139
      application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigrationService.java
  9. 61
      application/src/main/java/org/thingsboard/server/service/install/lts/LtsVersion.java
  10. 73
      application/src/main/java/org/thingsboard/server/service/install/lts/V4_2_2_3Migration.java
  11. 29
      application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_2Migration.java
  12. 73
      application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_3Migration.java
  13. 7
      application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
  14. 60
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  15. 50
      application/src/test/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsServiceTest.java
  16. 144
      application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationIntegrationTest.java
  17. 194
      application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java
  18. 78
      application/src/test/java/org/thingsboard/server/service/install/lts/LtsVersionTest.java
  19. 96
      application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java
  20. 96
      application/src/test/java/org/thingsboard/server/service/install/lts/V4_3_1_3MigrationTest.java
  21. 179
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

10
application/src/main/data/upgrade/lts/schema_update.sql → application/src/main/data/upgrade/lts/4.2.2.3/schema_update.sql

@ -14,16 +14,6 @@
-- limitations under the License.
--
-- LTS cumulative schema update file.
-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.).
-- This file is executed by SystemPatchApplier on every version increase within the LTS family.
-- CALCULATED FIELD ADDITIONAL INFO ADDITION START
ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
-- CALCULATED FIELD ADDITIONAL INFO ADDITION END
-- IOT HUB INSTALLED ITEM START
CREATE TABLE IF NOT EXISTS iot_hub_installed_item (

21
application/src/main/data/upgrade/lts/4.3.1.2/schema_update.sql

@ -0,0 +1,21 @@
--
-- 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.
--
-- CALCULATED FIELD ADDITIONAL INFO ADDITION START
ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar;
-- CALCULATED FIELD ADDITIONAL INFO ADDITION END

37
application/src/main/data/upgrade/lts/4.3.1.3/schema_update.sql

@ -0,0 +1,37 @@
--
-- 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.
--
-- IOT HUB INSTALLED ITEM START
CREATE TABLE IF NOT EXISTS iot_hub_installed_item (
id UUID NOT NULL PRIMARY KEY,
created_time BIGINT NOT NULL,
tenant_id UUID NOT NULL,
item_id UUID NOT NULL,
item_version_id UUID NOT NULL,
item_name VARCHAR NOT NULL,
item_type VARCHAR NOT NULL,
version VARCHAR NOT NULL,
descriptor JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_tenant_id ON iot_hub_installed_item(tenant_id);
CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_type ON iot_hub_installed_item(tenant_id, item_type);
CREATE INDEX IF NOT EXISTS idx_iot_hub_installed_item_item_id ON iot_hub_installed_item(tenant_id, item_id);
-- IOT HUB INSTALLED ITEM END

2
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();

21
application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java

@ -75,7 +75,12 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
@Override
public void updateSchemaVersion() {
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + getPackageSchemaVersionForDb());
updateSchemaVersion(getPackageSchemaVersion());
}
@Override
public void updateSchemaVersion(String version) {
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + toDbVersion(version));
}
@Override
@ -124,13 +129,15 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
}
private long getPackageSchemaVersionForDb() {
String[] versionParts = getPackageSchemaVersion().split("\\.");
long major = Integer.parseInt(versionParts[0]);
long minor = Integer.parseInt(versionParts[1]);
long maintenance = Integer.parseInt(versionParts[2]);
long patch = Integer.parseInt(versionParts[3]);
return toDbVersion(getPackageSchemaVersion());
}
private long toDbVersion(String version) {
String[] versionParts = version.split("\\.");
long major = Long.parseLong(versionParts[0]);
long minor = versionParts.length > 1 ? Long.parseLong(versionParts[1]) : 0;
long maintenance = versionParts.length > 2 ? Long.parseLong(versionParts[2]) : 0;
long patch = versionParts.length > 3 ? Long.parseLong(versionParts[3]) : 0;
return major * 1_000_000_000L + minor * 1_000_000L + maintenance * 1000L + patch;
}

12
application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java

@ -23,6 +23,7 @@ import org.springframework.jdbc.core.StatementCallback;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import java.io.IOException;
import java.io.UncheckedIOException;
@ -42,19 +43,26 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService
private final InstallScripts installScripts;
private final JdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final LtsMigrationService ltsMigrationService;
public SqlDatabaseUpgradeService(InstallScripts installScripts, JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
public SqlDatabaseUpgradeService(InstallScripts installScripts, JdbcTemplate jdbcTemplate,
PlatformTransactionManager transactionManager,
DatabaseSchemaSettingsService schemaSettingsService,
LtsMigrationService ltsMigrationService) {
this.installScripts = installScripts;
this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setTimeout((int) TimeUnit.MINUTES.toSeconds(120));
this.schemaSettingsService = schemaSettingsService;
this.ltsMigrationService = ltsMigrationService;
}
@Override
public void upgradeDatabase() {
log.info("Updating schema...");
loadSql(getSchemaUpdateFile("basic"));
loadSql(getSchemaUpdateFile("lts"));
ltsMigrationService.runSchemaMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion());
log.info("Schema updated.");
}

30
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() {
}
}

139
application/src/main/java/org/thingsboard/server/service/install/lts/LtsMigrationService.java

@ -0,0 +1,139 @@
/**
* 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());
// Run only migrations whose family matches the target version. Older-family
// migrations (e.g. 4.2.x) ride onto newer-family branches (e.g. 4.3.x) via the
// release-merge cascade, but each branch's own family of migrations is
// self-contained (newer-family copies reproduce the older schema/data changes),
// so a cross-family upgrade is fully handled by the target-family migrations.
// Excluding the dormant older-family beans avoids double-processing.
return version.sameFamily(to)
&& 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);
}
}
}

61
application/src/main/java/org/thingsboard/server/service/install/lts/LtsVersion.java

@ -0,0 +1,61 @@
/**
* 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;
public record LtsVersion(int major, int minor, int maintenance, int patch) implements Comparable<LtsVersion> {
public static LtsVersion parse(String version) {
if (version == null) {
throw new IllegalArgumentException("Version is null");
}
String[] parts = version.split("\\.");
try {
int major = Integer.parseInt(parts[0]);
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
return new LtsVersion(major, minor, maintenance, patch);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid version: " + version, e);
}
}
public boolean sameFamily(LtsVersion other) {
return major == other.major && minor == other.minor;
}
@Override
public int compareTo(LtsVersion o) {
int c = Integer.compare(major, o.major);
if (c != 0) {
return c;
}
c = Integer.compare(minor, o.minor);
if (c != 0) {
return c;
}
c = Integer.compare(maintenance, o.maintenance);
if (c != 0) {
return c;
}
return Integer.compare(patch, o.patch);
}
@Override
public String toString() {
return major + "." + minor + "." + maintenance + "." + patch;
}
}

73
application/src/main/java/org/thingsboard/server/service/install/lts/V4_2_2_3Migration.java

@ -0,0 +1,73 @@
/**
* 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.util.List;
@Slf4j
@Component
@TbCoreComponent
@RequiredArgsConstructor
public class V4_2_2_3Migration implements LtsMigration {
// Match on the bundle ALIAS, not the JSON filename stem.
// The "industrial_widgets" bundle shipped in a misspelled file (industial_widgets.json),
// but the alias inside it is correctly spelled.
private static final List<String> OBSOLETE_BUNDLE_ALIASES = List.of(
"air_quality", "indoor_environment", "industrial_widgets", "outdoor_environment");
private final WidgetsBundleService widgetsBundleService;
private final WidgetTypeService widgetTypeService;
@Override
public String getVersion() {
return "4.2.2.3";
}
@Override
public void apply() {
for (String alias : OBSOLETE_BUNDLE_ALIASES) {
deprecateTypesAndDeleteBundle(alias);
}
}
// Marks the bundle's widget types as deprecated (keeping the type entities) and deletes ONLY the bundle entity.
private void deprecateTypesAndDeleteBundle(String alias) {
WidgetsBundle bundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
if (bundle == null) {
return; // already removed — idempotent
}
List<WidgetTypeDetails> types = widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId());
for (WidgetTypeDetails type : types) {
if (!type.isDeprecated()) {
type.setDeprecated(true);
widgetTypeService.saveWidgetType(type);
}
}
widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
log.info("Deprecated {} widget type(s) and removed obsolete system widget bundle: {}", types.size(), alias);
}
}

29
application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_2Migration.java

@ -0,0 +1,29 @@
/**
* 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.springframework.stereotype.Component;
import org.thingsboard.server.queue.util.TbCoreComponent;
@Component
@TbCoreComponent
public class V4_3_1_2Migration implements LtsMigration {
@Override
public String getVersion() {
return "4.3.1.2";
}
}

73
application/src/main/java/org/thingsboard/server/service/install/lts/V4_3_1_3Migration.java

@ -0,0 +1,73 @@
/**
* 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.util.List;
@Slf4j
@Component
@TbCoreComponent
@RequiredArgsConstructor
public class V4_3_1_3Migration implements LtsMigration {
// Match on the bundle ALIAS, not the JSON filename stem.
// The "industrial_widgets" bundle shipped in a misspelled file (industial_widgets.json),
// but the alias inside it is correctly spelled.
private static final List<String> OBSOLETE_BUNDLE_ALIASES = List.of(
"air_quality", "indoor_environment", "industrial_widgets", "outdoor_environment");
private final WidgetsBundleService widgetsBundleService;
private final WidgetTypeService widgetTypeService;
@Override
public String getVersion() {
return "4.3.1.3";
}
@Override
public void apply() {
for (String alias : OBSOLETE_BUNDLE_ALIASES) {
deprecateTypesAndDeleteBundle(alias);
}
}
// Marks the bundle's widget types as deprecated (keeping the type entities) and deletes ONLY the bundle entity.
private void deprecateTypesAndDeleteBundle(String alias) {
WidgetsBundle bundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
if (bundle == null) {
return; // already removed — idempotent
}
List<WidgetTypeDetails> types = widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId());
for (WidgetTypeDetails type : types) {
if (!type.isDeprecated()) {
type.setDeprecated(true);
widgetTypeService.saveWidgetType(type);
}
}
widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
log.info("Deprecated {} widget type(s) and removed obsolete system widget bundle: {}", types.size(), alias);
}
}

7
application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java

@ -28,7 +28,9 @@ import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import org.thingsboard.server.service.component.RuleNodeClassInfo;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.DbUpgradeExecutorService;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.utils.TbNodeUpgradeUtils;
import java.util.ArrayList;
@ -47,12 +49,13 @@ public class DefaultDataUpdateService implements DataUpdateService {
private final RuleChainService ruleChainService;
private final ComponentDiscoveryService componentDiscoveryService;
private final DbUpgradeExecutorService executorService;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final LtsMigrationService ltsMigrationService;
@Override
public void updateData() throws Exception {
log.info("Updating data ...");
//TODO: should be cleaned after each release
ltsMigrationService.runDataMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion());
log.info("Data updated.");
}

60
application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java

@ -35,6 +35,8 @@ import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.service.install.lts.LtsVersion;
import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
import java.io.IOException;
@ -74,6 +76,7 @@ public class SystemPatchApplier {
private final WidgetTypeService widgetTypeService;
private final WidgetsBundleService widgetsBundleService;
private final ImageService imageService;
private final LtsMigrationService ltsMigrationService;
@PostConstruct
private void init() {
@ -101,7 +104,9 @@ public class SystemPatchApplier {
}
try {
updateLtsSqlSchema();
String dbVersion = schemaSettingsService.getDbSchemaVersion();
String packageVersion = schemaSettingsService.getPackageSchemaVersion();
ltsMigrationService.applyMigrations(dbVersion, packageVersion);
updateSqlViews();
log.info("Updated sql database views");
@ -129,15 +134,17 @@ public class SystemPatchApplier {
log.trace("Package version: {}, DB schema version: {}", packageVersion, dbVersion);
VersionInfo packageVersionInfo = parseVersion(packageVersion);
VersionInfo dbVersionInfo = parseVersion(dbVersion);
if (packageVersionInfo == null || dbVersionInfo == null) {
LtsVersion packageVersionInfo;
LtsVersion dbVersionInfo;
try {
packageVersionInfo = LtsVersion.parse(packageVersion);
dbVersionInfo = LtsVersion.parse(dbVersion);
} catch (IllegalArgumentException e) {
log.warn("Unable to parse versions. Package: {}, DB: {}", packageVersion, dbVersion);
return false;
}
if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) {
if (!packageVersionInfo.sameFamily(dbVersionInfo) || packageVersionInfo.compareTo(dbVersionInfo) <= 0) {
return false;
}
@ -145,31 +152,6 @@ public class SystemPatchApplier {
return true;
}
private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) {
if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) {
return false;
}
if (packageVersion.maintenance != dbVersion.maintenance) {
return packageVersion.maintenance > dbVersion.maintenance;
}
return packageVersion.patch > dbVersion.patch;
}
private void updateLtsSqlSchema() {
Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql");
if (!Files.exists(sqlFile)) {
log.trace("LTS schema update file does not exist: {}", sqlFile);
return;
}
try {
String sql = Files.readString(sqlFile);
jdbcTemplate.execute(sql);
log.info("Applied LTS SQL schema update from {}", sqlFile);
} catch (IOException e) {
throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e);
}
}
private void updateSqlViews() {
try {
URL schemaViewsUrl = Resources.getResource(SCHEMA_VIEWS_SQL);
@ -428,20 +410,6 @@ public class SystemPatchApplier {
}
}
private VersionInfo parseVersion(String version) {
try {
String[] parts = version.split("\\.");
int major = Integer.parseInt(parts[0]);
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
return new VersionInfo(major, minor, maintenance, patch);
} catch (Exception e) {
log.error("Failed to parse version: {}", version, e);
return null;
}
}
private Stream<Path> listDir(Path dir) {
try {
return Files.list(dir);
@ -452,8 +420,6 @@ public class SystemPatchApplier {
}
}
public record VersionInfo(int major, int minor, int maintenance, int patch) {}
public record WidgetTypeStats(int created, int updated) {}
private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED }

50
application/src/test/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsServiceTest.java

@ -0,0 +1,50 @@
/**
* 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;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class DefaultDatabaseSchemaSettingsServiceTest {
@Mock
private ProjectInfo projectInfo;
@Mock
private JdbcTemplate jdbcTemplate;
@InjectMocks
private DefaultDatabaseSchemaSettingsService service;
@Test
void updateSchemaVersionWithExplicitVersionEncodesAsLong() {
service.updateSchemaVersion("4.2.2.3");
verify(jdbcTemplate).execute("UPDATE tb_schema_settings SET schema_version = 4002002003");
}
@Test
void updateSchemaVersionWithShortVersionPadsMissingComponents() {
service.updateSchemaVersion("4.3");
verify(jdbcTemplate).execute("UPDATE tb_schema_settings SET schema_version = 4003000000");
}
}

144
application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationIntegrationTest.java

@ -0,0 +1,144 @@
/**
* 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 com.fasterxml.jackson.databind.JsonNode;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId;
import org.thingsboard.server.common.data.id.WidgetsBundleId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class LtsMigrationIntegrationTest extends AbstractControllerTest {
private static final long V_4_2_2_2 = 4_002_002_002L;
private static final long V_4_2_2_3 = 4_002_002_003L;
private static final String OBSOLETE_ALIAS = "air_quality";
@Autowired
private LtsMigrationService ltsMigrationService;
@Autowired
private WidgetsBundleService widgetsBundleService;
@Autowired
private WidgetTypeService widgetTypeService;
@Autowired
private JdbcTemplate jdbcTemplate;
private Long originalSchemaVersion;
private WidgetsBundleId bundleId;
private WidgetTypeId widgetTypeId;
@Before
public void setUp() {
// The test-context DB (DaoSqlTest) creates tb_schema_settings empty, so ensure the baseline
// version row a real installed DB always has exists before driving the migration.
originalSchemaVersion = jdbcTemplate.query("SELECT schema_version FROM tb_schema_settings",
rs -> rs.next() ? rs.getLong(1) : null);
if (originalSchemaVersion == null) {
jdbcTemplate.execute("INSERT INTO tb_schema_settings (schema_version, product) VALUES (" + V_4_2_2_2 + ", 'CE')");
}
// Seed an obsolete system widget bundle with one (non-deprecated) widget type linked to it.
WidgetsBundle bundle = new WidgetsBundle();
bundle.setTenantId(TenantId.SYS_TENANT_ID);
bundle.setAlias(OBSOLETE_ALIAS);
bundle.setTitle("Air quality");
bundleId = widgetsBundleService.saveWidgetsBundle(bundle).getId();
WidgetTypeDetails type = new WidgetTypeDetails();
type.setTenantId(TenantId.SYS_TENANT_ID);
type.setFqn("air_quality_sample_" + UUID.randomUUID());
type.setName("Air quality sample");
type.setDescriptor(JacksonUtil.fromString("{ \"type\": \"latest\" }", JsonNode.class));
WidgetTypeDetails saved = widgetTypeService.saveWidgetType(type);
widgetTypeId = saved.getId();
widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, bundleId, List.of(saved.getFqn()));
// Pretend the DB is at 4.2.2.2 so the 4.2.2.3 migration is in range (4.2.2.2, 4.2.2.3].
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + V_4_2_2_2);
}
@After
public void tearDown() {
// Restore the schema version (or drop the row we seeded) and clean up whatever the migration left behind.
if (originalSchemaVersion != null) {
jdbcTemplate.execute("UPDATE tb_schema_settings SET schema_version = " + originalSchemaVersion);
} else {
jdbcTemplate.execute("DELETE FROM tb_schema_settings");
}
if (widgetTypeId != null && widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId) != null) {
widgetTypeService.deleteWidgetType(TenantId.SYS_TENANT_ID, widgetTypeId);
}
WidgetsBundle bundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS);
if (bundle != null) {
widgetsBundleService.deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
}
}
@Test
public void appliesSqlDeprecatesTypesDeletesBundleAndRecordsVersion() {
ltsMigrationService.applyMigrations("4.2.2.2", "4.2.2.3");
// 1. The version's SQL ran: iot_hub_installed_item table now exists.
assertTrue(tableExists("iot_hub_installed_item"));
// 2. The version was recorded.
assertEquals(Long.valueOf(V_4_2_2_3),
jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
// 3. The obsolete bundle entity was deleted.
assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
// 4. The widget type was KEPT and marked deprecated.
WidgetTypeDetails type = widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId);
assertNotNull(type);
assertTrue(type.isDeprecated());
}
@Test
public void reRunFromCurrentVersionIsNoOp() {
ltsMigrationService.applyMigrations("4.2.2.2", "4.2.2.3");
// Re-running from the now-current version selects an empty range — nothing changes, nothing throws.
ltsMigrationService.applyMigrations("4.2.2.3", "4.2.2.3");
assertEquals(Long.valueOf(V_4_2_2_3),
jdbcTemplate.queryForObject("SELECT schema_version FROM tb_schema_settings", Long.class));
assertNull(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, OBSOLETE_ALIAS));
assertTrue(widgetTypeService.findWidgetTypeDetailsById(TenantId.SYS_TENANT_ID, widgetTypeId).isDeprecated());
}
private boolean tableExists(String table) {
Boolean exists = jdbcTemplate.queryForObject(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = ?)", Boolean.class, table);
return Boolean.TRUE.equals(exists);
}
}

194
application/src/test/java/org/thingsboard/server/service/install/lts/LtsMigrationServiceTest.java

@ -0,0 +1,194 @@
/**
* 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 skipsMigrationsOutsideTargetFamilyOnCrossFamilyUpgrade() {
List<String> applied = new ArrayList<>();
// 4.2.2.3 is carried onto a 4.3 branch by the release-merge cascade but must stay dormant:
// a cross-family 4.2 -> 4.3 upgrade is handled entirely by the 4.3-family migrations.
LtsMigrationService service = service(List.of(
migration("4.2.2.3", applied),
migration("4.3.1.2", applied),
migration("4.3.1.3", applied)));
service.runDataMigrations("4.2.2.2", "4.3.1.3");
assertEquals(List.of("4.3.1.2", "4.3.1.3"), applied);
}
@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))));
}
}

78
application/src/test/java/org/thingsboard/server/service/install/lts/LtsVersionTest.java

@ -0,0 +1,78 @@
/**
* 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.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LtsVersionTest {
@ParameterizedTest
@CsvSource({
"4.2.1, 4, 2, 1, 0",
"4.2.0, 4, 2, 0, 0",
"4.2, 4, 2, 0, 0",
"4.0.1.2, 4, 0, 1, 2",
"4, 4, 0, 0, 0",
"10.20.30.40, 10, 20, 30, 40"
})
void parsesValidVersions(String input, int major, int minor, int maintenance, int patch) {
LtsVersion v = LtsVersion.parse(input);
assertEquals(major, v.major());
assertEquals(minor, v.minor());
assertEquals(maintenance, v.maintenance());
assertEquals(patch, v.patch());
}
@ParameterizedTest
@ValueSource(strings = {"invalid", "a.b.c", "1.2.y.x", "1.x.3", ""})
void throwsOnInvalidVersions(String input) {
assertThrows(IllegalArgumentException.class, () -> LtsVersion.parse(input));
}
@Test
void throwsOnNull() {
assertThrows(IllegalArgumentException.class, () -> LtsVersion.parse(null));
}
@Test
void comparesByEachComponent() {
assertTrue(LtsVersion.parse("4.2.2.3").compareTo(LtsVersion.parse("4.2.2.2")) > 0);
assertTrue(LtsVersion.parse("4.2.2.0").compareTo(LtsVersion.parse("4.2.1.9")) > 0);
assertTrue(LtsVersion.parse("4.3.0.0").compareTo(LtsVersion.parse("4.2.9.9")) > 0);
assertEquals(0, LtsVersion.parse("4.2.2.3").compareTo(LtsVersion.parse("4.2.2.3")));
}
@Test
void sameFamilyComparesMajorAndMinorOnly() {
assertTrue(LtsVersion.parse("4.2.2.3").sameFamily(LtsVersion.parse("4.2.0.0")));
assertFalse(LtsVersion.parse("4.3.0.0").sameFamily(LtsVersion.parse("4.2.9.9")));
assertFalse(LtsVersion.parse("5.2.0.0").sameFamily(LtsVersion.parse("4.2.0.0")));
}
@Test
void toStringIsFourComponentDotForm() {
assertEquals("4.2.2.3", LtsVersion.parse("4.2.2.3").toString());
assertEquals("4.2.0.0", LtsVersion.parse("4.2").toString());
}
}

96
application/src/test/java/org/thingsboard/server/service/install/lts/V4_2_2_3MigrationTest.java

@ -0,0 +1,96 @@
/**
* 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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId;
import org.thingsboard.server.common.data.id.WidgetsBundleId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class V4_2_2_3MigrationTest {
@Mock
private WidgetsBundleService widgetsBundleService;
@Mock
private WidgetTypeService widgetTypeService;
@InjectMocks
private V4_2_2_3Migration migration;
@Test
void versionIs4223() {
assertEquals("4.2.2.3", migration.getVersion());
}
@Test
void deprecatesTypesThenDeletesBundleEntityOnly() {
WidgetsBundle bundle = new WidgetsBundle();
bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
bundle.setAlias("air_quality");
WidgetTypeDetails fresh = new WidgetTypeDetails();
fresh.setId(new WidgetTypeId(UUID.randomUUID()));
fresh.setDeprecated(false);
WidgetTypeDetails already = new WidgetTypeDetails();
already.setId(new WidgetTypeId(UUID.randomUUID()));
already.setDeprecated(true);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "air_quality")).thenReturn(bundle);
when(widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId())).thenReturn(List.of(fresh, already));
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "indoor_environment")).thenReturn(null);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "industrial_widgets")).thenReturn(null);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "outdoor_environment")).thenReturn(null);
migration.apply();
// only the previously non-deprecated type is re-saved, now deprecated
verify(widgetTypeService).saveWidgetType(argThat(WidgetTypeDetails::isDeprecated));
// widget types are NOT deleted
verify(widgetTypeService, never()).deleteWidgetTypesByBundleId(any(), any());
// only the bundle entity is removed
verify(widgetsBundleService).deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
}
@Test
void absentBundleIsNoOp() {
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(any(), any())).thenReturn(null);
migration.apply();
verify(widgetTypeService, never()).saveWidgetType(any());
verify(widgetsBundleService, never()).deleteWidgetsBundle(any(), any());
}
}

96
application/src/test/java/org/thingsboard/server/service/install/lts/V4_3_1_3MigrationTest.java

@ -0,0 +1,96 @@
/**
* 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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId;
import org.thingsboard.server.common.data.id.WidgetsBundleId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class V4_3_1_3MigrationTest {
@Mock
private WidgetsBundleService widgetsBundleService;
@Mock
private WidgetTypeService widgetTypeService;
@InjectMocks
private V4_3_1_3Migration migration;
@Test
void versionIs4313() {
assertEquals("4.3.1.3", migration.getVersion());
}
@Test
void deprecatesTypesThenDeletesBundleEntityOnly() {
WidgetsBundle bundle = new WidgetsBundle();
bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
bundle.setAlias("air_quality");
WidgetTypeDetails fresh = new WidgetTypeDetails();
fresh.setId(new WidgetTypeId(UUID.randomUUID()));
fresh.setDeprecated(false);
WidgetTypeDetails already = new WidgetTypeDetails();
already.setId(new WidgetTypeId(UUID.randomUUID()));
already.setDeprecated(true);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "air_quality")).thenReturn(bundle);
when(widgetTypeService.findWidgetTypesDetailsByWidgetsBundleId(TenantId.SYS_TENANT_ID, bundle.getId())).thenReturn(List.of(fresh, already));
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "indoor_environment")).thenReturn(null);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "industrial_widgets")).thenReturn(null);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "outdoor_environment")).thenReturn(null);
migration.apply();
// only the previously non-deprecated type is re-saved, now deprecated
verify(widgetTypeService).saveWidgetType(argThat(WidgetTypeDetails::isDeprecated));
// widget types are NOT deleted
verify(widgetTypeService, never()).deleteWidgetTypesByBundleId(any(), any());
// only the bundle entity is removed
verify(widgetsBundleService).deleteWidgetsBundle(TenantId.SYS_TENANT_ID, bundle.getId());
}
@Test
void absentBundleIsNoOp() {
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(any(), any())).thenReturn(null);
migration.apply();
verify(widgetTypeService, never()).saveWidgetType(any());
verify(widgetsBundleService, never()).deleteWidgetsBundle(any(), any());
}
}

179
application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

@ -21,7 +21,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
@ -39,6 +38,7 @@ import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.install.lts.LtsMigrationService;
import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
@ -56,7 +56,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@ -91,46 +90,15 @@ public class SystemPatchApplierTest {
@Mock
private ImageService imageService;
@Mock
private LtsMigrationService ltsMigrationService;
@InjectMocks
private SystemPatchApplier reconciler;
@TempDir
Path tempDir;
@ParameterizedTest(name = "Parse version {0} should return major={1}, minor={2}, patch={3}")
@CsvSource({
"4.2.1, 4, 2, 1, 0",
"4.2.0, 4, 2, 0, 0",
"4.2, 4, 2, 0, 0",
"4.0.1.2, 4, 0, 1, 2",
"4, 4, 0, 0, 0",
"1.0.5.7, 1, 0, 5, 7",
"10.20.30.40, 10, 20, 30, 40",
"0.0.1, 0, 0, 1, 0"
})
void testParseVersion(String versionString, int expectedMajor, int expectedMinor, int expectedMaintenance, int expectedPatch) {
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", versionString);
assertNotNull(version, "Version should not be null for: " + versionString);
assertEquals(expectedMajor, version.major(), "Major version mismatch");
assertEquals(expectedMinor, version.minor(), "Minor version mismatch");
assertEquals(expectedMaintenance, version.maintenance(), "Maintenance version mismatch");
assertEquals(expectedPatch, version.patch(), "Patch version mismatch");
}
@ParameterizedTest(name = "Parse invalid version: {0}")
@CsvSource({
"invalid",
"a.b.c",
"1.2.y.x",
"''",
"1.x.3"
})
void testParseInvalidVersion(String invalidVersion) {
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", invalidVersion);
assertNull(version, "Version should be null for invalid input: " + invalidVersion);
}
@Test
void whenLockIsNotAcquired_thenAcquiredIsSuccess() {
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(true);
@ -449,78 +417,6 @@ public class SystemPatchApplierTest {
verify(widgetTypeService, times(1)).saveWidgetType(any());
}
// --- isVersionIncreased tests ---
@ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}")
@MethodSource("provideVersionComparisonTestCases")
void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion,
SystemPatchApplier.VersionInfo dbVersion, boolean expected) {
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion);
assertEquals(expected, result, testName);
}
private static Stream<Arguments> provideVersionComparisonTestCases() {
return Stream.of(
// Maintenance digit increases within same LTS family
Arguments.of("maintenance increased",
new SystemPatchApplier.VersionInfo(4, 3, 1, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
Arguments.of("maintenance increased by more than one",
new SystemPatchApplier.VersionInfo(4, 3, 3, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
// Patch digit increases within same maintenance
Arguments.of("patch increased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 1),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
Arguments.of("patch increased by more than one",
new SystemPatchApplier.VersionInfo(4, 3, 0, 5),
new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true),
// Both maintenance and patch increased
Arguments.of("maintenance and patch both increased",
new SystemPatchApplier.VersionInfo(4, 3, 1, 1),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true),
// Maintenance increased, patch value is lower (irrelevant — maintenance wins)
Arguments.of("maintenance increased, patch is lower",
new SystemPatchApplier.VersionInfo(4, 3, 2, 0),
new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true),
// Same version — no increase
Arguments.of("same version",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("same version with non-zero parts",
new SystemPatchApplier.VersionInfo(4, 3, 1, 2),
new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false),
// Decreased versions — no increase
Arguments.of("maintenance decreased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false),
Arguments.of("patch decreased",
new SystemPatchApplier.VersionInfo(4, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false),
// Different major — different family, skip
Arguments.of("different major",
new SystemPatchApplier.VersionInfo(5, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("major decreased",
new SystemPatchApplier.VersionInfo(3, 3, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
// Different minor — different LTS family, skip
Arguments.of("minor increased (different LTS family)",
new SystemPatchApplier.VersionInfo(4, 4, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false),
Arguments.of("minor decreased",
new SystemPatchApplier.VersionInfo(4, 2, 0, 0),
new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false)
);
}
// --- isVersionChanged tests ---
@Test
@ -563,76 +459,22 @@ public class SystemPatchApplierTest {
assertFalse(result);
}
// --- updateLtsSqlSchema tests ---
@Test
void whenLtsSqlFileExists_thenExecutesSql() throws Exception {
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);");
}
@Test
void whenLtsSqlFileDoesNotExist_thenSkips() {
Path dataDir = tempDir.resolve("data");
// Don't create the file
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate, never()).execute(anyString());
}
@Test
void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception {
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
String sql = "DO $$ BEGIN\n" +
" IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" +
" CREATE TYPE test_type AS ENUM ('A', 'B');\n" +
" END IF;\n" +
"END $$;\n" +
"ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);";
Files.writeString(ltsDir.resolve("schema_update.sql"), sql);
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema");
verify(jdbcTemplate).execute(sql);
}
// --- applyPatchIfNeeded flow tests ---
@Test
void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception {
void whenVersionIncreased_thenAppliesMigrationsBeforeViewsAndWidgets() {
when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0");
when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0");
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
Path dataDir = tempDir.resolve("data");
Path ltsDir = dataDir.resolve("upgrade").resolve("lts");
Files.createDirectories(ltsDir);
Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(tempDir.resolve("widget_types"));
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
// LTS SQL was executed
verify(jdbcTemplate).execute("SELECT 1;");
// Schema version was updated
verify(ltsMigrationService).applyMigrations("4.3.0.0", "4.3.1.0");
verify(schemaSettingsService).updateSchemaVersion();
}
@ -668,17 +510,16 @@ public class SystemPatchApplierTest {
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true);
Path dataDir = tempDir.resolve("data");
when(installScripts.getDataDir()).thenReturn(dataDir.toString());
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString());
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
verify(schemaSettingsService).updateSchemaVersion();
verify(ltsMigrationService).applyMigrations("4.3.1.0", "4.3.2.0");
}
@Test

Loading…
Cancel
Save