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 <github@klug.nrw>
<artifactId>org.openhab.persistence.influxdb</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.persistence.inmemory</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.persistence.jdbc</artifactId>
--- /dev/null
+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
--- /dev/null
+# 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.
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>org.openhab.persistence.inmemory</artifactId>
+
+ <name>openHAB Add-ons :: Bundles :: Persistence Service :: InMemory</name>
+
+</project>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.persistence.inmemory-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+ <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+ <feature name="openhab-persistence-inmemory" description="InMemory Persistence" version="${project.version}">
+ <feature>openhab-runtime-base</feature>
+ <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.persistence.inmemory/${project.version}</bundle>
+ </feature>
+
+</features>
--- /dev/null
+/**
+ * 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<String, PersistItem> persistMap = new ConcurrentHashMap<>();
+ private long maxEntries = MAX_ENTRIES_DEFAULT;
+
+ @Activate
+ public void activate(Map<String, Object> config) {
+ modified(config);
+ logger.debug("InMemory persistence service is now activated.");
+ }
+
+ @Modified
+ public void modified(Map<String, Object> 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<PersistenceItemInfo> 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<PersistEntry> toRemove = persistItem.database().stream().filter(e -> applies(e, filter)).toList();
+ toRemove.forEach(persistItem.database()::remove);
+ } finally {
+ lock.unlock();
+ }
+ return true;
+ }
+
+ @Override
+ public Iterable<HistoricItem> 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<PersistenceStrategy> getDefaultStrategies() {
+ // persist nothing by default
+ return List.of();
+ }
+
+ private PersistenceItemInfo toItemInfo(Map.Entry<String, PersistItem> 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<PersistEntry> database, Lock lock) {
+ };
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="inmemory" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+ <type>persistence</type>
+ <name>InMemory Persistence</name>
+ <description>A volatile persistence service to temporarily store data.</description>
+ <connection>none</connection>
+
+ <service-id>org.openhab.inmemory</service-id>
+
+ <config-description>
+ <parameter name="maxEntries" type="integer" min="0">
+ <label>Maximum Entries</label>
+ <description>The maximum number of values stored for each item (0 = infinite).</description>
+ <default>512</default>
+ </parameter>
+ </config-description>
+
+</addon:addon>
--- /dev/null
+/**
+ * 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<HistoricItem> 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<HistoricItem> 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<HistoricItem> 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<HistoricItem> storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp));
+ service.query(new FilterCriteria()).forEach(storedStates::add);
+
+ assertThat(storedStates, is(empty()));
+ }
+
+ @Test
+ public void queryUnknownItemReturnsEmptyList() {
+ TreeSet<HistoricItem> 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<HistoricItem> 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)));
+ }
+}
<!-- persistence -->
<module>org.openhab.persistence.dynamodb</module>
<module>org.openhab.persistence.influxdb</module>
+ <module>org.openhab.persistence.inmemory</module>
<module>org.openhab.persistence.jdbc</module>
<module>org.openhab.persistence.jpa</module>
<module>org.openhab.persistence.mapdb</module>