Fix: Cache Adjustments (WIP)

This commit is contained in:
Youwes09
2026-05-16 22:46:45 -05:00
parent d98547d540
commit 47ae80a7d2
7 changed files with 178 additions and 31 deletions
+31 -2
View File
@@ -96,13 +96,43 @@ fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
#[tauri::command] #[tauri::command]
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> { pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
let window = app.get_webview_window("main").ok_or("no main window")?; let window = app.get_webview_window("main").ok_or("no main window")?;
window.clear_all_browsing_data().map_err(|e| e.to_string())?;
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
window
.with_webview(move |wv| {
#[cfg(target_os = "windows")]
{
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_2;
use windows::core::Interface;
let core = wv.controller().CoreWebView2().map_err(|e| e.to_string());
let result = core.and_then(|c| {
c.cast::<ICoreWebView2_2>()
.map_err(|e| e.to_string())
})
.and_then(|c2| {
unsafe {
c2.ClearBrowsingDataAll(None).map_err(|e| e.to_string())
}
});
let _ = tx.send(result);
}
#[cfg(not(target_os = "windows"))]
{
let _ = tx.send(Ok(()));
}
})
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())??;
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?; let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
if cache_dir.exists() { if cache_dir.exists() {
wait_until_deletable(&cache_dir, 3);
remove_dir_best_effort(&cache_dir); remove_dir_best_effort(&cache_dir);
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
} }
Ok(()) Ok(())
} }
@@ -135,7 +165,6 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
let data_dir = suwayomi_data_dir(); let data_dir = suwayomi_data_dir();
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"]; let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
// Wait up to 10s for the JVM to release file locks
for entry_name in &targets { for entry_name in &targets {
let p = data_dir.join(entry_name); let p = data_dir.join(entry_name);
if p.exists() { if p.exists() {
+7 -2
View File
@@ -5,8 +5,9 @@ import { uiAuth } from "@core/auth";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6; const MAX_CONCURRENT = 6;
let active = 0; let active = 0;
let drainScheduled = false; let drainScheduled = false;
let clearing = false;
interface QueueEntry { interface QueueEntry {
url: string; url: string;
@@ -34,7 +35,9 @@ function getAuthHeaders(): Record<string, string> {
async function doFetch(url: string): Promise<string> { async function doFetch(url: string): Promise<string> {
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() }); const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
if (!res.ok) throw new Error(`${res.status}`); if (!res.ok) throw new Error(`${res.status}`);
const blobUrl = URL.createObjectURL(await res.blob()); const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl); cache.set(url, blobUrl);
return blobUrl; return blobUrl;
} }
@@ -121,8 +124,10 @@ export function cancelQueuedFetches(): void {
} }
export function clearBlobCache(): void { export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches(); cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob)); cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear(); cache.clear();
inflight.clear(); inflight.clear();
clearing = false;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './memoryCache'; export * from './memoryCache';
export * from './pageCache'; export * from './pageCache';
export * from './imageCache'; export * from './imageCache';
export * from './queryCache'; export * from './queryCache';
+44
View File
@@ -0,0 +1,44 @@
interface MemEntry<T> {
value: T;
expiresAt: number;
key: string;
}
export class MemoryCache<T> {
readonly #cap: number;
readonly #ttl: number;
readonly #map = new Map<string, MemEntry<T>>();
constructor(capacity: number, ttlMs: number) {
this.#cap = capacity;
this.#ttl = ttlMs;
}
get(key: string): T | undefined {
const entry = this.#map.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
this.#map.delete(key);
this.#map.set(key, entry);
return entry.value;
}
set(key: string, value: T): void {
if (this.#map.has(key)) this.#map.delete(key);
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
}
has(key: string): boolean {
const entry = this.#map.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
return true;
}
delete(key: string): void { this.#map.delete(key); }
clear(): void { this.#map.clear(); }
get size(): number { return this.#map.size; }
}
+1 -4
View File
@@ -62,10 +62,7 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
} }
export function preloadImage(url: string, useBlob: boolean): void { export function preloadImage(url: string, useBlob: boolean): void {
if (useBlob) { if (useBlob) { preloadBlobUrls([url], 0); return; }
preloadBlobUrls([url], 0);
return;
}
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
} }
+74 -8
View File
@@ -1,11 +1,14 @@
interface Entry<T> { interface Entry<T> {
promise: Promise<T>; promise: Promise<T>;
fetchedAt: number; fetchedAt: number;
fetcher?: () => Promise<T>;
ttl?: number;
} }
const store = new Map<string, Entry<unknown>>(); const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>(); const subs = new Map<string, Set<() => void>>();
const groups = new Map<string, Set<string>>(); const keyToGroups = new Map<string, Set<string>>();
const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000; export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
@@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) {
for (const tag of Array.isArray(group) ? group : [group]) { for (const tag of Array.isArray(group) ? group : [group]) {
if (!groups.has(tag)) groups.set(tag, new Set()); if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key); groups.get(tag)!.add(key);
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
keyToGroups.get(key)!.add(tag);
}
}
function unregisterKey(key: string) {
const tags = keyToGroups.get(key);
if (tags) {
for (const tag of tags) groups.get(tag)?.delete(key);
keyToGroups.delete(key);
} }
} }
@@ -27,14 +40,20 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}) as Promise<T>; }) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() }); store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
registerGroups(key, group); registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {}); promise.then(() => notify(key)).catch(() => {});
return promise; return promise;
}, },
set<T>(key: string, value: T, group?: string | string[]) { set<T>(key: string, value: T, group?: string | string[]) {
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() }); const existing = store.get(key) as Entry<T> | undefined;
store.set(key, {
promise: Promise.resolve(value),
fetchedAt: Date.now(),
fetcher: existing?.fetcher,
ttl: existing?.ttl,
});
registerGroups(key, group); registerGroups(key, group);
notify(key); notify(key);
}, },
@@ -43,10 +62,38 @@ export const cache = {
const existing = store.get(key) as Entry<T> | undefined; const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return; if (!existing) return;
const next = existing.promise.then(fn); const next = existing.promise.then(fn);
store.set(key, { promise: next, fetchedAt: Date.now() }); store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {}); next.then(() => notify(key)).catch(() => {});
}, },
refresh<T>(key: string): Promise<T> | undefined {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing?.fetcher) return undefined;
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
return promise;
},
refreshGroup(tag: string): void {
const keys = groups.get(tag);
if (!keys) return;
for (const key of [...keys]) {
const existing = store.get(key);
if (existing?.fetcher) {
const promise = existing.fetcher().catch(err => {
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
});
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
promise.then(() => notify(key)).catch(() => {});
}
}
},
has(key: string): boolean { return store.has(key); }, has(key: string): boolean { return store.has(key); },
ageOf(key: string): number | undefined { ageOf(key: string): number | undefined {
@@ -54,18 +101,35 @@ export const cache = {
return e ? Date.now() - e.fetchedAt : undefined; return e ? Date.now() - e.fetchedAt : undefined;
}, },
clear(key: string) { store.delete(key); notify(key); }, isStale(key: string): boolean {
const e = store.get(key);
if (!e) return true;
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
},
clear(key: string) {
unregisterKey(key);
store.delete(key);
notify(key);
},
clearGroup(tag: string) { clearGroup(tag: string) {
const keys = groups.get(tag); const keys = groups.get(tag);
if (!keys) return; if (!keys) return;
for (const key of keys) { store.delete(key); notify(key); } for (const key of [...keys]) {
keyToGroups.get(key)?.delete(tag);
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
store.delete(key);
notify(key);
}
groups.delete(tag); groups.delete(tag);
}, },
clearAll() { clearAll() {
const allKeys = [...store.keys()]; const allKeys = [...store.keys()];
store.clear(); groups.clear(); store.clear();
groups.clear();
keyToGroups.clear();
allKeys.forEach(notify); allKeys.forEach(notify);
}, },
@@ -161,7 +225,9 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
} }
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> { export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
cache.clear(CACHE_KEYS.MANGA(mangaId)); const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
cache.clear(CACHE_KEYS.CHAPTERS(mangaId)); cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.LIBRARY);
cache.clear(CACHE_KEYS.ALL_MANGA); cache.clear(CACHE_KEYS.ALL_MANGA);
@@ -13,17 +13,18 @@
import type { BackupEntry } from "@core/persistence/persist"; import type { BackupEntry } from "@core/persistence/persist";
import { DEFAULT_SETTINGS } from "@types/settings"; import { DEFAULT_SETTINGS } from "@types/settings";
import { DEFAULT_READING_STATS } from "@types/history"; import { DEFAULT_READING_STATS } from "@types/history";
import { clearBlobCache } from "@core/cache/imageCache";
import { clearPageCache } from "@core/cache/pageCache";
import { cache as queryCache } from "@core/cache/queryCache";
type ResetState = "idle" | "busy" | "done" | "error"; type ResetState = "idle" | "busy" | "done" | "error";
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; } interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
let resetItems = $state<ResetItem[]>([ let resetItems = $state<ResetItem[]>([
{ key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false }, { key: "all-cache", label: "Clear all caches", desc: "Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.", state: "idle", error: null, confirm: false },
{ key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false }, { key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
{ key: "server-cache", label: "Clear server image cache", desc: "Removes cached chapter pages and thumbnails stored on the Suwayomi server.", state: "idle", error: null, confirm: false }, { key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true }, { key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
]); ]);
let confirming = $state<string | null>(null); let confirming = $state<string | null>(null);
@@ -73,19 +74,24 @@
}); });
} }
async function clearAllCaches(): Promise<void> {
clearBlobCache();
clearPageCache();
queryCache.clearAll();
await Promise.all([
invoke("clear_moku_cache"),
invoke("clear_suwayomi_cache"),
gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
]);
}
async function runReset(key: string) { async function runReset(key: string) {
confirming = null; confirming = null;
patchReset(key, { state: "busy", error: null }); patchReset(key, { state: "busy", error: null });
try { try {
switch (key) { switch (key) {
case "moku-cache": case "all-cache":
await invoke("clear_moku_cache"); await clearAllCaches();
break;
case "suwayomi-cache":
await invoke("clear_suwayomi_cache");
break;
case "server-cache":
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
break; break;
case "reading-history": case "reading-history":
store.clearHistory(); store.clearHistory();