diff --git a/graph/graph.js b/graph/graph.js index e5968ef..1dbfadd 100644 --- a/graph/graph.js +++ b/graph/graph.js @@ -104,6 +104,7 @@ export function createNode({ embedding: null, importance: Math.max(0, Math.min(10, importance)), accessCount: 0, + updatedAt: now, lastAccessTime: now, createdTime: now, prevId: null, @@ -164,6 +165,10 @@ export function updateNode(graph, nodeId, updates) { const node = getNode(graph, nodeId); if (!node) return false; + const nextUpdatedAt = Number.isFinite(Number(updates?.updatedAt)) + ? Number(updates.updatedAt) + : Date.now(); + if (updates.fields) { node.fields = { ...node.fields, ...updates.fields }; delete updates.fields; @@ -186,6 +191,7 @@ export function updateNode(graph, nodeId, updates) { } Object.assign(node, updates); + node.updatedAt = nextUpdatedAt; return true; } @@ -312,6 +318,7 @@ export function createEdge({ edgeType = 0, scope = undefined, }) { + const now = Date.now(); return { id: uuid(), fromId, @@ -319,9 +326,10 @@ export function createEdge({ relation, strength: Math.max(0, Math.min(1, strength)), edgeType, - createdTime: Date.now(), + createdTime: now, + updatedAt: now, // Graphiti 启发的时序字段 - validAt: Date.now(), // 关系生效时间 + validAt: now, // 关系生效时间 invalidAt: null, // 关系失效时间(null = 当前有效) expiredAt: null, // 系统标记过期时间 scope: normalizeMemoryScope(scope), @@ -362,15 +370,35 @@ export function addEdge(graph, edge) { existing.validAt || 0, edge.validAt || Date.now(), ); + existing.updatedAt = Math.max( + Number(existing.updatedAt || 0), + Number(edge.updatedAt || 0), + Number(existing.validAt || 0), + ); if (edge.invalidAt) { existing.invalidAt = edge.invalidAt; + existing.updatedAt = Math.max( + Number(existing.updatedAt || 0), + Number(existing.invalidAt || 0), + ); } if (edge.expiredAt) { existing.expiredAt = edge.expiredAt; + existing.updatedAt = Math.max( + Number(existing.updatedAt || 0), + Number(existing.expiredAt || 0), + ); } return existing; } + if (!Number.isFinite(Number(edge.updatedAt))) { + edge.updatedAt = Math.max( + Number(edge.validAt || 0), + Number(edge.createdTime || Date.now()), + ); + } + graph.edges.push(edge); return edge; } @@ -532,9 +560,14 @@ function isEdgeActive(edge, now = Date.now()) { */ export function invalidateEdge(edge) { if (!edge) return; + const now = Date.now(); if (!edge.invalidAt) { - edge.invalidAt = Date.now(); + edge.invalidAt = now; } + edge.updatedAt = Math.max( + Number(edge.updatedAt || 0), + Number(edge.invalidAt || now), + ); } /** diff --git a/index.js b/index.js index 2876d93..17d5dde 100644 --- a/index.js +++ b/index.js @@ -9845,10 +9845,18 @@ async function saveGraphToIndexedDb( minCombinedSerializedChars: persistDeltaBuildDiagnostics?.minCombinedSerializedChars ?? nativePersistGate.minCombinedSerializedChars, - beforeRecordCount: nativePersistGate.beforeRecordCount, - afterRecordCount: nativePersistGate.afterRecordCount, - maxSnapshotRecords: nativePersistGate.maxSnapshotRecords, - structuralDelta: nativePersistGate.structuralDelta, + beforeRecordCount: + persistDeltaBuildDiagnostics?.beforeRecordCount ?? + nativePersistGate.beforeRecordCount, + afterRecordCount: + persistDeltaBuildDiagnostics?.afterRecordCount ?? + nativePersistGate.afterRecordCount, + maxSnapshotRecords: + persistDeltaBuildDiagnostics?.maxSnapshotRecords ?? + nativePersistGate.maxSnapshotRecords, + structuralDelta: + persistDeltaBuildDiagnostics?.structuralDelta ?? + nativePersistGate.structuralDelta, preloadStatus: nativePersistPreloadStatus, preloadMs: nativePersistPreloadMs, preloadError: nativePersistPreloadError, diff --git a/sync/bme-db.js b/sync/bme-db.js index 2269610..d94a039 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -20,6 +20,13 @@ const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_STRUCTURAL_DELTA = 600; const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_SERIALIZED_CHARS = 4000000; const DEFAULT_PERSIST_NATIVE_DELTA_BRIDGE_MODE = "json"; const SUPPORTED_PERSIST_NATIVE_DELTA_BRIDGE_MODES = new Set(["json", "hash"]); +const PERSIST_RECORD_SERIALIZATION_CACHE_LIMIT = 50000; + +const persistRecordSerializationCacheByObject = new WeakMap(); +const persistRecordSerializationCacheByToken = new Map(); +let persistRecordSerializationCacheEpoch = 1; +const persistPreparedRecordSetCacheByArray = new WeakMap(); +let persistPreparedRecordSetCacheEpoch = 1; export const BME_RUNTIME_HISTORY_META_KEY = "runtimeHistoryState"; export const BME_RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; @@ -361,7 +368,7 @@ function normalizeNodeUpdatedAt(node = {}, fallbackNowMs = Date.now()) { function normalizeEdgeUpdatedAt(edge = {}, fallbackNowMs = Date.now()) { return normalizeTimestamp( - edge.updatedAt ?? edge.validAt ?? edge.createdTime, + edge.updatedAt ?? edge.invalidAt ?? edge.expiredAt ?? edge.validAt ?? edge.createdTime, fallbackNowMs, ); } @@ -410,27 +417,187 @@ function deriveEdgeSourceFloor(edge = {}, nodeSourceFloorById = new Map()) { return null; } +function clonePersistGraphInputRecord(record = null) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return null; + } + return { + ...record, + }; +} + +function buildPersistSnapshotGraphInput(graph = null, chatId = "") { + const sourceGraph = + graph && typeof graph === "object" && !Array.isArray(graph) + ? graph + : createEmptyGraph(); + const graphInput = { + ...sourceGraph, + historyState: + sourceGraph.historyState && + typeof sourceGraph.historyState === "object" && + !Array.isArray(sourceGraph.historyState) + ? { ...sourceGraph.historyState } + : {}, + vectorIndexState: + sourceGraph.vectorIndexState && + typeof sourceGraph.vectorIndexState === "object" && + !Array.isArray(sourceGraph.vectorIndexState) + ? { ...sourceGraph.vectorIndexState } + : {}, + nodes: toArray(sourceGraph.nodes) + .map((node) => clonePersistGraphInputRecord(node)) + .filter(Boolean), + edges: toArray(sourceGraph.edges) + .map((edge) => clonePersistGraphInputRecord(edge)) + .filter(Boolean), + batchJournal: Array.isArray(sourceGraph.batchJournal) + ? [...sourceGraph.batchJournal] + : sourceGraph.batchJournal, + maintenanceJournal: Array.isArray(sourceGraph.maintenanceJournal) + ? [...sourceGraph.maintenanceJournal] + : sourceGraph.maintenanceJournal, + }; + if (chatId) { + graphInput.historyState.chatId = chatId; + } + return graphInput; +} + +function buildPersistSnapshotRecordByIdMap(records = []) { + const map = new Map(); + for (const record of toArray(records)) { + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + const id = normalizeRecordId(record.id); + if (!id || map.has(id)) continue; + map.set(id, record); + } + return map; +} + +function clonePersistSnapshotRecord(record = null) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return null; + } + try { + return JSON.parse(JSON.stringify(record)); + } catch { + if (typeof globalThis.structuredClone === "function") { + try { + return globalThis.structuredClone(record); + } catch { + // no-op + } + } + return null; + } +} + +function normalizeComparablePersistNumber(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function hasReusablePersistNodeRecord(baseRecord, runtimeRecord, normalized = {}) { + if (!baseRecord || !runtimeRecord) return false; + const normalizedType = normalizeRecordId(normalized.type ?? runtimeRecord.type); + if (normalizeRecordId(baseRecord.type) !== normalizedType) return false; + if (Boolean(baseRecord.archived) !== Boolean(runtimeRecord.archived)) return false; + if ( + normalizeComparablePersistNumber(baseRecord.updatedAt) !== + normalizeComparablePersistNumber(normalized.updatedAt) + ) { + return false; + } + if ( + normalizeComparablePersistNumber(baseRecord.seq) !== + normalizeComparablePersistNumber(runtimeRecord.seq) + ) { + return false; + } + if (normalizeRecordId(baseRecord.parentId) !== normalizeRecordId(runtimeRecord.parentId)) { + return false; + } + if (normalizeRecordId(baseRecord.prevId) !== normalizeRecordId(runtimeRecord.prevId)) { + return false; + } + if (normalizeRecordId(baseRecord.nextId) !== normalizeRecordId(runtimeRecord.nextId)) { + return false; + } + return true; +} + +function hasReusablePersistEdgeRecord(baseRecord, runtimeRecord, normalized = {}) { + if (!baseRecord || !runtimeRecord) return false; + if (normalizeRecordId(baseRecord.fromId) !== normalizeRecordId(normalized.fromId)) { + return false; + } + if (normalizeRecordId(baseRecord.toId) !== normalizeRecordId(normalized.toId)) { + return false; + } + if (normalizeRecordId(baseRecord.relation) !== normalizeRecordId(runtimeRecord.relation)) { + return false; + } + if ( + normalizeComparablePersistNumber(baseRecord.updatedAt) !== + normalizeComparablePersistNumber(normalized.updatedAt) + ) { + return false; + } + if ( + normalizeComparablePersistNumber(baseRecord.invalidAt) !== + normalizeComparablePersistNumber(runtimeRecord.invalidAt) + ) { + return false; + } + if ( + normalizeComparablePersistNumber(baseRecord.expiredAt) !== + normalizeComparablePersistNumber(runtimeRecord.expiredAt) + ) { + return false; + } + return true; +} + +function hasReusablePersistTombstoneRecord(baseRecord, normalized = {}) { + if (!baseRecord) return false; + if (normalizeRecordId(baseRecord.kind) !== normalizeRecordId(normalized.kind)) { + return false; + } + if (normalizeRecordId(baseRecord.targetId) !== normalizeRecordId(normalized.targetId)) { + return false; + } + if ( + normalizeRecordId(baseRecord.sourceDeviceId) !== + normalizeRecordId(normalized.sourceDeviceId) + ) { + return false; + } + if ( + normalizeComparablePersistNumber(baseRecord.deletedAt) !== + normalizeComparablePersistNumber(normalized.deletedAt) + ) { + return false; + } + return true; +} + export function buildSnapshotFromGraph(graph, options = {}) { - const baseSnapshot = sanitizeSnapshot(options.baseSnapshot || {}); + const baseSnapshotInput = + options?.baseSnapshot && + typeof options.baseSnapshot === "object" && + !Array.isArray(options.baseSnapshot) + ? options.baseSnapshot + : {}; + const baseSnapshot = sanitizeSnapshot(baseSnapshotInput); + const baseSnapshotView = normalizePersistSnapshotView(baseSnapshotInput); const nowMs = normalizeTimestamp(options.nowMs, Date.now()); const chatId = normalizeChatId(options.chatId) || normalizeChatId(graph?.historyState?.chatId) || normalizeChatId(baseSnapshot.meta?.chatId); - const graphInput = toPlainData(graph, createEmptyGraph()); - if (!graphInput.historyState || typeof graphInput.historyState !== "object") { - graphInput.historyState = {}; - } - if ( - !graphInput.vectorIndexState || - typeof graphInput.vectorIndexState !== "object" - ) { - graphInput.vectorIndexState = {}; - } - if (chatId) { - graphInput.historyState.chatId = chatId; - } + const graphInput = buildPersistSnapshotGraphInput(graph, chatId); const legacyActiveOwnerKey = String( graphInput?.knowledgeState?.activeOwnerKey || "", ).trim(); @@ -444,49 +611,102 @@ export function buildSnapshotFromGraph(graph, options = {}) { chatId || graphInput.historyState.chatId || "", ); const runtimeGraph = normalizeGraphRuntimeState(graphInput, chatId); + const baseNodeById = buildPersistSnapshotRecordByIdMap(baseSnapshotView.nodes); + const baseEdgeById = buildPersistSnapshotRecordByIdMap(baseSnapshotView.edges); + const baseTombstoneById = buildPersistSnapshotRecordByIdMap( + baseSnapshotView.tombstones, + ); const nodes = toArray(runtimeGraph?.nodes) .map((node) => { - if (!node || typeof node !== "object" || Array.isArray(node)) return null; + if (!node || typeof node !== "object" || Array.isArray(node)) { + return null; + } const id = normalizeRecordId(node.id); if (!id) return null; - return { - ...node, - id, - updatedAt: normalizeNodeUpdatedAt(node, nowMs), - }; + const normalizedUpdatedAt = normalizeNodeUpdatedAt(node, nowMs); + const baseNode = baseNodeById.get(id); + if ( + hasReusablePersistNodeRecord(baseNode, node, { + type: node.type, + updatedAt: normalizedUpdatedAt, + }) + ) { + return baseNode; + } + const plainNode = clonePersistSnapshotRecord(node); + if (!plainNode || typeof plainNode !== "object" || Array.isArray(plainNode)) { + return null; + } + plainNode.id = id; + plainNode.updatedAt = normalizedUpdatedAt; + return plainNode; }) .filter(Boolean); const edges = toArray(runtimeGraph?.edges) .map((edge) => { - if (!edge || typeof edge !== "object" || Array.isArray(edge)) return null; + if (!edge || typeof edge !== "object" || Array.isArray(edge)) { + return null; + } const id = normalizeRecordId(edge.id); if (!id) return null; - return { - ...edge, - id, - fromId: normalizeRecordId(edge.fromId), - toId: normalizeRecordId(edge.toId), - updatedAt: normalizeEdgeUpdatedAt(edge, nowMs), - }; + const normalizedFromId = normalizeRecordId(edge.fromId); + const normalizedToId = normalizeRecordId(edge.toId); + const normalizedUpdatedAt = normalizeEdgeUpdatedAt(edge, nowMs); + const baseEdge = baseEdgeById.get(id); + if ( + hasReusablePersistEdgeRecord(baseEdge, edge, { + fromId: normalizedFromId, + toId: normalizedToId, + updatedAt: normalizedUpdatedAt, + }) + ) { + return baseEdge; + } + const plainEdge = clonePersistSnapshotRecord(edge); + if (!plainEdge || typeof plainEdge !== "object" || Array.isArray(plainEdge)) { + return null; + } + plainEdge.id = id; + plainEdge.fromId = normalizedFromId; + plainEdge.toId = normalizedToId; + plainEdge.updatedAt = normalizedUpdatedAt; + return plainEdge; }) .filter(Boolean); - const tombstones = toArray(options.tombstones ?? baseSnapshot.tombstones) + const tombstones = toArray(options.tombstones ?? baseSnapshotView.tombstones) .map((record) => { if (!record || typeof record !== "object" || Array.isArray(record)) return null; const id = normalizeRecordId(record.id); if (!id) return null; - return { - ...record, - id, - kind: normalizeRecordId(record.kind), - targetId: normalizeRecordId(record.targetId), - sourceDeviceId: normalizeRecordId(record.sourceDeviceId), - deletedAt: normalizeTimestamp(record.deletedAt, nowMs), - }; + const normalizedKind = normalizeRecordId(record.kind); + const normalizedTargetId = normalizeRecordId(record.targetId); + const normalizedSourceDeviceId = normalizeRecordId(record.sourceDeviceId); + const normalizedDeletedAt = normalizeTimestamp(record.deletedAt, nowMs); + const baseTombstone = baseTombstoneById.get(id); + if ( + hasReusablePersistTombstoneRecord(baseTombstone, { + kind: normalizedKind, + targetId: normalizedTargetId, + sourceDeviceId: normalizedSourceDeviceId, + deletedAt: normalizedDeletedAt, + }) + ) { + return baseTombstone; + } + const plainRecord = clonePersistSnapshotRecord(record); + if (!plainRecord || typeof plainRecord !== "object" || Array.isArray(plainRecord)) { + return null; + } + plainRecord.id = id; + plainRecord.kind = normalizedKind; + plainRecord.targetId = normalizedTargetId; + plainRecord.sourceDeviceId = normalizedSourceDeviceId; + plainRecord.deletedAt = normalizedDeletedAt; + return plainRecord; }) .filter(Boolean); @@ -635,6 +855,145 @@ function hashPersistSerializedRecord32(value = "") { return hash >>> 0; } +function resolvePersistRecordSerializationVersion(record = {}) { + const candidates = [ + record?.updatedAt, + record?.deletedAt, + record?.invalidAt, + record?.expiredAt, + record?.validAt, + record?.lastModified, + record?.createdTime, + record?.lastAccessTime, + ]; + for (const value of candidates) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return Math.floor(parsed); + } + return null; +} + +function resolvePersistRecordSerializationCacheToken(record = {}) { + const id = normalizeRecordId(record?.id); + const version = resolvePersistRecordSerializationVersion(record); + if (!id || version == null) return ""; + return [ + id, + version, + normalizeRecordId(record?.kind), + normalizeRecordId(record?.targetId), + normalizeRecordId(record?.fromId), + normalizeRecordId(record?.toId), + normalizeRecordId(record?.type), + normalizeRecordId(record?.relation), + record?.archived === true ? "1" : "0", + ].join("|"); +} + +function recordPersistSerializationCacheStat(stats = null, key = "") { + if (!stats || typeof stats !== "object" || !key) return; + stats[key] = Number(stats[key] || 0) + 1; +} + +function recordPersistPreparedRecordSetCacheStat(stats = null, key = "") { + if (!stats || typeof stats !== "object" || !key) return; + stats[key] = Number(stats[key] || 0) + 1; +} + +function resolvePreparedRecordSetCacheKey(options = {}) { + return [ + options?.includeSerializedList === true ? "s1" : "s0", + options?.includeHashList === true ? "h1" : "h0", + options?.includeSerializedLookup !== false ? "l1" : "l0", + options?.includeSerializedCharCount === true ? "c1" : "c0", + options?.includeTargetKeys === true ? "t1" : "t0", + ].join("|"); +} + +function touchPersistRecordSerializationTokenCache(token, entry) { + if (!token || !entry) return; + if (persistRecordSerializationCacheByToken.has(token)) { + persistRecordSerializationCacheByToken.delete(token); + } + persistRecordSerializationCacheByToken.set(token, entry); + while ( + persistRecordSerializationCacheByToken.size > + PERSIST_RECORD_SERIALIZATION_CACHE_LIMIT + ) { + const oldestKey = persistRecordSerializationCacheByToken.keys().next().value; + if (!oldestKey) break; + persistRecordSerializationCacheByToken.delete(oldestKey); + } +} + +function ensurePersistRecordSerializationHash(entry = null) { + if (!entry || typeof entry !== "object") return 0; + if (!Number.isFinite(Number(entry.hash))) { + entry.hash = hashPersistSerializedRecord32(String(entry.json || "")); + } + return Number(entry.hash) >>> 0; +} + +function getPersistRecordSerialization( + record, + { includeHash = false, cacheStats = null } = {}, +) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + const emptyEntry = { token: "", json: "null", length: 4, hash: 1996966820 }; + if (includeHash) emptyEntry.hash = hashPersistSerializedRecord32(emptyEntry.json); + return emptyEntry; + } + + const token = resolvePersistRecordSerializationCacheToken(record); + const cachedByObject = token + ? persistRecordSerializationCacheByObject.get(record) + : null; + if ( + cachedByObject && + cachedByObject.token === token && + cachedByObject.epoch === persistRecordSerializationCacheEpoch + ) { + recordPersistSerializationCacheStat(cacheStats, "objectHitCount"); + if (includeHash) ensurePersistRecordSerializationHash(cachedByObject); + return cachedByObject; + } + + const cachedByToken = token ? persistRecordSerializationCacheByToken.get(token) : null; + if (cachedByToken) { + persistRecordSerializationCacheByObject.set(record, cachedByToken); + touchPersistRecordSerializationTokenCache(token, cachedByToken); + recordPersistSerializationCacheStat(cacheStats, "tokenHitCount"); + if (includeHash) ensurePersistRecordSerializationHash(cachedByToken); + return cachedByToken; + } + + const json = JSON.stringify(record); + const entry = { + epoch: persistRecordSerializationCacheEpoch, + token, + json, + length: json.length, + hash: includeHash ? hashPersistSerializedRecord32(json) : null, + }; + if (token) { + persistRecordSerializationCacheByObject.set(record, entry); + touchPersistRecordSerializationTokenCache(token, entry); + } + recordPersistSerializationCacheStat(cacheStats, "missCount"); + return entry; +} + +function sumPersistSerializationCacheHits(stats = null) { + if (!stats || typeof stats !== "object") return 0; + return Number(stats.objectHitCount || 0) + Number(stats.tokenHitCount || 0); +} + +export function resetPersistRecordSerializationCaches() { + persistRecordSerializationCacheEpoch += 1; + persistRecordSerializationCacheByToken.clear(); + persistPreparedRecordSetCacheEpoch += 1; +} + function buildPreparedRecordSet( records = [], { @@ -644,38 +1003,71 @@ function buildPreparedRecordSet( includeHashList = false, includeSerializedLookup = true, includeSerializedCharCount = false, + serializationCacheStats = null, + preparedRecordSetCacheStats = null, + usePreparedRecordSetCache = true, } = {}, ) { const sourceRecords = toArray(records); + const cacheKey = + usePreparedRecordSetCache !== false && + Array.isArray(records) && + sourceRecords === records + ? resolvePreparedRecordSetCacheKey({ + includeSerializedList, + includeHashList, + includeSerializedLookup, + includeSerializedCharCount, + includeTargetKeys, + }) + : ""; + if (cacheKey) { + const cachedEntry = persistPreparedRecordSetCacheByArray.get(records); + const cachedRecordSet = + cachedEntry && + cachedEntry.epoch === persistPreparedRecordSetCacheEpoch && + cachedEntry.values instanceof Map + ? cachedEntry.values.get(cacheKey) + : null; + if (cachedRecordSet) { + recordPersistPreparedRecordSetCacheStat(preparedRecordSetCacheStats, "hitCount"); + return cachedRecordSet; + } + recordPersistPreparedRecordSetCacheStat(preparedRecordSetCacheStats, "missCount"); + } const ids = []; const serialized = includeSerializedList ? [] : null; const hashes = includeHashList ? [] : null; const serializedById = includeSerializedLookup ? new Map() : null; - const recordById = retainRecords ? new Map() : null; - const targetKeyById = includeTargetKeys ? new Map() : null; + const recordById = null; + const targetKeyById = null; + const targetKeys = includeTargetKeys ? [] : null; let serializedCharCount = 0; for (const record of sourceRecords) { if (!record || typeof record !== "object" || Array.isArray(record)) continue; const id = normalizeRecordId(record.id); if (!id) continue; - const json = JSON.stringify(record); + const serializedEntry = getPersistRecordSerialization(record, { + includeHash: includeHashList, + cacheStats: serializationCacheStats, + }); + const json = serializedEntry.json; ids.push(id); if (serialized) serialized.push(json); - if (hashes) hashes.push(hashPersistSerializedRecord32(json)); + if (hashes) hashes.push(ensurePersistRecordSerializationHash(serializedEntry)); if (serializedById) serializedById.set(id, json); if (includeSerializedCharCount) { - serializedCharCount += json.length; + serializedCharCount += serializedEntry.length; } - if (recordById) recordById.set(id, record); - if (targetKeyById) { + if (targetKeys) { const kind = normalizeRecordId(record.kind); const targetId = normalizeRecordId(record.targetId); - targetKeyById.set(id, kind && targetId ? `${kind}:${targetId}` : ""); + targetKeys.push(kind && targetId ? `${kind}:${targetId}` : ""); } } - return { + const preparedRecordSet = { ids, serialized, hashes, @@ -683,11 +1075,27 @@ function buildPreparedRecordSet( sourceRecords, recordById, targetKeyById, + targetKeys, serializedCharCount, }; + if (cacheKey) { + const cachedEntry = persistPreparedRecordSetCacheByArray.get(records); + const values = + cachedEntry && + cachedEntry.epoch === persistPreparedRecordSetCacheEpoch && + cachedEntry.values instanceof Map + ? cachedEntry.values + : new Map(); + values.set(cacheKey, preparedRecordSet); + persistPreparedRecordSetCacheByArray.set(records, { + epoch: persistPreparedRecordSetCacheEpoch, + values, + }); + } + return preparedRecordSet; } -function ensurePreparedSerializedLookup(recordSet = null) { +function ensurePreparedSerializedLookup(recordSet = null, cacheStats = null) { if (!recordSet || typeof recordSet !== "object") { return new Map(); } @@ -700,12 +1108,67 @@ function ensurePreparedSerializedLookup(recordSet = null) { if (!record || typeof record !== "object" || Array.isArray(record)) continue; const id = normalizeRecordId(record.id); if (!id) continue; - map.set(id, JSON.stringify(record)); + map.set( + id, + getPersistRecordSerialization(record, { + cacheStats, + }).json, + ); } recordSet.serializedById = map; return map; } +function ensurePreparedRecordLookup(recordSet = null) { + if (!recordSet || typeof recordSet !== "object") { + return new Map(); + } + if (recordSet.recordById instanceof Map) { + return recordSet.recordById; + } + + const map = new Map(); + for (const record of toArray(recordSet.sourceRecords)) { + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + const id = normalizeRecordId(record.id); + if (!id) continue; + map.set(id, record); + } + recordSet.recordById = map; + return map; +} + +function ensurePreparedTargetKeyLookup(recordSet = null) { + if (!recordSet || typeof recordSet !== "object") { + return new Map(); + } + if (recordSet.targetKeyById instanceof Map) { + return recordSet.targetKeyById; + } + + const map = new Map(); + if ( + Array.isArray(recordSet.ids) && + Array.isArray(recordSet.targetKeys) && + recordSet.ids.length === recordSet.targetKeys.length + ) { + for (let index = 0; index < recordSet.ids.length; index++) { + map.set(recordSet.ids[index], String(recordSet.targetKeys[index] || "")); + } + } else { + for (const record of toArray(recordSet.sourceRecords)) { + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + const id = normalizeRecordId(record.id); + if (!id) continue; + const kind = normalizeRecordId(record.kind); + const targetId = normalizeRecordId(record.targetId); + map.set(id, kind && targetId ? `${kind}:${targetId}` : ""); + } + } + recordSet.targetKeyById = map; + return map; +} + function buildPreparedPersistDeltaContext( beforeSnapshot, afterSnapshot, @@ -725,11 +1188,27 @@ function buildPreparedPersistDeltaContext( const includeCompactHashList = compactPayloadMode === "hash"; const includeSerializedLookup = options.includeSerializedLookup !== false; const includeSerializedCharCount = options.includeSerializedCharCount === true; + const serializationCacheStats = + options?.serializationCacheStats && + typeof options.serializationCacheStats === "object" && + !Array.isArray(options.serializationCacheStats) + ? options.serializationCacheStats + : null; + const preparedRecordSetCacheStats = + options?.preparedRecordSetCacheStats && + typeof options.preparedRecordSetCacheStats === "object" && + !Array.isArray(options.preparedRecordSetCacheStats) + ? options.preparedRecordSetCacheStats + : null; + const usePreparedRecordSetCache = options?.usePreparedRecordSetCache !== false; const beforeNodes = buildPreparedRecordSet(beforeSnapshot.nodes, { includeSerializedList: includeCompactSerializedList, includeHashList: includeCompactHashList, includeSerializedLookup, includeSerializedCharCount, + serializationCacheStats, + preparedRecordSetCacheStats, + usePreparedRecordSetCache, }); const afterNodes = buildPreparedRecordSet(afterSnapshot.nodes, { retainRecords: true, @@ -737,12 +1216,18 @@ function buildPreparedPersistDeltaContext( includeHashList: includeCompactHashList, includeSerializedLookup, includeSerializedCharCount, + serializationCacheStats, + preparedRecordSetCacheStats, + usePreparedRecordSetCache, }); const beforeEdges = buildPreparedRecordSet(beforeSnapshot.edges, { includeSerializedList: includeCompactSerializedList, includeHashList: includeCompactHashList, includeSerializedLookup, includeSerializedCharCount, + serializationCacheStats, + preparedRecordSetCacheStats, + usePreparedRecordSetCache, }); const afterEdges = buildPreparedRecordSet(afterSnapshot.edges, { retainRecords: true, @@ -750,12 +1235,18 @@ function buildPreparedPersistDeltaContext( includeHashList: includeCompactHashList, includeSerializedLookup, includeSerializedCharCount, + serializationCacheStats, + preparedRecordSetCacheStats, + usePreparedRecordSetCache, }); const beforeTombstones = buildPreparedRecordSet(beforeSnapshot.tombstones, { includeSerializedList: includeCompactSerializedList, includeHashList: includeCompactHashList, includeSerializedLookup, includeSerializedCharCount, + serializationCacheStats, + preparedRecordSetCacheStats, + usePreparedRecordSetCache, }); const afterTombstones = buildPreparedRecordSet(afterSnapshot.tombstones, { retainRecords: true, @@ -764,6 +1255,9 @@ function buildPreparedPersistDeltaContext( includeHashList: includeCompactHashList, includeSerializedLookup, includeSerializedCharCount, + serializationCacheStats, + preparedRecordSetCacheStats, + usePreparedRecordSetCache, }); const sourceDeviceId = normalizeRecordId( afterSnapshot.meta?.deviceId || beforeSnapshot.meta?.deviceId || "", @@ -803,6 +1297,7 @@ function buildPreparedPersistDeltaContext( Math.abs(afterTombstones.ids.length - beforeTombstones.ids.length), beforeSerializedChars, afterSerializedChars, + serializationCacheStats, compactPayload: compactPayloadMode === "json" ? { @@ -995,10 +1490,19 @@ function buildPersistDeltaFromIdShape(preparedContext, delta = null) { const normalized = normalizePersistDeltaIdShape(delta); if (!normalized) return null; + const afterNodeRecordById = ensurePreparedRecordLookup(preparedContext.afterNodes); + const afterEdgeRecordById = ensurePreparedRecordLookup(preparedContext.afterEdges); + const afterTombstoneRecordById = ensurePreparedRecordLookup( + preparedContext.afterTombstones, + ); + const afterTombstoneTargetKeyById = ensurePreparedTargetKeyLookup( + preparedContext.afterTombstones, + ); + const tombstoneMap = new Map(); for (const id of normalized.upsertTombstoneIds) { - const record = preparedContext.afterTombstones.recordById?.get(id); - const targetKey = preparedContext.afterTombstones.targetKeyById?.get(id) || ""; + const record = afterTombstoneRecordById.get(id); + const targetKey = afterTombstoneTargetKeyById.get(id) || ""; if (!record || !targetKey) continue; tombstoneMap.set(targetKey, record); } @@ -1024,11 +1528,11 @@ function buildPersistDeltaFromIdShape(preparedContext, delta = null) { return { upsertNodes: hydratePreparedRecords( - preparedContext.afterNodes.recordById, + afterNodeRecordById, normalized.upsertNodeIds, ), upsertEdges: hydratePreparedRecords( - preparedContext.afterEdges.recordById, + afterEdgeRecordById, normalized.upsertEdgeIds, ), deleteNodeIds: normalized.deleteNodeIds, @@ -1103,6 +1607,26 @@ function tryBuildNativePersistDelta( export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { const shouldCollectDiagnostics = typeof options?.onDiagnostics === "function"; const startedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; + const timings = shouldCollectDiagnostics + ? { + prepareMs: 0, + nativeAttemptMs: 0, + lookupMs: 0, + jsDiffMs: 0, + hydrateMs: 0, + } + : null; + const serializationCacheStats = { + objectHitCount: 0, + tokenHitCount: 0, + missCount: 0, + }; + const preparedRecordSetCacheStats = shouldCollectDiagnostics + ? { + hitCount: 0, + missCount: 0, + } + : null; const normalizedBefore = normalizePersistSnapshotView(beforeSnapshot); const normalizedAfter = normalizePersistSnapshotView(afterSnapshot); const nowMs = normalizeTimestamp(options.nowMs, Date.now()); @@ -1115,6 +1639,7 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { shouldCollectDiagnostics || (options?.useNativeDelta === true && (nativeGateOptions?.minCombinedSerializedChars || 0) > 0); + const prepareStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const preparedContext = buildPreparedPersistDeltaContext( normalizedBefore, normalizedAfter, @@ -1123,8 +1648,14 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { compactPayloadMode: options?.useNativeDelta === true ? nativeBridgeMode : "none", includeSerializedLookup: options?.useNativeDelta !== true, includeSerializedCharCount: shouldMeasureSerializedChars, + serializationCacheStats, + preparedRecordSetCacheStats, + usePreparedRecordSetCache: options?.usePreparedRecordSetCache !== false, }, ); + if (timings) { + timings.prepareMs = readPersistDeltaNow() - prepareStartedAt; + } const combinedSerializedChars = preparedContext.beforeSerializedChars + preparedContext.afterSerializedChars; const preparedNativeGate = @@ -1137,6 +1668,7 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { }) : null; + const nativeAttemptStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const nativeAttempt = options?.useNativeDelta !== true ? { @@ -1156,11 +1688,18 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { preparedContext, options, ); + if (timings) { + timings.nativeAttemptMs = readPersistDeltaNow() - nativeAttemptStartedAt; + } const nativeRawDelta = nativeAttempt.rawDelta; const nativeIdDelta = normalizePersistDeltaIdShape(nativeRawDelta); + const hydrateStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const nativeDelta = nativeIdDelta ? buildPersistDeltaFromIdShape(preparedContext, nativeIdDelta) : normalizePersistDeltaShape(nativeRawDelta); + if (timings && nativeRawDelta) { + timings.hydrateMs = readPersistDeltaNow() - hydrateStartedAt; + } if (nativeRawDelta && !nativeDelta) { if (options?.nativeFailOpen === false) { throw new Error("native-persist-delta-invalid-result"); @@ -1201,6 +1740,19 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { structuralDelta: preparedContext.structuralDelta, beforeSerializedChars: preparedContext.beforeSerializedChars, afterSerializedChars: preparedContext.afterSerializedChars, + prepareMs: timings?.prepareMs || 0, + nativeAttemptMs: timings?.nativeAttemptMs || 0, + lookupMs: timings?.lookupMs || 0, + jsDiffMs: timings?.jsDiffMs || 0, + hydrateMs: timings?.hydrateMs || 0, + serializationCacheObjectHits: Number(serializationCacheStats.objectHitCount || 0), + serializationCacheTokenHits: Number(serializationCacheStats.tokenHitCount || 0), + serializationCacheMisses: Number(serializationCacheStats.missCount || 0), + serializationCacheHits: sumPersistSerializationCacheHits( + serializationCacheStats, + ), + preparedRecordSetCacheHits: Number(preparedRecordSetCacheStats?.hitCount || 0), + preparedRecordSetCacheMisses: Number(preparedRecordSetCacheStats?.missCount || 0), minCombinedSerializedChars: preparedNativeGate?.minCombinedSerializedChars || 0, buildMs: readPersistDeltaNow() - startedAt, @@ -1214,31 +1766,50 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { return result; } + const lookupStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const beforeNodeSerializedById = ensurePreparedSerializedLookup( preparedContext.beforeNodes, + serializationCacheStats, ); const afterNodeSerializedById = ensurePreparedSerializedLookup( preparedContext.afterNodes, + serializationCacheStats, ); const beforeEdgeSerializedById = ensurePreparedSerializedLookup( preparedContext.beforeEdges, + serializationCacheStats, ); const afterEdgeSerializedById = ensurePreparedSerializedLookup( preparedContext.afterEdges, + serializationCacheStats, ); const beforeTombstoneSerializedById = ensurePreparedSerializedLookup( preparedContext.beforeTombstones, + serializationCacheStats, ); const afterTombstoneSerializedById = ensurePreparedSerializedLookup( preparedContext.afterTombstones, + serializationCacheStats, ); + const afterNodeRecordById = ensurePreparedRecordLookup(preparedContext.afterNodes); + const afterEdgeRecordById = ensurePreparedRecordLookup(preparedContext.afterEdges); + const afterTombstoneRecordById = ensurePreparedRecordLookup( + preparedContext.afterTombstones, + ); + const afterTombstoneTargetKeyById = ensurePreparedTargetKeyLookup( + preparedContext.afterTombstones, + ); + if (timings) { + timings.lookupMs = readPersistDeltaNow() - lookupStartedAt; + } + const jsDiffStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const upsertNodes = []; for (const id of preparedContext.afterNodes.ids) { if ( beforeNodeSerializedById.get(id) !== afterNodeSerializedById.get(id) ) { - const record = preparedContext.afterNodes.recordById?.get(id); + const record = afterNodeRecordById.get(id); if (record) upsertNodes.push(record); } } @@ -1248,7 +1819,7 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { if ( beforeEdgeSerializedById.get(id) !== afterEdgeSerializedById.get(id) ) { - const record = preparedContext.afterEdges.recordById?.get(id); + const record = afterEdgeRecordById.get(id); if (record) upsertEdges.push(record); } } @@ -1273,8 +1844,8 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { beforeTombstoneSerializedById.get(id) !== afterTombstoneSerializedById.get(id) ) { - const record = preparedContext.afterTombstones.recordById?.get(id); - const targetKey = preparedContext.afterTombstones.targetKeyById?.get(id) || ""; + const record = afterTombstoneRecordById.get(id); + const targetKey = afterTombstoneTargetKeyById.get(id) || ""; if (!record || !targetKey) continue; tombstoneMap.set(targetKey, record); } @@ -1314,6 +1885,9 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { : {}), }, }; + if (timings) { + timings.jsDiffMs = readPersistDeltaNow() - jsDiffStartedAt; + } if (shouldCollectDiagnostics) { emitPersistDeltaDiagnostics(options, { requestedNative: options?.useNativeDelta === true, @@ -1332,6 +1906,19 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { structuralDelta: preparedContext.structuralDelta, beforeSerializedChars: preparedContext.beforeSerializedChars, afterSerializedChars: preparedContext.afterSerializedChars, + prepareMs: timings?.prepareMs || 0, + nativeAttemptMs: timings?.nativeAttemptMs || 0, + lookupMs: timings?.lookupMs || 0, + jsDiffMs: timings?.jsDiffMs || 0, + hydrateMs: timings?.hydrateMs || 0, + serializationCacheObjectHits: Number(serializationCacheStats.objectHitCount || 0), + serializationCacheTokenHits: Number(serializationCacheStats.tokenHitCount || 0), + serializationCacheMisses: Number(serializationCacheStats.missCount || 0), + serializationCacheHits: sumPersistSerializationCacheHits( + serializationCacheStats, + ), + preparedRecordSetCacheHits: Number(preparedRecordSetCacheStats?.hitCount || 0), + preparedRecordSetCacheMisses: Number(preparedRecordSetCacheStats?.missCount || 0), minCombinedSerializedChars: preparedNativeGate?.minCombinedSerializedChars || 0, buildMs: readPersistDeltaNow() - startedAt, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 186f4cf..3088c7c 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -2627,6 +2627,15 @@ result = { assert.equal(persistDeltaDiagnostics.path, "js"); assert.equal(persistDeltaDiagnostics.requestedNative, false); assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.buildMs)), true); + assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.prepareMs)), true); + assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.lookupMs)), true); + assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.jsDiffMs)), true); + assert.equal( + Number(persistDeltaDiagnostics.serializationCacheHits || 0) + + Number(persistDeltaDiagnostics.serializationCacheMisses || 0) > + 0, + true, + ); } { diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs index b25b9c0..3a30c41 100644 --- a/tests/indexeddb-persistence.mjs +++ b/tests/indexeddb-persistence.mjs @@ -496,6 +496,31 @@ async function testGraphSnapshotConverters() { assert.equal(snapshot.state.extractionCount, 4); assert.equal(snapshot.nodes.length, 1); + const nextGraph = buildGraphFromSnapshot(snapshot, { + chatId: "chat-a", + }); + const reusedSnapshot = buildSnapshotFromGraph(nextGraph, { + chatId: "chat-a", + revision: 18, + baseSnapshot: snapshot, + }); + assert.equal( + reusedSnapshot.nodes[0], + snapshot.nodes[0], + "未变化节点应直接复用 baseSnapshot 记录对象", + ); + nextGraph.nodes[0].updatedAt = Number(nextGraph.nodes[0].updatedAt || 0) + 1; + const changedSnapshot = buildSnapshotFromGraph(nextGraph, { + chatId: "chat-a", + revision: 19, + baseSnapshot: snapshot, + }); + assert.notEqual( + changedSnapshot.nodes[0], + snapshot.nodes[0], + "节点变化后不应复用 baseSnapshot 记录对象", + ); + const rebuilt = buildGraphFromSnapshot(snapshot, { chatId: "chat-a", }); diff --git a/tests/native-persist-delta-hook.mjs b/tests/native-persist-delta-hook.mjs index 8da80ec..8c5520a 100644 --- a/tests/native-persist-delta-hook.mjs +++ b/tests/native-persist-delta-hook.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { buildPersistDelta, evaluatePersistNativeDeltaGate, + resetPersistRecordSerializationCaches, resolvePersistNativeDeltaBridgeMode, resolvePersistNativeDeltaGateOptions, shouldUseNativePersistDeltaForSnapshots, @@ -36,6 +37,10 @@ assert.equal(fallbackDiagnostics.path, "js"); assert.equal(fallbackDiagnostics.requestedNative, false); assert.equal(fallbackDiagnostics.usedNative, false); assert.equal(Number.isFinite(fallbackDiagnostics.buildMs), true); +assert.equal(Number.isFinite(fallbackDiagnostics.prepareMs), true); +assert.equal(Number.isFinite(fallbackDiagnostics.lookupMs), true); +assert.equal(Number.isFinite(fallbackDiagnostics.jsDiffMs), true); +assert.equal(fallbackDiagnostics.serializationCacheMisses > 0, true); const defaultGate = resolvePersistNativeDeltaGateOptions({}); assert.equal(defaultGate.minSnapshotRecords, 20000); @@ -112,6 +117,9 @@ assert.equal(nativeDelta.runtimeMetaPatch.jsPatch, true); assert.equal(nativeDiagnostics.path, "native-full"); assert.equal(nativeDiagnostics.requestedNative, true); assert.equal(nativeDiagnostics.usedNative, true); +assert.equal(Number.isFinite(nativeDiagnostics.prepareMs), true); +assert.equal(Number.isFinite(nativeDiagnostics.nativeAttemptMs), true); +assert.equal(Number.isFinite(nativeDiagnostics.hydrateMs), true); let payloadGateDiagnostics = null; let payloadGateBuilderCalled = false; @@ -134,6 +142,8 @@ assert.equal(payloadGateDiagnostics.path, "js"); assert.equal(payloadGateDiagnostics.nativeAttemptStatus, "gated-out"); assert.equal(payloadGateDiagnostics.gateAllowed, false); assert.deepEqual(payloadGateDiagnostics.gateReasons, ["below-serialized-chars-threshold"]); +assert.equal(Number.isFinite(payloadGateDiagnostics.lookupMs), true); +assert.equal(Number.isFinite(payloadGateDiagnostics.jsDiffMs), true); globalThis.__stBmeNativeBuildPersistDelta = (_before, _after, options = {}) => { assert.equal(Boolean(options?.preparedDeltaInput), true); @@ -168,6 +178,8 @@ assert.equal(compactDiagnostics.path, "native-compact-json"); assert.equal(compactDiagnostics.preparedBridgeMode, "json"); assert.equal(compactDiagnostics.requestedBridgeMode, "json"); assert.equal(compactDiagnostics.usedNative, true); +assert.equal(Number.isFinite(compactDiagnostics.nativeAttemptMs), true); +assert.equal(Number.isFinite(compactDiagnostics.hydrateMs), true); let hashDiagnostics = null; const hashNativeDelta = buildPersistDelta(beforeSnapshot, afterSnapshot, { @@ -189,6 +201,36 @@ assert.equal(hashDiagnostics.path, "native-compact-hash"); assert.equal(hashDiagnostics.preparedBridgeMode, "hash"); assert.equal(hashDiagnostics.requestedBridgeMode, "hash"); assert.equal(hashDiagnostics.usedNative, true); +assert.equal(Number.isFinite(hashDiagnostics.nativeAttemptMs), true); +assert.equal(Number.isFinite(hashDiagnostics.hydrateMs), true); + +let tokenCacheDiagnostics = null; +buildPersistDelta( + JSON.parse(JSON.stringify(beforeSnapshot)), + JSON.parse(JSON.stringify(afterSnapshot)), + { + onDiagnostics(snapshot) { + tokenCacheDiagnostics = snapshot; + }, + }, +); +assert.equal(tokenCacheDiagnostics.serializationCacheTokenHits > 0, true); + +resetPersistRecordSerializationCaches(); +let preparedCacheColdDiagnostics = null; +buildPersistDelta(beforeSnapshot, afterSnapshot, { + onDiagnostics(snapshot) { + preparedCacheColdDiagnostics = snapshot; + }, +}); +let preparedCacheWarmDiagnostics = null; +buildPersistDelta(beforeSnapshot, afterSnapshot, { + onDiagnostics(snapshot) { + preparedCacheWarmDiagnostics = snapshot; + }, +}); +assert.equal(preparedCacheColdDiagnostics.preparedRecordSetCacheMisses > 0, true); +assert.equal(preparedCacheWarmDiagnostics.preparedRecordSetCacheHits > 0, true); delete globalThis.__stBmeNativeBuildPersistDelta; diff --git a/tests/perf/persist-delta-bench.mjs b/tests/perf/persist-delta-bench.mjs index fcbe137..390cd83 100644 --- a/tests/perf/persist-delta-bench.mjs +++ b/tests/perf/persist-delta-bench.mjs @@ -1,6 +1,9 @@ import { performance } from "node:perf_hooks"; -import { buildPersistDelta } from "../../sync/bme-db.js"; +import { + buildPersistDelta, + resetPersistRecordSerializationCaches, +} from "../../sync/bme-db.js"; import { getNativeModuleStatus, installNativePersistDeltaHook, @@ -87,6 +90,40 @@ function buildSnapshots(seed = 5, nodeCount = 5000, edgeCount = 12000, churn = 0 }; } +function summarizeDiagnostics(samples = []) { + const summary = { + prepareMs: 0, + nativeAttemptMs: 0, + lookupMs: 0, + jsDiffMs: 0, + hydrateMs: 0, + serializationCacheHits: 0, + serializationCacheMisses: 0, + preparedRecordSetCacheHits: 0, + preparedRecordSetCacheMisses: 0, + }; + if (!samples.length) return summary; + for (const sample of samples) { + summary.prepareMs += Number(sample?.prepareMs || 0); + summary.nativeAttemptMs += Number(sample?.nativeAttemptMs || 0); + summary.lookupMs += Number(sample?.lookupMs || 0); + summary.jsDiffMs += Number(sample?.jsDiffMs || 0); + summary.hydrateMs += Number(sample?.hydrateMs || 0); + summary.serializationCacheHits += Number(sample?.serializationCacheHits || 0); + summary.serializationCacheMisses += Number(sample?.serializationCacheMisses || 0); + summary.preparedRecordSetCacheHits += Number( + sample?.preparedRecordSetCacheHits || 0, + ); + summary.preparedRecordSetCacheMisses += Number( + sample?.preparedRecordSetCacheMisses || 0, + ); + } + for (const key of Object.keys(summary)) { + summary[key] /= samples.length; + } + return summary; +} + function summarize(values = []) { if (!values.length) return { avg: 0, p95: 0, min: 0, max: 0 }; const sorted = [...values].sort((a, b) => a - b); @@ -100,83 +137,158 @@ function summarize(values = []) { }; } -async function main() { - await installNativePersistDeltaHook(); - const nativeStatus = getNativeModuleStatus(); - const jsSamples = []; - const nativeJsonSamples = []; - const nativeHashSamples = []; - for (let run = 0; run < RUNS; run++) { - const snapshots = buildSnapshots(17 + run, 5000, 12000, 0.12); - const jsStartedAt = performance.now(); - const jsDelta = buildPersistDelta(snapshots.before, snapshots.after, { - useNativeDelta: false, - }); - const jsElapsedMs = performance.now() - jsStartedAt; - jsSamples.push({ - elapsedMs: jsElapsedMs, - upsertNodes: jsDelta.upsertNodes.length, - upsertEdges: jsDelta.upsertEdges.length, - deleteNodeIds: jsDelta.deleteNodeIds.length, - deleteEdgeIds: jsDelta.deleteEdgeIds.length, - }); - - const nativeJsonStartedAt = performance.now(); - const nativeJsonDelta = buildPersistDelta(snapshots.before, snapshots.after, { +function buildModeOptions(mode, onDiagnostics, extraOptions = {}) { + if (mode === "native-json") { + return { useNativeDelta: true, minSnapshotRecords: 0, minStructuralDelta: 0, minCombinedSerializedChars: 0, persistNativeDeltaBridgeMode: "json", nativeFailOpen: false, - }); - const nativeJsonElapsedMs = performance.now() - nativeJsonStartedAt; - nativeJsonSamples.push({ - elapsedMs: nativeJsonElapsedMs, - upsertNodes: nativeJsonDelta.upsertNodes.length, - upsertEdges: nativeJsonDelta.upsertEdges.length, - deleteNodeIds: nativeJsonDelta.deleteNodeIds.length, - deleteEdgeIds: nativeJsonDelta.deleteEdgeIds.length, - }); - - const nativeHashStartedAt = performance.now(); - const nativeHashDelta = buildPersistDelta(snapshots.before, snapshots.after, { + onDiagnostics, + ...extraOptions, + }; + } + if (mode === "native-hash") { + return { useNativeDelta: true, minSnapshotRecords: 0, minStructuralDelta: 0, minCombinedSerializedChars: 0, persistNativeDeltaBridgeMode: "hash", nativeFailOpen: false, - }); - const nativeHashElapsedMs = performance.now() - nativeHashStartedAt; - nativeHashSamples.push({ - elapsedMs: nativeHashElapsedMs, - upsertNodes: nativeHashDelta.upsertNodes.length, - upsertEdges: nativeHashDelta.upsertEdges.length, - deleteNodeIds: nativeHashDelta.deleteNodeIds.length, - deleteEdgeIds: nativeHashDelta.deleteEdgeIds.length, - }); + onDiagnostics, + ...extraOptions, + }; + } + return { + useNativeDelta: false, + onDiagnostics, + ...extraOptions, + }; +} + +function runMeasuredPersistDeltaSample( + snapshots, + mode, + { resetCaches = true, usePreparedRecordSetCache = true } = {}, +) { + if (resetCaches) { + resetPersistRecordSerializationCaches(); + } + let diagnostics = null; + const startedAt = performance.now(); + const delta = buildPersistDelta( + snapshots.before, + snapshots.after, + buildModeOptions( + mode, + (snapshot) => { + diagnostics = snapshot; + }, + { usePreparedRecordSetCache }, + ), + ); + const elapsedMs = performance.now() - startedAt; + return { + elapsedMs, + upsertNodes: delta.upsertNodes.length, + upsertEdges: delta.upsertEdges.length, + deleteNodeIds: delta.deleteNodeIds.length, + deleteEdgeIds: delta.deleteEdgeIds.length, + prepareMs: diagnostics?.prepareMs, + nativeAttemptMs: diagnostics?.nativeAttemptMs, + lookupMs: diagnostics?.lookupMs, + jsDiffMs: diagnostics?.jsDiffMs, + hydrateMs: diagnostics?.hydrateMs, + serializationCacheHits: diagnostics?.serializationCacheHits, + serializationCacheMisses: diagnostics?.serializationCacheMisses, + preparedRecordSetCacheHits: diagnostics?.preparedRecordSetCacheHits, + preparedRecordSetCacheMisses: diagnostics?.preparedRecordSetCacheMisses, + }; +} + +function primePersistDeltaCaches(snapshots, mode) { + buildPersistDelta( + snapshots.before, + snapshots.after, + buildModeOptions(mode, undefined, { + usePreparedRecordSetCache: true, + onDiagnostics() {}, + }), + ); +} + +function formatTimingSummary(label, samples = []) { + const timingSummary = summarize(samples.map((sample) => sample.elapsedMs)); + return `${label} avg=${timingSummary.avg.toFixed(2)}ms p95=${timingSummary.p95.toFixed(2)}ms min=${timingSummary.min.toFixed(2)}ms max=${timingSummary.max.toFixed(2)}ms`; +} + +function formatStageSummary(label, samples = []) { + const diagnosticsSummary = summarizeDiagnostics(samples); + return `${label} prepare=${diagnosticsSummary.prepareMs.toFixed(2)}ms native=${diagnosticsSummary.nativeAttemptMs.toFixed(2)}ms lookup=${diagnosticsSummary.lookupMs.toFixed(2)}ms diff=${diagnosticsSummary.jsDiffMs.toFixed(2)}ms hydrate=${diagnosticsSummary.hydrateMs.toFixed(2)}ms ser-cache=${diagnosticsSummary.serializationCacheHits.toFixed(1)}H/${diagnosticsSummary.serializationCacheMisses.toFixed(1)}M set-cache=${diagnosticsSummary.preparedRecordSetCacheHits.toFixed(1)}H/${diagnosticsSummary.preparedRecordSetCacheMisses.toFixed(1)}M`; +} + +async function main() { + await installNativePersistDeltaHook(); + const nativeStatus = getNativeModuleStatus(); + const coldSamplesByMode = { + js: [], + "native-json": [], + "native-hash": [], + }; + const warmSamplesByMode = { + js: [], + "native-json": [], + "native-hash": [], + }; + const modes = ["js", "native-json", "native-hash"]; + for (let run = 0; run < RUNS; run++) { + const snapshots = buildSnapshots(17 + run, 5000, 12000, 0.12); + for (const mode of modes) { + coldSamplesByMode[mode].push( + runMeasuredPersistDeltaSample(snapshots, mode, { + resetCaches: true, + usePreparedRecordSetCache: false, + }), + ); + resetPersistRecordSerializationCaches(); + primePersistDeltaCaches(snapshots, mode); + warmSamplesByMode[mode].push( + runMeasuredPersistDeltaSample(snapshots, mode, { + resetCaches: false, + usePreparedRecordSetCache: true, + }), + ); + } } - const jsTimingSummary = summarize(jsSamples.map((sample) => sample.elapsedMs)); - const nativeJsonTimingSummary = summarize( - nativeJsonSamples.map((sample) => sample.elapsedMs), - ); - const nativeHashTimingSummary = summarize( - nativeHashSamples.map((sample) => sample.elapsedMs), - ); const avgUpserts = - jsSamples.reduce((acc, sample) => acc + sample.upsertNodes + sample.upsertEdges, 0) / - jsSamples.length; + coldSamplesByMode.js.reduce( + (acc, sample) => acc + sample.upsertNodes + sample.upsertEdges, + 0, + ) / coldSamplesByMode.js.length; const avgDeletes = - jsSamples.reduce((acc, sample) => acc + sample.deleteNodeIds + sample.deleteEdgeIds, 0) / - jsSamples.length; + coldSamplesByMode.js.reduce( + (acc, sample) => acc + sample.deleteNodeIds + sample.deleteEdgeIds, + 0, + ) / coldSamplesByMode.js.length; console.log( `[ST-BME][bench] persist-delta native-source=${nativeStatus.source || "unknown"}`, ); console.log( - `[ST-BME][bench] persist-delta runs=${RUNS} | js avg=${jsTimingSummary.avg.toFixed(2)}ms p95=${jsTimingSummary.p95.toFixed(2)}ms min=${jsTimingSummary.min.toFixed(2)}ms max=${jsTimingSummary.max.toFixed(2)}ms | native-json avg=${nativeJsonTimingSummary.avg.toFixed(2)}ms p95=${nativeJsonTimingSummary.p95.toFixed(2)}ms min=${nativeJsonTimingSummary.min.toFixed(2)}ms max=${nativeJsonTimingSummary.max.toFixed(2)}ms | native-hash avg=${nativeHashTimingSummary.avg.toFixed(2)}ms p95=${nativeHashTimingSummary.p95.toFixed(2)}ms min=${nativeHashTimingSummary.min.toFixed(2)}ms max=${nativeHashTimingSummary.max.toFixed(2)}ms | avgUpserts=${avgUpserts.toFixed(1)} avgDeletes=${avgDeletes.toFixed(1)}`, + `[ST-BME][bench] persist-delta cold runs=${RUNS} | ${formatTimingSummary("js", coldSamplesByMode.js)} | ${formatTimingSummary("native-json", coldSamplesByMode["native-json"])} | ${formatTimingSummary("native-hash", coldSamplesByMode["native-hash"])} | avgUpserts=${avgUpserts.toFixed(1)} avgDeletes=${avgDeletes.toFixed(1)}`, + ); + console.log( + `[ST-BME][bench] persist-delta cold stages | ${formatStageSummary("js", coldSamplesByMode.js)} | ${formatStageSummary("native-json", coldSamplesByMode["native-json"])} | ${formatStageSummary("native-hash", coldSamplesByMode["native-hash"])} `, + ); + console.log( + `[ST-BME][bench] persist-delta warm runs=${RUNS} | ${formatTimingSummary("js", warmSamplesByMode.js)} | ${formatTimingSummary("native-json", warmSamplesByMode["native-json"])} | ${formatTimingSummary("native-hash", warmSamplesByMode["native-hash"])} `, + ); + console.log( + `[ST-BME][bench] persist-delta warm stages | ${formatStageSummary("js", warmSamplesByMode.js)} | ${formatStageSummary("native-json", warmSamplesByMode["native-json"])} | ${formatStageSummary("native-hash", warmSamplesByMode["native-hash"])} `, ); } diff --git a/ui/panel.js b/ui/panel.js index 1f86c6b..c154267 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -8044,6 +8044,12 @@ function _renderPersistDeltaTraceCard(state) { const payloadCharsText = diagnostics.combinedSerializedChars ? `${Number(diagnostics.combinedSerializedChars || 0)} / ${Number(diagnostics.minCombinedSerializedChars || 0)}` : "—"; + const cacheText = `${Number(diagnostics.serializationCacheHits || 0)}H / ${Number( + diagnostics.serializationCacheMisses || 0, + )}M`; + const preparedSetCacheText = `${Number( + diagnostics.preparedRecordSetCacheHits || 0, + )}H / ${Number(diagnostics.preparedRecordSetCacheMisses || 0)}M`; return `