Add an access-tracking cache to be used in rules (#2887)

Signed-off-by: Jan N. Klug <github@klug.nrw>
pull/3147/head
J-N-K 2022-12-02 09:47:00 +01:00 committed by GitHub
parent dc2f5d54f4
commit 028724a73f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 528 additions and 0 deletions

View File

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

View File

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

View File

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