[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 <github@klug.nrw>
This commit is contained in:
J-N-K 2023-06-24 11:15:09 +02:00 committed by GitHub
parent 8a67d0ad94
commit 0b6bdad557
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 575 additions and 0 deletions

View File

@ -1961,6 +1961,11 @@
<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>

View File

@ -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

View File

@ -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.

View File

@ -0,0 +1,17 @@
<?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>

View File

@ -0,0 +1,10 @@
<?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>

View File

@ -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<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) {
};
}

View File

@ -0,0 +1,21 @@
<?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>

View File

@ -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<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)));
}
}

View File

@ -423,6 +423,7 @@
<!-- 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>