UNPKG

@pgxsinkit/pgwasm

Version:
460 lines (413 loc) • 18.8 kB
/** * An in-memory `RepackedPort` with the same exclusive-ownership, volatile/durable, and fault-injection * behaviour as the real OPFS port. It is production source, not test support: the sync broker * (`../broker/`) and any engine-agnostic host need a port that works off the main thread and outside a * browser, and every store-level test in the repo already diffs real behaviour against it. */ import { StoreOwnedError } from "./errors"; import { OWNED_FILE_NAMES } from "./port"; import type { OwnedFileName, RepackedFileHandle, RepackedPort, RepackedPortEntry } from "./port"; export type MemoryOperation = "enumerate" | "acquire" | "getSize" | "read" | "write" | "truncate" | "flush"; type FaultOutcome = "short" | "throw-before" | "throw-after" | "quota"; export interface MemoryFault { operation: MemoryOperation; outcome: FaultOutcome; file?: string; label?: string; bytes?: number; occurrence?: number; } interface WriteEffect { id: number; file: OwnedFileName; kind: "write"; at: number; data: Uint8Array; } interface TruncateEffect { id: number; file: OwnedFileName; kind: "truncate"; size: number; } type MemoryEffect = WriteEffect | TruncateEffect; export type TerminationDecision = "absent" | "full" | number; export interface MemoryEffectSummary { id: number; file: OwnedFileName; operation: "write" | "truncate"; bytes: number; } export interface MemoryOperationSummary { readonly operation: MemoryOperation; readonly file: OwnedFileName | undefined; readonly label: string; /** * Bytes this operation copied into the DURABLE image. Recorded on `flush` only, where it is the * deterministic stand-in for flush cost: a flush must copy the bytes written since the previous * flush, never the size of the file. Absent on every other operation. */ readonly copiedBytes?: number; } /** The private, still-writable form of a record: `handleFlush` fills `copiedBytes` in after the copy. */ interface MemoryOperationRecord { readonly operation: MemoryOperation; readonly file: OwnedFileName | undefined; readonly label: string; copiedBytes?: number; } function clone(bytes: Uint8Array): Uint8Array { return bytes.slice(); } /** * Replay a write onto a MATERIALIZED copy, for `terminate()`. Pure, and deliberately so: the * termination model rebuilds a fresh image from the durable bytes plus the effect log, and nothing in * it may alias the live volatile buffer. */ function applyWrite(current: Uint8Array, at: number, source: Uint8Array): Uint8Array { const required = at + source.byteLength; const next = required <= current.byteLength ? clone(current) : new Uint8Array(required); if (next.byteLength > current.byteLength) next.set(current); next.set(source, at); return next; } /** The `terminate()` twin of `applyWrite`, and pure for the same reason. */ function applyTruncate(current: Uint8Array, size: number): Uint8Array { if (size === current.byteLength) return clone(current); const next = new Uint8Array(size); next.set(current.subarray(0, Math.min(size, current.byteLength))); return next; } /** * Resize a LIVE image — the volatile file or the durable one — keeping the bytes it already holds and * reusing spare capacity. * * A live image is always `new Uint8Array(buffer, 0, length)`, so its backing buffer may be larger * than the file and `bytes.buffer.byteLength` is its capacity. That separation is what keeps this port * usable at real store sizes: replacing the whole array on every write and every truncate — which is * what the pure `apply*` helpers above do — makes each operation cost the size of the FILE rather than * the size of the operation, so an in-memory store grows QUADRATICALLY in the bytes written. Seeding a * 41 MB Postgres datadir (1,477 files, ~3,000 arena writes and growth truncates) took 98 seconds that * way and copied roughly 80 GB. Nothing needed those copies: the effect log holds the CALLER's source * bytes rather than the file's, `durableBytes()` clones on the way out, and the two images never share * a buffer — so both are safe to mutate in place, and the same seed takes about a second. * * Growth doubles, and any newly exposed region is zeroed: capacity left behind by an earlier truncate * still holds that truncated file's bytes, and a hole must read as zeros. */ function resizeImage(current: Uint8Array, size: number): Uint8Array { if (size <= current.byteLength) return new Uint8Array(current.buffer, 0, size); if (size <= current.buffer.byteLength) { const grown = new Uint8Array(current.buffer, 0, size); grown.fill(0, current.byteLength, size); return grown; } const capacity = Math.max(size, current.buffer.byteLength * 2, 64); const grown = new Uint8Array(new ArrayBuffer(capacity), 0, size); grown.set(current); return grown; } function isOwnedFileName(name: string): name is OwnedFileName { return (OWNED_FILE_NAMES as readonly string[]).includes(name); } class MemoryHandle implements RepackedFileHandle { readonly name: OwnedFileName; readonly #port: MemoryRepackedPort; readonly #epoch: number; #closed = false; constructor(port: MemoryRepackedPort, name: OwnedFileName, epoch: number) { this.#port = port; this.name = name; this.#epoch = epoch; } getSize(label: string): number { this.#assertOpen(); return this.#port.handleGetSize(this.name, label); } read(target: Uint8Array, at: number, label: string): number { this.#assertOpen(); return this.#port.handleRead(this.name, target, at, label); } write(source: Uint8Array, at: number, label: string): number { this.#assertOpen(); return this.#port.handleWrite(this.name, source, at, label); } truncate(size: number, label: string): void { this.#assertOpen(); this.#port.handleTruncate(this.name, size, label); } flush(label: string): void { this.#assertOpen(); this.#port.handleFlush(this.name, label); } close(): void { if (this.#closed) return; this.#closed = true; if (this.#epoch === this.#port.epoch) this.#port.handleClose(this.name); } #assertOpen(): void { if (this.#closed || this.#epoch !== this.#port.epoch) { throw new Error(`memory handle ${this.name} is closed`); } } } export class MemoryRepackedPort implements RepackedPort { readonly #durable = new Map<OwnedFileName, Uint8Array>(); readonly #volatile = new Map<OwnedFileName, Uint8Array>(); readonly #entryKinds = new Map<string, "file" | "directory">(); readonly #open = new Set<OwnedFileName>(); readonly #faults: MemoryFault[] = []; readonly #operations: MemoryOperationRecord[] = []; #effects: MemoryEffect[] = []; #nextEffectId = 1; #epoch = 1; get epoch(): number { return this.#epoch; } async enumerate(label: string): Promise<readonly RepackedPortEntry[]> { this.#recordOperation("enumerate", undefined, label); const fault = this.#takeFault("enumerate", undefined, label); if (fault !== undefined) throw new Error(`injected enumerate failure ${fault.outcome}`); return [...this.#entryKinds] .sort(([left], [right]) => left.localeCompare(right)) .map(([name, kind]) => ({ name, kind, })); } async acquire(name: OwnedFileName, label: string): Promise<RepackedFileHandle> { this.#recordOperation("acquire", name, label); const fault = this.#takeFault("acquire", name, label); if (fault?.outcome === "throw-before") throw new Error("injected acquire failure before effect"); if (this.#open.has(name)) throw new StoreOwnedError(); if (this.#entryKinds.get(name) === "directory") throw new Error(`memory entry ${name} is not a file`); if (!this.#volatile.has(name)) { this.#volatile.set(name, new Uint8Array()); this.#durable.set(name, new Uint8Array()); this.#entryKinds.set(name, "file"); } if (fault !== undefined) throw new Error(`injected acquire failure ${fault.outcome}`); this.#open.add(name); return new MemoryHandle(this, name, this.#epoch); } injectEntry(name: string, kind: "file" | "directory"): void { if (isOwnedFileName(name) && this.#open.has(name)) { throw new Error(`cannot replace acquired memory entry ${name}`); } this.#entryKinds.set(name, kind); if (isOwnedFileName(name)) { // Replacing an owned entry replaces BOTH images, so anything still pending for it describes a // file that no longer exists; keeping it would let a later flush or termination replay it onto // the replacement and break `durable + pending effects == volatile`. this.#effects = this.#effects.filter((effect) => effect.file !== name); if (kind === "file") { this.#volatile.set(name, new Uint8Array()); this.#durable.set(name, new Uint8Array()); } else { this.#volatile.delete(name); this.#durable.delete(name); } } } injectFault(fault: MemoryFault): void { if (fault.outcome === "quota" && fault.operation !== "truncate") { throw new TypeError("quota faults are supported only for arena growth truncates"); } if (fault.bytes !== undefined && (!Number.isSafeInteger(fault.bytes) || fault.bytes < 0)) { throw new TypeError("fault byte count must be a non-negative safe integer"); } if (fault.occurrence !== undefined && (!Number.isSafeInteger(fault.occurrence) || fault.occurrence < 0)) { throw new TypeError("fault occurrence must be a non-negative safe integer"); } this.#faults.push({ ...fault }); } pendingEffects(): readonly MemoryEffectSummary[] { return this.#effects.map((effect) => ({ id: effect.id, file: effect.file, operation: effect.kind, bytes: effect.kind === "write" ? effect.data.byteLength : effect.size, })); } openHandleCount(): number { return this.#open.size; } observedOperations(): readonly MemoryOperationSummary[] { return Object.freeze(this.#operations.map((operation) => Object.freeze({ ...operation }))); } clearObservedOperations(): void { this.#operations.length = 0; } durableBytes(name: OwnedFileName): Uint8Array { return clone(this.#durable.get(name) ?? new Uint8Array()); } terminate(decisions: Readonly<Record<number, TerminationDecision>> = {}): void { const materialized = new Map<OwnedFileName, Uint8Array>(); for (const [name, bytes] of this.#durable) materialized.set(name, clone(bytes)); for (const effect of this.#effects) { const decision = decisions[effect.id] ?? "absent"; if (decision === "absent") continue; const current = materialized.get(effect.file) ?? new Uint8Array(); if (effect.kind === "truncate") { if (decision !== "full") { throw new TypeError("truncate termination decisions must be absent or full"); } materialized.set(effect.file, applyTruncate(current, effect.size)); } else { const bytes = decision === "full" ? effect.data.byteLength : decision; if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > effect.data.byteLength) { throw new TypeError("write termination prefix is outside the effect"); } if (bytes > 0) materialized.set(effect.file, applyWrite(current, effect.at, effect.data.subarray(0, bytes))); } } this.#durable.clear(); this.#volatile.clear(); for (const [name, bytes] of materialized) { this.#durable.set(name, clone(bytes)); this.#volatile.set(name, clone(bytes)); } this.#effects = []; this.#faults.length = 0; this.#open.clear(); this.#epoch += 1; } handleGetSize(name: OwnedFileName, label: string): number { this.#recordOperation("getSize", name, label); const fault = this.#takeFault("getSize", name, label); if (fault !== undefined) throw new Error(`injected getSize failure ${fault.outcome}`); return this.#file(name).byteLength; } handleRead(name: OwnedFileName, target: Uint8Array, at: number, label: string): number { this.#validateRange(at, target.byteLength); this.#recordOperation("read", name, label); const fault = this.#takeFault("read", name, label); if (fault?.outcome === "throw-before" || fault?.outcome === "throw-after") { throw new Error(`injected read failure ${fault.outcome === "throw-before" ? "before" : "after"} effect`); } const source = this.#file(name); const available = Math.max(0, Math.min(target.byteLength, source.byteLength - at)); const count = fault?.outcome === "short" ? Math.min(available, fault.bytes ?? 0) : available; target.set(source.subarray(at, at + count)); return count; } handleWrite(name: OwnedFileName, source: Uint8Array, at: number, label: string): number { this.#validateRange(at, source.byteLength); this.#recordOperation("write", name, label); const fault = this.#takeFault("write", name, label); if (fault?.outcome === "throw-before") throw new Error("injected write failure before effect"); const count = fault?.outcome === "short" || fault?.outcome === "throw-after" ? Math.min(source.byteLength, fault.bytes ?? source.byteLength) : source.byteLength; if (count > 0) this.#recordWrite(name, at, source.subarray(0, count)); if (fault?.outcome === "throw-after") throw new Error("injected write failure after effect"); return count; } handleTruncate(name: OwnedFileName, size: number, label: string): void { this.#validateRange(size, 0); this.#recordOperation("truncate", name, label); const fault = this.#takeFault("truncate", name, label); if (fault?.outcome === "quota") throw new DOMException("injected arena quota exhaustion", "QuotaExceededError"); if (fault?.outcome === "throw-before") throw new Error("injected truncate failure before effect"); this.#volatile.set(name, resizeImage(this.#file(name), size)); this.#effects.push({ id: this.#nextEffectId++, file: name, kind: "truncate", size }); if (fault?.outcome === "throw-after") throw new Error("injected truncate failure after effect"); } handleFlush(name: OwnedFileName, label: string): void { const record = this.#recordOperation("flush", name, label); record.copiedBytes = 0; const fault = this.#takeFault("flush", name, label); // A throw-before flush made nothing durable and left every effect pending, so it copies nothing. if (fault?.outcome === "throw-before") throw new Error("injected flush failure before effect"); record.copiedBytes = this.#makeDurable(name); if (fault?.outcome === "throw-after") throw new Error("injected flush failure after effect"); } handleClose(name: OwnedFileName): void { this.#open.delete(name); } #file(name: OwnedFileName): Uint8Array { const bytes = this.#volatile.get(name); if (bytes === undefined) throw new Error(`memory file ${name} was not acquired`); return bytes; } #recordWrite(name: OwnedFileName, at: number, source: Uint8Array): void { // The effect log owns its own copy: it is replayed against a MATERIALIZED image in `terminate()`, // long after the live buffer moved on. const data = clone(source); const current = this.#file(name); const required = at + data.byteLength; const target = required > current.byteLength ? resizeImage(current, required) : current; target.set(data, at); this.#volatile.set(name, target); this.#effects.push({ id: this.#nextEffectId++, file: name, kind: "write", at, data }); } /** * Apply this file's pending effects onto the durable image, in order, and drop them. Returns the * bytes copied. * * The old implementation made a file durable by cloning the whole volatile image, so every flush * cost the size of the FILE. That is what the broker charges to every committing statement: a guest * `fd_sync` becomes a store-wide `strictSync()`, and on a seeded Postgres datadir the arena is tens * of MB, which showed up as a flat ~25 ms per writing statement in the browser benchmark. The effect * log already IS the dirty set — `durable + pending effects == volatile` for every file, at all * times — so replaying it costs the bytes written since the previous flush instead, and reproduces * the volatile image byte for byte because both sides take the same steps through `resizeImage`. */ #makeDurable(name: OwnedFileName): number { let image = this.#durable.get(name) ?? new Uint8Array(); const remaining: MemoryEffect[] = []; let copied = 0; for (const effect of this.#effects) { if (effect.file !== name) { remaining.push(effect); continue; } if (effect.kind === "truncate") { const resized = resizeImage(image, effect.size); // Growth past the current capacity carries the bytes already held into a new buffer; every // other resize is a view onto the same one. A truncate copies nothing of its own. if (resized.buffer !== image.buffer) copied += image.byteLength; image = resized; continue; } const required = effect.at + effect.data.byteLength; if (required > image.byteLength) { const grown = resizeImage(image, required); if (grown.buffer !== image.buffer) copied += image.byteLength; image = grown; } image.set(effect.data, effect.at); copied += effect.data.byteLength; } this.#durable.set(name, image); this.#effects = remaining; return copied; } #takeFault(operation: MemoryOperation, file: string | undefined, label: string): MemoryFault | undefined { const index = this.#faults.findIndex( (fault) => fault.operation === operation && (fault.file === undefined || fault.file === file) && (fault.label === undefined || fault.label === label), ); if (index < 0) return undefined; const fault = this.#faults[index]!; if ((fault.occurrence ?? 0) > 0) { this.#faults[index] = { ...fault, occurrence: fault.occurrence! - 1 }; return undefined; } return this.#faults.splice(index, 1)[0]; } #recordOperation(operation: MemoryOperation, file: OwnedFileName | undefined, label: string): MemoryOperationRecord { const record: MemoryOperationRecord = { operation, file, label }; this.#operations.push(record); return record; } #validateRange(at: number, length: number): void { if (!Number.isSafeInteger(at) || !Number.isSafeInteger(length) || at < 0 || length < 0) { throw new RangeError("memory port range is invalid"); } if (!Number.isSafeInteger(at + length)) throw new RangeError("memory port range exceeds safe integers"); } }