From 208b4980887fa4e977f60a99804d4df4036d03ed Mon Sep 17 00:00:00 2001 From: nea Date: Mon, 6 Feb 2023 03:03:15 +0100 Subject: ApiUtil: Add cache with per request timeout and per class histogram --- .../moulberry/notenoughupdates/util/ApiCache.kt | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt (limited to 'src/main/kotlin/io') diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt new file mode 100644 index 00000000..49a79ae9 --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt @@ -0,0 +1,138 @@ +/* + * 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 org.apache.http.NameValuePair +import java.time.Duration +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.function.Supplier +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( + val future: CompletableFuture, + val firedAt: TimeSource.Monotonic.ValueTimeMark, + ) + + val cachedRequests = mutableMapOf() + val histogramTotalRequests: MutableMap = mutableMapOf() + val histogramNonCachedRequests: MutableMap = mutableMapOf() + val timeout = 10.seconds + val maxCacheAge = 1.hours + + fun log(message: String) { + NEUDebugFlag.API_CACHE.log(message) + } + + 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 evictCache() { + synchronized(this) { + val it = cachedRequests.iterator() + while (it.hasNext()) { + if (it.next().value.firedAt.elapsedNow() >= maxCacheAge) + 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] = CacheResult(it, TimeSource.Monotonic.markNow()) + } + } + synchronized(this) { + val cachedRequest = cachedRequests[cacheKey] + if (cachedRequest == null) { + traceApiRequest(request, "no cache found") + return recache() + } + if (cachedRequest.future.isDone && cachedRequest.firedAt.elapsedNow() > maxAge.toKotlinDuration()) { + traceApiRequest(request, "outdated cache") + return recache() + } + if (!cachedRequest.future.isDone && cachedRequest.firedAt.elapsedNow() > timeout) { + traceApiRequest(request, "suspiciously slow api response") + return recache() + } + traceApiRequest(request, null) + return cachedRequest.future + } + } + +} -- cgit