/* * Copyright (C) 2023 NotEnoughUpdates contributors * * This file is part of NotEnoughUpdates. * * NotEnoughUpdates is free software: you can redistribute it * and/or modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation, either * version 3 of the License, or (at your option) any later version. * * NotEnoughUpdates is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with NotEnoughUpdates. If not, see . */ package io.github.moulberry.notenoughupdates.util import io.github.moulberry.notenoughupdates.NotEnoughUpdates import io.github.moulberry.notenoughupdates.options.customtypes.NEUDebugFlag import io.github.moulberry.notenoughupdates.util.ApiUtil.Request import io.github.moulberry.notenoughupdates.util.kotlin.supplyImmediate import org.apache.http.NameValuePair import java.nio.file.Files import java.nio.file.Path import java.time.Duration import java.util.* import java.util.concurrent.CompletableFuture import java.util.function.Supplier import kotlin.io.path.deleteIfExists import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime import kotlin.time.TimeSource import kotlin.time.toKotlinDuration @OptIn(ExperimentalTime::class) object ApiCache { data class CacheKey( val baseUrl: String, val requestParameters: List, val shouldGunzip: Boolean, ) data class CacheResult internal constructor( var cacheState: CacheState, val firedAt: TimeSource.Monotonic.ValueTimeMark, ) { constructor(future: CompletableFuture, firedAt: TimeSource.Monotonic.ValueTimeMark) : this( CacheState.WaitingForFuture(future), firedAt ) { future.thenAccept { text -> synchronized(this) { val f = Files.createTempFile(cacheBaseDir, "api-cache", ".bin") log("Writing cache to disk: $f") f.toFile().deleteOnExit() f.writeText(text) cacheState = CacheState.FileCached(f) } } } sealed interface CacheState { object Disposed : CacheState data class WaitingForFuture(val future: CompletableFuture) : CacheState data class FileCached(val file: Path) : CacheState } val isAvailable get() = cacheState is CacheState.FileCached fun getCachedFuture(): CompletableFuture { synchronized(this) { return when (val cs = cacheState) { CacheState.Disposed -> supplyImmediate { throw IllegalStateException("Attempting to read from a disposed future. Most likely caused by non synchronized access to ApiCache.cachedRequests") } is CacheState.FileCached -> supplyImmediate { cs.file.readText() } is CacheState.WaitingForFuture -> cs.future } } } /** * Should be called when removing / replacing a request from [cachedRequests]. * Should only be called while holding a lock on [ApiCache]. * This deletes the disk cache and smashes the internal state for it to be GCd. * After calling this method no other method may be called on this object. */ internal fun dispose() { synchronized(this) { val file = (cacheState as? CacheState.FileCached)?.file log("Disposing cache for $file") cacheState = CacheState.Disposed file?.deleteIfExists() } } } private val cacheBaseDir by lazy { val d = Files.createTempDirectory("neu-cache") d.toFile().deleteOnExit() d } private val cachedRequests = mutableMapOf() val histogramTotalRequests: MutableMap = mutableMapOf() val histogramNonCachedRequests: MutableMap = mutableMapOf() private val timeout = 10.seconds private val globalMaxCacheAge = 1.hours private fun log(message: String) { NEUDebugFlag.API_CACHE.log(message) } private fun traceApiRequest( request: Request, failReason: String?, ) { if (!NotEnoughUpdates.INSTANCE.config.hidden.dev) return val callingClass = Thread.currentThread().stackTrace .find { !it.className.startsWith("java.") && !it.className.startsWith("kotlin.") && it.className != ApiCache::class.java.name && it.className != ApiUtil::class.java.name && it.className != Request::class.java.name } val callingClassText = callingClass?.let { "${it.className}.${it.methodName} (${it.fileName}:${it.lineNumber})" } ?: "no calling class found" callingClass?.className?.let { histogramTotalRequests[it] = (histogramTotalRequests[it] ?: 0) + 1 if (failReason != null) histogramNonCachedRequests[it] = (histogramNonCachedRequests[it] ?: 0) + 1 } if (failReason != null) { log("Executing api request for url ${request.baseUrl} by $callingClassText: $failReason") } else { log("Cache hit for api request for url ${request.baseUrl} by $callingClassText.") } } fun clear() { synchronized(this) { cachedRequests.clear() } } private fun evictCache() { synchronized(this) { val it = cachedRequests.iterator() while (it.hasNext()) { val next = it.next() if (next.value.firedAt.elapsedNow() >= globalMaxCacheAge) { next.value.dispose() it.remove() } } } } fun cacheRequest( request: Request, cacheKey: CacheKey?, futureSupplier: Supplier>, maxAge: Duration? ): CompletableFuture { evictCache() if (cacheKey == null) { traceApiRequest(request, "uncacheable request (probably POST)") return futureSupplier.get() } if (maxAge == null) { traceApiRequest(request, "manually specified as uncacheable") return futureSupplier.get() } fun recache(): CompletableFuture { return futureSupplier.get().also { cachedRequests[cacheKey]?.dispose() // Safe to dispose like this because this function is always called in a synchronized block cachedRequests[cacheKey] = CacheResult(it, TimeSource.Monotonic.markNow()) } } synchronized(this) { val cachedRequest = cachedRequests[cacheKey] if (cachedRequest == null) { traceApiRequest(request, "no cache found") return recache() } return if (cachedRequest.isAvailable) { if (cachedRequest.firedAt.elapsedNow() > maxAge.toKotlinDuration()) { traceApiRequest(request, "outdated cache") recache() } else { // Using local cached request traceApiRequest(request, null) cachedRequest.getCachedFuture() } } else { if (cachedRequest.firedAt.elapsedNow() > timeout) { traceApiRequest(request, "suspiciously slow api response") recache() } else { // Joining ongoing request traceApiRequest(request, null) cachedRequest.getCachedFuture() } } } } }