From 06fee6168cebb1e7881dde97afbb35a1e487c4a3 Mon Sep 17 00:00:00 2001 From: J-N-K Date: Sat, 24 Jun 2023 11:15:09 +0200 Subject: [PATCH] [inmemory] Initial contribution (#15063) This is the initial contribution of a new volatile persistence service. It does store values in memory only and can especially be used for forecasts or other data where volatile storage is sufficient. Signed-off-by: Jan N. Klug Signed-off-by: Matt Myers --- bom/openhab-addons/pom.xml | 5 + .../org.openhab.persistence.inmemory/NOTICE | 13 + .../README.md | 14 + .../org.openhab.persistence.inmemory/pom.xml | 17 + .../src/main/feature/feature.xml | 10 + .../internal/InMemoryPersistenceService.java | 311 ++++++++++++++++++ .../src/main/resources/OH-INF/addon/addon.xml | 21 ++ .../internal/InMemoryPersistenceTests.java | 183 +++++++++++ bundles/pom.xml | 1 + 9 files changed, 575 insertions(+) create mode 100644 bundles/org.openhab.persistence.inmemory/NOTICE create mode 100644 bundles/org.openhab.persistence.inmemory/README.md create mode 100644 bundles/org.openhab.persistence.inmemory/pom.xml create mode 100644 bundles/org.openhab.persistence.inmemory/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.persistence.inmemory/src/main/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceService.java create mode 100644 bundles/org.openhab.persistence.inmemory/src/main/resources/OH-INF/addon/addon.xml create mode 100644 bundles/org.openhab.persistence.inmemory/src/test/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceTests.java diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 54802e935c9db..e19070fd25c57 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1961,6 +1961,11 @@ org.openhab.persistence.influxdb ${project.version} + + org.openhab.addons.bundles + org.openhab.persistence.inmemory + ${project.version} + org.openhab.addons.bundles org.openhab.persistence.jdbc diff --git a/bundles/org.openhab.persistence.inmemory/NOTICE b/bundles/org.openhab.persistence.inmemory/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.persistence.inmemory/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.persistence.inmemory/README.md b/bundles/org.openhab.persistence.inmemory/README.md new file mode 100644 index 0000000000000..f24ab44c85371 --- /dev/null +++ b/bundles/org.openhab.persistence.inmemory/README.md @@ -0,0 +1,14 @@ +# InMemory Persistence + +The InMemory persistence service provides a volatile storage, i.e. it is cleared on shutdown. +Because of that the `restoreOnStartup` strategy is not supported for this service. + +The main use-case is to store data that is needed during runtime, e.g. temporary storage of forecast data that is retrieved from a binding. + +Since all data is stored in memory only, there is no default strategy for this service. +Unlike other persistence services, you MUST add a configuration, otherwise no data will be persisted. +To avoid excessive memory usage, it is recommended to persist only a limited number of items and use a strategy that stores only data that is actually needed. + +The service has a global configuration option `maxEntries` to limit the number of datapoints per item, the default value is `512`. +When the number of datapoints is reached and a new value is persisted, the oldest (by timestamp) value will be removed. +A `maxEntries` value of `0` disables automatic purging. diff --git a/bundles/org.openhab.persistence.inmemory/pom.xml b/bundles/org.openhab.persistence.inmemory/pom.xml new file mode 100644 index 0000000000000..3df08eae2942f --- /dev/null +++ b/bundles/org.openhab.persistence.inmemory/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.0.0-SNAPSHOT + + + org.openhab.persistence.inmemory + + openHAB Add-ons :: Bundles :: Persistence Service :: InMemory + + diff --git a/bundles/org.openhab.persistence.inmemory/src/main/feature/feature.xml b/bundles/org.openhab.persistence.inmemory/src/main/feature/feature.xml new file mode 100644 index 0000000000000..6ec74479c09cf --- /dev/null +++ b/bundles/org.openhab.persistence.inmemory/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.persistence.inmemory/${project.version} + + + diff --git a/bundles/org.openhab.persistence.inmemory/src/main/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceService.java b/bundles/org.openhab.persistence.inmemory/src/main/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceService.java new file mode 100644 index 0000000000000..cffb2ce414b54 --- /dev/null +++ b/bundles/org.openhab.persistence.inmemory/src/main/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceService.java @@ -0,0 +1,311 @@ +/** + * Copyright (c) 2010-2023 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.inmemory.internal; + +import java.time.Instant; +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.core.ConfigParser; +import org.openhab.core.config.core.ConfigurableService; +import org.openhab.core.items.Item; +import org.openhab.core.persistence.FilterCriteria; +import org.openhab.core.persistence.HistoricItem; +import org.openhab.core.persistence.ModifiablePersistenceService; +import org.openhab.core.persistence.PersistenceItemInfo; +import org.openhab.core.persistence.PersistenceService; +import org.openhab.core.persistence.strategy.PersistenceStrategy; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Modified; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This is the implementation of the volatile {@link PersistenceService}. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@Component(service = { PersistenceService.class, + ModifiablePersistenceService.class }, configurationPid = "org.openhab.inmemory", // + property = Constants.SERVICE_PID + "=org.openhab.inmemory") +@ConfigurableService(category = "persistence", label = "InMemory Persistence Service", description_uri = InMemoryPersistenceService.CONFIG_URI) +public class InMemoryPersistenceService implements ModifiablePersistenceService { + + private static final String SERVICE_ID = "inmemory"; + private static final String SERVICE_LABEL = "In Memory"; + + protected static final String CONFIG_URI = "persistence:inmemory"; + private final String MAX_ENTRIES_CONFIG = "maxEntries"; + private final long MAX_ENTRIES_DEFAULT = 512; + + private final Logger logger = LoggerFactory.getLogger(InMemoryPersistenceService.class); + + private final Map persistMap = new ConcurrentHashMap<>(); + private long maxEntries = MAX_ENTRIES_DEFAULT; + + @Activate + public void activate(Map config) { + modified(config); + logger.debug("InMemory persistence service is now activated."); + } + + @Modified + public void modified(Map config) { + maxEntries = ConfigParser.valueAsOrElse(config.get(MAX_ENTRIES_CONFIG), Long.class, MAX_ENTRIES_DEFAULT); + + persistMap.values().forEach(persistItem -> { + Lock lock = persistItem.lock(); + lock.lock(); + try { + while (persistItem.database().size() > maxEntries) { + persistItem.database().pollFirst(); + } + } finally { + lock.unlock(); + } + }); + } + + @Deactivate + public void deactivate() { + logger.debug("InMemory persistence service deactivated."); + } + + @Override + public String getId() { + return SERVICE_ID; + } + + @Override + public String getLabel(@Nullable Locale locale) { + return SERVICE_LABEL; + } + + @Override + public Set getItemInfo() { + return persistMap.entrySet().stream().map(this::toItemInfo).collect(Collectors.toSet()); + } + + @Override + public void store(Item item) { + internalStore(item.getName(), ZonedDateTime.now(), item.getState()); + } + + @Override + public void store(Item item, @Nullable String alias) { + String finalName = Objects.requireNonNullElse(alias, item.getName()); + internalStore(finalName, ZonedDateTime.now(), item.getState()); + } + + @Override + public void store(Item item, ZonedDateTime date, State state) { + internalStore(item.getName(), date, state); + } + + @Override + public boolean remove(FilterCriteria filter) throws IllegalArgumentException { + String itemName = filter.getItemName(); + if (itemName == null) { + return false; + } + + PersistItem persistItem = persistMap.get(itemName); + if (persistItem == null) { + return false; + } + + Lock lock = persistItem.lock(); + lock.lock(); + try { + List toRemove = persistItem.database().stream().filter(e -> applies(e, filter)).toList(); + toRemove.forEach(persistItem.database()::remove); + } finally { + lock.unlock(); + } + return true; + } + + @Override + public Iterable query(FilterCriteria filter) { + String itemName = filter.getItemName(); + if (itemName == null) { + return List.of(); + } + + PersistItem persistItem = persistMap.get(itemName); + if (persistItem == null) { + return List.of(); + } + + Lock lock = persistItem.lock(); + lock.lock(); + try { + return persistItem.database().stream().filter(e -> applies(e, filter)).map(e -> toHistoricItem(itemName, e)) + .toList(); + } finally { + lock.unlock(); + } + } + + @Override + public List getDefaultStrategies() { + // persist nothing by default + return List.of(); + } + + private PersistenceItemInfo toItemInfo(Map.Entry itemEntry) { + Lock lock = itemEntry.getValue().lock(); + lock.lock(); + try { + String name = itemEntry.getKey(); + Integer count = itemEntry.getValue().database().size(); + Instant earliest = itemEntry.getValue().database().first().timestamp().toInstant(); + Instant latest = itemEntry.getValue().database.last().timestamp.toInstant(); + return new PersistenceItemInfo() { + + @Override + public String getName() { + return name; + } + + @Override + public @Nullable Integer getCount() { + return count; + } + + @Override + public @Nullable Date getEarliest() { + return Date.from(earliest); + } + + @Override + public @Nullable Date getLatest() { + return Date.from(latest); + } + }; + } finally { + lock.unlock(); + } + } + + private HistoricItem toHistoricItem(String itemName, PersistEntry entry) { + return new HistoricItem() { + @Override + public ZonedDateTime getTimestamp() { + return entry.timestamp(); + } + + @Override + public State getState() { + return entry.state(); + } + + @Override + public String getName() { + return itemName; + } + }; + } + + private void internalStore(String itemName, ZonedDateTime timestamp, State state) { + if (state instanceof UnDefType) { + return; + } + + PersistItem persistItem = Objects.requireNonNull(persistMap.computeIfAbsent(itemName, + k -> new PersistItem(new TreeSet<>(Comparator.comparing(PersistEntry::timestamp)), + new ReentrantLock()))); + + Lock lock = persistItem.lock(); + lock.lock(); + try { + persistItem.database().add(new PersistEntry(timestamp, state)); + + while (persistItem.database.size() > maxEntries) { + persistItem.database().pollFirst(); + } + } finally { + lock.unlock(); + } + } + + @SuppressWarnings({ "rawType", "unchecked" }) + private boolean applies(PersistEntry entry, FilterCriteria filter) { + ZonedDateTime beginDate = filter.getBeginDate(); + if (beginDate != null && entry.timestamp().isBefore(beginDate)) { + return false; + } + ZonedDateTime endDate = filter.getEndDate(); + if (endDate != null && entry.timestamp().isAfter(endDate)) { + return false; + } + + State refState = filter.getState(); + FilterCriteria.Operator operator = filter.getOperator(); + if (refState == null) { + // no state filter + return true; + } + + if (operator == FilterCriteria.Operator.EQ) { + return entry.state().equals(refState); + } + + if (operator == FilterCriteria.Operator.NEQ) { + return !entry.state().equals(refState); + } + + if (entry.state() instanceof Comparable comparableState && entry.state.getClass().equals(refState.getClass())) { + if (operator == FilterCriteria.Operator.GT) { + return comparableState.compareTo(refState) > 0; + } + if (operator == FilterCriteria.Operator.GTE) { + return comparableState.compareTo(refState) >= 0; + } + if (operator == FilterCriteria.Operator.LT) { + return comparableState.compareTo(refState) < 0; + } + if (operator == FilterCriteria.Operator.LTE) { + return comparableState.compareTo(refState) <= 0; + } + } else { + logger.warn("Using operator {} but state {} is not comparable!", operator, refState); + } + return true; + } + + private record PersistEntry(ZonedDateTime timestamp, State state) { + }; + + private record PersistItem(TreeSet database, Lock lock) { + }; +} diff --git a/bundles/org.openhab.persistence.inmemory/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.persistence.inmemory/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000000..a87035935e167 --- /dev/null +++ b/bundles/org.openhab.persistence.inmemory/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,21 @@ + + + + persistence + InMemory Persistence + A volatile persistence service to temporarily store data. + none + + org.openhab.inmemory + + + + + The maximum number of values stored for each item (0 = infinite). + 512 + + + + diff --git a/bundles/org.openhab.persistence.inmemory/src/test/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceTests.java b/bundles/org.openhab.persistence.inmemory/src/test/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceTests.java new file mode 100644 index 0000000000000..750e7f9b0a2d9 --- /dev/null +++ b/bundles/org.openhab.persistence.inmemory/src/test/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceTests.java @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2010-2023 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.inmemory.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.when; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.TreeSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.items.GenericItem; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.persistence.FilterCriteria; +import org.openhab.core.persistence.HistoricItem; +import org.openhab.core.types.State; + +/** + * The {@link InMemoryPersistenceTests} contains tests for the {@link InMemoryPersistenceService} + * + * @author Jan N. Klug - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class InMemoryPersistenceTests { + private static final String ITEM_NAME = "testItem"; + private static final String ALIAS = "alias"; + + private @NonNullByDefault({}) InMemoryPersistenceService service; + private @NonNullByDefault({}) @Mock GenericItem item; + + private @NonNullByDefault({}) FilterCriteria filterCriteria; + + @BeforeEach + public void setup() { + when(item.getName()).thenReturn(ITEM_NAME); + + filterCriteria = new FilterCriteria(); + filterCriteria.setItemName(ITEM_NAME); + + service = new InMemoryPersistenceService(); + } + + @Test + public void storeDirect() { + State state = new DecimalType(1); + when(item.getState()).thenReturn(state); + + ZonedDateTime expectedTime = ZonedDateTime.now(); + service.store(item); + + TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp)); + service.query(filterCriteria).forEach(storedStates::add); + + assertThat(storedStates, hasSize(1)); + assertThat(storedStates.first().getName(), is(ITEM_NAME)); + assertThat(storedStates.first().getState(), is(state)); + assertThat((double) storedStates.first().getTimestamp().toEpochSecond(), + is(closeTo(expectedTime.toEpochSecond(), 2))); + } + + @Test + public void storeAlias() { + State state = new PercentType(1); + when(item.getState()).thenReturn(state); + + ZonedDateTime expectedTime = ZonedDateTime.now(); + service.store(item, ALIAS); + + TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp)); + + // query with item name should return nothing + service.query(filterCriteria).forEach(storedStates::add); + assertThat(storedStates, is(empty())); + + filterCriteria.setItemName(ALIAS); + service.query(filterCriteria).forEach(storedStates::add); + + assertThat(storedStates.size(), is(1)); + assertThat(storedStates.first().getName(), is(ALIAS)); + assertThat(storedStates.first().getState(), is(state)); + assertThat((double) storedStates.first().getTimestamp().toEpochSecond(), + is(closeTo(expectedTime.toEpochSecond(), 2))); + } + + @Test + public void storeHistoric() { + State state = new HSBType("120,100,100"); + when(item.getState()).thenReturn(state); + + State historicState = new HSBType("40,50,50"); + ZonedDateTime expectedTime = ZonedDateTime.of(2022, 05, 31, 10, 0, 0, 0, ZoneId.systemDefault()); + service.store(item, expectedTime, historicState); + + TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp)); + service.query(filterCriteria).forEach(storedStates::add); + + assertThat(storedStates, hasSize(1)); + assertThat(storedStates.first().getName(), is(ITEM_NAME)); + assertThat(storedStates.first().getState(), is(historicState)); + assertThat(storedStates.first().getTimestamp(), is(expectedTime)); + } + + @Test + public void queryWithoutItemNameReturnsEmptyList() { + TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp)); + service.query(new FilterCriteria()).forEach(storedStates::add); + + assertThat(storedStates, is(empty())); + } + + @Test + public void queryUnknownItemReturnsEmptyList() { + TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp)); + service.query(filterCriteria).forEach(storedStates::add); + + assertThat(storedStates, is(empty())); + } + + @Test + public void removeBetweenTimes() { + State historicState1 = new StringType("value1"); + State historicState2 = new StringType("value2"); + State historicState3 = new StringType("value3"); + + ZonedDateTime expectedTime = ZonedDateTime.of(2022, 05, 31, 10, 0, 0, 0, ZoneId.systemDefault()); + service.store(item, expectedTime, historicState1); + service.store(item, expectedTime.plusHours(2), historicState2); + service.store(item, expectedTime.plusHours(4), historicState3); + + // ensure both are stored + TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp)); + service.query(filterCriteria).forEach(storedStates::add); + + assertThat(storedStates, hasSize(3)); + + filterCriteria.setBeginDate(expectedTime.plusHours(1)); + filterCriteria.setEndDate(expectedTime.plusHours(3)); + service.remove(filterCriteria); + + filterCriteria = new FilterCriteria(); + filterCriteria.setItemName(ITEM_NAME); + storedStates.clear(); + service.query(filterCriteria).forEach(storedStates::add); + + assertThat(storedStates, hasSize(2)); + + assertThat(storedStates.first().getName(), is(ITEM_NAME)); + assertThat(storedStates.first().getState(), is(historicState1)); + assertThat(storedStates.first().getTimestamp(), is(expectedTime)); + + assertThat(storedStates.last().getName(), is(ITEM_NAME)); + assertThat(storedStates.last().getState(), is(historicState3)); + assertThat(storedStates.last().getTimestamp(), is(expectedTime.plusHours(4))); + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index 4020bcde0491a..5fb1ccee88b30 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -423,6 +423,7 @@ org.openhab.persistence.dynamodb org.openhab.persistence.influxdb + org.openhab.persistence.inmemory org.openhab.persistence.jdbc org.openhab.persistence.jpa org.openhab.persistence.mapdb