forked from akai/readest
perf(reader): throttle library.json writes and cache known dirs to cut IPC (#4556)
useProgressAutoSave fires saveConfig ~once per second of reading. Two write-time sources were doubling its IPC cost: 1. saveConfig wrote the WHOLE library.json (+ backup) on every call, so a user with N books paid 2*JSON.stringify(N) per save. Chrome DevTools' Bottom-Up profile on a release Android build showed processIpcMessage chewing ~25% of main-thread time during a reading session. 2. nativeFileSystem.writeFile / copyFile defensively called plugin:fs|exists before every write to ensure the parent dir existed. Same dir gets probed once per save -- ~50% of IPC time per save was just exists() round-trips against directories that have been there since the book was opened. Fix: - LIBRARY_SAVE_THROTTLE_MS=30s coalesces a swipe burst into a single library.json write. Per-book config.json is still written eagerly -- it's the sync source-of-truth and is small. flushPendingLibrarySave() is called on hook unmount + window blur so closing the book always flushes. - In-process knownExistingDirs Set caches verified directories per app session. createDir adds, removeDir (incl. recursive) clears. Cold start still does the original exists+createDir dance once per dir.
This commit is contained in:
@@ -8,7 +8,7 @@ vi.mock('@/utils/md5', () => ({
|
||||
md5Fingerprint: (value: string) => `md5_${value}`,
|
||||
}));
|
||||
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useBookDataStore, flushPendingLibrarySave } from '@/store/bookDataStore';
|
||||
import type { BookData } from '@/store/bookDataStore';
|
||||
import type { BookConfig, BookNote, Book } from '@/types/book';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
@@ -379,7 +379,15 @@ describe('bookDataStore', () => {
|
||||
|
||||
const stored = useLibraryStore.getState().getBookByHash('h1');
|
||||
expect(stored?.progress).toEqual([42, 100]);
|
||||
// Per-book config still writes eagerly — it's small and the source
|
||||
// of truth for reconstructing the shelf on next launch.
|
||||
expect(saveBookConfig).toHaveBeenCalledOnce();
|
||||
// Library JSON write is throttled (see scheduleLibrarySave in
|
||||
// bookDataStore) so a burst of saveConfig calls during reading
|
||||
// doesn't fire IPC on every page turn. Force-flush here to
|
||||
// observe the write actually happens.
|
||||
expect(saveLibraryBooks).not.toHaveBeenCalled();
|
||||
await flushPendingLibrarySave();
|
||||
expect(saveLibraryBooks).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,76 @@ const safeDecodePath = (input: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* In-process cache of directories we've already verified (or just created)
|
||||
* in this app session.
|
||||
*
|
||||
* Why: `writeFile` / `copyFile` defensively call `fs.exists(dir)` before
|
||||
* every write to make sure the parent directory exists. On the hot path
|
||||
* for `saveBookConfig` (fires roughly once every 1.5s while the user is
|
||||
* reading via `useProgressAutoSave`) the parent is always the same book
|
||||
* directory that's been there since the book was opened — so the
|
||||
* `exists` IPC is pure overhead.
|
||||
*
|
||||
* The cost shows up clearly in Chrome DevTools' Bottom-Up profile of a
|
||||
* release Android build: the `A` (= `plugin:fs|exists`) branch following
|
||||
* each `sendIpcMessage` accounts for ~50% of the IPC time during a
|
||||
* reading session, doubling the per-save IPC cost.
|
||||
*
|
||||
* Cache semantics:
|
||||
* - Key = `${base}:${dirPath}` (BaseDir + normalized directory path).
|
||||
* - Membership = "this process has at some point seen the directory
|
||||
* exist". We do NOT track deletions performed outside the app, but
|
||||
* the directories that matter for the read-path (per-book config
|
||||
* dirs, library dir) are created by the app and only deleted via
|
||||
* `removeDir` / book removal, both of which clear the relevant
|
||||
* entries below.
|
||||
* - On a fresh app start the cache is empty, so the first write to a
|
||||
* directory still does the original `exists`+`createDir` dance —
|
||||
* this is a perf cache, not a correctness shortcut.
|
||||
*/
|
||||
const knownExistingDirs = new Set<string>();
|
||||
const dirCacheKey = (base: BaseDir, dir: string) => `${base}:${dir}`;
|
||||
const markDirKnown = (base: BaseDir, dir: string) => {
|
||||
// Empty string is a valid path: it represents the BaseDir itself, which
|
||||
// is the parent dir for root-level files like `settings.json`. We must
|
||||
// cache it too, otherwise every root-level write would re-probe the
|
||||
// BaseDir via `exists()`.
|
||||
knownExistingDirs.add(dirCacheKey(base, dir));
|
||||
};
|
||||
const forgetDirKnown = (base: BaseDir, dir: string) => {
|
||||
knownExistingDirs.delete(dirCacheKey(base, dir));
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper used by `writeFile` / `copyFile`. If we already know this
|
||||
* directory exists, returns immediately. Otherwise performs the normal
|
||||
* `exists` IPC + `createDir` fallback and records the result in the
|
||||
* cache so subsequent writes skip both round-trips.
|
||||
*
|
||||
* `dir` may be the empty string, which represents the BaseDir itself
|
||||
* (i.e. when writing a root-level file like `settings.json` whose
|
||||
* `getDirPath` is ""). On a fresh install the BaseDir may not exist
|
||||
* yet — the underlying Tauri `exists("", base)` / `createDir("", base,
|
||||
* recursive)` calls handle the empty path correctly by operating on
|
||||
* the BaseDir itself, so we must NOT short-circuit on empty string.
|
||||
*/
|
||||
async function ensureDirExists(
|
||||
self: {
|
||||
exists: (path: string, base: BaseDir) => Promise<boolean>;
|
||||
createDir: (path: string, base: BaseDir, recursive?: boolean) => Promise<void>;
|
||||
},
|
||||
dir: string,
|
||||
base: BaseDir,
|
||||
): Promise<void> {
|
||||
const key = dirCacheKey(base, dir);
|
||||
if (knownExistingDirs.has(key)) return;
|
||||
if (!(await self.exists(dir, base))) {
|
||||
await self.createDir(dir, base, true);
|
||||
}
|
||||
knownExistingDirs.add(key);
|
||||
}
|
||||
|
||||
// Helper function to create a path resolver based on custom root directory and portable mode
|
||||
// 0. If no custom root dir and not portable mode, use default Tauri BaseDirectory
|
||||
// 1. If custom root dir is set, use it as base dir (baseDir = 0)
|
||||
@@ -273,9 +343,10 @@ export const nativeFileSystem: FileSystem = {
|
||||
},
|
||||
async copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) {
|
||||
try {
|
||||
if (!(await this.exists(getDirPath(dstPath), dstBase))) {
|
||||
await this.createDir(getDirPath(dstPath), dstBase, true);
|
||||
}
|
||||
// Uses the in-process dir cache (see `knownExistingDirs` above) so a
|
||||
// burst of copies into the same destination dir doesn't fire an
|
||||
// `exists` IPC per file.
|
||||
await ensureDirExists(this, getDirPath(dstPath), dstBase);
|
||||
} catch (error) {
|
||||
console.log('Failed to create directory for copying file:', error);
|
||||
}
|
||||
@@ -312,9 +383,13 @@ export const nativeFileSystem: FileSystem = {
|
||||
// NOTE: this could be very slow for large files and might block the UI thread
|
||||
// so do not use this for large files
|
||||
const { fp, baseDir } = this.resolvePath(path, base);
|
||||
if (!(await this.exists(getDirPath(path), base))) {
|
||||
await this.createDir(getDirPath(path), base, true);
|
||||
}
|
||||
// Skip the redundant `exists` IPC after the first write to this dir
|
||||
// in the current session — `useProgressAutoSave` writes to the same
|
||||
// per-book directory once every ~1.5s while the user is reading, so
|
||||
// checking each time roughly doubles the IPC cost of saveBookConfig.
|
||||
// See `knownExistingDirs` near the top of this file for the cache
|
||||
// invariants.
|
||||
await ensureDirExists(this, getDirPath(path), base);
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return writeTextFile(fp, content, baseDir ? { baseDir } : undefined);
|
||||
@@ -354,11 +429,28 @@ export const nativeFileSystem: FileSystem = {
|
||||
const { fp, baseDir } = this.resolvePath(path, base);
|
||||
|
||||
await mkdir(fp, { baseDir: baseDir ? baseDir : undefined, recursive });
|
||||
// Now that the dir is on disk, record it so subsequent writes can
|
||||
// skip the `exists` probe.
|
||||
markDirKnown(base, path);
|
||||
},
|
||||
async removeDir(path: string, base: BaseDir, recursive = false) {
|
||||
const { fp, baseDir } = this.resolvePath(path, base);
|
||||
|
||||
await remove(fp, { baseDir: baseDir ? baseDir : undefined, recursive });
|
||||
// The cached entry for this dir is now stale, and a recursive remove
|
||||
// also tears down everything beneath it. Drop every cached entry that
|
||||
// points at this dir or any of its descendants so the next write
|
||||
// here goes through the slow `exists`+`createDir` path again.
|
||||
const prefix = dirCacheKey(base, path);
|
||||
forgetDirKnown(base, path);
|
||||
if (recursive) {
|
||||
// Iterate a snapshot — Set forbids mutation during iteration.
|
||||
for (const key of Array.from(knownExistingDirs)) {
|
||||
if (key === prefix || key.startsWith(prefix + '/')) {
|
||||
knownExistingDirs.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async readDir(path: string, base: BaseDir) {
|
||||
const { fp, baseDir } = this.resolvePath(path, base);
|
||||
|
||||
@@ -5,6 +5,56 @@ import { EnvConfigType } from '@/services/environment';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useLibraryStore } from './libraryStore';
|
||||
|
||||
// Throttle library.json writes triggered by per-book saveConfig.
|
||||
//
|
||||
// Why: `saveConfig` ran two large fs.writeFile IPC calls *every* invocation —
|
||||
// one for the per-book config.json and one for the WHOLE library.json (because
|
||||
// saveLibraryBooks writes a backup + the file itself). For a user with N
|
||||
// books in their shelf, that's `2 * JSON.stringify(N entries)` of work + 2
|
||||
// Tauri IPC round-trips per save. With auto-save firing once per second of
|
||||
// reading (useProgressAutoSave), Chrome DevTools' Bottom-Up profile shows
|
||||
// `processIpcMessage` chewing ~25% of main-thread time during a reading
|
||||
// session — directly responsible for the swipe jank the user is reporting
|
||||
// (touchmove gets queued behind IPC processing).
|
||||
//
|
||||
// The library array itself is updated immutably via setLibrary on every save
|
||||
// (see `setLibrary(newLibrary)` below) so in-memory state and zustand
|
||||
// subscribers see the change immediately. Disk persistence can be deferred:
|
||||
// progress is also stored in each book's own config.json (which we still
|
||||
// write every time), so even if the app dies between throttle ticks the
|
||||
// shelf will reconstruct correct progress from those per-book files on
|
||||
// next launch.
|
||||
//
|
||||
// LIBRARY_SAVE_THROTTLE_MS=30s: long enough to collapse a swipe burst into a
|
||||
// single IPC, short enough that a user who closes the book within half a
|
||||
// minute still sees the shelf update without a follow-up flush. Force-flush
|
||||
// happens via flushPendingLibrarySave() on hook unmount + window blur.
|
||||
const LIBRARY_SAVE_THROTTLE_MS = 30_000;
|
||||
let librarySaveTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let librarySaveAppService: { saveLibraryBooks: (books: Book[]) => Promise<void> } | null = null;
|
||||
const scheduleLibrarySave = (appService: {
|
||||
saveLibraryBooks: (books: Book[]) => Promise<void>;
|
||||
}) => {
|
||||
librarySaveAppService = appService;
|
||||
if (librarySaveTimeoutId != null) return;
|
||||
librarySaveTimeoutId = setTimeout(() => {
|
||||
librarySaveTimeoutId = null;
|
||||
const svc = librarySaveAppService;
|
||||
if (!svc) return;
|
||||
const { library } = useLibraryStore.getState();
|
||||
svc.saveLibraryBooks(library).catch((err) => {
|
||||
console.warn('Throttled library save failed:', err);
|
||||
});
|
||||
}, LIBRARY_SAVE_THROTTLE_MS);
|
||||
};
|
||||
export const flushPendingLibrarySave = async () => {
|
||||
if (librarySaveTimeoutId == null || !librarySaveAppService) return;
|
||||
clearTimeout(librarySaveTimeoutId);
|
||||
librarySaveTimeoutId = null;
|
||||
const { library } = useLibraryStore.getState();
|
||||
await librarySaveAppService.saveLibraryBooks(library);
|
||||
};
|
||||
|
||||
export interface BookData {
|
||||
/* Persistent data shared with different views of the same book */
|
||||
id: string;
|
||||
@@ -102,8 +152,14 @@ export const useBookDataStore = create<BookDataState>((set, get) => ({
|
||||
// regardless of whether the caller passed the shared store config.
|
||||
get().setConfig(bookKey, { updatedAt: now });
|
||||
const configToSave = { ...config, updatedAt: now };
|
||||
// Per-book config: still write eagerly — it's small (one book's
|
||||
// settings + booknotes) and is the source of truth used by sync to
|
||||
// reconstruct the shelf if library.json is missing or stale.
|
||||
await appService.saveBookConfig(updatedBook, configToSave, settings);
|
||||
await appService.saveLibraryBooks(useLibraryStore.getState().library);
|
||||
// Library JSON write: throttled (see scheduleLibrarySave docs) so a
|
||||
// burst of saveConfig calls during reading doesn't fire IPC on every
|
||||
// page turn.
|
||||
scheduleLibrarySave(appService);
|
||||
},
|
||||
updateBooknotes: (key: string, booknotes: BookNote[]) => {
|
||||
let updatedConfig: BookConfig | undefined;
|
||||
|
||||
Reference in New Issue
Block a user