Skip to content
Open
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
1,113 changes: 1,113 additions & 0 deletions docs/superpowers/plans/2026-05-28-cache-buffer-perf.md

Large diffs are not rendered by default.

253 changes: 253 additions & 0 deletions docs/superpowers/specs/2026-05-28-cache-buffer-perf-design.md

Large diffs are not rendered by default.

206 changes: 137 additions & 69 deletions lib/CacheAndBufferLayer.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions test/memory/test_tojson.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,15 @@ describe(__filename, () => {
// @ts-expect-error TS(2775): Assertions require every name in the call target t... Remove this comment to see the full error message
assert.deepEqual(await db.get('key'), ['toJSON 0']);
});
it("object property containing a function survives the round-trip via the cache", async () => {
// cloneIn preserves functions inside objects (they hit the `typeof !== 'object'` branch and
// pass through unchanged). cloneOut therefore needs to tolerate function-containing values
// when reading from the cache; structuredClone would throw DataCloneError without the
// cloneIn fallback in cloneOut.
const fn = () => "hello";
await db.set("key", { fn });
const out: any = await db.get("key");
assert.equal(typeof out.fn, "function");
assert.equal(out.fn(), "hello");
});
});
98 changes: 98 additions & 0 deletions test/mock/test_dirty_set.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as ueberdb from '../../index';
import {ConsoleLogger} from '../../lib/logging';
import {afterEach, describe, expect, it} from 'vitest';

type MockSettings = {mock?: any};

const logger = new ConsoleLogger();

const dirtyKeys = (db: any): Set<string> => (db.db as any)._dirtyKeys;

describe(__filename, () => {
let db: any = null;
let mock: any = null;

const createDb = async (wrapperSettings: Record<string, unknown> = {}) => {
const settings: MockSettings = {};
db = new ueberdb.Database('mock', settings, {json: false, ...wrapperSettings}, logger);
await db.init();
mock = settings.mock;
mock.once('init', (cb: any) => cb());
};

afterEach(async () => {
if (mock != null) {
mock.removeAllListeners();
mock.once('close', (cb: any) => cb());
mock = null;
}
if (db != null) {
await db.close();
db = null;
}
});

it('set() adds the key to _dirtyKeys; flush() drains it', async () => {
// writeInterval=1e9 means the lazy flush timer effectively never fires; we drive flush manually.
await createDb({writeInterval: 1e9});
mock.on('set', (k: any, v: any, cb: any) => cb());
const writeP = db.set('k', 'v');
// _setLocked is reached via `await this._lock(key)` which (with no contention) resolves
// as a microtask. setImmediate fires after the microtask queue is fully drained, so
// _dirtyKeys.add(key) and buffer.set(key, entry) have both completed by the time we resume.
await new Promise((r) => setImmediate(r));
expect(dirtyKeys(db).has('k')).toBe(true);
expect(dirtyKeys(db).size).toBe(1);
await Promise.all([writeP, db.flush()]);
expect(dirtyKeys(db).size).toBe(0);
});

it('re-set during an in-flight write keeps the key in _dirtyKeys', async () => {
await createDb({writeInterval: 1e9});
let releaseFirstWrite: (() => void) | null = null;
const firstWriteSeen = new Promise<void>((resolve) => {
mock.once('set', (k: any, v: any, cb: any) => {
resolve();
releaseFirstWrite = () => cb();
});
});
const firstWriteP = db.set('k', 'v1');
const flushedP = db.flush();
await firstWriteSeen;
// While the first write is in flight, queue a second write to the same key.
let releaseSecondWrite: (() => void) | null = null;
const secondWriteSeen = new Promise<void>((resolve) => {
mock.once('set', (k: any, v: any, cb: any) => {
resolve();
releaseSecondWrite = () => cb();
});
});
const secondWriteP = db.set('k', 'v2');
// The key must remain in _dirtyKeys: the old in-flight entry is being written,
// and a new dirty entry has taken its place in the buffer.
expect(dirtyKeys(db).has('k')).toBe(true);
// Release the first write. markDone for v1 will run, hit the reference-equality guard,
// and see that the buffer entry is no longer v1's — so the key MUST remain in _dirtyKeys.
releaseFirstWrite!();
await new Promise((r) => setImmediate(r));
expect(dirtyKeys(db).has('k')).toBe(true);
// Wait for the second write to be picked up by the flush loop.
await secondWriteSeen;
releaseSecondWrite!();
await Promise.all([firstWriteP, secondWriteP, flushedP]);
// Drain any remaining dirty entry (the v2 write) — it may have been picked up by the
// same flush() loop, but to be robust against scheduling we call flush() once more.
await db.flush();
expect(dirtyKeys(db).size).toBe(0);
});

it('failed write removes the key from _dirtyKeys and rejects the caller', async () => {
await createDb({writeInterval: 1e9});
mock.on('set', (k: any, v: any, cb: any) => cb(new Error('boom')));
const writeP = db.set('k', 'v');
const flushedP = db.flush();
await expect(writeP).rejects.toThrow('boom');
await flushedP;
expect(dirtyKeys(db).size).toBe(0);
});
});
77 changes: 77 additions & 0 deletions test/mock/test_lazy_flush.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as ueberdb from '../../index';
import {ConsoleLogger} from '../../lib/logging';
import {afterEach, describe, expect, it} from 'vitest';

type MockSettings = {mock?: any};

const logger = new ConsoleLogger();

const flushTimer = (db: any): unknown => (db.db as any)._flushTimer;

describe(__filename, () => {
let db: any = null;
let mock: any = null;

const createDb = async (wrapperSettings: Record<string, unknown> = {}) => {
const settings: MockSettings = {};
db = new ueberdb.Database('mock', settings, {json: false, ...wrapperSettings}, logger);
await db.init();
mock = settings.mock;
mock.once('init', (cb: any) => cb());
};

afterEach(async () => {
if (mock != null) {
mock.removeAllListeners();
mock.once('close', (cb: any) => cb());
mock = null;
}
if (db != null) {
await db.close();
db = null;
}
});

it('idle database does not arm the flush timer', async () => {
await createDb({writeInterval: 50});
expect(flushTimer(db)).toBe(null);
// Give the event loop a tick or two to confirm nothing schedules itself.
await new Promise((r) => setTimeout(r, 30));
expect(flushTimer(db)).toBe(null);
});

it('set() arms the timer; flush() leaves it null after draining', async () => {
await createDb({writeInterval: 1e9}); // huge interval so the timer cannot fire during the test
mock.on('set', (k: any, v: any, cb: any) => cb());
const writeP = db.set('k', 'v');
await new Promise((r) => setImmediate(r));
// _setLocked runs after `await this._lock(key)`. setImmediate fires after the
// microtask queue is drained, so by the time we resume, _scheduleFlush() has fired.
expect(flushTimer(db)).not.toBe(null);
await Promise.all([writeP, db.flush()]);
// After an explicit flush() that drained everything, the timer must be null.
expect(flushTimer(db)).toBe(null);
});

it('close() clears a pending flush timer', async () => {
await createDb({writeInterval: 1e9});
mock.on('set', (k: any, v: any, cb: any) => cb());
void db.set('k', 'v');
// Yield so _setLocked runs and arms the timer.
await new Promise((r) => setImmediate(r));
// Timer must be armed before close().
expect(flushTimer(db)).not.toBe(null);
mock.once('close', (cb: any) => cb());
// close() must cancel the timer itself (no explicit flush() before this call) and complete cleanly.
await db.close();
expect(flushTimer(db)).toBe(null);
db = null; // prevent afterEach from double-closing
});

it('writeInterval=0 mode never arms the timer', async () => {
await createDb({writeInterval: 0});
mock.on('set', (k: any, v: any, cb: any) => cb());
await db.set('k', 'v');
expect(flushTimer(db)).toBe(null);
});
});
120 changes: 120 additions & 0 deletions test/mock/test_lock_fast_path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as ueberdb from "../../index";
import { ConsoleLogger } from "../../lib/logging";
import { afterEach, describe, expect, it } from "vitest";

type MockSettings = { mock?: any };

const logger = new ConsoleLogger();

describe(__filename, () => {
let db: any = null;
let mock: any = null;

const createDb = async (wrapperSettings: Record<string, unknown> = {}) => {
const settings: MockSettings = {};
db = new ueberdb.Database("mock", settings, { json: false, ...wrapperSettings }, logger);
await db.init();
mock = settings.mock;
mock.once("init", (cb: any) => cb());
};

afterEach(async () => {
if (mock != null) {
mock.removeAllListeners();
mock.once("close", (cb: any) => cb());
mock = null;
}
if (db != null) {
await db.close();
db = null;
}
});

it("cache-hit get() does not acquire the per-key lock", async () => {
await createDb({ writeInterval: 1e9 });
// Prime the cache: a single set+flush is enough to populate the buffer with the value.
mock.once("set", (k: any, v: any, cb: any) => cb());
await Promise.all([db.set("k", "v"), db.flush()]);
// After the write is finished, the buffer holds the value; the lock map is empty.
const before = { ...db.metrics };
const val = await db.get("k");
expect(val).toBe("v");
const after = db.metrics;
expect(after.lockAcquires - before.lockAcquires).toBe(0);
expect(after.lockReleases - before.lockReleases).toBe(0);
expect(after.readsFromCache - before.readsFromCache).toBe(1);
});

it("cache-miss get() still acquires the lock", async () => {
await createDb({ writeInterval: 1e9 });
mock.once("get", (k: any, cb: any) => cb(null, "v"));
const before = { ...db.metrics };
const val = await db.get("k");
expect(val).toBe("v");
const after = db.metrics;
expect(after.lockAcquires - before.lockAcquires).toBe(1);
expect(after.lockReleases - before.lockReleases).toBe(1);
});

it("get() during a write-in-progress with the lock released returns the buffered value via fast path", async () => {
await createDb({ writeInterval: 1e9 });
let releaseWrite: (() => void) | null = null;
const writeStarted = new Promise<void>((resolve) => {
mock.once("set", (k: any, v: any, cb: any) => {
resolve();
releaseWrite = () => cb();
});
});
const writeP = db.set("k", "v2");
const flushedP = db.flush();
await writeStarted;
// At this moment: _write is awaiting the mock's callback. The per-key lock has been released
// (set() releases the lock before awaiting entry.dirty). The buffer holds value 'v2'.
// The fast path must apply.
// Defensive yield: the lock is released synchronously inside set()'s finally block before
// _write begins awaiting, so by the time writeStarted fires the lock is already gone.
// The yield is belt-and-suspenders.
await new Promise<void>((r) => setImmediate(r));
const before = { ...db.metrics };
const val = await db.get("k");
expect(val).toBe("v2");
expect(db.metrics.lockAcquires - before.lockAcquires).toBe(0);
expect(db.metrics.readsFromCache - before.readsFromCache).toBe(1);
releaseWrite!();
await Promise.all([writeP, flushedP]);
});

it("get() while a setter holds the lock takes the slow path", async () => {
await createDb({writeInterval: 1e9});
// Hold the first set's database write open so its key-level lock cannot be released
// — wait, _setLocked releases the lock BEFORE awaiting entry.dirty. We need contention
// on the _LOCK ITSELF, not on _write. Drive that by holding the FIRST _lock open via
// an in-flight setSub that does an awaited _getLocked under the lock.
//
// Simpler approach: a setSub holds the lock through its entire walk (because it awaits
// _getLocked under the lock). Pause the mock's get() callback so _getLocked never resolves
// — this leaves setSub holding the lock indefinitely. Then issue db.get('k'); it must
// take the slow path and increment lockAwaits.
let releaseGet: (() => void) | null = null;
const getStarted = new Promise<void>((resolve) => {
mock.once('get', (k: any, cb: any) => {
resolve();
releaseGet = () => cb(null, null);
});
});
// setSub triggers a get under the lock; that get is paused by our mock, so the lock is held.
const setSubP = db.setSub('k', ['s'], 'v2');
await getStarted;
// At this moment: setSub holds the lock on 'k' (still awaiting _getLocked).
// Issue a get; it must observe _locks.has('k') === true and take the slow path.
const before = {...db.metrics};
const getP = db.get('k');
// After issuing get, lockAwaits should already be incremented (lock-acquire is sync).
// But we'll only assert it after we release everything, to avoid a timing race on
// the synchronous increment.
mock.on('set', (k: any, v: any, cb: any) => cb()); // for setSub's eventual write
releaseGet!();
await Promise.all([setSubP, getP, db.flush()]);
expect(db.metrics.lockAwaits - before.lockAwaits).toBe(1);
});
});
Loading
Loading