Refactor extraction persistence into two-stage status model

This commit is contained in:
Youzini-afk
2026-04-10 01:19:49 +08:00
parent 8f0db97c78
commit 7faa9cfc7f
10 changed files with 1239 additions and 71 deletions

View File

@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { executeExtractionBatchController } from "../maintenance/extraction-controller.js";
import {
createBatchStatusSkeleton,
finalizeBatchStatus,
setBatchStageOutcome,
} from "../ui/ui-status.js";
function createRuntime(persistResult) {
const graph = {
nodes: [],
edges: [],
historyState: {},
};
let processedHistoryUpdates = 0;
return {
graph,
processedHistoryUpdates,
ensureCurrentGraphRuntimeState() {},
throwIfAborted() {},
getCurrentGraph() {
return graph;
},
getLastProcessedAssistantFloor() {
return 4;
},
getExtractionCount() {
return 6;
},
cloneGraphSnapshot(value) {
return JSON.parse(JSON.stringify(value));
},
buildExtractionMessages() {
return [{ seq: 5, role: "assistant", content: "测试消息" }];
},
createBatchStatusSkeleton,
async extractMemories() {
return {
success: true,
newNodes: 1,
updatedNodes: 0,
newEdges: 0,
newNodeIds: ["node-1"],
processedRange: [5, 5],
};
},
getSchema() {
return [];
},
getEmbeddingConfig() {
return null;
},
setLastExtractionStatus() {},
setBatchStageOutcome,
async handleExtractionSuccess(result, _endIdx, _settings, _signal, batchStatus) {
setBatchStageOutcome(batchStatus, "finalize", "success");
return {
postProcessArtifacts: [],
vectorHashesInserted: [],
warnings: [],
batchStatus,
};
},
async persistExtractionBatchResult() {
return persistResult;
},
finalizeBatchStatus,
shouldAdvanceProcessedHistory(batchStatus) {
return batchStatus.historyAdvanceAllowed === true;
},
updateProcessedHistorySnapshot() {
processedHistoryUpdates += 1;
},
appendBatchJournal() {},
createBatchJournalEntry() {
return { id: "journal-1" };
},
computePostProcessArtifacts() {
return [];
},
getGraphPersistenceState() {
return { chatId: "chat-test" };
},
console,
get processedHistoryUpdates() {
return processedHistoryUpdates;
},
};
}
{
const runtime = createRuntime({
saved: false,
queued: true,
blocked: true,
accepted: false,
reason: "persist-queued",
revision: 7,
saveMode: "immediate",
storageTier: "none",
});
const result = await executeExtractionBatchController(runtime, {
chat: [{ is_user: false, mes: "测试" }],
startIdx: 5,
endIdx: 5,
settings: {},
});
assert.equal(result.success, true);
assert.equal(result.historyAdvanceAllowed, false);
assert.equal(runtime.processedHistoryUpdates, 0);
assert.equal(
runtime.graph.historyState.lastBatchStatus.persistence.outcome,
"queued",
);
assert.equal(
runtime.graph.historyState.lastBatchStatus.historyAdvanceAllowed,
false,
);
}
{
const runtime = createRuntime({
saved: true,
queued: false,
blocked: false,
accepted: true,
reason: "indexeddb",
revision: 8,
saveMode: "indexeddb",
storageTier: "indexeddb",
});
const result = await executeExtractionBatchController(runtime, {
chat: [{ is_user: false, mes: "测试" }],
startIdx: 5,
endIdx: 5,
settings: {},
});
assert.equal(result.success, true);
assert.equal(result.historyAdvanceAllowed, true);
assert.equal(runtime.processedHistoryUpdates, 1);
assert.equal(
runtime.graph.historyState.lastBatchStatus.persistence.outcome,
"saved",
);
assert.equal(
runtime.graph.historyState.lastBatchStatus.historyAdvanceAllowed,
true,
);
}
console.log("extraction-persistence-gating tests passed");

View File

@@ -11,12 +11,16 @@ import {
} from "../sync/bme-db.js";
import { onMessageReceivedController } from "../host/event-binding.js";
import {
buildGraphCommitMarker,
detectIndexedDbSnapshotCommitMarkerMismatch,
cloneGraphForPersistence,
cloneRuntimeDebugValue,
findGraphShadowSnapshotByIntegrity,
getAcceptedCommitMarkerRevision,
getGraphPersistedRevision,
getGraphIdentityAliasCandidates,
getGraphPersistenceMeta,
GRAPH_COMMIT_MARKER_KEY,
getGraphShadowSnapshotStorageKey,
GRAPH_LOAD_PENDING_CHAT_ID,
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
@@ -27,6 +31,8 @@ import {
GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX,
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
MODULE_NAME,
normalizeGraphCommitMarker,
readGraphCommitMarker,
readGraphShadowSnapshot,
rememberGraphIdentityAlias,
removeGraphShadowSnapshot,
@@ -384,11 +390,15 @@ async function createGraphPersistenceHarness({
formatRecallContextLine,
readPersistedRecallFromUserMessage,
cloneGraphForPersistence,
buildGraphCommitMarker,
cloneRuntimeDebugValue,
detectIndexedDbSnapshotCommitMarkerMismatch,
onMessageReceivedController,
getAcceptedCommitMarkerRevision,
getGraphPersistenceMeta,
getGraphPersistedRevision,
getGraphIdentityAliasCandidates,
GRAPH_COMMIT_MARKER_KEY,
getGraphShadowSnapshotStorageKey,
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
GRAPH_LOAD_PENDING_CHAT_ID,
@@ -400,6 +410,8 @@ async function createGraphPersistenceHarness({
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
MODULE_NAME,
findGraphShadowSnapshotByIntegrity,
normalizeGraphCommitMarker,
readGraphCommitMarker,
readGraphShadowSnapshot,
rememberGraphIdentityAlias,
removeGraphShadowSnapshot,
@@ -1221,8 +1233,8 @@ result = {
reason: "blocked-save",
markMutation: false,
});
assert.equal(result.saved, true);
assert.equal(result.queued, false);
assert.equal(result.saved, false);
assert.equal(result.queued, true);
assert.equal(result.blocked, false);
assert.equal(result.saveMode, "indexeddb-queued");
assert.equal(harness.runtimeContext.__chatContext.chatMetadata, undefined);
@@ -1941,7 +1953,8 @@ result = {
reason: "first-meaningful-graph",
});
assert.equal(result.saved, true);
assert.equal(result.saved, false);
assert.equal(result.queued, true);
assert.equal(result.saveMode, "indexeddb-queued");
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);

View File

@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import {
buildGraphCommitMarker,
detectIndexedDbSnapshotCommitMarkerMismatch,
getAcceptedCommitMarkerRevision,
GRAPH_COMMIT_MARKER_KEY,
normalizeGraphCommitMarker,
readGraphCommitMarker,
writeChatMetadataPatch,
} from "../graph/graph-persistence.js";
import { addNode, createEmptyGraph, createNode } from "../graph/graph.js";
const graph = createEmptyGraph();
graph.historyState.chatId = "chat-marker";
graph.historyState.lastProcessedAssistantFloor = 10;
graph.historyState.extractionCount = 4;
addNode(
graph,
createNode({
type: "event",
fields: { title: "事件A", summary: "测试事件" },
seq: 10,
}),
);
const marker = buildGraphCommitMarker(graph, {
revision: 12,
storageTier: "indexeddb",
accepted: true,
reason: "unit-test",
});
assert.equal(marker.revision, 12);
assert.equal(marker.accepted, true);
assert.equal(marker.lastProcessedAssistantFloor, 10);
assert.equal(marker.extractionCount, 4);
assert.equal(marker.nodeCount, 1);
assert.equal(marker.edgeCount, 0);
assert.equal(marker.archivedCount, 0);
assert.equal(getAcceptedCommitMarkerRevision(marker), 12);
const normalized = normalizeGraphCommitMarker({
revision: "15",
lastProcessedAssistantFloor: "18",
extractionCount: "6",
nodeCount: "9",
edgeCount: "3",
archivedCount: "2",
storageTier: "shadow",
accepted: true,
reason: "normalized",
});
assert.equal(normalized.revision, 15);
assert.equal(normalized.lastProcessedAssistantFloor, 18);
assert.equal(normalized.storageTier, "shadow");
const context = {
chatMetadata: {},
};
writeChatMetadataPatch(context, {
[GRAPH_COMMIT_MARKER_KEY]: marker,
});
assert.deepEqual(readGraphCommitMarker(context), marker);
const mismatch = detectIndexedDbSnapshotCommitMarkerMismatch(
{
meta: {
revision: 9,
},
},
marker,
);
assert.equal(mismatch.mismatched, true);
assert.equal(
mismatch.reason,
"persist-mismatch:indexeddb-behind-commit-marker",
);
assert.equal(mismatch.markerRevision, 12);
assert.equal(mismatch.snapshotRevision, 9);
const noMismatch = detectIndexedDbSnapshotCommitMarkerMismatch(
{
meta: {
revision: 12,
},
},
marker,
);
assert.equal(noMismatch.mismatched, false);
console.log("persistence-commit-marker tests passed");