Skip to content

Commit

Permalink
restore previous state and last state change date on startup
Browse files Browse the repository at this point in the history
Signed-off-by: Mark Herwege <[email protected]>
  • Loading branch information
mherwege committed Nov 29, 2024
1 parent 41cb337 commit d460de5
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Copyright (c) 2010-2024 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.core.persistence;

import java.time.Instant;
import java.time.ZonedDateTime;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.types.State;

/**
* This interface is used by persistence services to represent the full persisted state of an item, including the
* previous state, and last update and change timestamps.
* It can be used in restoring the full state of an item.
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public interface PersistedItem extends HistoricItem {

/**
* returns the timestamp of the last state change of the persisted item
*
* @return the timestamp of the last state change of the item
*/
@Nullable
ZonedDateTime getLastStateChange();

/**
* returns the timestamp of the last state change of the persisted item
*
* @return the timestamp of the last state change of the item
*/
@Nullable
default Instant getLastStateChangeInstant() {
ZonedDateTime lastStateChange = getLastStateChange();
return lastStateChange != null ? lastStateChange.toInstant() : null;
}

/**
* returns the last state of the item
*
* @return the last state
*/
@Nullable
State getLastState();
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,23 @@
*/
package org.openhab.core.persistence;

import java.time.ZonedDateTime;
import java.util.Iterator;
import java.util.Set;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.persistence.FilterCriteria.Ordering;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;

/**
* A queryable persistence service which can be used to store and retrieve
* data from openHAB. This is most likely some kind of database system.
*
* @author Kai Kreuzer - Initial contribution
* @author Chris Jackson - Added getItems method
* @author Mark Herwege - Added methods to retrieve lastUpdate, lastChange and lastState from persistence
*/
@NonNullByDefault
public interface QueryablePersistenceService extends PersistenceService {
Expand All @@ -43,4 +50,90 @@ public interface QueryablePersistenceService extends PersistenceService {
* @return a set of information about the persisted items
*/
Set<PersistenceItemInfo> getItemInfo();

/**
* Returns a {@link PersistedItem} representing the persisted state, last update and change timestamps and previous
* persisted state. This can be used to restore the full state of an item.
* The default implementation queries the service and iterates backward to find the last change and previous
* persisted state. Persistence services can override this default implementation with a more specific or efficient
* algorithm.
*
* @return a {@link PersistedItem} or null if the item has not been persisted
*/
default @Nullable PersistedItem persistedItem(String itemName) {
State currentState = UnDefType.NULL;
State previousState = null;
ZonedDateTime lastUpdate = null;
ZonedDateTime lastChange = null;

int pageNumber = 0;
FilterCriteria filter = new FilterCriteria().setItemName(itemName).setEndDate(ZonedDateTime.now())
.setOrdering(Ordering.DESCENDING).setPageSize(1000).setPageNumber(pageNumber);
Iterable<HistoricItem> items = query(filter);
while (items != null) {
Iterator<HistoricItem> it = items.iterator();
int itemCount = 0;
if (UnDefType.NULL.equals(currentState) && it.hasNext()) {
HistoricItem historicItem = it.next();
itemCount++;
currentState = historicItem.getState();
lastUpdate = historicItem.getTimestamp();
lastChange = lastUpdate;
}
while (it.hasNext()) {
HistoricItem historicItem = it.next();
itemCount++;
if (!historicItem.getState().equals(currentState)) {
previousState = historicItem.getState();
items = null;
break;
}
lastChange = historicItem.getTimestamp();
}
if (itemCount == filter.getPageSize()) {
filter.setPageNumber(++pageNumber);
items = query(filter);
} else {
items = null;
}
}

if (UnDefType.NULL.equals(currentState) || lastUpdate == null) {
return null;
}

final State state = currentState;
final ZonedDateTime lastStateUpdate = lastUpdate;
final State lastState = previousState;
// if we don't find a previous state in persistence, we also don't know when it last changed
final ZonedDateTime lastStateChange = previousState != null ? lastChange : null;

return new PersistedItem() {

@Override
public ZonedDateTime getTimestamp() {
return lastStateUpdate;
}

@Override
public State getState() {
return state;
}

@Override
public String getName() {
return itemName;
}

@Override
public @Nullable ZonedDateTime getLastStateChange() {
return lastStateChange;
}

@Override
public @Nullable State getLastState() {
return lastState;
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
package org.openhab.core.persistence.internal;

import static org.openhab.core.persistence.FilterCriteria.Ordering.ASCENDING;
import static org.openhab.core.persistence.strategy.PersistenceStrategy.Globals.FORECAST;
import static org.openhab.core.persistence.strategy.PersistenceStrategy.Globals.RESTORE;
import static org.openhab.core.persistence.strategy.PersistenceStrategy.Globals.UPDATE;
import static org.openhab.core.persistence.strategy.PersistenceStrategy.Globals.*;

import java.time.Instant;
import java.time.ZoneId;
Expand Down Expand Up @@ -50,6 +48,7 @@
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.HistoricItem;
import org.openhab.core.persistence.ModifiablePersistenceService;
import org.openhab.core.persistence.PersistedItem;
import org.openhab.core.persistence.PersistenceItemConfiguration;
import org.openhab.core.persistence.PersistenceManager;
import org.openhab.core.persistence.PersistenceService;
Expand Down Expand Up @@ -90,6 +89,7 @@
* @author Markus Rathgeb - Separation of persistence core and model, drop Quartz usage.
* @author Jan N. Klug - Refactored to use service configuration registry
* @author Jan N. Klug - Added time series support
* @author Mark Herwege - Added restoring lastState, lastStateChange and lastStateUpdate
*/
@Component(immediate = true, service = PersistenceManager.class)
@NonNullByDefault
Expand Down Expand Up @@ -521,36 +521,30 @@ public void removeItem(String itemName) {
private void restoreItemStateIfPossible(Item item) {
QueryablePersistenceService queryService = (QueryablePersistenceService) persistenceService;

FilterCriteria filter = new FilterCriteria().setItemName(item.getName()).setEndDate(ZonedDateTime.now())
.setPageSize(1);
Iterable<HistoricItem> result = safeCaller.create(queryService, QueryablePersistenceService.class)
PersistedItem persistedItem = safeCaller.create(queryService, QueryablePersistenceService.class)
.onTimeout(
() -> logger.warn("Querying persistence service '{}' to restore '{}' takes more than {}ms.",
queryService.getId(), item.getName(), SafeCaller.DEFAULT_TIMEOUT))
.onException(e -> logger.error(
"Exception occurred while querying persistence service '{}' to restore '{}': {}",
queryService.getId(), item.getName(), e.getMessage(), e))
.build().query(filter);
if (result == null) {
// in case of an exception or timeout, the safe caller returns null
.build().persistedItem(item.getName());
if (persistedItem == null) {
return;
}
Iterator<HistoricItem> it = result.iterator();
if (it.hasNext()) {
HistoricItem historicItem = it.next();
GenericItem genericItem = (GenericItem) item;
if (!UnDefType.NULL.equals(item.getState())) {
// someone else already restored the state or a new state was set
return;
}
genericItem.removeStateChangeListener(PersistenceManagerImpl.this);
genericItem.setState(historicItem.getState());
genericItem.addStateChangeListener(PersistenceManagerImpl.this);
if (logger.isDebugEnabled()) {
logger.debug("Restored item state from '{}' for item '{}' -> '{}'",
DateTimeFormatter.ISO_ZONED_DATE_TIME.format(historicItem.getTimestamp()), item.getName(),
historicItem.getState());
}
GenericItem genericItem = (GenericItem) item;
if (!UnDefType.NULL.equals(item.getState())) {
// someone else already restored the state or a new state was set
return;
}
genericItem.removeStateChangeListener(PersistenceManagerImpl.this);
genericItem.setState(persistedItem.getState(), persistedItem.getLastState(), persistedItem.getTimestamp(),
persistedItem.getLastStateChange());
genericItem.addStateChangeListener(PersistenceManagerImpl.this);
if (logger.isDebugEnabled()) {
logger.debug("Restored item state from '{}' for item '{}' -> '{}'",
DateTimeFormatter.ISO_ZONED_DATE_TIME.format(persistedItem.getTimestamp()), item.getName(),
persistedItem.getState());
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

Expand Down Expand Up @@ -54,6 +55,7 @@
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.HistoricItem;
import org.openhab.core.persistence.ModifiablePersistenceService;
import org.openhab.core.persistence.PersistedItem;
import org.openhab.core.persistence.PersistenceItemConfiguration;
import org.openhab.core.persistence.PersistenceItemInfo;
import org.openhab.core.persistence.PersistenceService;
Expand Down Expand Up @@ -98,6 +100,7 @@ public class PersistenceManagerTest {
private static final GroupItem TEST_GROUP_ITEM = new GroupItem(TEST_GROUP_ITEM_NAME);

private static final State TEST_STATE = new StringType("testState1");
private static final State TEST_LAST_STATE = new StringType("testState2");

private static final HistoricItem TEST_HISTORIC_ITEM = new HistoricItem() {
@Override
Expand All @@ -116,6 +119,33 @@ public String getName() {
}
};

private static final PersistedItem TEST_PERSISTED_ITEM = new PersistedItem() {
@Override
public ZonedDateTime getTimestamp() {
return ZonedDateTime.now().minusDays(1);
}

@Override
public State getState() {
return TEST_STATE;
}

@Override
public String getName() {
return TEST_ITEM_NAME;
}

@Override
public @Nullable ZonedDateTime getLastStateChange() {
return ZonedDateTime.now().minusDays(2);
}

@Override
public @Nullable State getLastState() {
return TEST_LAST_STATE;
}
};

private static final String TEST_PERSISTENCE_SERVICE_ID = "testPersistenceService";
private static final String TEST_QUERYABLE_PERSISTENCE_SERVICE_ID = "testQueryablePersistenceService";

Expand Down Expand Up @@ -154,6 +184,7 @@ public void setUp() throws ItemNotFoundException {
when(persistenceServiceMock.getId()).thenReturn(TEST_PERSISTENCE_SERVICE_ID);
when(queryablePersistenceServiceMock.getId()).thenReturn(TEST_QUERYABLE_PERSISTENCE_SERVICE_ID);
when(queryablePersistenceServiceMock.query(any())).thenReturn(List.of(TEST_HISTORIC_ITEM));
when(queryablePersistenceServiceMock.persistedItem(any())).thenReturn(TEST_PERSISTED_ITEM);
when(modifiablePersistenceServiceMock.getId()).thenReturn(TEST_MODIFIABLE_PERSISTENCE_SERVICE_ID);

manager = new PersistenceManagerImpl(cronSchedulerMock, schedulerMock, itemRegistryMock, safeCallerMock,
Expand Down Expand Up @@ -285,11 +316,21 @@ public void restoreOnStartupWhenItemNull() {
manager.onReadyMarkerAdded(new ReadyMarker("", ""));
verify(readyServiceMock, timeout(1000)).markReady(any());

assertThat(TEST_ITEM2.getState(), is(TEST_STATE));
assertThat(TEST_ITEM.getState(), is(TEST_STATE));
assertThat(TEST_ITEM.getLastState(), is(TEST_LAST_STATE));
assertThat(TEST_ITEM2.getState(), is(TEST_STATE));
assertThat(TEST_ITEM2.getLastState(), is(TEST_LAST_STATE));
assertThat(TEST_GROUP_ITEM.getState(), is(TEST_STATE));
assertThat(TEST_GROUP_ITEM.getLastState(), is(TEST_LAST_STATE));

verify(queryablePersistenceServiceMock, times(3)).persistedItem(any());

verify(queryablePersistenceServiceMock, times(3)).query(any());
ZonedDateTime lastStateUpdate = TEST_ITEM.getLastStateUpdate();
assertNotNull(lastStateUpdate);
assertTrue(lastStateUpdate.isAfter(ZonedDateTime.now().minusDays(2)));
ZonedDateTime lastStateChange = TEST_ITEM.getLastStateChange();
assertNotNull(lastStateChange);
assertTrue(lastStateChange.isBefore(ZonedDateTime.now().minusDays(2)));

verifyNoMoreInteractions(queryablePersistenceServiceMock);
verifyNoMoreInteractions(persistenceServiceMock);
Expand All @@ -307,10 +348,20 @@ public void noRestoreOnStartupWhenItemNotNull() {
verify(readyServiceMock, timeout(1000)).markReady(any());

assertThat(TEST_ITEM.getState(), is(initialValue));
assertThat(TEST_ITEM.getLastState(), is(UnDefType.NULL));
assertThat(TEST_ITEM2.getState(), is(TEST_STATE));
assertThat(TEST_ITEM2.getLastState(), is(TEST_LAST_STATE));
assertThat(TEST_GROUP_ITEM.getState(), is(TEST_STATE));
assertThat(TEST_GROUP_ITEM.getLastState(), is(TEST_LAST_STATE));

verify(queryablePersistenceServiceMock, times(2)).persistedItem(any());

verify(queryablePersistenceServiceMock, times(2)).query(any());
ZonedDateTime lastStateUpdate = TEST_ITEM.getLastStateUpdate();
assertNotNull(lastStateUpdate);
assertTrue(lastStateUpdate.isAfter(ZonedDateTime.now().minusDays(1)));
ZonedDateTime lastStateChange = TEST_ITEM.getLastStateChange();
assertNotNull(lastStateChange);
assertTrue(lastStateChange.isAfter(ZonedDateTime.now().minusDays(1)));

verifyNoMoreInteractions(queryablePersistenceServiceMock);
verifyNoMoreInteractions(persistenceServiceMock);
Expand Down
Loading

0 comments on commit d460de5

Please sign in to comment.