Add an access-tracking cache to be used in rules (#2887)
Signed-off-by: Jan N. Klug <github@klug.nrw>pull/3147/head
parent
dc2f5d54f4
commit
028724a73f
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Copyright (c) 2010-2022 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.automation.module.script.rulesupport.internal;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.Timer;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.automation.module.script.ScriptExtensionProvider;
|
||||
import org.openhab.core.automation.module.script.rulesupport.shared.ValueCache;
|
||||
import org.openhab.core.common.ThreadPoolManager;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link CacheScriptExtension} extends scripts to use a cache shared between rules or subsequent runs of the same
|
||||
* rule
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@Component(immediate = true)
|
||||
@NonNullByDefault
|
||||
public class CacheScriptExtension implements ScriptExtensionProvider {
|
||||
static final String PRESET_NAME = "cache";
|
||||
static final String SHARED_CACHE_NAME = "sharedCache";
|
||||
static final String PRIVATE_CACHE_NAME = "privateCache";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(CacheScriptExtension.class);
|
||||
private final ScheduledExecutorService scheduler = ThreadPoolManager
|
||||
.getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
|
||||
|
||||
private final Lock cacheLock = new ReentrantLock();
|
||||
private final Map<String, Object> sharedCache = new HashMap<>();
|
||||
private final Map<String, Set<String>> sharedCacheKeyAccessors = new ConcurrentHashMap<>();
|
||||
|
||||
private final Map<String, ValueCacheImpl> privateCaches = new ConcurrentHashMap<>();
|
||||
|
||||
public CacheScriptExtension() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> getDefaultPresets() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> getPresets() {
|
||||
return Set.of(PRESET_NAME);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> getTypes() {
|
||||
return Set.of(PRIVATE_CACHE_NAME, SHARED_CACHE_NAME);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object get(String scriptIdentifier, String type) throws IllegalArgumentException {
|
||||
if (SHARED_CACHE_NAME.equals(type)) {
|
||||
return new TrackingValueCacheImpl(scriptIdentifier);
|
||||
} else if (PRIVATE_CACHE_NAME.equals(type)) {
|
||||
return privateCaches.computeIfAbsent(scriptIdentifier, ValueCacheImpl::new);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> importPreset(String scriptIdentifier, String preset) {
|
||||
if (PRESET_NAME.equals(preset)) {
|
||||
Object privateCache = Objects
|
||||
.requireNonNull(privateCaches.computeIfAbsent(scriptIdentifier, ValueCacheImpl::new));
|
||||
return Map.of(SHARED_CACHE_NAME, new TrackingValueCacheImpl(scriptIdentifier), PRIVATE_CACHE_NAME,
|
||||
privateCache);
|
||||
}
|
||||
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unload(String scriptIdentifier) {
|
||||
cacheLock.lock();
|
||||
try {
|
||||
// remove the scriptIdentifier from cache-key access list
|
||||
sharedCacheKeyAccessors.values().forEach(cacheKey -> cacheKey.remove(scriptIdentifier));
|
||||
// remove the key from access list and cache if no accessor left
|
||||
Iterator<Map.Entry<String, Set<String>>> it = sharedCacheKeyAccessors.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<String, Set<String>> element = it.next();
|
||||
if (element.getValue().isEmpty()) {
|
||||
// accessor list is empty
|
||||
it.remove();
|
||||
// remove from cache and cancel ScheduledFutures or Timer tasks
|
||||
asyncCancelJob(sharedCache.remove(element.getKey()));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cacheLock.unlock();
|
||||
}
|
||||
|
||||
// remove private cache
|
||||
ValueCacheImpl privateCache = privateCaches.remove(scriptIdentifier);
|
||||
if (privateCache != null) {
|
||||
// cancel ScheduledFutures or Timer tasks
|
||||
privateCache.values().forEach(this::asyncCancelJob);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if object is {@link ScheduledFuture} or {@link Timer} and schedule cancellation of those jobs
|
||||
*
|
||||
* @param o the {@link Object} to check
|
||||
*/
|
||||
private void asyncCancelJob(@Nullable Object o) {
|
||||
if (o instanceof ScheduledFuture) {
|
||||
scheduler.execute(() -> ((ScheduledFuture<?>) o).cancel(true));
|
||||
} else if (o instanceof Timer) {
|
||||
// not using execute so ensure this operates in another thread and we don't block here
|
||||
scheduler.schedule(() -> ((Timer) o).cancel(), 0, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private static class ValueCacheImpl implements ValueCache {
|
||||
private final Map<String, Object> cache = new HashMap<>();
|
||||
|
||||
public ValueCacheImpl(String scriptIdentifier) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object put(String key, Object value) {
|
||||
return cache.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object remove(String key) {
|
||||
return cache.remove(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object get(String key) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get(String key, Supplier<Object> supplier) {
|
||||
return Objects.requireNonNull(cache.computeIfAbsent(key, k -> supplier.get()));
|
||||
}
|
||||
|
||||
private Collection<Object> values() {
|
||||
return cache.values();
|
||||
}
|
||||
}
|
||||
|
||||
private class TrackingValueCacheImpl implements ValueCache {
|
||||
private final String scriptIdentifier;
|
||||
|
||||
public TrackingValueCacheImpl(String scriptIdentifier) {
|
||||
this.scriptIdentifier = scriptIdentifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object put(String key, Object value) {
|
||||
cacheLock.lock();
|
||||
try {
|
||||
rememberAccessToKey(key);
|
||||
Object oldValue = sharedCache.put(key, value);
|
||||
logger.trace("PUT to cache from '{}': '{}' -> '{}' (was: '{}')", scriptIdentifier, key, value,
|
||||
oldValue);
|
||||
return oldValue;
|
||||
} finally {
|
||||
cacheLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object remove(String key) {
|
||||
cacheLock.lock();
|
||||
try {
|
||||
sharedCacheKeyAccessors.remove(key);
|
||||
Object oldValue = sharedCache.remove(key);
|
||||
|
||||
logger.trace("REMOVE from cache from '{}': '{}' -> '{}'", scriptIdentifier, key, oldValue);
|
||||
return oldValue;
|
||||
} finally {
|
||||
cacheLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object get(String key) {
|
||||
cacheLock.lock();
|
||||
try {
|
||||
rememberAccessToKey(key);
|
||||
Object value = sharedCache.get(key);
|
||||
|
||||
logger.trace("GET to cache from '{}': '{}' -> '{}'", scriptIdentifier, key, value);
|
||||
return value;
|
||||
} finally {
|
||||
cacheLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get(String key, Supplier<Object> supplier) {
|
||||
cacheLock.lock();
|
||||
try {
|
||||
rememberAccessToKey(key);
|
||||
Object value = Objects.requireNonNull(sharedCache.computeIfAbsent(key, k -> supplier.get()));
|
||||
|
||||
logger.trace("GET with supplier to cache from '{}': '{}' -> '{}'", scriptIdentifier, key, value);
|
||||
return value;
|
||||
} finally {
|
||||
cacheLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberAccessToKey(String key) {
|
||||
Objects.requireNonNull(sharedCacheKeyAccessors.computeIfAbsent(key, k -> new HashSet<>()))
|
||||
.add(scriptIdentifier);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Copyright (c) 2010-2022 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.automation.module.script.rulesupport.shared;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* The {@link ValueCache} can be used by scripts to share information between subsequent runs of the same script or
|
||||
* between scripts (depending on implementation).
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface ValueCache {
|
||||
|
||||
/**
|
||||
* Add a new key-value-pair to the cache. If the key is already present, the old value is replaces by the new value.
|
||||
*
|
||||
* @param key a string used as key
|
||||
* @param value an {@code Object} to store with the key
|
||||
* @return the old value associated with this key or {@code null} if key didn't exist
|
||||
*/
|
||||
@Nullable
|
||||
Object put(String key, Object value);
|
||||
|
||||
/**
|
||||
* Remove a key (and its associated value) from the cache
|
||||
*
|
||||
* @param key the key to remove
|
||||
* @return the previously associated value to this key or {@code null} if key not present
|
||||
*/
|
||||
@Nullable
|
||||
Object remove(String key);
|
||||
|
||||
/**
|
||||
* Get a value from the cache
|
||||
*
|
||||
* @param key the key of the requested value
|
||||
* @return the value associated with the key or {@code null} if key not present
|
||||
*/
|
||||
@Nullable
|
||||
Object get(String key);
|
||||
|
||||
/**
|
||||
* Get a value from the cache or create a new key-value-pair from the given supplier
|
||||
*
|
||||
* @param key the key of the requested value
|
||||
* @param supplier a supplier that returns a non-null value to be used if the key was not present
|
||||
* @return the value associated with the key
|
||||
*/
|
||||
Object get(String key, Supplier<Object> supplier);
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Copyright (c) 2010-2022 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.automation.module.script.rulesupport.internal;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.hamcrest.Matchers.sameInstance;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Timer;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.core.automation.module.script.rulesupport.shared.ValueCache;
|
||||
|
||||
/**
|
||||
* The {@link CacheScriptExtensionTest} contains tests for {@link CacheScriptExtension}
|
||||
*
|
||||
* @author Jan N. Klug - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CacheScriptExtensionTest {
|
||||
private static final String SCRIPT1 = "script1";
|
||||
private static final String SCRIPT2 = "script2";
|
||||
|
||||
private static final String KEY1 = "key1";
|
||||
private static final String KEY2 = "key2";
|
||||
|
||||
private static final String VALUE1 = "value1";
|
||||
private static final String VALUE2 = "value2";
|
||||
|
||||
@Test
|
||||
public void sharedCacheBasicFunction() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache = getCache(se, SCRIPT1, CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
|
||||
testCacheBasicFunctions(cache);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void privateCacheBasicFunction() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache = getCache(se, SCRIPT1, CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
|
||||
testCacheBasicFunctions(cache);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedCacheIsSharedBetweenTwoRuns() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache1 = getCache(se, SCRIPT1, CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
Objects.requireNonNull(cache1);
|
||||
|
||||
cache1.put(KEY1, VALUE1);
|
||||
assertThat(cache1.get(KEY1), is(VALUE1));
|
||||
|
||||
ValueCache cache2 = getCache(se, SCRIPT1, CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
assertThat(cache2, not(sameInstance(cache1)));
|
||||
|
||||
assertThat(cache2.get(KEY1), is(VALUE1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedCacheIsClearedIfScriptUnloaded() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache1 = getCache(se, SCRIPT1, CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
|
||||
cache1.put(KEY1, VALUE1);
|
||||
assertThat(cache1.get(KEY1), is(VALUE1));
|
||||
|
||||
se.unload(SCRIPT1);
|
||||
|
||||
ValueCache cache1new = getCache(se, SCRIPT2, CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
assertThat(cache1new.get(KEY1), nullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedCachesIsSharedBetweenTwoScripts() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache1 = getCache(se, SCRIPT1, CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
ValueCache cache2 = getCache(se, SCRIPT2, CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
|
||||
assertThat(cache1, not(is(cache2)));
|
||||
|
||||
cache1.put(KEY1, VALUE1);
|
||||
assertThat(cache1.get(KEY1), is(VALUE1));
|
||||
assertThat(cache2.get(KEY1), is(VALUE1));
|
||||
|
||||
cache2.remove(KEY1);
|
||||
assertThat(cache2.get(KEY1), nullValue());
|
||||
assertThat(cache1.get(KEY1), nullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void privateCacheIsSharedBetweenTwoRuns() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache1 = getCache(se, SCRIPT1, CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
|
||||
cache1.put(KEY1, VALUE1);
|
||||
assertThat(cache1.get(KEY1), is(VALUE1));
|
||||
|
||||
ValueCache cache2 = getCache(se, SCRIPT1, CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
assertThat(cache2, sameInstance(cache1));
|
||||
|
||||
assertThat(cache2.get(KEY1), is(VALUE1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void privateCacheIsClearedIfScriptUnloaded() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache1 = getCache(se, SCRIPT1, CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
|
||||
cache1.put(KEY1, VALUE1);
|
||||
assertThat(cache1.get(KEY1), is(VALUE1));
|
||||
|
||||
se.unload(SCRIPT1);
|
||||
|
||||
ValueCache cache1new = getCache(se, SCRIPT2, CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
assertThat(cache1new, not(sameInstance(cache1)));
|
||||
assertThat(cache1new.get(KEY1), nullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void privateCachesIsNotSharedBetweenTwoScripts() {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache1 = getCache(se, SCRIPT1, CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
ValueCache cache2 = getCache(se, SCRIPT2, CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
|
||||
assertThat(cache1, not(is(cache2)));
|
||||
|
||||
cache1.put(KEY1, VALUE1);
|
||||
assertThat(cache1.get(KEY1), is(VALUE1));
|
||||
assertThat(cache2.get(KEY1), nullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void jobsInSharedCacheAreCancelledOnUnload() {
|
||||
testJobCancellation(CacheScriptExtension.SHARED_CACHE_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void jobsInPrivateCacheAreCancelledOnUnload() {
|
||||
testJobCancellation(CacheScriptExtension.PRIVATE_CACHE_NAME);
|
||||
}
|
||||
|
||||
public void testJobCancellation(String cacheType) {
|
||||
CacheScriptExtension se = new CacheScriptExtension();
|
||||
ValueCache cache = getCache(se, SCRIPT1, cacheType);
|
||||
|
||||
Timer timerMock = mock(Timer.class);
|
||||
ScheduledFuture<?> futureMock = mock(ScheduledFuture.class);
|
||||
|
||||
cache.put(KEY1, timerMock);
|
||||
cache.put(KEY2, futureMock);
|
||||
|
||||
// ensure jobs are not cancelled on removal
|
||||
cache.remove(KEY1);
|
||||
cache.remove(KEY2);
|
||||
verifyNoMoreInteractions(timerMock, futureMock);
|
||||
|
||||
cache.put(KEY1, timerMock);
|
||||
cache.put(KEY2, futureMock);
|
||||
se.unload(SCRIPT1);
|
||||
verify(timerMock, timeout(1000)).cancel();
|
||||
verify(futureMock, timeout(1000)).cancel(true);
|
||||
}
|
||||
|
||||
public void testCacheBasicFunctions(ValueCache cache) {
|
||||
// cache is initially empty
|
||||
assertThat(cache.get(KEY1), nullValue());
|
||||
|
||||
// return value is null if no value before and new value can be retrieved
|
||||
assertThat(cache.put(KEY1, VALUE1), nullValue());
|
||||
assertThat(cache.get(KEY1), is(VALUE1));
|
||||
|
||||
// value returns old value on update and updated value can be retrieved
|
||||
assertThat(cache.put(KEY1, VALUE2), is(VALUE1));
|
||||
assertThat(cache.get(KEY1), is(VALUE2));
|
||||
|
||||
// old value is returned on removal and cache empty afterwards
|
||||
assertThat(cache.remove(KEY1), is(VALUE2));
|
||||
assertThat(cache.get(KEY1), nullValue());
|
||||
|
||||
// new value is inserted from supplier
|
||||
assertThat(cache.get(KEY1, () -> VALUE1), is(VALUE1));
|
||||
assertThat(cache.get(KEY1), is(VALUE1));
|
||||
|
||||
// different keys return different values
|
||||
cache.put(KEY2, VALUE2);
|
||||
assertThat(cache.get(KEY1), is(VALUE1));
|
||||
assertThat(cache.get(KEY2), is(VALUE2));
|
||||
}
|
||||
|
||||
private ValueCache getCache(CacheScriptExtension se, String scriptIdentifier, String type) {
|
||||
ValueCache cache = (ValueCache) se.get(scriptIdentifier, type);
|
||||
Objects.requireNonNull(cache);
|
||||
|
||||
return cache;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue