@remnic/core 9.69.103 → 9.69.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-admin-ops-surface.js +10 -10
- package/dist/access-authorization-probe.js +11 -11
- package/dist/access-boundary.js +10 -10
- package/dist/access-cli.js +30 -30
- package/dist/access-extraction-force-flush.js +10 -10
- package/dist/access-http-briefing.js +11 -11
- package/dist/access-http-query.js +10 -10
- package/dist/access-http.js +23 -23
- package/dist/access-identity-continuity-surface.js +10 -10
- package/dist/access-lcm-surface.js +10 -10
- package/dist/access-mcp.js +17 -17
- package/dist/access-namespace-preflight.js +10 -10
- package/dist/access-observe-write-category.js +7 -7
- package/dist/access-observe-write-surface.js +10 -10
- package/dist/access-offline-manifest.js +6 -6
- package/dist/access-operations-batch.js +11 -11
- package/dist/access-operations.js +16 -16
- package/dist/access-recall-concurrency.js +10 -10
- package/dist/access-recall-response.js +10 -10
- package/dist/access-recall-surface.js +10 -10
- package/dist/access-schema.js +6 -6
- package/dist/access-service.js +10 -10
- package/dist/access-surface-catalog.js +12 -12
- package/dist/active-recall.js +6 -6
- package/dist/{chunk-47LTKAGL.js → chunk-2COXDQ6C.js} +21 -6
- package/dist/chunk-2COXDQ6C.js.map +1 -0
- package/dist/{chunk-KN4GNQH7.js → chunk-2TCNEJ36.js} +4 -4
- package/dist/{chunk-5FHH4NJR.js → chunk-5R4YPRAR.js} +17 -4
- package/dist/chunk-5R4YPRAR.js.map +1 -0
- package/dist/{chunk-ANPRNTPT.js → chunk-63LFM77Z.js} +15 -2
- package/dist/chunk-63LFM77Z.js.map +1 -0
- package/dist/{chunk-JHZOY4CJ.js → chunk-6WRHNA4I.js} +7 -7
- package/dist/{chunk-HTXTVY64.js → chunk-7NBUE5VY.js} +2 -2
- package/dist/{chunk-4QHWRWCF.js → chunk-7YQR6XHY.js} +8 -8
- package/dist/{chunk-2JJD7GIW.js → chunk-ATFFWIKL.js} +2 -2
- package/dist/{chunk-2GSO4FEC.js → chunk-AU43D3IT.js} +3 -3
- package/dist/{chunk-OC6ATPIP.js → chunk-CGBE3N7T.js} +2 -2
- package/dist/{chunk-NN62MUZA.js → chunk-DB6XFMOF.js} +3 -3
- package/dist/{chunk-5GKRQBPQ.js → chunk-FYX2LXIX.js} +2 -2
- package/dist/{chunk-6RWPKWCZ.js → chunk-GZ2KV2RO.js} +3 -3
- package/dist/{chunk-D4MXH276.js → chunk-JC7QQ2DJ.js} +2 -2
- package/dist/{chunk-GWWHV7F3.js → chunk-KFHEZJQZ.js} +2 -2
- package/dist/{chunk-B44TVSEI.js → chunk-KV7LFOPF.js} +2 -2
- package/dist/{chunk-L6BUCPGI.js → chunk-LHDSCWDK.js} +11 -11
- package/dist/{chunk-5PEBJUYD.js → chunk-MZOQ26LH.js} +2 -2
- package/dist/{chunk-NBYSCXOY.js → chunk-OFGTRDWX.js} +2 -2
- package/dist/{chunk-26DJ7FG6.js → chunk-PZ2QQULY.js} +2 -2
- package/dist/{chunk-LP4UW5WI.js → chunk-QAIVTLL2.js} +2 -2
- package/dist/{chunk-HYFVZJLX.js → chunk-QUNCLKHM.js} +2 -2
- package/dist/{chunk-Q3LWW7WS.js → chunk-QX3K3INR.js} +2 -2
- package/dist/{chunk-K5ABKRXK.js → chunk-SFDBLCZV.js} +7 -7
- package/dist/{chunk-XM32D6YZ.js → chunk-STQC2FYQ.js} +4 -4
- package/dist/{chunk-PNZAFABF.js → chunk-TXTRHF3B.js} +2 -2
- package/dist/{chunk-SOVRTUD3.js → chunk-VDVHIRCK.js} +2 -2
- package/dist/{chunk-D42DLA4D.js → chunk-WR66CTVU.js} +3 -3
- package/dist/{chunk-UCPGOUDZ.js → chunk-Y57GJDG5.js} +108 -3
- package/dist/chunk-Y57GJDG5.js.map +1 -0
- package/dist/{chunk-NIGZ3LE7.js → chunk-YI6XIFQV.js} +2 -2
- package/dist/cli.js +27 -27
- package/dist/config.js +6 -6
- package/dist/deep-recall-http-glue.js +7 -7
- package/dist/embedding-fallback.d.ts +2 -1
- package/dist/embedding-fallback.d.ts.map +1 -1
- package/dist/embedding-fallback.js +2 -2
- package/dist/embedding-index-storage.d.ts +28 -1
- package/dist/embedding-index-storage.d.ts.map +1 -1
- package/dist/embedding-index-storage.js +1 -1
- package/dist/external-wiki-access.js +11 -11
- package/dist/index.js +30 -30
- package/dist/memory-browse-operations.js +11 -11
- package/dist/offline-sync-changeset-generations.js +4 -4
- package/dist/offline-sync-embedding-generation.js +3 -3
- package/dist/offline-sync-exclude-globs.d.ts +8 -0
- package/dist/offline-sync-exclude-globs.d.ts.map +1 -1
- package/dist/offline-sync-exclude-globs.js +3 -1
- package/dist/offline-sync-snapshot-stream.js +6 -6
- package/dist/offline-sync.d.ts.map +1 -1
- package/dist/offline-sync.js +5 -5
- package/dist/operator-toolkit.js +7 -7
- package/dist/orchestrator.js +30 -30
- package/dist/recall-navigation-operations.js +11 -11
- package/dist/recall-navigation.js +10 -10
- package/dist/resume-bundles.js +7 -7
- package/dist/support-passport/index.js +13 -13
- package/package.json +2 -2
- package/src/embedding-fallback-cross-process.test.ts +533 -0
- package/src/embedding-fallback.ts +19 -7
- package/src/embedding-index-storage.test.ts +482 -2
- package/src/embedding-index-storage.ts +131 -2
- package/src/offline-sync-exclude-globs.ts +23 -0
- package/src/offline-sync.test.ts +325 -0
- package/src/offline-sync.ts +17 -1
- package/dist/chunk-47LTKAGL.js.map +0 -1
- package/dist/chunk-5FHH4NJR.js.map +0 -1
- package/dist/chunk-ANPRNTPT.js.map +0 -1
- package/dist/chunk-UCPGOUDZ.js.map +0 -1
- /package/dist/{chunk-KN4GNQH7.js.map → chunk-2TCNEJ36.js.map} +0 -0
- /package/dist/{chunk-JHZOY4CJ.js.map → chunk-6WRHNA4I.js.map} +0 -0
- /package/dist/{chunk-HTXTVY64.js.map → chunk-7NBUE5VY.js.map} +0 -0
- /package/dist/{chunk-4QHWRWCF.js.map → chunk-7YQR6XHY.js.map} +0 -0
- /package/dist/{chunk-2JJD7GIW.js.map → chunk-ATFFWIKL.js.map} +0 -0
- /package/dist/{chunk-2GSO4FEC.js.map → chunk-AU43D3IT.js.map} +0 -0
- /package/dist/{chunk-OC6ATPIP.js.map → chunk-CGBE3N7T.js.map} +0 -0
- /package/dist/{chunk-NN62MUZA.js.map → chunk-DB6XFMOF.js.map} +0 -0
- /package/dist/{chunk-5GKRQBPQ.js.map → chunk-FYX2LXIX.js.map} +0 -0
- /package/dist/{chunk-6RWPKWCZ.js.map → chunk-GZ2KV2RO.js.map} +0 -0
- /package/dist/{chunk-D4MXH276.js.map → chunk-JC7QQ2DJ.js.map} +0 -0
- /package/dist/{chunk-GWWHV7F3.js.map → chunk-KFHEZJQZ.js.map} +0 -0
- /package/dist/{chunk-B44TVSEI.js.map → chunk-KV7LFOPF.js.map} +0 -0
- /package/dist/{chunk-L6BUCPGI.js.map → chunk-LHDSCWDK.js.map} +0 -0
- /package/dist/{chunk-5PEBJUYD.js.map → chunk-MZOQ26LH.js.map} +0 -0
- /package/dist/{chunk-NBYSCXOY.js.map → chunk-OFGTRDWX.js.map} +0 -0
- /package/dist/{chunk-26DJ7FG6.js.map → chunk-PZ2QQULY.js.map} +0 -0
- /package/dist/{chunk-LP4UW5WI.js.map → chunk-QAIVTLL2.js.map} +0 -0
- /package/dist/{chunk-HYFVZJLX.js.map → chunk-QUNCLKHM.js.map} +0 -0
- /package/dist/{chunk-Q3LWW7WS.js.map → chunk-QX3K3INR.js.map} +0 -0
- /package/dist/{chunk-K5ABKRXK.js.map → chunk-SFDBLCZV.js.map} +0 -0
- /package/dist/{chunk-XM32D6YZ.js.map → chunk-STQC2FYQ.js.map} +0 -0
- /package/dist/{chunk-PNZAFABF.js.map → chunk-TXTRHF3B.js.map} +0 -0
- /package/dist/{chunk-SOVRTUD3.js.map → chunk-VDVHIRCK.js.map} +0 -0
- /package/dist/{chunk-D42DLA4D.js.map → chunk-WR66CTVU.js.map} +0 -0
- /package/dist/{chunk-NIGZ3LE7.js.map → chunk-YI6XIFQV.js.map} +0 -0
|
@@ -10,8 +10,9 @@ import test from "node:test";
|
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { lstat, mkdtemp, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
14
|
-
import {
|
|
13
|
+
import { chmod, lstat, mkdtemp, mkdir, readFile, readdir, rename, rm, stat, symlink, utimes, writeFile } from "node:fs/promises";
|
|
14
|
+
import { EmbeddingGenerationLockLostError } from "./embedding-generation-lock.js";
|
|
15
|
+
import { EmbeddingIndexFileStore, EmbeddingIndexStorageError, type EmbeddingIndexStoreIo } from "./embedding-index-storage.js";
|
|
15
16
|
|
|
16
17
|
const SHARD_FILE = JSON.stringify({
|
|
17
18
|
version: 1,
|
|
@@ -92,6 +93,485 @@ test("detectLayout returns sharded on the first post-gap call and ignores a stra
|
|
|
92
93
|
}
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// PR #3176 codex P2 — the generation stamp must not alias consecutive
|
|
98
|
+
// publications. A dirty-shard write keeps the published directory's inode and
|
|
99
|
+
// only moves its mtime; on a coarse-tick filesystem two consecutive
|
|
100
|
+
// publications can report the same directory stat, so the stamp must move
|
|
101
|
+
// through something that cannot alias.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
test("consecutive shard publications move the generation stamp even when the directory stat is preserved", async () => {
|
|
105
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-stamp-"));
|
|
106
|
+
const store = newStore(memoryDir);
|
|
107
|
+
try {
|
|
108
|
+
const shardDir = path.join(memoryDir, "state", "embeddings");
|
|
109
|
+
await mkdir(shardDir, { recursive: true });
|
|
110
|
+
// mem-old hashes to shard-0058 (same fixture as the membership test).
|
|
111
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
112
|
+
const identity = { provider: "openai" as const, model: "text-embedding-3-small" };
|
|
113
|
+
const entryOld = { vector: [1, 0], path: "facts/old.md" };
|
|
114
|
+
const entryNew = { vector: [0, 1], path: "facts/new.md" };
|
|
115
|
+
|
|
116
|
+
await store.persist(
|
|
117
|
+
{ version: 1, ...identity, entries: { "mem-old": entryOld } },
|
|
118
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
119
|
+
);
|
|
120
|
+
// Force the exact stat the stamp reads, emulating a coarse mtime tick:
|
|
121
|
+
// the aliased stamp must still move on the NEXT publication.
|
|
122
|
+
await utimes(shardDir, 1_700_000_000.5, 1_700_000_000.5);
|
|
123
|
+
const stampBefore = await store.identityStamp();
|
|
124
|
+
|
|
125
|
+
await store.persist(
|
|
126
|
+
{ version: 1, ...identity, entries: { "mem-old": entryOld, "mem-new": entryNew } },
|
|
127
|
+
{ touchedIds: ["mem-new"], memoryId: "mem-new" },
|
|
128
|
+
);
|
|
129
|
+
await utimes(shardDir, 1_700_000_000.5, 1_700_000_000.5);
|
|
130
|
+
|
|
131
|
+
const stampAfter = await store.identityStamp();
|
|
132
|
+
assert.notEqual(
|
|
133
|
+
stampAfter,
|
|
134
|
+
stampBefore,
|
|
135
|
+
"two consecutive publications must never alias to one generation stamp",
|
|
136
|
+
);
|
|
137
|
+
// The published generation itself carries both entries: the stamp move
|
|
138
|
+
// is about revalidation, not about losing data.
|
|
139
|
+
const merged: Record<string, { vector: number[]; path: string }> = {};
|
|
140
|
+
await store.readShardGenerationInto(merged);
|
|
141
|
+
assert.deepEqual(Object.keys(merged).sort(), ["mem-new", "mem-old"]);
|
|
142
|
+
} finally {
|
|
143
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a publication's stamp moves twice: in-flight before its writes and final only after them", async () => {
|
|
148
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-barrier-"));
|
|
149
|
+
const store = newStore(memoryDir);
|
|
150
|
+
try {
|
|
151
|
+
const shardDir = path.join(memoryDir, "state", "embeddings");
|
|
152
|
+
await mkdir(shardDir, { recursive: true });
|
|
153
|
+
// mem-old hashes to shard-0058 (same fixture as the membership test).
|
|
154
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
155
|
+
const identity = { provider: "openai" as const, model: "text-embedding-3-small" };
|
|
156
|
+
const entryOld = { vector: [1, 0], path: "facts/old.md" };
|
|
157
|
+
const entryNew = { vector: [0, 1], path: "facts/new.md" };
|
|
158
|
+
|
|
159
|
+
await store.persist(
|
|
160
|
+
{ version: 1, ...identity, entries: { "mem-old": entryOld } },
|
|
161
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
162
|
+
);
|
|
163
|
+
const stampBefore = await store.identityStamp();
|
|
164
|
+
|
|
165
|
+
// Freeze the publication between its first shard write and completion;
|
|
166
|
+
// the write-boundary signal guarantees the in-flight marker already moved.
|
|
167
|
+
const { promise: release, resolve: unblock } = Promise.withResolvers<void>();
|
|
168
|
+
const { promise: atWrite, resolve: reachedWrite } = Promise.withResolvers<void>();
|
|
169
|
+
let gated = false;
|
|
170
|
+
const io: EmbeddingIndexStoreIo = {
|
|
171
|
+
readUtf8: async (filePath) => readFile(filePath, "utf-8"),
|
|
172
|
+
writeUtf8: async (filePath, contents) => {
|
|
173
|
+
if (!gated) {
|
|
174
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
175
|
+
await writeFile(filePath, contents, "utf-8");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
gated = false;
|
|
179
|
+
reachedWrite();
|
|
180
|
+
await release;
|
|
181
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
182
|
+
await writeFile(filePath, contents, "utf-8");
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
const gatedStore = new EmbeddingIndexFileStore(
|
|
186
|
+
path.join(memoryDir, "state", "embeddings.json"),
|
|
187
|
+
shardDir,
|
|
188
|
+
path.join(memoryDir, "state", "embedding-fallback-status.json"),
|
|
189
|
+
io,
|
|
190
|
+
);
|
|
191
|
+
gated = true;
|
|
192
|
+
const publication = gatedStore.persist(
|
|
193
|
+
{ version: 1, ...identity, entries: { "mem-old": entryOld, "mem-new": entryNew } },
|
|
194
|
+
{ touchedIds: ["mem-new"], memoryId: "mem-new" },
|
|
195
|
+
);
|
|
196
|
+
await atWrite;
|
|
197
|
+
const stampMidFlight = await store.identityStamp();
|
|
198
|
+
assert.notEqual(stampMidFlight, stampBefore, "a reader must never see the previous final stamp during a publication");
|
|
199
|
+
|
|
200
|
+
unblock();
|
|
201
|
+
await publication;
|
|
202
|
+
const stampFinal = await store.identityStamp();
|
|
203
|
+
assert.notEqual(
|
|
204
|
+
stampFinal,
|
|
205
|
+
stampMidFlight,
|
|
206
|
+
"the stamp must move again when the publication completes: a mid-flight stamp must never be the last stable one",
|
|
207
|
+
);
|
|
208
|
+
const merged: Record<string, { vector: number[]; path: string }> = {};
|
|
209
|
+
await store.readShardGenerationInto(merged);
|
|
210
|
+
assert.deepEqual(Object.keys(merged).sort(), ["mem-new", "mem-old"]);
|
|
211
|
+
} finally {
|
|
212
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("an in-flight marker never yields a repeatable stamp, so a cached probe always revalidates", async () => {
|
|
217
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-inflight-"));
|
|
218
|
+
const store = newStore(memoryDir);
|
|
219
|
+
try {
|
|
220
|
+
const shardDir = path.join(memoryDir, "state", "embeddings");
|
|
221
|
+
await mkdir(shardDir, { recursive: true });
|
|
222
|
+
// mem-old hashes to shard-0058 (same fixture as the membership test).
|
|
223
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
224
|
+
const identity = { provider: "openai" as const, model: "text-embedding-3-small" };
|
|
225
|
+
|
|
226
|
+
await store.persist(
|
|
227
|
+
{ version: 1, ...identity, entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
228
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
229
|
+
);
|
|
230
|
+
// Real post-crash state: the in-flight marker landed, the completion
|
|
231
|
+
// marker never did. A reader may have cached this stamp mid-flight; it
|
|
232
|
+
// must never see the same stamp again until a publication completes.
|
|
233
|
+
await writeFile(path.join(memoryDir, "state", "embeddings.generation"), "in-flight");
|
|
234
|
+
const probeA = await store.identityStamp();
|
|
235
|
+
const probeB = await store.identityStamp();
|
|
236
|
+
const probeC = await store.identityStamp();
|
|
237
|
+
assert.notEqual(probeB, probeA, "an in-flight marker must never produce a repeatable stamp");
|
|
238
|
+
assert.notEqual(probeC, probeB, "an in-flight marker must never produce a repeatable stamp");
|
|
239
|
+
|
|
240
|
+
await store.persist(
|
|
241
|
+
{ version: 1, ...identity, entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" }, "mem-new": { vector: [0, 1], path: "facts/new.md" } } },
|
|
242
|
+
{ touchedIds: ["mem-new"], memoryId: "mem-new" },
|
|
243
|
+
);
|
|
244
|
+
const settledA = await store.identityStamp();
|
|
245
|
+
const settledB = await store.identityStamp();
|
|
246
|
+
assert.notEqual(settledA, probeC, "the completed publication must move the stamp off every in-flight probe");
|
|
247
|
+
assert.equal(settledB, settledA, "a completed publication's stamp is stable");
|
|
248
|
+
} finally {
|
|
249
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
test("a lock lost after the in-flight marker stops the swapped publication before the live renames", async () => {
|
|
255
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-refence-swap-"));
|
|
256
|
+
const store = newStore(memoryDir);
|
|
257
|
+
try {
|
|
258
|
+
const stateDir = path.join(memoryDir, "state");
|
|
259
|
+
const shardDir = path.join(stateDir, "embeddings");
|
|
260
|
+
await mkdir(shardDir, { recursive: true });
|
|
261
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
262
|
+
await store.persist(
|
|
263
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
264
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const stagingDir = path.join(stateDir, "embeddings.staging.tmp-peer");
|
|
268
|
+
await mkdir(stagingDir, { recursive: true });
|
|
269
|
+
await writeFile(
|
|
270
|
+
path.join(stagingDir, "shard-0058.json"),
|
|
271
|
+
JSON.stringify({
|
|
272
|
+
version: 1,
|
|
273
|
+
provider: "openai",
|
|
274
|
+
model: "text-embedding-3-small",
|
|
275
|
+
entries: { "mem-peer": { vector: [0, 0], path: "facts/peer.md" } },
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
let calls = 0;
|
|
280
|
+
const fence = async () => {
|
|
281
|
+
calls += 1;
|
|
282
|
+
if (calls >= 3) throw new EmbeddingGenerationLockLostError("test:lock");
|
|
283
|
+
};
|
|
284
|
+
await assert.rejects(
|
|
285
|
+
() => store.publishSwappedGeneration(stagingDir, fence),
|
|
286
|
+
(err: unknown) => err instanceof EmbeddingGenerationLockLostError,
|
|
287
|
+
);
|
|
288
|
+
assert.ok(calls >= 3, "the fence must be reasserted after the in-flight marker write");
|
|
289
|
+
assert.equal(
|
|
290
|
+
await readFile(path.join(shardDir, "shard-0058.json"), "utf-8"),
|
|
291
|
+
SHARD_FILE,
|
|
292
|
+
"the published generation is untouched: the renames never ran",
|
|
293
|
+
);
|
|
294
|
+
await stat(stagingDir);
|
|
295
|
+
assert.equal(
|
|
296
|
+
(await readFile(path.join(stateDir, "embeddings.generation"), "utf-8")).trim(),
|
|
297
|
+
"in-flight",
|
|
298
|
+
"the abort lands after the barrier's first half; the winning peer finalizes the marker",
|
|
299
|
+
);
|
|
300
|
+
} finally {
|
|
301
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a lock lost after the in-flight marker stops the dirty-shard publication before the shard writes", async () => {
|
|
306
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-refence-dirty-"));
|
|
307
|
+
const store = newStore(memoryDir);
|
|
308
|
+
try {
|
|
309
|
+
const stateDir = path.join(memoryDir, "state");
|
|
310
|
+
const shardDir = path.join(stateDir, "embeddings");
|
|
311
|
+
await mkdir(shardDir, { recursive: true });
|
|
312
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
313
|
+
await store.persist(
|
|
314
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
315
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
let calls = 0;
|
|
319
|
+
const fence = async () => {
|
|
320
|
+
calls += 1;
|
|
321
|
+
if (calls >= 2) throw new EmbeddingGenerationLockLostError("test:lock");
|
|
322
|
+
};
|
|
323
|
+
await assert.rejects(
|
|
324
|
+
() => store.persist(
|
|
325
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" }, "mem-new": { vector: [0, 1], path: "facts/new.md" } } },
|
|
326
|
+
{ touchedIds: ["mem-new"], memoryId: "mem-new", fence },
|
|
327
|
+
),
|
|
328
|
+
(err: unknown) => err instanceof EmbeddingGenerationLockLostError,
|
|
329
|
+
);
|
|
330
|
+
assert.deepEqual(await readdir(shardDir), ["shard-0058.json"], "no shard byte moved after a lost lock");
|
|
331
|
+
assert.equal(await readFile(path.join(shardDir, "shard-0058.json"), "utf-8"), SHARD_FILE);
|
|
332
|
+
assert.equal(
|
|
333
|
+
(await readFile(path.join(stateDir, "embeddings.generation"), "utf-8")).trim(),
|
|
334
|
+
"in-flight",
|
|
335
|
+
);
|
|
336
|
+
} finally {
|
|
337
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("a lock lost after the in-flight marker stops the legacy publication before the index write", async () => {
|
|
342
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-refence-legacy-"));
|
|
343
|
+
const store = newStore(memoryDir);
|
|
344
|
+
try {
|
|
345
|
+
let calls = 0;
|
|
346
|
+
const fence = async () => {
|
|
347
|
+
calls += 1;
|
|
348
|
+
if (calls >= 2) throw new EmbeddingGenerationLockLostError("test:lock");
|
|
349
|
+
};
|
|
350
|
+
await assert.rejects(
|
|
351
|
+
() => store.persist(
|
|
352
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
353
|
+
{ fence },
|
|
354
|
+
),
|
|
355
|
+
(err: unknown) => err instanceof EmbeddingGenerationLockLostError,
|
|
356
|
+
);
|
|
357
|
+
await assert.rejects(
|
|
358
|
+
() => readFile(path.join(memoryDir, "state", "embeddings.json"), "utf-8"),
|
|
359
|
+
(err: NodeJS.ErrnoException) => err.code === "ENOENT",
|
|
360
|
+
);
|
|
361
|
+
assert.equal(
|
|
362
|
+
(await readFile(path.join(memoryDir, "state", "embeddings.generation"), "utf-8")).trim(),
|
|
363
|
+
"in-flight",
|
|
364
|
+
);
|
|
365
|
+
} finally {
|
|
366
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
test("a pre-marker writer's shard publication moves the completed stamp", async () => {
|
|
372
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-rolling-"));
|
|
373
|
+
const store = newStore(memoryDir);
|
|
374
|
+
try {
|
|
375
|
+
const shardDir = path.join(memoryDir, "state", "embeddings");
|
|
376
|
+
await mkdir(shardDir, { recursive: true });
|
|
377
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
378
|
+
await store.persist(
|
|
379
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
380
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
381
|
+
);
|
|
382
|
+
const stampBefore = await store.identityStamp();
|
|
383
|
+
|
|
384
|
+
await writeFile(
|
|
385
|
+
path.join(shardDir, "shard-0058.json"),
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
version: 1,
|
|
388
|
+
provider: "openai",
|
|
389
|
+
model: "text-embedding-3-small",
|
|
390
|
+
entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" }, "mem-peer": { vector: [0, 0], path: "facts/peer.md" } },
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
await utimes(shardDir, 1_700_000_001.5, 1_700_000_001.5);
|
|
394
|
+
const stampAfter = await store.identityStamp();
|
|
395
|
+
assert.notEqual(stampAfter, stampBefore, "a pre-marker writer's publication must move an upgraded warm cache's stamp");
|
|
396
|
+
assert.equal(await store.identityStamp(), stampAfter, "the stamp stays stable while nothing changes");
|
|
397
|
+
} finally {
|
|
398
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("a pre-marker writer's legacy publication moves the completed stamp", async () => {
|
|
403
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-rolling-legacy-"));
|
|
404
|
+
const store = newStore(memoryDir);
|
|
405
|
+
try {
|
|
406
|
+
const stateDir = path.join(memoryDir, "state");
|
|
407
|
+
await store.persist(
|
|
408
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
409
|
+
);
|
|
410
|
+
assert.equal(await store.detectLayout(), "legacy");
|
|
411
|
+
const stampBefore = await store.identityStamp();
|
|
412
|
+
|
|
413
|
+
const indexPath = path.join(stateDir, "embeddings.json");
|
|
414
|
+
const tempPath = `${indexPath}.tmp-peer`;
|
|
415
|
+
await writeFile(
|
|
416
|
+
tempPath,
|
|
417
|
+
JSON.stringify({
|
|
418
|
+
version: 1,
|
|
419
|
+
provider: "openai",
|
|
420
|
+
model: "text-embedding-3-small",
|
|
421
|
+
entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" }, "mem-peer": { vector: [0, 0], path: "facts/peer.md" } },
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
await rename(tempPath, indexPath);
|
|
425
|
+
await utimes(indexPath, 1_700_000_001.5, 1_700_000_001.5);
|
|
426
|
+
const stampAfter = await store.identityStamp();
|
|
427
|
+
assert.notEqual(stampAfter, stampBefore, "a pre-marker writer's publication must move an upgraded warm cache's stamp");
|
|
428
|
+
assert.equal(await store.identityStamp(), stampAfter, "the stamp stays stable while nothing changes");
|
|
429
|
+
} finally {
|
|
430
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("recovering an interrupted replacement finalizes the marker on the restored generation", async () => {
|
|
435
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-recovery-"));
|
|
436
|
+
const store = newStore(memoryDir);
|
|
437
|
+
try {
|
|
438
|
+
const stateDir = path.join(memoryDir, "state");
|
|
439
|
+
const shardDir = path.join(stateDir, "embeddings");
|
|
440
|
+
await mkdir(shardDir, { recursive: true });
|
|
441
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
442
|
+
await store.persist(
|
|
443
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
444
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// Crash after the in-flight marker, before the staging publication: the
|
|
448
|
+
// published directory sits in the transaction backup and the marker is
|
|
449
|
+
// still in-flight.
|
|
450
|
+
await rename(shardDir, path.join(stateDir, "embeddings.pre-replace.tmp"));
|
|
451
|
+
await writeFile(path.join(stateDir, "embeddings.generation"), "in-flight");
|
|
452
|
+
|
|
453
|
+
assert.equal(await store.recoverIfInterrupted(), true);
|
|
454
|
+
const marker = (await readFile(path.join(stateDir, "embeddings.generation"), "utf-8")).trim();
|
|
455
|
+
assert.notEqual(marker, "in-flight", "a recovered generation must not keep the in-flight marker");
|
|
456
|
+
const probeA = await store.identityStamp();
|
|
457
|
+
const probeB = await store.identityStamp();
|
|
458
|
+
assert.equal(probeA, probeB, "the recovered generation's stamp is stable: warm searches must not reload");
|
|
459
|
+
const merged: Record<string, { vector: number[]; path: string }> = {};
|
|
460
|
+
await store.readShardGenerationInto(merged);
|
|
461
|
+
assert.deepEqual(Object.keys(merged), ["mem-old"], "the restored generation is intact");
|
|
462
|
+
} finally {
|
|
463
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
for (const legacy of [false, true]) {
|
|
468
|
+
test(`a failed first publication preserves the ${legacy ? "legacy" : "empty"} generation with a stable stamp`, async () => {
|
|
469
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-first-failure-"));
|
|
470
|
+
const store = newStore(memoryDir);
|
|
471
|
+
const stateDir = path.join(memoryDir, "state");
|
|
472
|
+
const indexPath = path.join(stateDir, "embeddings.json");
|
|
473
|
+
try {
|
|
474
|
+
await mkdir(stateDir, { recursive: true });
|
|
475
|
+
if (legacy) await writeFile(indexPath, SHARD_FILE);
|
|
476
|
+
await assert.rejects(
|
|
477
|
+
() => store.publishSwappedGeneration(path.join(stateDir, "embeddings.staging.tmp-nonexistent")),
|
|
478
|
+
(err: NodeJS.ErrnoException) => err.code === "ENOENT",
|
|
479
|
+
);
|
|
480
|
+
assert.equal(await store.detectLayout(), legacy ? "legacy" : "empty");
|
|
481
|
+
if (legacy) assert.equal(await readFile(indexPath, "utf-8"), SHARD_FILE);
|
|
482
|
+
else await assert.rejects(stat(indexPath), { code: "ENOENT" });
|
|
483
|
+
const stamp = await store.identityStamp();
|
|
484
|
+
assert.equal(await store.identityStamp(), stamp);
|
|
485
|
+
} finally {
|
|
486
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
test("a failed publication's rollback finalizes the marker on the restored generation", async () => {
|
|
492
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-rollback-"));
|
|
493
|
+
const store = newStore(memoryDir);
|
|
494
|
+
try {
|
|
495
|
+
const stateDir = path.join(memoryDir, "state");
|
|
496
|
+
const shardDir = path.join(stateDir, "embeddings");
|
|
497
|
+
await mkdir(shardDir, { recursive: true });
|
|
498
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
499
|
+
await store.persist(
|
|
500
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
501
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// A publication whose staging rename fails: the shard directory was
|
|
505
|
+
// already demoted to the backup, so the catch rolls it back. The
|
|
506
|
+
// restored generation must be finalized, not left in-flight.
|
|
507
|
+
await assert.rejects(
|
|
508
|
+
() => store.publishSwappedGeneration(path.join(stateDir, "embeddings.staging.tmp-nonexistent")),
|
|
509
|
+
(err: NodeJS.ErrnoException) => err.code === "ENOENT",
|
|
510
|
+
);
|
|
511
|
+
const marker = (await readFile(path.join(stateDir, "embeddings.generation"), "utf-8")).trim();
|
|
512
|
+
assert.notEqual(marker, "in-flight", "a rolled-back generation must not keep the in-flight marker");
|
|
513
|
+
const probeA = await store.identityStamp();
|
|
514
|
+
const probeB = await store.identityStamp();
|
|
515
|
+
assert.equal(probeA, probeB, "the rolled-back generation's stamp is stable");
|
|
516
|
+
const merged: Record<string, { vector: number[]; path: string }> = {};
|
|
517
|
+
await store.readShardGenerationInto(merged);
|
|
518
|
+
assert.deepEqual(Object.keys(merged), ["mem-old"], "the rolled-back generation is intact");
|
|
519
|
+
} finally {
|
|
520
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("a failed recovery rollback keeps the marker in-flight and a later recovery finalizes it", {
|
|
525
|
+
skip:
|
|
526
|
+
process.platform === "win32" || process.getuid?.() === 0
|
|
527
|
+
? "directory permissions are not enforced for this user/platform"
|
|
528
|
+
: false,
|
|
529
|
+
}, async () => {
|
|
530
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3176-rollbackfail-"));
|
|
531
|
+
const store = newStore(memoryDir);
|
|
532
|
+
try {
|
|
533
|
+
const stateDir = path.join(memoryDir, "state");
|
|
534
|
+
const shardDir = path.join(stateDir, "embeddings");
|
|
535
|
+
await mkdir(shardDir, { recursive: true });
|
|
536
|
+
await writeFile(path.join(shardDir, "shard-0058.json"), SHARD_FILE);
|
|
537
|
+
await store.persist(
|
|
538
|
+
{ version: 1, provider: "openai", model: "text-embedding-3-small", entries: { "mem-old": { vector: [1, 0], path: "facts/old.md" } } },
|
|
539
|
+
{ touchedIds: ["mem-old"], memoryId: "mem-old" },
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
await rename(shardDir, path.join(stateDir, "embeddings.pre-replace.tmp"));
|
|
543
|
+
await writeFile(path.join(stateDir, "embeddings.generation"), "in-flight");
|
|
544
|
+
|
|
545
|
+
// Real rollback failure at the filesystem boundary: the state dir is
|
|
546
|
+
// unwritable, so the restore rename fails and recovery fails closed.
|
|
547
|
+
const prevMode = (await stat(stateDir)).mode;
|
|
548
|
+
await chmod(stateDir, 0o500);
|
|
549
|
+
try {
|
|
550
|
+
await assert.rejects(
|
|
551
|
+
() => store.recoverIfInterrupted(),
|
|
552
|
+
(err: NodeJS.ErrnoException) => err.code === "EACCES",
|
|
553
|
+
);
|
|
554
|
+
assert.equal(
|
|
555
|
+
await readFile(path.join(stateDir, "embeddings.generation"), "utf-8"),
|
|
556
|
+
"in-flight",
|
|
557
|
+
"a failed rollback must never finalize the marker",
|
|
558
|
+
);
|
|
559
|
+
} finally {
|
|
560
|
+
await chmod(stateDir, prevMode);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// A later recovery under a writable state dir restores and finalizes.
|
|
564
|
+
assert.equal(await store.recoverIfInterrupted(), true);
|
|
565
|
+
const marker = (await readFile(path.join(stateDir, "embeddings.generation"), "utf-8")).trim();
|
|
566
|
+
assert.notEqual(marker, "in-flight");
|
|
567
|
+
const probeA = await store.identityStamp();
|
|
568
|
+
const probeB = await store.identityStamp();
|
|
569
|
+
assert.equal(probeA, probeB, "the recovered generation's stamp is stable");
|
|
570
|
+
} finally {
|
|
571
|
+
await rm(memoryDir, { recursive: true, force: true }).catch(() => undefined);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
95
575
|
test("a corrupt replacement backup shape surfaces as a tagged storage error, never the legacy fallback", async () => {
|
|
96
576
|
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-emb3146-gapbackup-"));
|
|
97
577
|
const store = newStore(memoryDir);
|