fix: harden persistence tiers and opfs durability

This commit is contained in:
Youzini-afk
2026-04-14 15:43:59 +08:00
parent 246af61f6c
commit 33b8d298f7
11 changed files with 1916 additions and 438 deletions

View File

@@ -1098,6 +1098,7 @@ result = {
applyGraphLoadState,
maybeFlushQueuedGraphPersist,
retryPendingGraphPersist,
persistExtractionBatchResult,
saveGraphToIndexedDb,
cloneGraphForPersistence,
assertRecoveryChatStillActive,
@@ -3185,4 +3186,89 @@ result = {
assert.equal(result?.commitMarker?.storageTier, "chat-state");
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-generic-primary-no-mirror",
globalChatId: "chat-generic-primary-no-mirror",
characterId: "char-generic",
chatMetadata: {
integrity: "meta-generic-primary-no-mirror",
},
});
const graph = stampPersistedGraph(
createMeaningfulGraph("chat-generic-primary-no-mirror", "generic-primary"),
{
revision: 5,
integrity: "meta-generic-primary-no-mirror",
chatId: "chat-generic-primary-no-mirror",
reason: "generic-primary-seed",
},
);
harness.api.setCurrentGraph(graph);
const result = await harness.api.persistExtractionBatchResult({
reason: "generic-primary-persist",
lastProcessedAssistantFloor: 6,
});
assert.equal(result.accepted, true);
assert.equal(result.storageTier, "indexeddb");
assert.equal(
harness.runtimeContext.__chatContext.__chatStateStore.size,
0,
"generic ST 主写成功后不应再常驻 mirror 到 chat-state",
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-luker-primary",
globalChatId: "chat-luker-primary",
characterId: "char-luker",
chatMetadata: {
integrity: "meta-luker-primary",
},
});
harness.runtimeContext.Luker = {
getContext() {
return harness.runtimeContext.__chatContext;
},
};
const graph = stampPersistedGraph(
createMeaningfulGraph("chat-luker-primary", "luker-primary"),
{
revision: 8,
integrity: "meta-luker-primary",
chatId: "chat-luker-primary",
reason: "luker-primary-seed",
},
);
harness.api.setCurrentGraph(graph);
const result = await harness.api.persistExtractionBatchResult({
reason: "luker-primary-persist",
lastProcessedAssistantFloor: 6,
});
assert.equal(result.accepted, true);
assert.equal(result.storageTier, "luker-chat-state");
assert.equal(result.acceptedBy, "luker-chat-state");
const stored = await harness.runtimeContext.__chatContext.getChatState(
GRAPH_CHAT_STATE_NAMESPACE,
);
assert.equal(stored?.revision, result.revision);
assert.equal(stored?.storageTier, "luker-chat-state");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0) >= result.revision,
true,
"Luker 主存储成功后应异步补写本地缓存",
);
assert.equal(
harness.api.getGraphPersistenceState().acceptedStorageTier,
"luker-chat-state",
);
}
console.log("graph-persistence tests passed");

View File

@@ -0,0 +1,107 @@
export function createNotFoundError(message) {
const error = new Error(String(message || "Not found"));
error.name = "NotFoundError";
return error;
}
export class MemoryOpfsFileHandle {
constructor(parent, name) {
this.parent = parent;
this.name = String(name || "");
}
async getFile() {
const parent = this.parent;
const name = this.name;
return {
async text() {
return String(parent.files.get(name) ?? "");
},
};
}
async createWritable() {
const parent = this.parent;
const name = this.name;
let buffer = String(parent.files.get(name) ?? "");
return {
async write(chunk) {
if (typeof chunk === "string") {
buffer = chunk;
return;
}
if (chunk == null) {
buffer = "";
return;
}
buffer = String(chunk);
},
async close() {
if (Number(parent.writeDelayMs) > 0) {
await new Promise((resolve) =>
setTimeout(resolve, Number(parent.writeDelayMs)),
);
}
parent.files.set(name, buffer);
},
};
}
}
export class MemoryOpfsDirectoryHandle {
constructor(name = "", { writeDelayMs = 0 } = {}) {
this.name = String(name || "");
this.directories = new Map();
this.files = new Map();
this.writeDelayMs = Number(writeDelayMs) || 0;
}
async getDirectoryHandle(name, options = {}) {
const normalizedName = String(name || "");
let directory = this.directories.get(normalizedName) || null;
if (!directory) {
if (!options.create) {
throw createNotFoundError(`Directory not found: ${normalizedName}`);
}
directory = new MemoryOpfsDirectoryHandle(normalizedName, {
writeDelayMs: this.writeDelayMs,
});
this.directories.set(normalizedName, directory);
}
return directory;
}
async getFileHandle(name, options = {}) {
const normalizedName = String(name || "");
if (!this.files.has(normalizedName)) {
if (!options.create) {
throw createNotFoundError(`File not found: ${normalizedName}`);
}
this.files.set(normalizedName, "");
}
return new MemoryOpfsFileHandle(this, normalizedName);
}
async removeEntry(name, options = {}) {
const normalizedName = String(name || "");
if (this.files.delete(normalizedName)) {
return;
}
const directory = this.directories.get(normalizedName) || null;
if (directory) {
const canDelete =
options.recursive === true ||
(directory.files.size === 0 && directory.directories.size === 0);
if (!canDelete) {
throw new Error(`Directory not empty: ${normalizedName}`);
}
this.directories.delete(normalizedName);
return;
}
throw createNotFoundError(`Entry not found: ${normalizedName}`);
}
}
export function createMemoryOpfsRoot(options = {}) {
return new MemoryOpfsDirectoryHandle("root", options);
}

View File

@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const indexPath = path.resolve(moduleDir, "../index.js");
const indexSource = await fs.readFile(indexPath, "utf8");
function extractSnippet(startMarker, endMarker) {
const start = indexSource.indexOf(startMarker);
const end = indexSource.indexOf(endMarker, start);
if (start < 0 || end < 0 || end <= start) {
throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`);
}
return indexSource.slice(start, end).replace(/^export\s+/gm, "");
}
const saveGraphSnippet = extractSnippet(
"async function saveGraphToIndexedDb(",
"function queueGraphPersistToIndexedDb(",
);
const tempModulePath = path.resolve(
moduleDir,
"../.tmp-index-esm-entry-smoke.mjs",
);
await fs.writeFile(
tempModulePath,
`
const GRAPH_LOAD_STATES = { SHADOW_RESTORED: "shadow-restored", LOADED: "loaded" };
let currentGraph = null;
let graphPersistenceState = {
metadataIntegrity: "",
loadState: "loaded",
revision: 0,
lastPersistedRevision: 0,
lastAcceptedRevision: 0,
cacheMirrorState: "idle",
persistDiagnosticTier: "none",
hostProfile: "generic-st",
primaryStorageTier: "indexeddb",
cacheStorageTier: "none",
shadowSnapshotRevision: 0,
shadowSnapshotUpdatedAt: "",
shadowSnapshotReason: "",
};
function normalizeChatIdCandidate(value = "") { return String(value ?? "").trim(); }
function normalizeIndexedDbRevision(value, fallbackValue = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : Math.max(0, Number(fallbackValue) || 0);
}
function getContext() { return { chatId: "chat-esm", chatMetadata: {}, characterId: "char-esm" }; }
function getSettings() {
return {
persistNativeDeltaBridgeMode: "json",
persistUseNativeDelta: false,
graphNativeForceDisable: false,
nativeEngineFailOpen: true,
};
}
function ensureBmeChatManager() {
return {
async getCurrentDb() {
return {
async exportSnapshot() {
return { meta: { revision: 0 }, nodes: [], edges: [], tombstones: [], state: { lastProcessedFloor: -1, extractionCount: 0 } };
},
async commitDelta(delta, options = {}) {
if (globalThis.__testCommitShouldThrow) {
throw new Error("commit-failed");
}
return {
revision: Number(options.requestedRevision || 1),
lastModified: Date.now(),
delta,
};
},
};
},
};
}
function getPreferredGraphLocalStorePresentationSync() {
return { storagePrimary: "indexeddb", storageMode: "indexeddb", statusLabel: "IndexedDB", reasonPrefix: "indexeddb" };
}
function resolveDbGraphStorePresentation(db) {
return { storagePrimary: "indexeddb", storageMode: "indexeddb", statusLabel: "IndexedDB", reasonPrefix: "indexeddb" };
}
function buildPersistenceEnvironment() {
return { hostProfile: "generic-st", primaryStorageTier: "indexeddb", cacheStorageTier: "none" };
}
function resolveCurrentChatIdentity() {
return { integrity: "meta-esm", hostChatId: "host-esm" };
}
function readCachedIndexedDbSnapshot() { return null; }
function resolvePersistRevisionFloor(revision = 0) { return Number(revision) || 1; }
function buildSnapshotFromGraph(graph, options = {}) {
return {
meta: {
revision: Number(options.revision || 1),
storagePrimary: "indexeddb",
storageMode: "indexeddb",
integrity: "meta-esm",
},
nodes: [],
edges: [],
tombstones: [],
state: { lastProcessedFloor: -1, extractionCount: 0 },
};
}
function evaluatePersistNativeDeltaGate() {
return {
allowed: false,
reasons: [],
minSnapshotRecords: 0,
minStructuralDelta: 0,
minCombinedSerializedChars: 0,
beforeRecordCount: 0,
afterRecordCount: 0,
maxSnapshotRecords: 0,
structuralDelta: 0,
};
}
function readPersistDeltaDiagnosticsNow() { return Date.now(); }
function updatePersistDeltaDiagnostics() {}
function buildPersistDelta() {
return {
upsertNodes: [],
upsertEdges: [],
deleteNodeIds: [],
deleteEdgeIds: [],
tombstones: [],
runtimeMetaPatch: {},
};
}
function cloneRuntimeDebugValue(value, fallback = null) { return value == null ? fallback : JSON.parse(JSON.stringify(value)); }
function buildBmeSyncRuntimeOptions() { return {}; }
function scheduleUpload() {}
function cacheIndexedDbSnapshot() {}
function stampGraphPersistenceMeta() {}
function getChatMetadataIntegrity() { return "meta-esm"; }
function clearPendingGraphPersistRetry() {}
function areChatIdsEquivalentForResolvedIdentity() { return false; }
function applyGraphLoadState() {}
function rememberResolvedGraphIdentityAlias() {}
function resolveLocalStoreTierFromPresentation() { return "indexeddb"; }
function updateGraphPersistenceState(patch = {}) { graphPersistenceState = { ...graphPersistenceState, ...(patch || {}) }; return graphPersistenceState; }
${saveGraphSnippet}
export { saveGraphToIndexedDb };
`,
"utf8",
);
try {
const smokeModule = await import(
`${pathToFileURL(tempModulePath).href}?t=${Date.now()}`
);
const success = await smokeModule.saveGraphToIndexedDb(
"chat-esm",
{ historyState: {} },
{ revision: 2, reason: "esm-success" },
);
assert.equal(success.saved, true);
assert.equal(success.accepted, true);
globalThis.__testCommitShouldThrow = true;
const failed = await smokeModule.saveGraphToIndexedDb(
"chat-esm",
{ historyState: {} },
{ revision: 3, reason: "esm-failure" },
);
assert.equal(failed.saved, false);
assert.equal(failed.reason, "indexeddb-write-failed");
} finally {
delete globalThis.__testCommitShouldThrow;
await fs.unlink(tempModulePath).catch(() => {});
}
console.log("index-esm-entry-smoke tests passed");

View File

@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
function moduleUrl(tag) {
return `../vendor/wasm/stbme_core.js?test=${Date.now()}-${tag}`;
}
globalThis.__stBmeDisableWasmPackArtifacts = true;
delete globalThis.__stBmeLoadRustWasmLayout;
const firstLoad = await import(moduleUrl("native-persist-first"));
let firstError = "";
try {
await firstLoad.installNativePersistDeltaHook();
} catch (error) {
firstError = error?.message || String(error);
}
assert.match(
firstError,
/native module unavailable|native persist delta builder unavailable|global-loader|Rust\/WASM artifact is not initialized/i,
);
globalThis.__stBmeLoadRustWasmLayout = async () => ({
solve_layout() {
return {
ok: true,
positions: [],
diagnostics: {
solver: "mock-rust-wasm",
},
};
},
build_persist_delta() {
return {
upsertNodes: [],
upsertEdges: [],
deleteNodeIds: [],
deleteEdgeIds: [],
tombstones: [],
runtimeMetaPatch: {},
};
},
});
const retryStatus = await firstLoad.installNativePersistDeltaHook();
assert.equal(retryStatus.loaded, true);
assert.equal(typeof globalThis.__stBmeNativeBuildPersistDelta, "function");
delete globalThis.__stBmeNativeBuildPersistDelta;
delete globalThis.__stBmeLoadRustWasmLayout;
delete globalThis.__stBmeDisableWasmPackArtifacts;
console.log("native-persist-delta-failopen tests passed");

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import {
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW,
OpfsGraphStore,
} from "../sync/bme-opfs-store.js";
import { createMemoryOpfsRoot } from "./helpers/memory-opfs.mjs";
const rootDirectory = createMemoryOpfsRoot();
const store = new OpfsGraphStore("chat-opfs-meta-fast-path", {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW,
});
await store.open();
await store.importSnapshot(
{
meta: {
revision: 3,
lastBackupFilename: "before.json",
lastSyncUploadedAt: 10,
},
state: {
lastProcessedFloor: 2,
extractionCount: 1,
},
nodes: [
{
id: "node-1",
type: "event",
fields: { title: "A" },
archived: false,
updatedAt: 1,
},
],
edges: [],
tombstones: [],
},
{
mode: "replace",
preserveRevision: true,
},
);
const originalLoadSnapshot = store._loadSnapshot.bind(store);
let loadSnapshotCalls = 0;
store._loadSnapshot = async (...args) => {
loadSnapshotCalls += 1;
return await originalLoadSnapshot(...args);
};
assert.equal(await store.getMeta("lastBackupFilename", ""), "before.json");
assert.equal(await store.getRevision(), 3);
await store.patchMeta({
lastBackupFilename: "after.json",
lastProcessedFloor: 9,
extractionCount: 4,
});
assert.equal(
loadSnapshotCalls,
0,
"manifest-only meta fast path should not load full snapshot",
);
const snapshot = await originalLoadSnapshot();
assert.equal(snapshot.meta.lastBackupFilename, "after.json");
assert.equal(snapshot.state.lastProcessedFloor, 9);
assert.equal(snapshot.state.extractionCount, 4);
assert.equal(snapshot.nodes.length, 1);
console.log("opfs-meta-fast-path tests passed");

View File

@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import {
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
OpfsGraphStore,
} from "../sync/bme-opfs-store.js";
import { createMemoryOpfsRoot } from "./helpers/memory-opfs.mjs";
async function testCommitDeltaAndPatchMetaSerialize() {
const rootDirectory = createMemoryOpfsRoot({
writeDelayMs: 5,
});
const store = new OpfsGraphStore("chat-opfs-serialize-meta", {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
});
await store.open();
await store.importSnapshot(
{
meta: {
revision: 1,
lastBackupFilename: "",
},
state: {
lastProcessedFloor: 0,
extractionCount: 0,
},
nodes: [],
edges: [],
tombstones: [],
},
{
mode: "replace",
preserveRevision: true,
},
);
await Promise.all([
store.commitDelta(
{
upsertNodes: [
{
id: "node-1",
type: "event",
fields: {
title: "serialized",
},
archived: false,
updatedAt: 100,
},
],
},
{
reason: "serialized-node",
},
),
store.patchMeta({
lastBackupFilename: "backup-a.json",
lastProcessedFloor: 7,
extractionCount: 3,
}),
]);
const snapshot = await store.exportSnapshot();
assert.equal(snapshot.nodes.length, 1);
assert.equal(snapshot.nodes[0]?.id, "node-1");
assert.equal(snapshot.meta.lastBackupFilename, "backup-a.json");
assert.equal(snapshot.state.lastProcessedFloor, 7);
assert.equal(snapshot.state.extractionCount, 3);
}
async function testImportSnapshotAndClearAllSerialize() {
const rootDirectory = createMemoryOpfsRoot({
writeDelayMs: 5,
});
const store = new OpfsGraphStore("chat-opfs-serialize-clear", {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
});
await store.open();
await store.importSnapshot(
{
meta: { revision: 2 },
state: { lastProcessedFloor: 5, extractionCount: 2 },
nodes: [
{
id: "seed-node",
type: "event",
fields: { title: "seed" },
archived: false,
updatedAt: 1,
},
],
edges: [],
tombstones: [],
},
{ mode: "replace", preserveRevision: true },
);
await Promise.all([
store.clearAll(),
store.importSnapshot(
{
meta: { revision: 4 },
state: { lastProcessedFloor: 9, extractionCount: 4 },
nodes: [
{
id: "after-clear-node",
type: "fact",
fields: { title: "after-clear" },
archived: false,
updatedAt: 2,
},
],
edges: [],
tombstones: [],
},
{ mode: "replace", preserveRevision: true },
),
]);
const snapshot = await store.exportSnapshot();
assert.equal(snapshot.nodes.length, 1);
assert.equal(snapshot.nodes[0]?.id, "after-clear-node");
assert.equal(snapshot.state.lastProcessedFloor, 9);
assert.equal(snapshot.state.extractionCount, 4);
}
await testCommitDeltaAndPatchMetaSerialize();
await testImportSnapshotAndClearAllSerialize();
console.log("opfs-write-serialization tests passed");