@pgxsinkit/pgwasm
Version:
460 lines (413 loc) • 18.8 kB
text/typescript
/**
* 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");
}
}