com.github.robozonky.app.tenant.Cache Maven / Gradle / Ivy
/*
* Copyright 2020 The RoboZonky Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.robozonky.app.tenant;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.github.robozonky.api.remote.entities.Loan;
import com.github.robozonky.internal.tenant.Tenant;
import com.github.robozonky.internal.test.DateUtil;
import com.github.robozonky.internal.util.functional.Either;
import com.github.robozonky.internal.util.functional.Tuple;
import com.github.robozonky.internal.util.functional.Tuple2;
final class Cache {
private static final Logger LOGGER = LogManager.getLogger(Cache.class);
private static final Backend LOAN_BACKEND = new Backend<>() {
@Override
public Duration getEvictEvery() {
return Duration.ofHours(1);
}
@Override
public Duration getEvictAfter() {
return Duration.ofDays(1);
}
@Override
public Class getItemClass() {
return Loan.class;
}
@Override
public Either getItem(final long id, final Tenant tenant) {
try { // TODO convert loan IDs to longs to get rid of the cast.
return Either.right(tenant.call(zonky -> zonky.getLoan((int) id)));
} catch (final Exception ex) {
return Either.left(ex);
}
}
@Override
public boolean shouldCache(final Loan item) {
return item.getRemainingInvestment()
.isZero();
}
};
private final AtomicBoolean isClosed = new AtomicBoolean(false);
private final Tenant tenant;
private final Backend backend;
private final Map> storage = new ConcurrentHashMap<>(20);
private final Executor evictor;
private Cache(final Tenant tenant, final Backend backend) {
LOGGER.debug("Starting {} cache for {}.", backend.getItemClass(), tenant);
this.tenant = tenant;
this.backend = backend;
this.evictor = backend.startEviction(this::evict);
}
public static Cache forLoan(final Tenant tenant) {
return new Cache<>(tenant, LOAN_BACKEND);
}
private static String identify(final Class> clz, final long id) {
return clz.getCanonicalName() + " #" + id;
}
private boolean isExpired(final Tuple2 p) {
var now = DateUtil.zonedNow();
var expiration = p._2()
.plus(backend.getEvictAfter());
return expiration.isBefore(now);
}
private void evict() {
LOGGER.trace("Evicting {}, total: {}.", backend.getItemClass(), storage.size());
var evictedCount = storage.entrySet()
.stream()
.filter(e -> isExpired(e.getValue()))
.peek(e -> storage.remove(e.getKey()))
.count();
LOGGER.trace("Evicted {} items.", evictedCount);
}
Optional getFromCache(final long id) {
var result = storage.get(id);
if (result == null || isExpired(result)) {
LOGGER.trace("Miss for {}.", identify(id));
return Optional.empty();
} else {
LOGGER.trace("Hit for {}.", identify(id));
return Optional.of(result._1());
}
}
private String identify(final long id) {
return identify(backend.getItemClass(), id);
}
private void add(final long id, final T item) {
storage.put(id, Tuple.of(item, DateUtil.zonedNow()));
}
public T get(final long id) {
return get(id, false);
}
public T get(final long id, final boolean forceLoad) {
if (isClosed.get()) {
throw new IllegalStateException("Already closed.");
} else if (forceLoad) {
storage.remove(id);
}
return getFromCache(id).orElseGet(() -> {
var item = backend.getItem(id, tenant)
.getOrElseThrow(e -> new IllegalStateException("Can not read " + identify(id) + " from Zonky.", e));
if (backend.shouldCache(item)) {
add(id, item);
} else {
// prevent caching information which will soon be outdated
LOGGER.debug("Not adding {} as it is not yet fully invested.", identify(id));
}
return item;
});
}
/**
* For testing purposes only.
*/
Executor getEvictor() {
return evictor;
}
private interface Backend {
Duration getEvictEvery();
Duration getEvictAfter();
Class getItemClass();
Either getItem(long id, Tenant tenant);
boolean shouldCache(I item);
default Executor startEviction(Runnable eviction) {
var executor = CompletableFuture.delayedExecutor(getEvictEvery().toNanos(), TimeUnit.NANOSECONDS);
executor.execute(() -> {
try {
eviction.run();
} finally {
executor.execute(eviction);
}
});
return executor;
}
}
}