mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[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:
@@ -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>
|
||||
|
||||
+41
-6
@@ -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
|
||||
|
||||
+312
@@ -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 {
|
||||
* ...
|
||||
* }
|
||||
*/
|
||||
}
|
||||
Reference in New Issue
Block a user