Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ This library targets the gap between those options: an ephemeral SQLite database

A plain `SharedArrayBuffer` is fixed-size at construction, which forces a bad choice for a database with a large ceiling: either commit the maximum capacity up front (hundreds of MiB for a mostly-small database), or re-allocate bigger buffers as data grows and re-broadcast the new handles to every worker mid-flight, copying bytes and re-coordinating every open connection.

`SharedArrayBuffer.prototype.grow` removes that dilemma. Buffers start at the initial capacity and grow in place up to `maxByteLength`, and every worker that already holds the runtime observes the new length immediately — no re-sharing, no copies. Files are split into segments so each buffer's reservation stays bounded, and write-ahead segments past the logical end drop out of the active set after `PRAGMA wal_checkpoint(TRUNCATE)` (tracked in diagnostics; the underlying allocation is not returned to the OS, since shared buffers cannot shrink).
`SharedArrayBuffer.prototype.grow` removes that dilemma. Buffers start at the initial capacity and grow in place up to `maxByteLength`, and every worker that already holds the runtime observes the new length immediately — no re-sharing, no copies. Each growth doubles the buffer, starting at 64 KiB and adding at most 1 MiB per step (or exactly what a larger write needs), so a run of page-sized appends does not resize a buffer on every write. Files are split into segments so each buffer's reservation stays bounded, and write-ahead segments past the logical end drop out of the active set after `PRAGMA wal_checkpoint(TRUNCATE)` (tracked in diagnostics; the underlying allocation is not returned to the OS, since shared buffers cannot shrink).

On engines without growable `SharedArrayBuffer`, `allowFixedCapacityFallback: true` restores the fixed-size strategy at the cost of allocating each file's max capacity immediately.

Expand Down Expand Up @@ -134,7 +134,7 @@ When an application deliberately terminates a worker, it can recover that worker

## Attached databases

Register each attached database in the shared runtime before executing `ATTACH DATABASE`. While the owning context remains live, atomic transactions spanning the primary and attached databases are coordinated through SQLite's super-journal path. Transactions opened directly on an attached database are serialized across connections because attached databases use SQLite's rollback-journal protocol rather than the primary database's write-ahead path.
Register each attached database in the shared runtime before executing `ATTACH DATABASE`. While the owning context remains live, atomic transactions spanning the primary and attached databases are coordinated through SQLite's super-journal path. Transactions opened directly on an attached database are serialized across connections because attached databases use SQLite's rollback-journal protocol rather than the primary database's write-ahead path. For the same reason an attached database reserves only its database file: `ensureMemoryWriteAheadDatabaseFiles` returns an empty `writeAhead` list for any filename other than the runtime's primary database.

```ts
import { ensureMemoryWriteAheadDatabaseFiles } from 'memory-write-ahead-vfs';
Expand All @@ -149,7 +149,7 @@ import { ensureMemoryWriteAheadDatabaseFiles, registerMemoryWriteAheadSharedFile

// Worker that first opens the attached database:
const attachedFiles = ensureMemoryWriteAheadDatabaseFiles(workerRuntime, '/analytics.sqlite');
postMessage({ files: [attachedFiles.database, ...attachedFiles.writeAhead] });
postMessage({ files: [attachedFiles.database] });

// Runtime-owning parent after receiving that structured clone:
registerMemoryWriteAheadSharedFiles({
Expand Down
60 changes: 42 additions & 18 deletions src/memoryWriteAheadSharedRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const LOCK_CLOCK_MODULO_MS = 0x7fffffff;
const MAX_INT32_ATOMIC_COUNTER = 0x7fffffff;
export const MEMORY_WRITE_AHEAD_OWNER_LEASE_SLOT_COUNT = 32;
const MIN_SEGMENT_COUNT = 1;
const MIN_AMORTIZED_SEGMENT_CAPACITY_BYTES = 64 * KIB;
const MAX_SEGMENT_GROWTH_STEP_BYTES = MIB;

const memoryWriteAheadRuntimeLogger = noopMemoryWriteAheadLogger;

Expand Down Expand Up @@ -122,7 +124,7 @@ export type MemoryWriteAheadSharedRuntime = {

export type MemoryWriteAheadDatabaseFiles = {
database: MemoryWriteAheadSharedFile;
writeAhead: [MemoryWriteAheadSharedFile, MemoryWriteAheadSharedFile];
writeAhead: MemoryWriteAheadSharedFile[];
};

export type MemoryWriteAheadSharedFilesRegistration = {
Expand Down Expand Up @@ -564,7 +566,7 @@ const ensureLocalRuntimeLease = (

// Without COOP/COEP response headers a browser context is not cross-origin isolated and exposes
// no SharedArrayBuffer at all; the generic capability error would hide that deployment mistake.
const createGrowableSharedArrayBufferSupportError = () => {
const createGrowableSharedArrayBufferSupportError = (reservationError?: unknown) => {
if (globalThis.crossOriginIsolated === false) {
const error = new Error(
'MemoryWriteAheadVFS requires growable SharedArrayBuffer support: this page is not cross-origin isolated. Serve it with the Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp response headers.'
Expand All @@ -576,6 +578,15 @@ const createGrowableSharedArrayBufferSupportError = () => {
return error;
}

if (reservationError !== undefined) {
return new Error(
`MemoryWriteAheadVFS could not reserve a growable SharedArrayBuffer: ${
reservationError instanceof Error ? reservationError.message : String(reservationError)
}`,
{ cause: reservationError }
);
}

return new Error('MemoryWriteAheadVFS requires growable SharedArrayBuffer support');
};

Expand Down Expand Up @@ -607,9 +618,9 @@ const createSharedSegmentData = (
data: buffer,
};
}
} catch (_error) {
} catch (reservationError) {
if (!allowFixedCapacityFallback) {
throw createGrowableSharedArrayBufferSupportError();
throw createGrowableSharedArrayBufferSupportError(reservationError);
}
}

Expand Down Expand Up @@ -776,7 +787,12 @@ const growSegment = (segment: MemoryWriteAheadSharedSegment, requiredBytes: numb
}

if (segment.capacityMode === 'growable') {
segment.data.grow(requiredBytes);
const currentCapacityBytes = segment.data.byteLength;
const amortizedCapacityBytes = Math.min(
Math.max(MIN_AMORTIZED_SEGMENT_CAPACITY_BYTES, currentCapacityBytes * 2),
currentCapacityBytes + MAX_SEGMENT_GROWTH_STEP_BYTES
);
segment.data.grow(Math.min(segment.maxCapacityBytes, Math.max(requiredBytes, amortizedCapacityBytes)));
return true;
}

Expand Down Expand Up @@ -927,14 +943,21 @@ export const ensureMemoryWriteAheadDatabaseFiles = (
dbFilename: string
): MemoryWriteAheadDatabaseFiles => {
const normalizedDbFilename = normalizeMemoryWriteAheadPathname(dbFilename);
const database = ensureSharedFile(
runtime,
normalizedDbFilename,
runtime.capacities.initialDatabaseCapacityBytes,
runtime.capacities.maxDatabaseCapacityBytes
);

// Only the runtime's primary database is opened with a WriteAhead. Attached databases use SQLite's rollback
// journal, so a -wa0/-wa1 pair for them would only consume growable SharedArrayBuffer reservation.
if (normalizedDbFilename !== runtime.dbFilename) {
return { database, writeAhead: [] };
}

return {
database: ensureSharedFile(
runtime,
normalizedDbFilename,
runtime.capacities.initialDatabaseCapacityBytes,
runtime.capacities.maxDatabaseCapacityBytes
),
database,
writeAhead: [
ensureSharedFile(
runtime,
Expand Down Expand Up @@ -1095,13 +1118,7 @@ export const recoverTerminatedMemoryWriteAheadRuntimeOwner = (
};

const resetMemoryWriteAheadRuntimeState = (runtime: MemoryWriteAheadSharedRuntime) => {
recoverAbandonedRuntimeOwnerLeases(runtime);
const runtimeMeta = getRuntimeMeta(runtime);
const openHandleCount = getOpenHandleCount(runtime);

if (openHandleCount > 0) {
throw new Error(`Cannot reset MemoryWriteAheadVFS while ${openHandleCount} open handles remain`);
}

Object.values(runtime.files).forEach((file) => {
const meta = getFileMeta(file);
Expand All @@ -1127,7 +1144,8 @@ const resetMemoryWriteAheadRuntimeState = (runtime: MemoryWriteAheadSharedRuntim
};

// With a dbFilename, resets only that runtime so concurrent clients (e.g. other databases) keep their
// runtimes; without one, resets every registered runtime (used by test teardown for a clean slate).
// runtimes, and refuses while that runtime has open handles. Without one, resets and deregisters every
// registered runtime even when handles were never released (used by test teardown for a clean slate).
export const resetMemoryWriteAheadSharedRuntime = (dbFilename?: string) => {
const registry = getRuntimeRegistry();

Expand All @@ -1138,6 +1156,12 @@ export const resetMemoryWriteAheadSharedRuntime = (dbFilename?: string) => {
return;
}

recoverAbandonedRuntimeOwnerLeases(runtime);
const openHandleCount = getOpenHandleCount(runtime);
if (openHandleCount > 0) {
throw new Error(`Cannot reset MemoryWriteAheadVFS while ${openHandleCount} open handles remain`);
}

resetMemoryWriteAheadRuntimeState(runtime);
registry.delete(normalizedDbFilename);
return;
Expand Down
65 changes: 64 additions & 1 deletion tests/browser/memoryWriteAheadVfs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ const browserFixtureNames = [
'runAbandonedWorkerLeaseRecovery',
'runAttachedDatabaseAtomicTransaction',
'runAttachedDatabaseConcurrentWrites',
'runAttachedDatabaseWithoutWriteAheadFiles',
'runBoundedSegmentGrowth',
'runCapacityExceededFailure',
'runCleanSlateResetWithOpenHandle',
'runDuplicatePendingOpenRejection',
'runDeferredWriteUpgrade',
'runFileLockRegression',
'runMultipleRuntimeIsolation',
'runOwnerLeaseClaimRecovery',
'runOwnerLeaseExhaustionRecovery',
'runReadmeQuickstart',
'runRejectedGrowableReservation',
'runRollbackAndPragmaBehavior',
'runRuntimeLifecycle',
'runRuntimeOptionValidation',
Expand All @@ -25,6 +29,7 @@ const browserFixtureNames = [
'runSnapshotIsolation',
'runStaleDeferredWriteRejection',
'runStaleOwnedLeaseSlotPreservation',
'runTargetedResetRefusal',
'runWalRoundTrip',
'runWorkerConcurrency',
'runZeroByteWriteAndDiagnostics',
Expand Down Expand Up @@ -61,6 +66,16 @@ test.describe('MemoryWriteAheadVFS browser integration', () => {
]);
});

test('names the allocator error when a growable SharedArrayBuffer reservation is rejected', async ({ page }) => {
const result = await runBrowserFixture(page, 'runRejectedGrowableReservation');

expect(result.message).toBe(
'MemoryWriteAheadVFS could not reserve a growable SharedArrayBuffer: Array buffer allocation failed'
);
expect(result.causeIsAllocatorError).toBe(true);
expect(result.runtimeRegistered).toBe(false);
});

test('tracks lifecycle diagnostics and refuses reset while handles are open', async ({ page }) => {
const result = await runBrowserFixture(page, 'runRuntimeLifecycle');

Expand All @@ -73,6 +88,23 @@ test.describe('MemoryWriteAheadVFS browser integration', () => {
expect(result.resetWhileOpenMessage).toBe('Cannot reset MemoryWriteAheadVFS while 3 open handles remain');
});

test('resets and deregisters every runtime even when one still holds an open handle', async ({ page }) => {
const result = await runBrowserFixture(page, 'runCleanSlateResetWithOpenHandle');

expect(result.resetError).toBeNull();
expect(result.leakingRuntimeRegistered).toBe(false);
expect(result.followingRuntimeRegistered).toBe(false);
expect(result.followingFileSizeAfterReset).toBe(0);
});

test('refuses a targeted reset with open handles without touching the runtime', async ({ page }) => {
const result = await runBrowserFixture(page, 'runTargetedResetRefusal');

expect(result.resetMessage).toBe('Cannot reset MemoryWriteAheadVFS while 1 open handles remain');
expect(result.openHandleCountAfterRefusal).toBe(1);
expect(result.fileSizeAfterRefusal).toBe(4);
});

test('reopens committed WAL data from the shared in-memory runtime', async ({ page }) => {
const result = await runBrowserFixture(page, 'runWalRoundTrip');

Expand Down Expand Up @@ -112,6 +144,28 @@ test.describe('MemoryWriteAheadVFS browser integration', () => {
expect(result.reclaimedSegmentCount).toBe(0x7fffffff);
});

test('grows segments in bounded steps and keeps grown bytes visible to cloned runtimes', async ({ page }) => {
const { clonedReader, finalPartialSegment, largeSegment, smallAppends } = await runBrowserFixture(
page,
'runBoundedSegmentGrowth'
);

expect(smallAppends.afterFirstAppend).toBe(64 * 1024);
expect(smallAppends.afterSecondAppend).toBe(64 * 1024);
expect(smallAppends.logicalSizeBytes).toBe(1024 * 1024 + 2 * 4096);
expect(smallAppends.bytesReadPastEnd).toBe(0);
expect(smallAppends.pastEndUntouched).toBe(true);
expect(largeSegment.secondSegmentBytes).toBe(4 * 1024 * 1024);
expect(finalPartialSegment.secondSegmentBytes).toBe(1000);
expect(finalPartialSegment.bytesRead).toBe(64 * 1024 + 1000);
expect(finalPartialSegment.readbackMatches).toBe(true);
expect(clonedReader.bytesRead).toBe(64 * 1024 + 4096);
expect(clonedReader.readbackMatches).toBe(true);
expect(clonedReader.tailUntouched).toBe(true);
expect(clonedReader.readerLogicalSizeBytes).toBe(64 * 1024 + 4096);
expect(clonedReader.readerSecondSegmentBytes).toBe(clonedReader.writerSecondSegmentBytes);
});

test('extends logical size for zero-byte writes and records high-water diagnostics', async ({ page }) => {
const result = await runBrowserFixture(page, 'runZeroByteWriteAndDiagnostics');

Expand Down Expand Up @@ -160,7 +214,16 @@ test.describe('MemoryWriteAheadVFS browser integration', () => {

expect(result.mainRows).toEqual(['main']);
expect(result.attachedRows).toEqual(['attached']);
expect(result.fileCount).toBe(6);
expect(result.fileCount).toBe(4);
});

test('reserves no write-ahead files for an attached database', async ({ page }) => {
const result = await runBrowserFixture(page, 'runAttachedDatabaseWithoutWriteAheadFiles');

expect(result.primaryWriteAheadFileCount).toBe(2);
expect(result.attachedWriteAheadFileCount).toBe(0);
expect(result.pathnames).toEqual(['main', 'main-wa0', 'main-wa1', 'attached']);
expect(result.attachedRows).toEqual(['attached']);
});

test('serializes concurrent transactions opened directly on an attached shared database', async ({ page }) => {
Expand Down
Loading
Loading