[mapdb] Improve shutdown handling and add tests (#20153)

* [mapdb] Add unit tests

Signed-off-by: Holger Friedrich <mail@holger-friedrich.de>
This commit is contained in:
Holger Friedrich
2026-02-14 14:27:42 +01:00
committed by GitHub
parent f65514fcdb
commit 6b541b061b
3 changed files with 405 additions and 6 deletions
@@ -24,6 +24,58 @@
<artifactId>mapdb</artifactId>
<version>1.0.9</version>
</dependency>
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>org.jacoco.agent</artifactId>
<version>0.8.14</version>
<classifier>runtime</classifier>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<jacoco-agent.destfile>target/jacoco.exec</jacoco-agent.destfile>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.14</version>
<configuration>
<excludes>
<exclude>org/mapdb/**/*</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>default-instrument</id>
<goals>
<goal>instrument</goal>
</goals>
</execution>
<execution>
<id>default-restore-instrumented-classes</id>
<goals>
<goal>restore-instrumented-classes</goal>
</goals>
<phase>test</phase>
</execution>
<execution>
<id>default-report</id>
<goals>
<goal>report</goal>
</goals>
<phase>test</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -26,6 +26,8 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -73,10 +75,13 @@ public class MapDbPersistenceService implements QueryablePersistenceService {
private static final Path DB_DIR = new File(OpenHAB.getUserDataFolder(), "persistence").toPath().resolve("mapdb");
private static final Path BACKUP_DIR = DB_DIR.resolve("backup");
private static final String DB_FILE_NAME = "storage.mapdb";
private static final long DEACTIVATE_TIMEOUT_MS = 30000; // 30 seconds
private final Logger logger = LoggerFactory.getLogger(MapDbPersistenceService.class);
private final ExecutorService threadPool = ThreadPoolManager.getPool(getClass().getSimpleName());
private final AtomicInteger pendingTasks = new AtomicInteger(0);
private volatile boolean active;
/**
* holds the local instance of the MapDB database
@@ -91,6 +96,7 @@ public class MapDbPersistenceService implements QueryablePersistenceService {
@Activate
public void activate() {
logger.debug("MapDB persistence service is being activated");
active = true;
try {
Files.createDirectories(DB_DIR);
@@ -147,6 +153,20 @@ public class MapDbPersistenceService implements QueryablePersistenceService {
@Deactivate
public void deactivate() {
logger.debug("MapDB persistence service deactivated");
active = false;
long deadline = System.currentTimeMillis() + DEACTIVATE_TIMEOUT_MS;
while (pendingTasks.get() > 0 && System.currentTimeMillis() < deadline) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.warn("Interrupted while waiting for MapDB persistence tasks to finish.");
break;
}
}
if (pendingTasks.get() > 0) {
logger.warn("Timed out waiting for MapDB persistence tasks; {} tasks still pending.", pendingTasks.get());
}
if (db != null) {
db.close();
}
@@ -175,6 +195,10 @@ public class MapDbPersistenceService implements QueryablePersistenceService {
@Override
public void store(Item item, @Nullable String alias) {
if (!active) {
logger.info("Skipping store of item '{}' because persistence service is not active", item.getName());
return;
}
if (item.getState() instanceof UnDefType) {
return;
}
@@ -192,12 +216,23 @@ public class MapDbPersistenceService implements QueryablePersistenceService {
mItem.setTimestamp(lastStateUpdate != null ? Date.from(lastStateUpdate.toInstant()) : new Date());
ZonedDateTime lastStateChange = item.getLastStateChange();
mItem.setLastStateChange(lastStateChange != null ? Date.from(lastStateChange.toInstant()) : null);
threadPool.submit(() -> {
String json = serialize(mItem);
map.put(localAlias, json);
db.commit();
logger.debug("Stored '{}' with state '{}' as '{}' in MapDB database", localAlias, state, json);
});
pendingTasks.incrementAndGet();
try {
threadPool.submit(() -> {
try {
String json = serialize(mItem);
map.put(localAlias, json);
db.commit();
logger.debug("Stored '{}' with state '{}' as '{}' in MapDB database", localAlias, state, json);
} finally {
pendingTasks.decrementAndGet();
}
});
} catch (RejectedExecutionException e) {
logger.warn("Task submission rejected for item '{}': {}", localAlias, e.getMessage());
pendingTasks.decrementAndGet();
}
}
@Override
@@ -0,0 +1,312 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.mapdb;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.Mockito.when;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.items.StringItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.HistoricItem;
import org.openhab.core.persistence.PersistedItem;
import org.openhab.persistence.mapdb.internal.MapDbPersistenceService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Tests for {@link MapDbPersistenceService}.
*
* @author Copilot - Initial contribution
* @author Holger Friedrich - refactoring and additional tests
*/
@ExtendWith(MockitoExtension.class)
class MapDbPersistenceServiceTest {
private static final long STORAGE_TIMEOUT_MS = 20000; // 20 seconds for CI
private static final long POLL_INTERVAL_MS = 250; // Check every 250ms
private final Logger logger = LoggerFactory.getLogger(MapDbPersistenceServiceTest.class);
@Mock
private NumberItem numberItem;
@Mock
private SwitchItem switchItem;
@Mock
private StringItem stringItem;
private MapDbPersistenceService service;
@BeforeEach
void setUp() throws Exception {
// Create service and activate OSGi lifecycle manually for tests
service = new MapDbPersistenceService();
service.activate();
}
/**
* Waits for data to be persisted by polling the database.
* This is more robust than Thread.sleep() in CI environments with resource contention.
*
* @param itemName the name of the item to check
* @param timeoutMs maximum time to wait in milliseconds
* @throws InterruptedException if interrupted while waiting
*/
private void waitForStorage(String itemName, long timeoutMs) throws InterruptedException {
long startTime = System.currentTimeMillis();
int attempts = 0;
while (System.currentTimeMillis() - startTime < timeoutMs) {
attempts++;
FilterCriteria criteria = new FilterCriteria();
criteria.setItemName(itemName);
criteria.setPageSize(1);
try {
Iterable<HistoricItem> results = service.query(criteria);
if (results.iterator().hasNext()) {
long elapsed = System.currentTimeMillis() - startTime;
logger.info("Storage completed for '{}' after {}ms ({} attempts)", itemName, elapsed, attempts);
return; // Success!
}
} catch (Exception e) {
// Query might fail if data not ready yet, continue polling
logger.info("Query attempt {} failed: {}", attempts, e.getMessage());
}
Thread.sleep(POLL_INTERVAL_MS);
}
long elapsed = System.currentTimeMillis() - startTime;
fail(String.format("Data for item '%s' was not persisted within %dms (%d polling attempts).", itemName, elapsed,
attempts));
}
private void configureNumberItem(String suffix) throws Exception {
when(numberItem.getName()).thenReturn("TestNumber" + suffix);
when(numberItem.getState()).thenReturn(new DecimalType(42.5));
}
private void configureStringItem(String suffix) throws Exception {
when(stringItem.getName()).thenReturn("TestString" + suffix);
when(stringItem.getState()).thenReturn(new StringType("TestValue"));
}
private void configureSwitchItem(String suffix) throws Exception {
when(switchItem.getName()).thenReturn("TestSwitch" + suffix);
when(switchItem.getState()).thenReturn(OnOffType.ON);
}
@AfterEach
void tearDown() throws Exception {
if (service != null) {
service.deactivate();
}
}
// Test storing and retrieving a number value, with and without service reload in between.
// With reloadAfterStore=true, this verifies that data is correctly persisted and can be retrieved
// even after a restart.
@ParameterizedTest
@ValueSource(booleans = { true, false })
void storeAndRetrieveNumberValue(boolean reloadAfterStore) throws Exception {
logger.debug("Starting storeAndRetrieveNumberValue with reloadAfterStore={}", reloadAfterStore);
configureNumberItem(reloadAfterStore ? "_PERSISTED" : "_MEMORY");
// Store a value
service.store(numberItem);
if (reloadAfterStore) {
service.deactivate();
service.activate();
}
// Wait for background storage to complete
waitForStorage(numberItem.getName(), STORAGE_TIMEOUT_MS);
// Query the value back
FilterCriteria criteria = new FilterCriteria();
criteria.setItemName(numberItem.getName());
criteria.setOrdering(FilterCriteria.Ordering.DESCENDING);
criteria.setPageSize(1);
criteria.setPageNumber(0);
// HistoricItem will anyway only return last stored value for MapDb, but this should work without errors
Iterable<HistoricItem> results = service.query(criteria);
assertNotNull(results);
// Verify the retrieved value
HistoricItem item = results.iterator().next();
assertNotNull(item);
assertEquals(numberItem.getName(), item.getName());
assertEquals(new DecimalType(42.5), item.getState());
logger.debug("Ending storeAndRetrieveNumberValue with reloadAfterStore={}", reloadAfterStore);
}
@ParameterizedTest
@ValueSource(booleans = { true, false })
void storeAndRetrieveStringValue(boolean reloadAfterStore) throws Exception {
logger.debug("Starting storeAndRetrieveStringValue with reloadAfterStore={}", reloadAfterStore);
configureStringItem(reloadAfterStore ? "_PERSISTED" : "_MEMORY");
// Store a value
service.store(stringItem);
if (reloadAfterStore) {
service.deactivate();
service.activate();
}
// Wait for background storage to complete
waitForStorage(stringItem.getName(), STORAGE_TIMEOUT_MS);
// Query the value back
FilterCriteria criteria = new FilterCriteria();
criteria.setItemName(stringItem.getName());
criteria.setOrdering(FilterCriteria.Ordering.DESCENDING);
criteria.setPageSize(1);
criteria.setPageNumber(0);
Iterable<HistoricItem> results = service.query(criteria);
assertNotNull(results);
// Verify the retrieved value
// HistoricItem will anyway only return last stored value for MapDb, but this should work without errors
HistoricItem item = results.iterator().next();
assertNotNull(item);
assertEquals(stringItem.getName(), item.getName());
assertEquals(new StringType("TestValue"), item.getState());
PersistedItem persistedItem = service.persistedItem(stringItem.getName(), null);
assertNotNull(persistedItem);
assertEquals(stringItem.getName(), persistedItem.getName());
assertEquals(new StringType("TestValue"), persistedItem.getState());
logger.debug("Ending storeAndRetrieveStringValue with reloadAfterStore={}", reloadAfterStore);
}
@ParameterizedTest
@ValueSource(booleans = { true, false })
void storeAndRetrieveSwitchValue(boolean reloadAfterStore) throws Exception {
logger.debug("Starting storeAndRetrieveSwitchValue with reloadAfterStore={}", reloadAfterStore);
configureSwitchItem(reloadAfterStore ? "_PERSISTED" : "_MEMORY");
// Store a value
service.store(switchItem);
if (reloadAfterStore) {
service.deactivate();
service.activate();
}
// Wait for background storage to complete
waitForStorage(switchItem.getName(), STORAGE_TIMEOUT_MS);
// Query the value back
FilterCriteria criteria = new FilterCriteria();
criteria.setItemName(switchItem.getName());
criteria.setOrdering(FilterCriteria.Ordering.DESCENDING);
criteria.setPageSize(1);
criteria.setPageNumber(0);
Iterable<HistoricItem> results = service.query(criteria);
assertNotNull(results);
// Verify the retrieved value (converted back to OnOffType by toStateMapper)
// HistoricItem will anyway only return last stored value for MapDb, but this should work without errors
HistoricItem item = results.iterator().next();
assertNotNull(item);
assertEquals(switchItem.getName(), item.getName());
assertEquals(OnOffType.ON, item.getState());
PersistedItem persistedItem = service.persistedItem(switchItem.getName(), null);
assertNotNull(persistedItem);
assertEquals(switchItem.getName(), persistedItem.getName());
assertEquals(OnOffType.ON, persistedItem.getState());
logger.debug("Ending storeAndRetrieveSwitchValue with reloadAfterStore={}", reloadAfterStore);
}
@ParameterizedTest
@ValueSource(booleans = { true, false })
void queryWithTimeRange(boolean reloadAfterStore) throws Exception {
logger.debug("Starting queryWithTimeRange with reloadAfterStore={}", reloadAfterStore);
configureNumberItem(reloadAfterStore ? "_PERSISTED" : "_MEMORY");
// Store a value
service.store(numberItem);
if (reloadAfterStore) {
service.deactivate();
service.activate();
}
// Wait for background storage to complete
waitForStorage(numberItem.getName(), STORAGE_TIMEOUT_MS);
// Query with time range
FilterCriteria criteria = new FilterCriteria();
criteria.setItemName(numberItem.getName());
criteria.setBeginDate(ZonedDateTime.now(ZoneId.systemDefault()).minusHours(1));
criteria.setEndDate(ZonedDateTime.now(ZoneId.systemDefault()).plusHours(1));
criteria.setOrdering(FilterCriteria.Ordering.ASCENDING);
// HistoricItem will anyway only return last stored value for MapDb, but this should work without errors
Iterable<HistoricItem> results = service.query(criteria);
assertNotNull(results);
// Verify we got at least one result
assertTrue(results.iterator().hasNext());
// try to get a non-existing persisted item
PersistedItem persistedItem = service.persistedItem("UnknownTestItem", null);
assertNull(persistedItem);
logger.debug("Ending queryWithTimeRange with reloadAfterStore={}", reloadAfterStore);
}
@Test
void serviceIdIsCorrect() throws Exception {
assertEquals("mapdb", service.getId());
}
@Test
void labelIsCorrect() throws Exception {
assertEquals("MapDB", service.getLabel(null));
}
/*
* Does not make much sense, MapDb stores only last value and creates a new DB on failures
* void checkMapDbFormatCompatibility() throws Exception {
* ...
* }
*/
}