From fed584b7ac6b475eb1b4a5b831ae06e7673fae28 Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Fri, 28 Nov 2025 09:05:24 -0700 Subject: [PATCH 1/8] feat(memory): add benchmarks for fact operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive benchmarks to measure write and read performance, including isolation benchmarks to identify specific bottlenecks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/memory/deno.json | 4 + packages/memory/test/benchmark.ts | 923 ++++++++++++++++++++++++++++++ 2 files changed, 927 insertions(+) create mode 100644 packages/memory/test/benchmark.ts diff --git a/packages/memory/deno.json b/packages/memory/deno.json index 32adc9bf1..fb9fc759a 100644 --- a/packages/memory/deno.json +++ b/packages/memory/deno.json @@ -17,6 +17,10 @@ "migrate": { "description": "Performs database migration", "command": "deno run -A ./migrate.ts" + }, + "bench": { + "description": "Run benchmarks for fact operations", + "command": "deno bench --allow-read --allow-write --allow-net --allow-ffi --allow-env --no-check test/benchmark.ts" } }, "test": { diff --git a/packages/memory/test/benchmark.ts b/packages/memory/test/benchmark.ts new file mode 100644 index 000000000..3d6bd5f4f --- /dev/null +++ b/packages/memory/test/benchmark.ts @@ -0,0 +1,923 @@ +/** + * Benchmarks for memory fact operations: set, get, retract + * + * Run with: deno bench test/benchmark.ts + */ + +import { Database } from "@db/sqlite"; +import { refer } from "merkle-reference"; +import * as Space from "../space.ts"; +import * as Fact from "../fact.ts"; +import * as Transaction from "../transaction.ts"; +import * as Changes from "../changes.ts"; +import * as Query from "../query.ts"; +import { alice, space } from "./principal.ts"; + +const the = "application/json"; + +// Helper to create unique document IDs +let docCounter = 0; +const createDoc = () => `of:${refer({ id: docCounter++ })}` as const; + +// Helper to create realistic ~16KB payload (typical fact size) +function createTypicalPayload(): Record { + const basePayload = { + id: crypto.randomUUID(), + createdAt: Date.now(), + updatedAt: Date.now(), + metadata: { + version: 1, + type: "document", + tags: ["benchmark", "test"], + }, + }; + + const baseSize = JSON.stringify(basePayload).length; + const contentSize = Math.max(0, 16 * 1024 - baseSize - 50); + + return { + ...basePayload, + content: "X".repeat(contentSize), + }; +} + +// Helper to open a fresh in-memory space +async function openSpace() { + const result = await Space.open({ + url: new URL(`memory:${space.did()}`), + }); + if (result.error) throw result.error; + return result.ok; +} + +// -------------------------------------------------------------------------- +// Benchmark: Set a fact (assertion) +// -------------------------------------------------------------------------- + +// Helper to warm up a session with an initial transaction +function warmUp(session: Space.View) { + const warmupDoc = createDoc(); + const warmupAssertion = Fact.assert({ + the, + of: warmupDoc, + is: { warmup: true }, + }); + session.transact( + Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([warmupAssertion]), + }) + ); +} + +Deno.bench({ + name: "set fact (single ~16KB assertion)", + group: "set", + baseline: true, + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const doc = createDoc(); + const payload = createTypicalPayload(); + + b.start(); + const assertion = Fact.assert({ + the, + of: doc, + is: payload, + }); + + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + + const result = session.transact(transaction); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +Deno.bench({ + name: "set fact (10 ~16KB assertions batch)", + group: "set", + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const docs = Array.from({ length: 10 }, () => createDoc()); + const payloads = Array.from({ length: 10 }, () => createTypicalPayload()); + + b.start(); + const assertions = docs.map((doc, i) => + Fact.assert({ + the, + of: doc, + is: payloads[i], + }) + ); + + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from(assertions), + }); + + const result = session.transact(transaction); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +Deno.bench({ + name: "set fact (100 ~16KB assertions batch)", + group: "set", + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const docs = Array.from({ length: 100 }, () => createDoc()); + const payloads = Array.from({ length: 100 }, () => createTypicalPayload()); + + b.start(); + const assertions = docs.map((doc, i) => + Fact.assert({ + the, + of: doc, + is: payloads[i], + }) + ); + + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from(assertions), + }); + + const result = session.transact(transaction); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +// -------------------------------------------------------------------------- +// Benchmark: Get a fact (query) +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "get fact (single ~16KB query)", + group: "get", + baseline: true, + async fn(b) { + const session = await openSpace(); + const doc = createDoc(); + + // Setup: create the fact first + const assertion = Fact.assert({ the, of: doc, is: createTypicalPayload() }); + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + session.transact(transaction); + + b.start(); + const query = Query.create({ + issuer: alice.did(), + subject: space.did(), + select: { [doc]: { [the]: {} } }, + }); + const result = session.query(query); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +Deno.bench({ + name: "get fact (query 10 specific ~16KB docs)", + group: "get", + async fn(b) { + const session = await openSpace(); + const docs = Array.from({ length: 10 }, () => createDoc()); + + // Setup: create facts for all docs + const assertions = docs.map((doc) => + Fact.assert({ the, of: doc, is: createTypicalPayload() }) + ); + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from(assertions), + }); + session.transact(transaction); + + b.start(); + // Query each doc individually + for (const doc of docs) { + const query = Query.create({ + issuer: alice.did(), + subject: space.did(), + select: { [doc]: { [the]: {} } }, + }); + session.query(query); + } + b.end(); + + session.close(); + }, +}); + +Deno.bench({ + name: "get fact (wildcard query 100 ~16KB docs)", + group: "get", + async fn(b) { + const session = await openSpace(); + + // Setup: create 100 facts + const assertions = Array.from({ length: 100 }, () => + Fact.assert({ + the, + of: createDoc(), + is: createTypicalPayload(), + }) + ); + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from(assertions), + }); + session.transact(transaction); + + b.start(); + const query = Query.create({ + issuer: alice.did(), + subject: space.did(), + select: { _: { [the]: {} } }, + }); + const result = session.query(query); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +// -------------------------------------------------------------------------- +// Benchmark: Retract a fact +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "retract fact (single ~16KB)", + group: "retract", + baseline: true, + async fn(b) { + const session = await openSpace(); + const doc = createDoc(); + + // Setup: create the fact first + const assertion = Fact.assert({ the, of: doc, is: createTypicalPayload() }); + const createTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + session.transact(createTx); + + b.start(); + const retraction = Fact.retract(assertion); + const retractTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([retraction]), + }); + const result = session.transact(retractTx); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +Deno.bench({ + name: "retract fact (10 ~16KB retractions batch)", + group: "retract", + async fn(b) { + const session = await openSpace(); + + // Setup: create 10 facts first + const assertions = Array.from({ length: 10 }, () => + Fact.assert({ + the, + of: createDoc(), + is: createTypicalPayload(), + }) + ); + const createTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from(assertions), + }); + session.transact(createTx); + + b.start(); + const retractions = assertions.map((a) => Fact.retract(a)); + const retractTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from(retractions), + }); + const result = session.transact(retractTx); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +// -------------------------------------------------------------------------- +// Benchmark: Update fact (set new value with cause chain) +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "update fact (single ~16KB)", + group: "update", + baseline: true, + async fn(b) { + const session = await openSpace(); + const doc = createDoc(); + const payload1 = createTypicalPayload(); + const payload2 = createTypicalPayload(); + + // Setup: create the initial fact + const v1 = Fact.assert({ the, of: doc, is: payload1 }); + const createTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([v1]), + }); + session.transact(createTx); + + b.start(); + const v2 = Fact.assert({ the, of: doc, is: payload2, cause: v1 }); + const updateTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([v2]), + }); + const result = session.transact(updateTx); + b.end(); + + if (result.error) throw result.error; + session.close(); + }, +}); + +Deno.bench({ + name: "update fact (10 sequential ~16KB updates)", + group: "update", + async fn(b) { + const session = await openSpace(); + const doc = createDoc(); + const payloads = Array.from({ length: 11 }, () => createTypicalPayload()); + + // Setup: create the initial fact + let current = Fact.assert({ the, of: doc, is: payloads[0] }); + const createTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([current]), + }); + session.transact(createTx); + + b.start(); + for (let i = 1; i <= 10; i++) { + const next = Fact.assert({ the, of: doc, is: payloads[i], cause: current }); + const updateTx = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([next]), + }); + const result = session.transact(updateTx); + if (result.error) throw result.error; + current = next; + } + b.end(); + + session.close(); + }, +}); + +// -------------------------------------------------------------------------- +// Benchmark: Combined operations (typical workflow) +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "workflow: create -> read -> update -> read -> retract (~16KB)", + group: "workflow", + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const doc = createDoc(); + const payload1 = createTypicalPayload(); + const payload2 = createTypicalPayload(); + + b.start(); + // Create + const v1 = Fact.assert({ the, of: doc, is: payload1 }); + session.transact( + Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([v1]), + }) + ); + + // Read + session.query( + Query.create({ + issuer: alice.did(), + subject: space.did(), + select: { [doc]: { [the]: {} } }, + }) + ); + + // Update + const v2 = Fact.assert({ the, of: doc, is: payload2, cause: v1 }); + session.transact( + Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([v2]), + }) + ); + + // Read again + session.query( + Query.create({ + issuer: alice.did(), + subject: space.did(), + select: { [doc]: { [the]: {} } }, + }) + ); + + // Retract + const r = Fact.retract(v2); + session.transact( + Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([r]), + }) + ); + b.end(); + + session.close(); + }, +}); + +// -------------------------------------------------------------------------- +// Benchmark: Payload sizes (realistic: avg ~16KB) +// -------------------------------------------------------------------------- + +// Helper to create payloads of approximate sizes +function createPayload(targetBytes: number): Record { + // JSON overhead means we need to account for keys and structure + const basePayload = { + id: crypto.randomUUID(), + createdAt: Date.now(), + updatedAt: Date.now(), + metadata: { + version: 1, + type: "document", + tags: ["benchmark", "test"], + }, + }; + + // Estimate base size and fill with content + const baseSize = JSON.stringify(basePayload).length; + const contentSize = Math.max(0, targetBytes - baseSize - 50); // reserve space for content key + + return { + ...basePayload, + content: "X".repeat(contentSize), + }; +} + +Deno.bench({ + name: "set fact (~4KB payload)", + group: "payload", + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const doc = createDoc(); + const payload = createPayload(4 * 1024); + + b.start(); + const assertion = Fact.assert({ + the, + of: doc, + is: payload, + }); + + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + + session.transact(transaction); + b.end(); + + session.close(); + }, +}); + +Deno.bench({ + name: "set fact (~16KB payload - typical)", + group: "payload", + baseline: true, + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const doc = createDoc(); + const payload = createPayload(16 * 1024); + + b.start(); + const assertion = Fact.assert({ + the, + of: doc, + is: payload, + }); + + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + + session.transact(transaction); + b.end(); + + session.close(); + }, +}); + +Deno.bench({ + name: "set fact (~64KB payload)", + group: "payload", + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const doc = createDoc(); + const payload = createPayload(64 * 1024); + + b.start(); + const assertion = Fact.assert({ + the, + of: doc, + is: payload, + }); + + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + + session.transact(transaction); + b.end(); + + session.close(); + }, +}); + +Deno.bench({ + name: "set fact (~256KB payload)", + group: "payload", + async fn(b) { + const session = await openSpace(); + warmUp(session); + + const doc = createDoc(); + const payload = createPayload(256 * 1024); + + b.start(); + const assertion = Fact.assert({ + the, + of: doc, + is: payload, + }); + + const transaction = Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + + session.transact(transaction); + b.end(); + + session.close(); + }, +}); + +// -------------------------------------------------------------------------- +// Benchmark: Pre-populated database queries +// -------------------------------------------------------------------------- + +// Create a session with 1000 ~16KB facts pre-populated +let prepopulatedSession: Space.View | null = null; +let prepopulatedDocs: string[] = []; + +async function getOrCreatePrepopulatedSession() { + if (!prepopulatedSession) { + const result = await Space.open({ + url: new URL(`memory:${space.did()}-prepopulated`), + }); + if (result.error) throw result.error; + prepopulatedSession = result.ok; + + // Create 1000 ~16KB facts + prepopulatedDocs = Array.from({ length: 1000 }, () => createDoc()); + const assertions = prepopulatedDocs.map((doc) => + Fact.assert({ the, of: doc, is: createTypicalPayload() }) + ); + + // Batch insert in groups of 100 + for (let i = 0; i < assertions.length; i += 100) { + const batch = assertions.slice(i, i + 100); + prepopulatedSession.transact( + Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from(batch), + }) + ); + } + } + return { session: prepopulatedSession, docs: prepopulatedDocs }; +} + +Deno.bench({ + name: "query single ~16KB doc (from 1000 docs)", + group: "scale", + baseline: true, + async fn(b) { + const { session, docs } = await getOrCreatePrepopulatedSession(); + const randomDoc = docs[Math.floor(Math.random() * docs.length)]; + + b.start(); + const query = Query.create({ + issuer: alice.did(), + subject: space.did(), + select: { [randomDoc]: { [the]: {} } }, + }); + session.query(query); + b.end(); + }, +}); + +Deno.bench({ + name: "wildcard query all (1000 ~16KB docs)", + group: "scale", + async fn(b) { + const { session } = await getOrCreatePrepopulatedSession(); + + b.start(); + const query = Query.create({ + issuer: alice.did(), + subject: space.did(), + select: { _: { [the]: {} } }, + }); + session.query(query); + b.end(); + }, +}); + +Deno.bench({ + name: "insert ~16KB into populated db (1000 existing docs)", + group: "scale", + async fn(b) { + const { session } = await getOrCreatePrepopulatedSession(); + const doc = createDoc(); + const payload = createTypicalPayload(); + + b.start(); + const assertion = Fact.assert({ + the, + of: doc, + is: payload, + }); + + session.transact( + Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }) + ); + b.end(); + }, +}); + +// ========================================================================== +// ISOLATION BENCHMARKS: Identify where time is spent +// ========================================================================== + +// -------------------------------------------------------------------------- +// Baseline: Raw SQLite performance +// -------------------------------------------------------------------------- + +const RAW_SCHEMA = ` + CREATE TABLE IF NOT EXISTS test_datum ( + id TEXT PRIMARY KEY, + data TEXT + ); +`; + +let rawDb: Database | null = null; +let rawInsertStmt: ReturnType | null = null; + +function getRawDb() { + if (!rawDb) { + rawDb = new Database(":memory:"); + rawDb.exec(RAW_SCHEMA); + // Warm up with one insert + rawDb.run("INSERT INTO test_datum (id, data) VALUES (?, ?)", ["warmup", "{}"]); + rawInsertStmt = rawDb.prepare("INSERT INTO test_datum (id, data) VALUES (?, ?)"); + } + return { db: rawDb, stmt: rawInsertStmt! }; +} + +Deno.bench({ + name: "raw SQLite INSERT (16KB, prepared stmt)", + group: "isolation", + baseline: true, + fn(b) { + const { stmt } = getRawDb(); + const id = `id-${docCounter++}`; + const payload = createTypicalPayload(); + const json = JSON.stringify(payload); + + b.start(); + stmt.run([id, json]); + b.end(); + }, +}); + +Deno.bench({ + name: "raw SQLite INSERT (16KB, new stmt each time)", + group: "isolation", + fn(b) { + const { db } = getRawDb(); + const id = `id-${docCounter++}`; + const payload = createTypicalPayload(); + const json = JSON.stringify(payload); + + b.start(); + db.run("INSERT INTO test_datum (id, data) VALUES (?, ?)", [id, json]); + b.end(); + }, +}); + +// -------------------------------------------------------------------------- +// Isolation: JSON.stringify cost +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "JSON.stringify (16KB payload)", + group: "isolation", + fn(b) { + const payload = createTypicalPayload(); + + b.start(); + JSON.stringify(payload); + b.end(); + }, +}); + +// -------------------------------------------------------------------------- +// Isolation: Merkle reference (refer) cost +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "refer() on 16KB payload", + group: "isolation", + fn(b) { + const payload = createTypicalPayload(); + + b.start(); + refer(payload); + b.end(); + }, +}); + +Deno.bench({ + name: "refer() on small object {the, of}", + group: "isolation", + fn(b) { + const doc = createDoc(); + + b.start(); + refer({ the: "application/json", of: doc }); + b.end(); + }, +}); + +Deno.bench({ + name: "refer() on assertion (16KB is + metadata)", + group: "isolation", + fn(b) { + const doc = createDoc(); + const payload = createTypicalPayload(); + + b.start(); + refer({ the: "application/json", of: doc, is: payload }); + b.end(); + }, +}); + +// -------------------------------------------------------------------------- +// Isolation: Fact.assert cost (creates merkle refs internally) +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "Fact.assert() call only", + group: "isolation", + fn(b) { + const doc = createDoc(); + const payload = createTypicalPayload(); + + b.start(); + Fact.assert({ + the: "application/json", + of: doc, + is: payload, + }); + b.end(); + }, +}); + +// -------------------------------------------------------------------------- +// Isolation: Transaction.create cost +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "Transaction.create() + Changes.from()", + group: "isolation", + fn(b) { + const doc = createDoc(); + const payload = createTypicalPayload(); + const assertion = Fact.assert({ + the: "application/json", + of: doc, + is: payload, + }); + + b.start(); + Transaction.create({ + issuer: alice.did(), + subject: space.did(), + changes: Changes.from([assertion]), + }); + b.end(); + }, +}); + +// -------------------------------------------------------------------------- +// Combined: Multiple refer() calls as done in a real transaction +// -------------------------------------------------------------------------- + +Deno.bench({ + name: "3x refer() calls (simulating transaction)", + group: "isolation", + fn(b) { + const doc = createDoc(); + const payload = createTypicalPayload(); + + b.start(); + // Simulates what happens in a transaction: + // 1. refer(datum) for the payload + refer(payload); + // 2. refer(unclaimed) for the base + refer({ the: "application/json", of: doc }); + // 3. refer(assertion) for the fact + refer({ the: "application/json", of: doc, is: payload }); + b.end(); + }, +}); From 52570fd95daf00a05b927b63989a30417206b72f Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Fri, 28 Nov 2025 09:21:42 -0700 Subject: [PATCH 2/8] perf(memory): add LRU memoization for merkle reference hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bounded LRU cache (1000 entries) to memoize refer() results in reference.ts. refer() is a pure function computing SHA-256 hashes, which was identified as the primary bottleneck via isolation benchmarks. Benchmark results for cache hits: - 3x refer() calls: 44µs vs ~500µs uncached (27x faster) - 10x unclaimed refs: 2.5µs (400k ops/sec) The memoization benefits real-world usage patterns: - Repeated entity access (queries, updates on same docs) - unclaimed({ the, of }) patterns called multiple times - Multi-step transaction flows referencing same content Implementation: - reference.ts: LRU cache using Map with bounded eviction - Updated imports in fact.ts, access.ts, error.ts, entity.ts to use memoized refer() from ./reference.ts instead of merkle-reference The cache uses JSON.stringify as key (~7µs for 16KB) which is ~25x faster than the SHA-256 hash computation (~170µs for 16KB). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/memory/access.ts | 2 +- packages/memory/entity.ts | 2 +- packages/memory/error.ts | 2 +- packages/memory/fact.ts | 2 +- packages/memory/reference.ts | 40 +++++++++++++++++++++++++++++ packages/memory/test/benchmark.ts | 42 +++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 4 deletions(-) diff --git a/packages/memory/access.ts b/packages/memory/access.ts index c3911ffd0..d10bee286 100644 --- a/packages/memory/access.ts +++ b/packages/memory/access.ts @@ -8,7 +8,7 @@ import { Reference, Signer, } from "./interface.ts"; -import { refer } from "merkle-reference"; +import { refer } from "./reference.ts"; import { unauthorized } from "./error.ts"; import { type DID } from "@commontools/identity"; import { fromDID } from "./util.ts"; diff --git a/packages/memory/entity.ts b/packages/memory/entity.ts index f5b9bd1ec..509b71197 100644 --- a/packages/memory/entity.ts +++ b/packages/memory/entity.ts @@ -1,4 +1,4 @@ -import { fromJSON, refer } from "merkle-reference"; +import { fromJSON, refer } from "./reference.ts"; export interface Entity> { "@": ToString>; diff --git a/packages/memory/error.ts b/packages/memory/error.ts index 8bbfb9782..efc66261d 100644 --- a/packages/memory/error.ts +++ b/packages/memory/error.ts @@ -13,7 +13,7 @@ import type { TransactionError, } from "./interface.ts"; import { MemorySpace } from "./interface.ts"; -import { refer } from "merkle-reference"; +import { refer } from "./reference.ts"; export const unauthorized = ( message: string, diff --git a/packages/memory/fact.ts b/packages/memory/fact.ts index 22e57aa71..d5523ad3f 100644 --- a/packages/memory/fact.ts +++ b/packages/memory/fact.ts @@ -16,7 +16,7 @@ import { fromString, is as isReference, refer, -} from "merkle-reference"; +} from "./reference.ts"; /** * Creates an unclaimed fact. diff --git a/packages/memory/reference.ts b/packages/memory/reference.ts index b2d119e49..6d50995ab 100644 --- a/packages/memory/reference.ts +++ b/packages/memory/reference.ts @@ -6,3 +6,43 @@ export * from "merkle-reference"; export const fromString = Reference.fromString as ( source: string, ) => Reference.Reference; + +/** + * Bounded LRU cache for memoizing refer() results. + * refer() is a pure function (same input → same output), so caching is safe. + * We use JSON.stringify as the cache key since it's ~25x faster than refer(). + */ +const CACHE_MAX_SIZE = 1000; +const referCache = new Map(); + +/** + * Memoized version of refer() that caches results. + * Provides significant speedup for repeated references to the same objects, + * which is common in transaction processing where the same payload is + * referenced multiple times (datum, assertion, commit log). + */ +export const refer = (source: T): Reference.Reference => { + const key = JSON.stringify(source); + + let ref = referCache.get(key); + if (ref !== undefined) { + // Move to end (most recently used) by re-inserting + referCache.delete(key); + referCache.set(key, ref); + return ref as Reference.Reference; + } + + // Compute new reference + ref = Reference.refer(source); + + // Evict oldest entry if at capacity + if (referCache.size >= CACHE_MAX_SIZE) { + const oldest = referCache.keys().next().value; + if (oldest !== undefined) { + referCache.delete(oldest); + } + } + + referCache.set(key, ref); + return ref as Reference.Reference; +}; diff --git a/packages/memory/test/benchmark.ts b/packages/memory/test/benchmark.ts index 3d6bd5f4f..181a5c672 100644 --- a/packages/memory/test/benchmark.ts +++ b/packages/memory/test/benchmark.ts @@ -921,3 +921,45 @@ Deno.bench({ b.end(); }, }); + +// Test memoization benefit: same content referenced multiple times +import { refer as memoizedRefer } from "../reference.ts"; + +Deno.bench({ + name: "memoized: 3x refer() same payload (cache hits)", + group: "isolation", + fn(b) { + const doc = createDoc(); + const payload = createTypicalPayload(); + + // First call populates cache + memoizedRefer(payload); + memoizedRefer({ the: "application/json", of: doc }); + + b.start(); + // These should be cache hits + memoizedRefer(payload); + memoizedRefer({ the: "application/json", of: doc }); + memoizedRefer(payload); + b.end(); + }, +}); + +Deno.bench({ + name: "memoized: repeated unclaimed refs (common pattern)", + group: "isolation", + fn(b) { + const doc = createDoc(); + const unclaimed = { the: "application/json", of: doc }; + + // Warm cache + memoizedRefer(unclaimed); + + b.start(); + // Simulates multiple unclaimed refs in transaction flow + for (let i = 0; i < 10; i++) { + memoizedRefer({ the: "application/json", of: doc }); + } + b.end(); + }, +}); From 882b854ede3e2541151d753e359a11d95d4485cd Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Fri, 28 Nov 2025 11:10:10 -0700 Subject: [PATCH 3/8] perf(memory): cache and reuse SQLite prepared statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented prepared statement caching to eliminate redundant statement preparation overhead on every database operation. Uses a WeakMap-based cache per database connection to ensure proper cleanup and memory safety. Changes: - Added PreparedStatements type and getPreparedStatement() helper - Cached 7 frequently-used SQL statements (EXPORT, CAUSE_CHAIN, GET_FACT, IMPORT_DATUM, IMPORT_FACT, IMPORT_MEMORY, SWAP) - Removed manual finalize() calls as statements are reused - Added finalizePreparedStatements() to close() for cleanup - Updated all database query functions to use cached statements Benchmark results (before → after): - Single GET query: 117.5µs → 53.4µs (54.6% faster / 2.2x speedup) - Single UPDATE: 906.6µs → 705.8µs (22.1% faster) - Batch retract (10): 2.5ms → 1.9ms (24.0% faster) - Query from 1000 docs: 89.6µs → 66.7µs (25.5% faster) - Batch SET (100): 99.4ms → 88.1ms (11.4% faster) - Batch SET (10): 8.6ms → 7.9ms (8.1% faster) - Single SET: 1.2ms → 1.1ms (8.3% faster) Overall, the optimization provides consistent improvements across all operations with particularly strong gains in read-heavy workloads. All 31 existing tests pass without modifications. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/memory/space.ts | 250 +++++++++++++++++++++++---------------- 1 file changed, 145 insertions(+), 105 deletions(-) diff --git a/packages/memory/space.ts b/packages/memory/space.ts index cfc7d7a8b..9309510b5 100644 --- a/packages/memory/space.ts +++ b/packages/memory/space.ts @@ -1,6 +1,7 @@ import { Database, SqliteError, + Statement, Transaction as DBTransaction, } from "@db/sqlite"; @@ -196,6 +197,65 @@ WHERE fact.this = :fact; `; +/** + * Cache for prepared statements associated with each database connection. + * Using WeakMap ensures statements are cleaned up when database is closed. + */ +type PreparedStatements = { + export?: Statement; + causeChain?: Statement; + getFact?: Statement; + importDatum?: Statement; + importFact?: Statement; + importMemory?: Statement; + swap?: Statement; +}; + +const preparedStatementsCache = new WeakMap(); + +/** + * Get or create a prepared statement for a database connection. + * Prepared statements are cached and reused for better performance. + */ +const getPreparedStatement = ( + db: Database, + key: keyof PreparedStatements, + sql: string, +): Statement => { + let cache = preparedStatementsCache.get(db); + if (!cache) { + cache = {}; + preparedStatementsCache.set(db, cache); + } + + if (!cache[key]) { + cache[key] = db.prepare(sql); + } + + return cache[key]!; +}; + +/** + * Finalize all prepared statements for a database connection. + * Called when closing the database to clean up resources. + */ +const finalizePreparedStatements = (db: Database): void => { + const cache = preparedStatementsCache.get(db); + if (cache) { + for (const stmt of Object.values(cache)) { + if (stmt) { + try { + stmt.finalize(); + } catch (error) { + // Ignore errors during finalization + console.error("Error finalizing prepared statement:", error); + } + } + } + preparedStatementsCache.delete(db); + } +}; + export type Options = { url: URL; }; @@ -393,6 +453,7 @@ export const close = ({ addMemoryAttributes(span, { operation: "close" }); try { + finalizePreparedStatements(store); store.close(); return { ok: {} }; } catch (cause) { @@ -414,29 +475,25 @@ const recall = ( { store }: Session, { the, of }: { the: MIME; of: URI }, ): Revision | null => { - const stmt = store.prepare(EXPORT); - try { - const row = stmt.get({ the, of }) as StateRow | undefined; - if (row) { - const revision: Revision = { - the, - of, - cause: row.cause - ? (fromString(row.cause) as Reference) - : refer(unclaimed({ the, of })), - since: row.since, - }; - - if (row.is) { - revision.is = JSON.parse(row.is); - } + const stmt = getPreparedStatement(store, "export", EXPORT); + const row = stmt.get({ the, of }) as StateRow | undefined; + if (row) { + const revision: Revision = { + the, + of, + cause: row.cause + ? (fromString(row.cause) as Reference) + : refer(unclaimed({ the, of })), + since: row.since, + }; - return revision; - } else { - return null; + if (row.is) { + revision.is = JSON.parse(row.is); } - } finally { - stmt.finalize(); + + return revision; + } else { + return null; } }; @@ -462,25 +519,21 @@ const _causeChain = ( excludeFact: string | undefined, ): Revision[] => { const { store } = session; - const stmt = store.prepare(CAUSE_CHAIN); - try { - const rows = stmt.all({ of, the }) as CauseRow[]; - const revisions = []; - if (rows && rows.length) { - for (const result of rows) { - if (result.fact === excludeFact) { - continue; - } - const revision = getFact(session, { fact: result.fact }); - if (revision) { - revisions.push(revision); - } + const stmt = getPreparedStatement(store, "causeChain", CAUSE_CHAIN); + const rows = stmt.all({ of, the }) as CauseRow[]; + const revisions = []; + if (rows && rows.length) { + for (const result of rows) { + if (result.fact === excludeFact) { + continue; + } + const revision = getFact(session, { fact: result.fact }); + if (revision) { + revisions.push(revision); } } - return revisions; - } finally { - stmt.finalize(); } + return revisions; }; /** @@ -495,31 +548,27 @@ const getFact = ( { store }: Session, { fact }: { fact: string }, ): Revision | undefined => { - const stmt = store.prepare(GET_FACT); - try { - const row = stmt.get({ fact }) as StateRow | undefined; - if (row === undefined) { - return undefined; - } - // It's possible to have more than one matching fact, but since the fact's id - // incorporates its cause chain, we would have to have issued a retraction, - // followed by the same chain of facts. At that point, it really is the same. - // Since `the` and `of` are part of the fact reference, they are also unique. - const revision: Revision = { - the: row.the as MIME, - of: row.of as URI, - cause: row.cause - ? (fromString(row.cause) as Reference) - : refer(unclaimed(row as FactAddress)), - since: row.since, - }; - if (row.is) { - revision.is = JSON.parse(row.is); - } - return revision; - } finally { - stmt.finalize(); + const stmt = getPreparedStatement(store, "getFact", GET_FACT); + const row = stmt.get({ fact }) as StateRow | undefined; + if (row === undefined) { + return undefined; } + // It's possible to have more than one matching fact, but since the fact's id + // incorporates its cause chain, we would have to have issued a retraction, + // followed by the same chain of facts. At that point, it really is the same. + // Since `the` and `of` are part of the fact reference, they are also unique. + const revision: Revision = { + the: row.the as MIME, + of: row.of as URI, + cause: row.cause + ? (fromString(row.cause) as Reference) + : refer(unclaimed(row as FactAddress)), + since: row.since, + }; + if (row.is) { + revision.is = JSON.parse(row.is); + } + return revision; }; const select = ( @@ -585,47 +634,39 @@ export const selectFacts = function ( { store }: Session, { the, of, cause, is, since }: FactSelector, ): SelectedFact[] { - const stmt = store.prepare(EXPORT); - try { - const results = []; - for ( - const row of stmt.iter({ - the: the === SelectAllString ? null : the, - of: of === SelectAllString ? null : of, - cause: cause === SelectAllString ? null : cause, - is: is === undefined ? null : {}, - since: since ?? null, - }) as Iterable - ) { - results.push(toFact(row)); - } - return results; - } finally { - stmt.finalize(); + const stmt = getPreparedStatement(store, "export", EXPORT); + const results = []; + for ( + const row of stmt.iter({ + the: the === SelectAllString ? null : the, + of: of === SelectAllString ? null : of, + cause: cause === SelectAllString ? null : cause, + is: is === undefined ? null : {}, + since: since ?? null, + }) as Iterable + ) { + results.push(toFact(row)); } + return results; }; export const selectFact = function ( { store }: Session, { the, of, since }: { the: MIME; of: URI; since?: number }, ): SelectedFact | undefined { - const stmt = store.prepare(EXPORT); - try { - for ( - const row of stmt.iter({ - the: the, - of: of, - cause: null, - is: null, - since: since ?? null, - }) as Iterable - ) { - return toFact(row); - } - return undefined; - } finally { - stmt.finalize(); + const stmt = getPreparedStatement(store, "export", EXPORT); + for ( + const row of stmt.iter({ + the: the, + of: of, + cause: null, + is: null, + since: since ?? null, + }) as Iterable + ) { + return toFact(row); } + return undefined; }; /** @@ -643,7 +684,8 @@ const importDatum = ( return "undefined"; } else { const is = refer(datum).toString(); - session.store.run(IMPORT_DATUM, { + const stmt = getPreparedStatement(session.store, "importDatum", IMPORT_DATUM); + stmt.run({ this: is, source: JSON.stringify(datum), }); @@ -709,7 +751,8 @@ const swap = ( if (source.assert || source.retract) { // First we import datum and and then use its primary key as `is` field // in the `fact` table upholding foreign key constraint. - imported = session.store.run(IMPORT_FACT, { + const importFactStmt = getPreparedStatement(session.store, "importFact", IMPORT_FACT); + imported = importFactStmt.run({ this: fact, the, of, @@ -731,14 +774,16 @@ const swap = ( // therefore we insert or ignore here to ensure fact record exists and then // use update afterwards to update to desired state from expected `cause` state. if (expected == null) { - session.store.run(IMPORT_MEMORY, { the, of, fact }); + const importMemoryStmt = getPreparedStatement(session.store, "importMemory", IMPORT_MEMORY); + importMemoryStmt.run({ the, of, fact }); } // Finally we perform a memory swap, using conditional update so it only // updates memory if the `cause` references expected state. We use return // value to figure out whether update took place, if it is `0` no records // were updated indicating potential conflict which we handle below. - const updated = session.store.run(SWAP, { fact, cause, the, of }); + const swapStmt = getPreparedStatement(session.store, "swap", SWAP); + const updated = swapStmt.run({ fact, cause, the, of }); // If no records were updated it implies that there was no record with // matching `cause`. It may be because `cause` referenced implicit fact @@ -781,13 +826,8 @@ const commit = ( ): Commit => { const the = COMMIT_LOG_TYPE; const of = transaction.sub; - const stmt = session.store.prepare(EXPORT); - let row; - try { - row = stmt.get({ the, of }) as StateRow | undefined; - } finally { - stmt.finalize(); - } + const stmt = getPreparedStatement(session.store, "export", EXPORT); + const row = stmt.get({ the, of }) as StateRow | undefined; const [since, cause] = row ? [ (JSON.parse(row.is as string) as CommitData).since + 1, From b13857423f122ba246a9dd6b8b8ba1bff6a827a4 Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Fri, 28 Nov 2025 11:44:10 -0700 Subject: [PATCH 4/8] perf(memory): reorder datum/fact hashing to leverage merkle sub-object caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merkle-reference library caches sub-objects by identity during traversal. By computing the datum hash BEFORE the fact hash, the subsequent refer(assertion) call hits the cache when it encounters the payload sub-object, avoiding redundant hashing of the same 16KB payload twice. Before: refer(assertion) then refer(datum) - payload hashed twice After: refer(datum) then refer(assertion) - payload hash reused via WeakMap This ordering matters because: 1. refer(datum) hashes the payload and caches it by object identity 2. refer(assertion) traverses {the, of, is: payload, cause} - when it reaches the 'is' field, the payload object reference hits the WeakMap cache Benchmark results (16KB payload): - set fact (single): 1.1ms → 924.7µs (16% faster) - retract fact (single): 483.8µs → 462.4µs (4% faster) - update fact (single): ~705µs → ~723µs (within noise) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/memory/space.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/memory/space.ts b/packages/memory/space.ts index 9309510b5..861856103 100644 --- a/packages/memory/space.ts +++ b/packages/memory/space.ts @@ -736,6 +736,15 @@ const swap = ( const base = refer(unclaimed({ the, of })).toString(); const expected = cause === base ? null : (expect as Reference); + // IMPORTANT: Import datum BEFORE computing fact reference. The fact hash + // includes the datum as a sub-object, and merkle-reference caches sub-objects + // by identity during traversal. By hashing the datum first, we ensure the + // subsequent refer(assertion) call hits the cache for the payload (~2-4x faster). + let datumRef: string | undefined; + if (source.assert || source.retract) { + datumRef = importDatum(session, is); + } + // Derive the merkle reference to the fact that memory will have after // successful update. If we have an assertion or retraction we derive fact // from it, but if it is a confirmation `cause` is the fact itself. @@ -745,18 +754,15 @@ const swap = ( ? refer(source.retract).toString() : source.claim.fact.toString(); - // If this is an assertion we need to import asserted datum and then insert - // fact referencing it. + // If this is an assertion we need to insert fact referencing the datum. let imported = 0; if (source.assert || source.retract) { - // First we import datum and and then use its primary key as `is` field - // in the `fact` table upholding foreign key constraint. const importFactStmt = getPreparedStatement(session.store, "importFact", IMPORT_FACT); imported = importFactStmt.run({ this: fact, the, of, - is: importDatum(session, is), + is: datumRef!, cause, since, }); From 926524fa25630aece76cc84cbd537a7cf8d38e08 Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Fri, 28 Nov 2025 11:53:14 -0700 Subject: [PATCH 5/8] perf(memory): batch label lookups with SELECT...IN via json_each() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, getLabels() performed N individual SELECT queries to look up labels for N facts in a transaction. This adds latency proportional to the number of facts being processed. Now uses a single batched query with SQLite's json_each() function to handle an array of 'of' values: SELECT ... WHERE state.the = :the AND state.of IN (SELECT value FROM json_each(:ofs)) This reduces N queries to 1 query regardless of transaction size. Changes: - Added GET_LABELS_BATCH query constant using json_each() for IN clause - Added 'getLabelsBatch' to prepared statement cache - Rewrote getLabels() to collect 'of' values and execute single batch query The optimization benefits workloads with label facts (access control, classification). Benchmarks show ~4% improvement on batch operations, with larger gains expected in label-heavy workloads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/memory/space.ts | 69 ++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/memory/space.ts b/packages/memory/space.ts index 861856103..689e32797 100644 --- a/packages/memory/space.ts +++ b/packages/memory/space.ts @@ -197,6 +197,24 @@ WHERE fact.this = :fact; `; +// Batch query for labels using json_each() to handle array of 'of' values +// This replaces N individual queries with a single query +const GET_LABELS_BATCH = `SELECT + state.the as the, + state.of as of, + state.'is' as 'is', + state.cause as cause, + state.since as since, + state.fact as fact +FROM + state +WHERE + state.the = :the + AND state.of IN (SELECT value FROM json_each(:ofs)) +ORDER BY + since ASC +`; + /** * Cache for prepared statements associated with each database connection. * Using WeakMap ensures statements are cleaned up when database is closed. @@ -205,6 +223,7 @@ type PreparedStatements = { export?: Statement; causeChain?: Statement; getFact?: Statement; + getLabelsBatch?: Statement; importDatum?: Statement; importFact?: Statement; importMemory?: Statement; @@ -1007,6 +1026,7 @@ export type FactSelectionValue = { is?: JSONValue; since: number }; // Get the labels associated with a set of commits. // It's possible to get more than one label for a single doc because our // includedFacts may include more than one cause for a single doc. +// Uses a batched query (SELECT...IN) instead of N individual queries for performance. export function getLabels< Space extends MemorySpace, T, @@ -1015,25 +1035,42 @@ export function getLabels< includedFacts: OfTheCause>, ): OfTheCause { const labels: OfTheCause = {}; + + // Collect unique 'of' values, excluding labels themselves + const ofs: URI[] = []; for (const fact of iterate(includedFacts)) { - // We don't restrict acccess to labels - if (fact.the === LABEL_TYPE) { - continue; - } - const labelFact = getLabel(session, fact.of); - if (labelFact !== undefined) { - set>( - labels, - labelFact.of, - labelFact.the, - labelFact.cause, - { - since: labelFact.since, - ...(labelFact.is ? { is: labelFact.is } : {}), - }, - ); + // We don't restrict access to labels + if (fact.the !== LABEL_TYPE) { + ofs.push(fact.of); } } + + // No facts to look up labels for + if (ofs.length === 0) { + return labels; + } + + // Batch query for all labels in a single SELECT...IN query + const stmt = getPreparedStatement(session.store, "getLabelsBatch", GET_LABELS_BATCH); + for ( + const row of stmt.iter({ + the: LABEL_TYPE, + ofs: JSON.stringify(ofs), + }) as Iterable + ) { + const labelFact = toFact(row); + set>( + labels, + labelFact.of, + labelFact.the, + labelFact.cause, + { + since: labelFact.since, + ...(labelFact.is ? { is: labelFact.is } : {}), + }, + ); + } + return labels; } From fabe855e32a1f4d43ae9b93dd31494e366c6de6b Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Fri, 28 Nov 2025 12:05:44 -0700 Subject: [PATCH 6/8] perf(memory): use stored fact hash instead of recomputing with refer() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In conflict detection, we were reading a fact from the database and then calling refer(actual) to compute its hash for comparison. But the fact hash is already stored in the database (row.fact) - we were discarding it and recomputing it unnecessarily. Changes: - Added RevisionWithFact type that includes the stored fact hash - Updated recall() to return row.fact in the revision - Use revision.fact directly instead of refer(actual).toString() - Strip 'fact' field from error reporting to maintain API compatibility This eliminates a refer() call (~50-170µs) on the conflict detection path, which is taken for duplicate detection and first insertions. Benchmark results: - set fact (single): ~1.0ms → 846µs (15% faster) - update fact (single): ~740µs → 688µs (7% faster) - retract fact (single): ~428µs → 360µs (16% faster) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/memory/space.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/memory/space.ts b/packages/memory/space.ts index 689e32797..a8002f0b5 100644 --- a/packages/memory/space.ts +++ b/packages/memory/space.ts @@ -490,20 +490,24 @@ type StateRow = { since: number; }; +// Extended revision type that includes the stored fact hash +type RevisionWithFact = Revision & { fact: string }; + const recall = ( { store }: Session, { the, of }: { the: MIME; of: URI }, -): Revision | null => { +): RevisionWithFact | null => { const stmt = getPreparedStatement(store, "export", EXPORT); const row = stmt.get({ the, of }) as StateRow | undefined; if (row) { - const revision: Revision = { + const revision: RevisionWithFact = { the, of, cause: row.cause ? (fromString(row.cause) as Reference) : refer(unclaimed({ the, of })), since: row.since, + fact: row.fact, // Include stored hash to avoid recomputing with refer() }; if (row.is) { @@ -818,12 +822,12 @@ const swap = ( // the record and comparing it to desired state. if (updated === 0) { const revision = recall(session, { the, of }); - const { since: _, ...actual } = revision ? revision : { actual: null }; // If actual state matches desired state it either was inserted by the // `IMPORT_MEMORY` or this was a duplicate call. Either way we do not treat // it as a conflict as current state is the asserted one. - if (refer(actual).toString() !== fact) { + // Use stored fact hash directly instead of recomputing with refer(). + if (revision?.fact !== fact) { // Disable including history tracking for performance. // Re-enable this if you need to debug cause chains. const revisions: Revision[] = []; @@ -832,12 +836,18 @@ const swap = ( // { the, of }, // (imported !== 0) ? fact : undefined, // ); + // Strip internal 'fact' field from revision for error reporting + let actual: Revision | null = null; + if (revision) { + const { fact: _, ...rest } = revision; + actual = rest as Revision; + } throw Error.conflict(transaction, { space: transaction.sub, the, of, expected, - actual: revision as Revision, + actual, existsInHistory: imported === 0, history: revisions, }); From 74f990f097d0687fbb76ba1f1d46aa6d79843879 Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Fri, 28 Nov 2025 17:57:51 -0700 Subject: [PATCH 7/8] fix(memory): correct Reference type annotations and validate benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Reference type annotations for memoized refer() - Validate Result in benchmarks to catch silent failures - Apply deno fmt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/memory/fact.ts | 7 +--- packages/memory/reference.ts | 10 ++--- packages/memory/space.ts | 26 +++++++++--- packages/memory/test/benchmark.ts | 70 ++++++++++++++++++++----------- 4 files changed, 72 insertions(+), 41 deletions(-) diff --git a/packages/memory/fact.ts b/packages/memory/fact.ts index d5523ad3f..789db97d8 100644 --- a/packages/memory/fact.ts +++ b/packages/memory/fact.ts @@ -11,12 +11,7 @@ import { State, Unclaimed, } from "./interface.ts"; -import { - fromJSON, - fromString, - is as isReference, - refer, -} from "./reference.ts"; +import { fromJSON, fromString, is as isReference, refer } from "./reference.ts"; /** * Creates an unclaimed fact. diff --git a/packages/memory/reference.ts b/packages/memory/reference.ts index 6d50995ab..901197399 100644 --- a/packages/memory/reference.ts +++ b/packages/memory/reference.ts @@ -5,7 +5,7 @@ export * from "merkle-reference"; // workaround it like this. export const fromString = Reference.fromString as ( source: string, -) => Reference.Reference; +) => Reference.View; /** * Bounded LRU cache for memoizing refer() results. @@ -13,7 +13,7 @@ export const fromString = Reference.fromString as ( * We use JSON.stringify as the cache key since it's ~25x faster than refer(). */ const CACHE_MAX_SIZE = 1000; -const referCache = new Map(); +const referCache = new Map(); /** * Memoized version of refer() that caches results. @@ -21,7 +21,7 @@ const referCache = new Map(); * which is common in transaction processing where the same payload is * referenced multiple times (datum, assertion, commit log). */ -export const refer = (source: T): Reference.Reference => { +export const refer = (source: T): Reference.View => { const key = JSON.stringify(source); let ref = referCache.get(key); @@ -29,7 +29,7 @@ export const refer = (source: T): Reference.Reference => { // Move to end (most recently used) by re-inserting referCache.delete(key); referCache.set(key, ref); - return ref as Reference.Reference; + return ref as Reference.View; } // Compute new reference @@ -44,5 +44,5 @@ export const refer = (source: T): Reference.Reference => { } referCache.set(key, ref); - return ref as Reference.Reference; + return ref as Reference.View; }; diff --git a/packages/memory/space.ts b/packages/memory/space.ts index a8002f0b5..5cd9c46c8 100644 --- a/packages/memory/space.ts +++ b/packages/memory/space.ts @@ -507,7 +507,7 @@ const recall = ( ? (fromString(row.cause) as Reference) : refer(unclaimed({ the, of })), since: row.since, - fact: row.fact, // Include stored hash to avoid recomputing with refer() + fact: row.fact, // Include stored hash to avoid recomputing with refer() }; if (row.is) { @@ -707,7 +707,11 @@ const importDatum = ( return "undefined"; } else { const is = refer(datum).toString(); - const stmt = getPreparedStatement(session.store, "importDatum", IMPORT_DATUM); + const stmt = getPreparedStatement( + session.store, + "importDatum", + IMPORT_DATUM, + ); stmt.run({ this: is, source: JSON.stringify(datum), @@ -780,7 +784,11 @@ const swap = ( // If this is an assertion we need to insert fact referencing the datum. let imported = 0; if (source.assert || source.retract) { - const importFactStmt = getPreparedStatement(session.store, "importFact", IMPORT_FACT); + const importFactStmt = getPreparedStatement( + session.store, + "importFact", + IMPORT_FACT, + ); imported = importFactStmt.run({ this: fact, the, @@ -803,7 +811,11 @@ const swap = ( // therefore we insert or ignore here to ensure fact record exists and then // use update afterwards to update to desired state from expected `cause` state. if (expected == null) { - const importMemoryStmt = getPreparedStatement(session.store, "importMemory", IMPORT_MEMORY); + const importMemoryStmt = getPreparedStatement( + session.store, + "importMemory", + IMPORT_MEMORY, + ); importMemoryStmt.run({ the, of, fact }); } @@ -1061,7 +1073,11 @@ export function getLabels< } // Batch query for all labels in a single SELECT...IN query - const stmt = getPreparedStatement(session.store, "getLabelsBatch", GET_LABELS_BATCH); + const stmt = getPreparedStatement( + session.store, + "getLabelsBatch", + GET_LABELS_BATCH, + ); for ( const row of stmt.iter({ the: LABEL_TYPE, diff --git a/packages/memory/test/benchmark.ts b/packages/memory/test/benchmark.ts index 181a5c672..d162519e3 100644 --- a/packages/memory/test/benchmark.ts +++ b/packages/memory/test/benchmark.ts @@ -6,6 +6,7 @@ import { Database } from "@db/sqlite"; import { refer } from "merkle-reference"; +import type { JSONValue } from "@commontools/runner"; import * as Space from "../space.ts"; import * as Fact from "../fact.ts"; import * as Transaction from "../transaction.ts"; @@ -20,7 +21,7 @@ let docCounter = 0; const createDoc = () => `of:${refer({ id: docCounter++ })}` as const; // Helper to create realistic ~16KB payload (typical fact size) -function createTypicalPayload(): Record { +function createTypicalPayload(): JSONValue { const basePayload = { id: crypto.randomUUID(), createdAt: Date.now(), @@ -62,13 +63,14 @@ function warmUp(session: Space.View) { of: warmupDoc, is: { warmup: true }, }); - session.transact( + const result = session.transact( Transaction.create({ issuer: alice.did(), subject: space.did(), changes: Changes.from([warmupAssertion]), - }) + }), ); + if (result.error) throw result.error; } Deno.bench({ @@ -224,16 +226,20 @@ Deno.bench({ b.start(); // Query each doc individually + const results = []; for (const doc of docs) { const query = Query.create({ issuer: alice.did(), subject: space.did(), select: { [doc]: { [the]: {} } }, }); - session.query(query); + results.push(session.query(query)); } b.end(); + for (const result of results) { + if (result.error) throw result.error; + } session.close(); }, }); @@ -250,8 +256,7 @@ Deno.bench({ the, of: createDoc(), is: createTypicalPayload(), - }) - ); + })); const transaction = Transaction.create({ issuer: alice.did(), subject: space.did(), @@ -321,8 +326,7 @@ Deno.bench({ the, of: createDoc(), is: createTypicalPayload(), - }) - ); + })); const createTx = Transaction.create({ issuer: alice.did(), subject: space.did(), @@ -402,7 +406,12 @@ Deno.bench({ b.start(); for (let i = 1; i <= 10; i++) { - const next = Fact.assert({ the, of: doc, is: payloads[i], cause: current }); + const next = Fact.assert({ + the, + of: doc, + is: payloads[i], + cause: current, + }); const updateTx = Transaction.create({ issuer: alice.did(), subject: space.did(), @@ -436,53 +445,59 @@ Deno.bench({ b.start(); // Create const v1 = Fact.assert({ the, of: doc, is: payload1 }); - session.transact( + const createResult = session.transact( Transaction.create({ issuer: alice.did(), subject: space.did(), changes: Changes.from([v1]), - }) + }), ); // Read - session.query( + const readResult1 = session.query( Query.create({ issuer: alice.did(), subject: space.did(), select: { [doc]: { [the]: {} } }, - }) + }), ); // Update const v2 = Fact.assert({ the, of: doc, is: payload2, cause: v1 }); - session.transact( + const updateResult = session.transact( Transaction.create({ issuer: alice.did(), subject: space.did(), changes: Changes.from([v2]), - }) + }), ); // Read again - session.query( + const readResult2 = session.query( Query.create({ issuer: alice.did(), subject: space.did(), select: { [doc]: { [the]: {} } }, - }) + }), ); // Retract const r = Fact.retract(v2); - session.transact( + const retractResult = session.transact( Transaction.create({ issuer: alice.did(), subject: space.did(), changes: Changes.from([r]), - }) + }), ); b.end(); + if (createResult.error) throw createResult.error; + if (readResult1.error) throw readResult1.error; + if (updateResult.error) throw updateResult.error; + if (readResult2.error) throw readResult2.error; + if (retractResult.error) throw retractResult.error; + session.close(); }, }); @@ -492,7 +507,7 @@ Deno.bench({ // -------------------------------------------------------------------------- // Helper to create payloads of approximate sizes -function createPayload(targetBytes: number): Record { +function createPayload(targetBytes: number): JSONValue { // JSON overhead means we need to account for keys and structure const basePayload = { id: crypto.randomUUID(), @@ -642,7 +657,7 @@ Deno.bench({ // Create a session with 1000 ~16KB facts pre-populated let prepopulatedSession: Space.View | null = null; -let prepopulatedDocs: string[] = []; +let prepopulatedDocs: `of:${string}`[] = []; async function getOrCreatePrepopulatedSession() { if (!prepopulatedSession) { @@ -666,7 +681,7 @@ async function getOrCreatePrepopulatedSession() { issuer: alice.did(), subject: space.did(), changes: Changes.from(batch), - }) + }), ); } } @@ -729,7 +744,7 @@ Deno.bench({ issuer: alice.did(), subject: space.did(), changes: Changes.from([assertion]), - }) + }), ); b.end(); }, @@ -758,8 +773,13 @@ function getRawDb() { rawDb = new Database(":memory:"); rawDb.exec(RAW_SCHEMA); // Warm up with one insert - rawDb.run("INSERT INTO test_datum (id, data) VALUES (?, ?)", ["warmup", "{}"]); - rawInsertStmt = rawDb.prepare("INSERT INTO test_datum (id, data) VALUES (?, ?)"); + rawDb.run("INSERT INTO test_datum (id, data) VALUES (?, ?)", [ + "warmup", + "{}", + ]); + rawInsertStmt = rawDb.prepare( + "INSERT INTO test_datum (id, data) VALUES (?, ?)", + ); } return { db: rawDb, stmt: rawInsertStmt! }; } From d066037a1084fea275fcc07f1cda1b4ff2786af6 Mon Sep 17 00:00:00 2001 From: Will Kelly Date: Tue, 2 Dec 2025 17:54:55 -0700 Subject: [PATCH 8/8] rename benchmark.ts -> memory_bench.ts This makes it work automatically with `deno bench` --- packages/memory/test/{benchmark.ts => memory_bench.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/memory/test/{benchmark.ts => memory_bench.ts} (100%) diff --git a/packages/memory/test/benchmark.ts b/packages/memory/test/memory_bench.ts similarity index 100% rename from packages/memory/test/benchmark.ts rename to packages/memory/test/memory_bench.ts