All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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

}