fix: backend vector state marked dirty on sync/restore import and query failure

- sync/bme-sync.js: conservatively clear backend hash mappings and mark
  vectorIndexState dirty before importing remote snapshots via download,
  merge, and cloud backup restore, preventing stale clean-looking state
  after cross-device sync or restore
- vector/vector-index.js: mark backend vector state dirty on real backend
  query failures (HTTP/network) instead of silently returning empty results
- regression: indexeddb-sync.mjs covers download/restore/merge import
  dirty marking; p0-regressions.mjs covers backend query failure dirtying
This commit is contained in:
Youzini-afk
2026-04-12 19:42:36 +08:00
parent d350de809e
commit 913a102b39
4 changed files with 331 additions and 42 deletions

View File

@@ -332,6 +332,23 @@ async function testDownloadImport() {
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
runtimeVectorIndexState: {
mode: "backend",
collectionId: "st-bme::chat-download",
source: "openai",
hashToNodeId: {
"hash-remote-node": "remote-node",
},
nodeToHash: {
"remote-node": "hash-remote-node",
},
lastStats: {
total: 1,
indexed: 1,
stale: 0,
pending: 0,
},
},
},
nodes: [{ id: "remote-node", updatedAt: 400 }],
edges: [],
@@ -348,6 +365,17 @@ async function testDownloadImport() {
assert.equal(result.downloaded, true);
assert.equal(db.lastImportPayload.meta.revision, 12);
assert.equal(db.lastImportPayload.nodes[0].id, "remote-node");
assert.equal(db.lastImportPayload.meta.runtimeVectorIndexState.dirty, true);
assert.equal(
db.lastImportPayload.meta.runtimeVectorIndexState.dirtyReason,
"backend-sync-download-unverified",
);
assert.deepEqual(db.lastImportPayload.meta.runtimeVectorIndexState.hashToNodeId, {});
assert.deepEqual(db.lastImportPayload.meta.runtimeVectorIndexState.nodeToHash, {});
assert.equal(
db.lastImportPayload.meta.runtimeVectorIndexState.pendingRepairFromFloor,
0,
);
}
async function testLegacyRemoteFilenameFallbackAndReuse() {
@@ -648,6 +676,23 @@ async function testManualBackupAndRestoreFlow() {
{ id: "journal-5", processedRange: [4, 4], createdAt: 55 },
{ id: "journal-6", processedRange: [5, 5], createdAt: 66 },
],
runtimeVectorIndexState: {
mode: "backend",
collectionId: "st-bme::chat-backup-flow",
source: "openai",
hashToNodeId: {
"hash-local-node": "local-node",
},
nodeToHash: {
"local-node": "hash-local-node",
},
lastStats: {
total: 1,
indexed: 1,
stale: 0,
pending: 0,
},
},
maintenanceJournal: [
{ id: "maintenance-a", updatedAt: 70 },
{ id: "maintenance-b", updatedAt: 80 },
@@ -768,6 +813,13 @@ async function testManualBackupAndRestoreFlow() {
assert.equal(db.snapshot.meta.runtimeHistoryState.lastMutationReason, "");
assert.equal(db.snapshot.meta.runtimeHistoryState.lastMutationSource, "");
assert.equal(db.snapshot.meta.runtimeHistoryState.lastRecoveryResult, null);
assert.equal(db.snapshot.meta.runtimeVectorIndexState.dirty, true);
assert.equal(
db.snapshot.meta.runtimeVectorIndexState.dirtyReason,
"backend-backup-restore-unverified",
);
assert.deepEqual(db.snapshot.meta.runtimeVectorIndexState.hashToNodeId, {});
assert.deepEqual(db.snapshot.meta.runtimeVectorIndexState.nodeToHash, {});
assert.ok(Number(db.meta.get("lastBackupRestoredAt")) > 0);
const safetyStatus = await getRestoreSafetySnapshotStatus(
"chat-backup-flow",
@@ -1247,6 +1299,23 @@ async function testSyncAppliedHook() {
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
runtimeVectorIndexState: {
mode: "backend",
collectionId: "st-bme::chat-hook-merge",
source: "openai",
hashToNodeId: {
"hash-local-merge": "local-merge",
},
nodeToHash: {
"local-merge": "hash-local-merge",
},
lastStats: {
total: 1,
indexed: 1,
stale: 0,
pending: 0,
},
},
},
nodes: [{ id: "local-merge", updatedAt: 20 }],
edges: [],
@@ -1263,7 +1332,33 @@ async function testSyncAppliedHook() {
state: { lastProcessedFloor: 2, extractionCount: 1 },
});
remoteFiles.set("ST-BME_sync_chat-hook-merge.json", {
meta: { schemaVersion: 1, chatId: "chat-hook-merge", revision: 4, lastModified: 25, deviceId: "remote", nodeCount: 1, edgeCount: 0, tombstoneCount: 0 },
meta: {
schemaVersion: 1,
chatId: "chat-hook-merge",
revision: 4,
lastModified: 25,
deviceId: "remote",
nodeCount: 1,
edgeCount: 0,
tombstoneCount: 0,
runtimeVectorIndexState: {
mode: "backend",
collectionId: "st-bme::chat-hook-merge",
source: "openai",
hashToNodeId: {
"hash-remote-merge": "remote-merge",
},
nodeToHash: {
"remote-merge": "hash-remote-merge",
},
lastStats: {
total: 1,
indexed: 1,
stale: 0,
pending: 0,
},
},
},
nodes: [{ id: "remote-merge", updatedAt: 25 }],
edges: [],
tombstones: [],
@@ -1284,6 +1379,22 @@ async function testSyncAppliedHook() {
assert.equal(downloadResult.revision, 3);
assert.equal(mergeResult.revision, 5);
assert.equal(
dbByChatId.get("chat-hook-merge").lastImportPayload.meta.runtimeVectorIndexState.dirty,
true,
);
assert.equal(
dbByChatId.get("chat-hook-merge").lastImportPayload.meta.runtimeVectorIndexState.dirtyReason,
"backend-sync-merge-unverified",
);
assert.deepEqual(
dbByChatId.get("chat-hook-merge").lastImportPayload.meta.runtimeVectorIndexState.hashToNodeId,
{},
);
assert.deepEqual(
dbByChatId.get("chat-hook-merge").lastImportPayload.meta.runtimeVectorIndexState.nodeToHash,
{},
);
assert.deepEqual(hookCalls.map((item) => item.action), ["download", "merge"]);
assert.deepEqual(hookCalls.map((item) => item.chatId), ["chat-hook-download", "chat-hook-merge"]);

View File

@@ -155,7 +155,10 @@ const {
removeNode,
} = await import("../graph/graph.js");
const { compressType } = await import("../maintenance/compressor.js");
const { syncGraphVectorIndex } = await import("../vector/vector-index.js");
const {
findSimilarNodesByText,
syncGraphVectorIndex,
} = await import("../vector/vector-index.js");
const {
extractMemories,
generateReflection,
@@ -1984,6 +1987,55 @@ async function testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure() {
}
}
async function testBackendVectorQueryFailureMarksStateDirty() {
const originalFetch = globalThis.fetch;
const graph = normalizeGraphRuntimeState(createEmptyGraph(), "chat-backend-query");
const node = makeEvent(1, "后端向量节点");
addNode(graph, node);
graph.vectorIndexState.mode = "backend";
graph.vectorIndexState.source = "openai";
graph.vectorIndexState.collectionId = "st-bme::chat-backend-query";
graph.vectorIndexState.hashToNodeId = {
"hash-backend-node": node.id,
};
graph.vectorIndexState.nodeToHash = {
[node.id]: "hash-backend-node",
};
graph.vectorIndexState.lastStats = {
total: 1,
indexed: 1,
stale: 0,
pending: 0,
};
globalThis.fetch = async () => {
throw new Error("backend-down");
};
try {
await assert.rejects(
findSimilarNodesByText(
graph,
"测试后端向量失败",
{
mode: "backend",
source: "openai",
model: "text-embedding-3-small",
},
5,
[node],
),
/backend-down/,
);
assert.equal(graph.vectorIndexState.dirty, true);
assert.equal(graph.vectorIndexState.dirtyReason, "backend-query-failed");
assert.equal(graph.vectorIndexState.pendingRepairFromFloor, 0);
assert.match(graph.vectorIndexState.lastWarning, /后端向量查询失败/);
} finally {
globalThis.fetch = originalFetch;
}
}
async function testCompressTypeAcceptsTopLevelFieldsResult() {
const graph = createEmptyGraph();
const typeDef = {
@@ -6725,6 +6777,7 @@ async function testManualSleepExplainsThatItIsLocalOnlyWhenNothingChanges() {
await testCompressorMigratesEdgesToCompressedNode();
await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure();
await testBackendVectorQueryFailureMarksStateDirty();
await testCompressTypeAcceptsTopLevelFieldsResult();
await testExtractorFailsOnUnknownOperation();
await testExtractorNormalizesFlatCreateOperation();