From b88f4a94186c83fa258d27c650f5045e823b4f77 Mon Sep 17 00:00:00 2001 From: nea Date: Sun, 12 Feb 2023 21:31:18 +0100 Subject: Disk cache for the API --- .../commands/misc/PronounsCommand.java | 2 +- .../moulberry/notenoughupdates/util/ApiUtil.java | 5 +- .../notenoughupdates/util/MinecraftExecutor.java | 37 -------- .../moulberry/notenoughupdates/util/ApiCache.kt | 105 ++++++++++++++++++--- .../notenoughupdates/util/MinecraftExecutor.kt | 47 +++++++++ 5 files changed, 142 insertions(+), 54 deletions(-) delete mode 100644 src/main/java/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.java create mode 100644 src/main/kotlin/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.kt diff --git a/src/main/java/io/github/moulberry/notenoughupdates/commands/misc/PronounsCommand.java b/src/main/java/io/github/moulberry/notenoughupdates/commands/misc/PronounsCommand.java index 5a4f1400..cf0d0c56 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/commands/misc/PronounsCommand.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/commands/misc/PronounsCommand.java @@ -88,7 +88,7 @@ public class PronounsCommand extends ClientCommandBase { "§e[NEU] Pronouns for §b" + user + " §eon §b" + platform + "§e:"), id); betterPronounChoice.render().forEach(it -> nc.printChatMessage(new ChatComponentText("§e[NEU] §a" + it))); return null; - }, MinecraftExecutor.INSTANCE); + }, MinecraftExecutor.OnThread); } } diff --git a/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java b/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java index 708f4677..e34324fe 100644 --- a/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java +++ b/src/main/java/io/github/moulberry/notenoughupdates/util/ApiUtil.java @@ -56,6 +56,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.zip.GZIPInputStream; @@ -210,7 +211,7 @@ public class ApiUtil { conn.setDoOutput(true); OutputStream os = conn.getOutputStream(); try { - os.write(this.postData.getBytes("utf-8")); + os.write(this.postData.getBytes(StandardCharsets.UTF_8)); } finally { os.close(); } @@ -252,7 +253,7 @@ public class ApiUtil { } public CompletableFuture requestJson(Class clazz) { - return requestString().thenApply(str -> gson.fromJson(str, clazz)); + return requestString().thenApplyAsync(str -> gson.fromJson(str, clazz)); } } diff --git a/src/main/java/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.java b/src/main/java/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.java deleted file mode 100644 index bf973b76..00000000 --- a/src/main/java/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2022 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 net.minecraft.client.Minecraft; -import org.jetbrains.annotations.NotNull; - -import java.util.concurrent.Executor; - -public class MinecraftExecutor implements Executor { - - public static MinecraftExecutor INSTANCE = new MinecraftExecutor(); - - private MinecraftExecutor() {} - - @Override - public void execute(@NotNull Runnable runnable) { - Minecraft.getMinecraft().addScheduledTask(runnable); - } -} diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt index d8f3f0f4..c14df425 100644 --- a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/ApiCache.kt @@ -23,10 +23,15 @@ 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.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 @@ -43,15 +48,74 @@ object ApiCache { ) data class CacheResult( - val future: CompletableFuture, + private var future: CompletableFuture?, val firedAt: TimeSource.Monotonic.ValueTimeMark, - ) + private var file: Path? = null, + private var disposed: Boolean = false, + ) { + init { + future!!.thenAcceptAsync { text -> + synchronized(this) { + if (disposed) { + return@synchronized + } + future = null + val f = Files.createTempFile(cacheBaseDir, "api-cache", ".bin") + log("Writing cache to disk: $f") + f.toFile().deleteOnExit() + f.writeText(text) + file = f + } + } + } + + val isAvailable get() = file != null && !disposed + fun getCachedFuture(): CompletableFuture { + synchronized(this) { + if (disposed) { + return CompletableFuture.supplyAsync { + throw IllegalStateException("Attempting to read from a disposed future at $file. Most likely caused by non synchronized access to ApiCache.cachedRequests") + } + } + val fut = future + if (fut != null) { + return fut + } else { + val text = file!!.readText() + return CompletableFuture.completedFuture(text) + } + } + } + + /** + * 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) { + if (disposed) return + log("Disposing cache for $file") + disposed = true + file?.deleteIfExists() + future = null + } + } + } + + 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 maxCacheAge = 1.hours + private val globalMaxCacheAge = 1.hours private fun log(message: String) { NEUDebugFlag.API_CACHE.log(message) @@ -83,15 +147,17 @@ object ApiCache { } else { log("Cache hit for api request for url ${request.baseUrl} by $callingClassText.") } - } private fun evictCache() { synchronized(this) { val it = cachedRequests.iterator() while (it.hasNext()) { - if (it.next().value.firedAt.elapsedNow() >= maxCacheAge) + val next = it.next() + if (next.value.firedAt.elapsedNow() >= globalMaxCacheAge) { + next.value.dispose() it.remove() + } } } } @@ -113,6 +179,7 @@ object ApiCache { } 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()) } } @@ -122,16 +189,26 @@ object ApiCache { 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() + + 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() + } } - traceApiRequest(request, null) - return cachedRequest.future } } diff --git a/src/main/kotlin/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.kt b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.kt new file mode 100644 index 00000000..bb0bc8b4 --- /dev/null +++ b/src/main/kotlin/io/github/moulberry/notenoughupdates/util/MinecraftExecutor.kt @@ -0,0 +1,47 @@ +/* + * 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 net.minecraft.client.Minecraft +import java.util.concurrent.Executor +import java.util.concurrent.ForkJoinPool + +object MinecraftExecutor { + + @JvmField + val OnThread = Executor { + val mc = Minecraft.getMinecraft() + if (mc.isCallingFromMinecraftThread) { + it.run() + } else { + Minecraft.getMinecraft().addScheduledTask(it) + } + } + + @JvmField + val OffThread = Executor { + val mc = Minecraft.getMinecraft() + if (mc.isCallingFromMinecraftThread) { + ForkJoinPool.commonPool().execute(it) + } else { + it.run() + } + } +} -- cgit