
org.kiwiproject.consul.cache.ConsulCache Maven / Gradle / Ivy
package org.kiwiproject.consul.cache;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Stopwatch;
import com.google.common.collect.ImmutableMap;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import org.kiwiproject.consul.async.ConsulResponseCallback;
import org.kiwiproject.consul.config.CacheConfig;
import org.kiwiproject.consul.model.ConsulResponse;
import org.kiwiproject.consul.monitoring.ClientEventHandler;
import org.kiwiproject.consul.option.ImmutableQueryOptions;
import org.kiwiproject.consul.option.QueryOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
/**
* A cache structure that can provide an up-to-date read-only
* map backed by consul data
*
* @param the type of keys this cache contains
* @param the type of values this cache contains
*/
public class ConsulCache implements AutoCloseable {
/**
* Represents the possible states of a ConsulCache.
*/
public enum State {
LATENT, STARTING, STARTED, STOPPED
}
private static final Logger LOG = LoggerFactory.getLogger(ConsulCache.class);
private final AtomicReference latestIndex = new AtomicReference<>(null);
private final AtomicLong lastContact = new AtomicLong();
private final AtomicBoolean isKnownLeader = new AtomicBoolean();
private final AtomicReference lastCacheInfo = new AtomicReference<>(null);
private final AtomicReference> lastResponse = new AtomicReference<>(null);
private final AtomicReference state = new AtomicReference<>(State.LATENT);
private final CountDownLatch initLatch = new CountDownLatch(1);
private final Scheduler scheduler;
private final CopyOnWriteArrayList> listeners = new CopyOnWriteArrayList<>();
private final ReentrantLock listenersStartingLock = new ReentrantLock();
private final Stopwatch stopWatch = Stopwatch.createUnstarted();
private final Function keyConversion;
private final CallbackConsumer callBackConsumer;
private final ConsulResponseCallback> responseCallback;
private final ClientEventHandler eventHandler;
private final CacheDescriptor cacheDescriptor;
protected ConsulCache(
Function keyConversion,
CallbackConsumer callbackConsumer,
CacheConfig cacheConfig,
ClientEventHandler eventHandler,
CacheDescriptor cacheDescriptor) {
this(keyConversion, callbackConsumer, cacheConfig, eventHandler, cacheDescriptor, createDefault());
}
protected ConsulCache(
Function keyConversion,
CallbackConsumer callbackConsumer,
CacheConfig cacheConfig,
ClientEventHandler eventHandler,
CacheDescriptor cacheDescriptor,
ScheduledExecutorService callbackScheduleExecutorService) {
this(keyConversion, callbackConsumer, cacheConfig, eventHandler, cacheDescriptor, new ExternalScheduler(callbackScheduleExecutorService));
}
protected ConsulCache(
Function keyConversion,
CallbackConsumer callbackConsumer,
CacheConfig cacheConfig,
ClientEventHandler eventHandler,
CacheDescriptor cacheDescriptor,
Scheduler callbackScheduler) {
checkArgument(nonNull(keyConversion), "keyConversion must not be null");
checkArgument(nonNull(callbackConsumer), "callbackConsumer must not be null");
checkArgument(nonNull(cacheConfig), "cacheConfig must not be null");
checkArgument(nonNull(eventHandler), "eventHandler must not be null");
checkArgument(nonNull(cacheDescriptor), "cacheDescriptor must not be null");
checkArgument(nonNull(callbackScheduler), "callbackScheduler must not be null");
this.keyConversion = keyConversion;
this.callBackConsumer = callbackConsumer;
this.eventHandler = eventHandler;
this.cacheDescriptor = cacheDescriptor;
this.scheduler = callbackScheduler;
this.responseCallback = new DefaultConsulResponseCallback(cacheConfig);
}
/**
* @implNote This was extracted from an anonymous class declaration into a separate class mainly
* for organization and (somewhat) better readability. Several small methods were also extracted
* such as notifyListeners, and the updateIndex method was moved into this class since it is
* only used here. It cannot be static because it uses instance fields from ConsulCache directly.
* It might be possible to make it static if we pass in the required fields to the constructor, since
* they are accessed only via their methods and are not reassigned.
*/
class DefaultConsulResponseCallback implements ConsulResponseCallback> {
private final CacheConfig cacheConfig;
public DefaultConsulResponseCallback(CacheConfig cacheConfig) {
this.cacheConfig = requireNonNull(cacheConfig);
}
@Override
public void onComplete(ConsulResponse> consulResponse) {
if (isNotRunning()) {
return;
}
long elapsedTime = stopWatch.elapsed(TimeUnit.MILLISECONDS);
updateIndex(consulResponse);
LOG.debug("Consul cache updated for {} (index={}), request duration: {} ms",
cacheDescriptor, latestIndex, elapsedTime);
ImmutableMap full = convertToMap(consulResponse);
boolean changed = !full.equals(lastResponse.get());
eventHandler.cachePollingSuccess(cacheDescriptor, changed, elapsedTime);
if (changed) {
// changes
lastResponse.set(full);
// metadata changes
lastContact.set(consulResponse.getLastContact());
isKnownLeader.set(consulResponse.isKnownLeader());
performListenerActionOptionallyLocking(() -> notifyListeners(full));
}
if (state.compareAndSet(State.STARTING, State.STARTED)) {
initLatch.countDown();
}
Duration timeToWait = cacheConfig.getMinimumDurationBetweenRequests();
Duration minimumDelayOnEmptyResult = cacheConfig.getMinimumDurationDelayOnEmptyResult();
if (hasNullOrEmptyResponse(consulResponse) && isLongerThan(minimumDelayOnEmptyResult, timeToWait)) {
timeToWait = minimumDelayOnEmptyResult;
}
timeToWait = timeToWait.minusMillis(elapsedTime);
scheduler.schedule(ConsulCache.this::runCallback, timeToWait.toMillis(), TimeUnit.MILLISECONDS);
}
private void updateIndex(ConsulResponse> consulResponse) {
if (nonNull(consulResponse) && nonNull(consulResponse.getIndex())) {
latestIndex.set(consulResponse.getIndex());
}
}
private void notifyListeners(ImmutableMap newValues) {
for (Listener l : listeners) {
try {
l.notify(newValues);
} catch (RuntimeException e) {
LOG.warn("ConsulCache Listener's notify method threw an exception.", e);
}
}
}
private boolean hasNullOrEmptyResponse(ConsulResponse> consulResponse) {
return isNull(consulResponse.getResponse()) || consulResponse.getResponse().isEmpty();
}
private boolean isLongerThan(Duration duration1, Duration duration2) {
return duration1.compareTo(duration2) > 0;
}
@Override
public void onFailure(Throwable throwable) {
if (isNotRunning()) {
return;
}
eventHandler.cachePollingError(cacheDescriptor, throwable);
long delayMs = computeBackOffDelayMs(cacheConfig);
String message = String.format("Error getting response from consul for %s, will retry in %d %s",
cacheDescriptor, delayMs, TimeUnit.MILLISECONDS);
cacheConfig.getRefreshErrorLoggingConsumer().accept(LOG, message, throwable);
scheduler.schedule(ConsulCache.this::runCallback, delayMs, TimeUnit.MILLISECONDS);
}
private boolean isNotRunning() {
return !isRunning();
}
}
static long computeBackOffDelayMs(CacheConfig cacheConfig) {
return cacheConfig.getMinimumBackOffDelay().toMillis() +
Math.round(Math.random() * (cacheConfig.getMaximumBackOffDelay().minus(cacheConfig.getMinimumBackOffDelay()).toMillis()));
}
public void start() {
checkState(state.compareAndSet(State.LATENT, State.STARTING),"Cannot transition from state %s to %s", state.get(), State.STARTING);
eventHandler.cacheStart(cacheDescriptor);
runCallback();
}
public void stop() {
try {
eventHandler.cacheStop(cacheDescriptor);
} catch (RejectedExecutionException ree) {
LOG.error("Unable to propagate cache stop event. ", ree);
}
State previous = state.getAndSet(State.STOPPED);
if (stopWatch.isRunning()) {
stopWatch.stop();
}
if (previous != State.STOPPED) {
scheduler.shutdownNow();
}
}
@Override
public void close() {
stop();
}
private void runCallback() {
if (isRunning()) {
stopWatch.reset().start();
callBackConsumer.consume(latestIndex.get(), responseCallback);
}
}
private boolean isRunning() {
return state.get() == State.STARTED || state.get() == State.STARTING;
}
public boolean awaitInitialized(long timeout, TimeUnit unit) throws InterruptedException {
return initLatch.await(timeout, unit);
}
public ImmutableMap getMap() {
return lastResponse.get();
}
public ConsulResponse> getMapWithMetadata() {
return new ConsulResponse<>(lastResponse.get(), lastContact.get(), isKnownLeader.get(), latestIndex.get(), Optional.ofNullable(lastCacheInfo.get()));
}
@VisibleForTesting
ImmutableMap convertToMap(final ConsulResponse> response) {
if (isNull(response) || isNull(response.getResponse()) || response.getResponse().isEmpty()) {
return ImmutableMap.of();
}
ImmutableMap.Builder builder = ImmutableMap.builder();
Set keySet = new HashSet<>();
for (V v : response.getResponse()) {
K key = keyConversion.apply(v);
if (nonNull(key)) {
if (keySet.contains(key)) {
LOG.warn("Duplicate service encountered. May differ by tags. Try using more specific tags? {}", key);
} else {
builder.put(key, v);
keySet.add(key);
}
}
}
return builder.build();
}
protected static QueryOptions watchParams(BigInteger index, int blockSeconds, QueryOptions queryOptions) {
checkArgument(queryOptions.getIndex().isEmpty() && queryOptions.getWait().isEmpty(),
"Index and wait cannot be overridden");
ImmutableQueryOptions.Builder builder = ImmutableQueryOptions.builder()
.from(watchDefaultParams(index, blockSeconds))
.token(queryOptions.getToken())
.consistencyMode(queryOptions.getConsistencyMode())
.near(queryOptions.getNear())
.datacenter(queryOptions.getDatacenter());
for (String tag : queryOptions.getTag()) {
builder.addTag(tag);
}
return builder.build();
}
private static QueryOptions watchDefaultParams(final BigInteger index, final int blockSeconds) {
if (isNull(index)) {
return QueryOptions.BLANK;
} else {
return QueryOptions.blockSeconds(blockSeconds, index).build();
}
}
protected static Scheduler createDefault() {
return new DefaultScheduler();
}
protected static Scheduler createExternal(ScheduledExecutorService executor) {
return new ExternalScheduler(executor);
}
/**
* passed in by creators to vary the content of the cached values
*
* @param the type of values to be consumed
*/
protected interface CallbackConsumer {
void consume(BigInteger index, ConsulResponseCallback> callback);
}
/**
* Implementers can register a listener to receive
* a new map when it changes
*
* @param the type of keys
* @param the type of values
*/
public interface Listener {
void notify(Map newValues);
}
/**
* Add a new listener.
*
* @param listener the listener to add
* @return true to indicate the listener was added
* @implNote This always returns true because {@link CopyOnWriteArrayList} is used to store the listeners, and
* its {@link CopyOnWriteArrayList#add(Object)} method always returns true
*/
public boolean addListener(Listener listener) {
performListenerActionOptionallyLocking(() -> {
listeners.add(listener);
if (state.get() == State.STARTED) {
try {
listener.notify(lastResponse.get());
} catch (RuntimeException e) {
LOG.warn("ConsulCache Listener's notify method threw an exception.", e);
}
}
});
return true;
}
private void performListenerActionOptionallyLocking(Runnable action) {
var locked = false;
if (state.get() == State.STARTING) {
listenersStartingLock.lock();
locked = true;
}
try {
action.run();
} finally {
if (locked) {
listenersStartingLock.unlock();
}
}
}
public List> getListeners() {
return List.copyOf(listeners);
}
public boolean removeListener(Listener listener) {
return listeners.remove(listener);
}
public State getState() {
return state.get();
}
protected static class Scheduler {
public Scheduler(ScheduledExecutorService executor) {
this.executor = executor;
}
void schedule(Runnable r, long delay, TimeUnit unit) {
executor.schedule(r, delay, unit);
}
void shutdownNow() {
executor.shutdownNow();
}
private final ScheduledExecutorService executor;
}
private static class DefaultScheduler extends Scheduler {
public DefaultScheduler() {
super(Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryBuilder()
.setNameFormat("consulCacheScheduledCallback-%d")
.setDaemon(true)
.build()));
}
}
private static class ExternalScheduler extends Scheduler {
public ExternalScheduler(ScheduledExecutorService executor) {
super(executor);
}
@Override
public void shutdownNow() {
// do nothing, since the executor was externally created
}
}
protected static void checkWatch(int networkReadMillis, int cacheWatchSeconds) {
if (networkReadMillis <= TimeUnit.SECONDS.toMillis(cacheWatchSeconds)) {
throw new IllegalArgumentException("Cache watchInterval="+ cacheWatchSeconds + "sec >= networkClientReadTimeout="
+ networkReadMillis + "ms. It can cause issues");
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy