From dcecda298466f1844fd2e20e2b714bb93150e52d Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 5 Apr 2025 17:27:07 +0800 Subject: [PATCH] fix: cache NativeFile with LRU, closes #797 (#816) --- apps/readest-app/src/services/appService.ts | 13 ++++ apps/readest-app/src/utils/file.ts | 86 ++++++++++++++++++--- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 03366966..4a5d5cf1 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -35,6 +35,7 @@ import { import { getOSPlatform, isCJKEnv, isContentURI, isValidURL } from '@/utils/misc'; import { deserializeConfig, serializeConfig } from '@/utils/serializer'; import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage'; +import { ClosableFile } from '@/utils/file'; import { ProgressHandler } from '@/utils/transfer'; import { TxtToEpubConverter } from '@/utils/txt'; import { BOOK_FILE_NOT_FOUND_ERROR } from './errors'; @@ -244,6 +245,10 @@ export abstract class BaseAppService implements AppService { } } book.coverImageUrl = await this.generateCoverImageUrl(book); + const f = file as ClosableFile; + if (f && f.close) { + await f.close(); + } return book; } catch (error) { @@ -282,6 +287,10 @@ export abstract class BaseAppService implements AppService { const file = await this.fs.openFile(lfp, 'Books', cfp); const localFullpath = `${this.localBooksDir}/${lfp}`; await uploadFile(file, localFullpath, handleProgress, hash); + const f = file as ClosableFile; + if (f && f.close) { + await f.close(); + } } async uploadBook(book: Book, onProgress?: ProgressHandler): Promise { @@ -421,6 +430,10 @@ export abstract class BaseAppService implements AppService { } const { file } = (await this.loadBookContent(book, settings)) as BookContent; const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc; + const f = file as ClosableFile; + if (f && f.close) { + await f.close(); + } return bookDoc.metadata; } diff --git a/apps/readest-app/src/utils/file.ts b/apps/readest-app/src/utils/file.ts index 972ad2e4..aa9808db 100644 --- a/apps/readest-app/src/utils/file.ts +++ b/apps/readest-app/src/utils/file.ts @@ -50,7 +50,12 @@ class DeferredBlob extends Blob { } } -export class NativeFile extends File { +export interface ClosableFile extends File { + open(): Promise; + close(): Promise; +} + +export class NativeFile extends File implements ClosableFile { #handle: FileHandle | null = null; #fp: string; #name: string; @@ -59,6 +64,11 @@ export class NativeFile extends File { #size: number = -1; #type: string = ''; + static MAX_CACHE_CHUNK_SIZE = 1024 * 1024; + static MAX_CACHE_ITEMS_SIZE = 20; + #cache: Map = new Map(); + #order: number[] = []; + constructor(fp: string, name?: string, baseDir: BaseDirectory | null = null, type = '') { super([], name || fp, { type }); this.#fp = fp; @@ -74,6 +84,15 @@ export class NativeFile extends File { return this; } + async close() { + if (this.#handle) { + await this.#handle.close(); + this.#handle = null; + } + this.#cache.clear(); + this.#order = []; + } + override get name() { return this.#name; } @@ -94,10 +113,6 @@ export class NativeFile extends File { return this.#handle?.stat(); } - async close(): Promise { - return this.#handle?.close(); - } - async seek(offset: number, whence: SeekMode): Promise { if (!this.#handle) { throw new Error('File handle is not open'); @@ -112,10 +127,58 @@ export class NativeFile extends File { } start = Math.max(0, start); end = Math.max(start, Math.min(this.size, end)); - await this.#handle.seek(start, SeekMode.Start); - const buffer = new Uint8Array(end - start); + const size = end - start; + + if (size > NativeFile.MAX_CACHE_CHUNK_SIZE) { + await this.#handle.seek(start, SeekMode.Start); + const buffer = new Uint8Array(size); + await this.#handle.read(buffer); + return buffer.buffer; + } + + const cachedChunkStart = Array.from(this.#cache.keys()).find((chunkStart) => { + const buffer = this.#cache.get(chunkStart)!; + return start >= chunkStart && end <= chunkStart + buffer.byteLength; + }); + + if (cachedChunkStart !== undefined) { + this.#updateAccessOrder(cachedChunkStart); + const buffer = this.#cache.get(cachedChunkStart)!; + const offset = start - cachedChunkStart; + return buffer.slice(offset, offset + size); + } + + const chunkStart = Math.max(0, start - 1024); + const chunkEnd = Math.min(this.size, chunkStart + NativeFile.MAX_CACHE_CHUNK_SIZE); + const chunkSize = chunkEnd - chunkStart; + + await this.#handle.seek(chunkStart, SeekMode.Start); + const buffer = new Uint8Array(chunkSize); await this.#handle.read(buffer); - return buffer.buffer; + + this.#cache.set(chunkStart, buffer.buffer); + this.#updateAccessOrder(chunkStart); + this.#ensureCacheSize(); + + const offset = start - chunkStart; + return buffer.buffer.slice(offset, offset + size); + } + + #updateAccessOrder(chunkStart: number) { + const index = this.#order.indexOf(chunkStart); + if (index > -1) { + this.#order.splice(index, 1); + } + this.#order.unshift(chunkStart); + } + + #ensureCacheSize() { + while (this.#cache.size > NativeFile.MAX_CACHE_ITEMS_SIZE) { + const oldestKey = this.#order.pop(); + if (oldestKey !== undefined) { + this.#cache.delete(oldestKey); + } + } } override slice(start = 0, end = this.size, contentType = this.type): Blob { @@ -172,7 +235,7 @@ export class NativeFile extends File { } } -export class RemoteFile extends File { +export class RemoteFile extends File implements ClosableFile { url: string; #name: string; #lastModified: number; @@ -238,7 +301,10 @@ export class RemoteFile extends File { } } - async close(): Promise {} + async close(): Promise { + this.#cache.clear(); + this.#order = []; + } async fetchRangePart(start: number, end: number) { start = Math.max(0, start);