This commit is contained in:
2026-08-02 19:11:54 +02:00
parent 4cc07b9abc
commit 44b2eaf273
18 changed files with 1035 additions and 278 deletions
+150 -18
View File
@@ -10,7 +10,9 @@
*/
import { resolve } from "node:path";
import type { ParsedFile } from "./types";
import type { IndexedFile, ParsedFile } from "./types";
import type { IndexRecords } from "./records";
import type { ResolveResult } from "./includeResolver";
/** Case-insensitive absolute path key. */
export function normKey(path: string): string {
@@ -18,14 +20,21 @@ export function normKey(path: string): string {
}
/**
* LRU cache for fully parsed XML documents. Parse trees of huge mods can be
* memory-heavy, so only a bounded number of recent documents is retained;
* evicted entries are re-read from disk on demand.
* LRU cache for fully parsed XML documents.
*
* Parse trees of huge mods can be memory-heavy (~17x the source text), so
* retention is bounded twice: by entry count (least-recently-used eviction)
* and by a total element budget (largest trees are evicted first). Evicted
* entries are re-read from disk on demand.
*/
export class DocumentCache {
private map = new Map<string, ParsedFile>();
private totalElements = 0;
constructor(private capacity = 64) {}
constructor(
private capacity = 64,
private elementBudget = 2_000_000,
) {}
get(path: string): ParsedFile | undefined {
const key = normKey(path);
@@ -36,10 +45,111 @@ export class DocumentCache {
return hit;
}
/** Number of cached documents (for diagnostics). */
get size(): number {
return this.map.size;
}
/** Total elements held by cached parse trees (for diagnostics). */
get elements(): number {
return this.totalElements;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
const prev = this.map.get(key);
if (prev) this.totalElements -= elementCount(prev);
this.map.delete(key);
this.map.set(key, parsed);
this.totalElements += elementCount(parsed);
this.evict();
}
invalidate(path: string): void {
const key = normKey(path);
const prev = this.map.get(key);
if (prev) this.totalElements -= elementCount(prev);
this.map.delete(key);
}
clear(): void {
this.map.clear();
this.totalElements = 0;
}
private evict(): void {
while (this.map.size > this.capacity || this.totalElements > this.elementBudget) {
if (this.map.size === 0) break;
if (this.map.size > this.capacity) {
// Over capacity: drop the least recently used entry.
const oldest = this.map.keys().next().value;
if (oldest === undefined) break;
this.remove(oldest);
} else {
// Over the element budget: drop the largest tree, which frees the
// most memory per eviction.
let largestKey: string | undefined;
let largest = -1;
for (const [key, value] of this.map) {
const n = elementCount(value);
if (n > largest) {
largest = n;
largestKey = key;
}
}
if (largestKey === undefined || largest <= 0) break;
this.remove(largestKey);
}
}
}
private remove(key: string): void {
const prev = this.map.get(key);
if (prev) this.totalElements -= elementCount(prev);
this.map.delete(key);
}
}
function elementCount(parsed: ParsedFile): number {
return parsed.parse?.elements.length ?? 0;
}
export interface IndexRecordsCacheEntry {
stat: IndexedFile["stat"];
records: IndexRecords;
/** "shallow" for art-asset scans (.w3x), "full" for parsed XML. */
kind: "shallow" | "full";
}
/**
* Cache for per-file index records (top-level assets, defines, includes,
* xi:include targets). Records are tiny compared to DOM trees or line maps of
* multi-megabyte model files, so the capacity comfortably covers a whole mod
* (Corona: ~9k files) and rebuilds never re-read unchanged files.
*/
export class IndexRecordsCache {
private map = new Map<string, IndexRecordsCacheEntry>();
constructor(private capacity = 16384) {}
get(path: string): IndexRecordsCacheEntry | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
/** Number of cached record sets (for diagnostics). */
get size(): number {
return this.map.size;
}
set(path: string, entry: IndexRecordsCacheEntry): void {
const key = normKey(path);
this.map.delete(key);
this.map.set(key, entry);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
@@ -56,17 +166,21 @@ export class DocumentCache {
}
/**
* Cache for shallow-scanned art-asset documents (.w3x and content-sniffed
* XML files). The retained records are tiny (top-level assets, includes,
* defines) compared to a full DOM, so the capacity can be much larger.
* Cache for Include/@source (and xi:include/@href) resolution results.
*
* Resolving a source performs synchronous statSync existence checks against
* every search base; a Corona build issues ~110k of them (tens of seconds on
* a mechanical drive). Content edits never change *existence*, so this cache
* survives content rebuilds and is only cleared when files are created or
* deleted (or on a forced reindex).
*/
export class ShallowScanCache {
private map = new Map<string, ParsedFile>();
export class IncludeResolveCache {
private map = new Map<string, ResolveResult>();
private manifestMap = new Map<string, string | null>();
constructor(private capacity = 8192) {}
constructor(private capacity = 262144) {}
get(path: string): ParsedFile | undefined {
const key = normKey(path);
get(key: string): ResolveResult | undefined {
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
@@ -74,21 +188,39 @@ export class ShallowScanCache {
return hit;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
set(key: string, result: ResolveResult): void {
this.map.delete(key);
this.map.set(key, parsed);
this.map.set(key, result);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
invalidate(path: string): void {
this.map.delete(normKey(path));
getManifest(key: string): string | null | undefined {
const hit = this.manifestMap.get(key);
if (hit === undefined) return undefined;
this.manifestMap.delete(key);
this.manifestMap.set(key, hit);
return hit;
}
setManifest(key: string, path: string | null): void {
this.manifestMap.delete(key);
this.manifestMap.set(key, path);
if (this.manifestMap.size > this.capacity) {
const oldest = this.manifestMap.keys().next().value;
if (oldest !== undefined) this.manifestMap.delete(oldest);
}
}
clear(): void {
this.map.clear();
this.manifestMap.clear();
}
/** Number of cached resolutions (for diagnostics). */
get size(): number {
return this.map.size;
}
}