@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
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process warm-cache revalidation for EmbeddingFallback (issue #3165,
|
|
3
|
+
* the declined P1 on PR #3148). The warm search path trusts the in-memory
|
|
4
|
+
* index because "every in-process mutation saves through saveIndex" — that
|
|
5
|
+
* argument does not cover a second process publishing the generation on
|
|
6
|
+
* disk (the offline-sync apply of remote-authoritative runtime state). Each
|
|
7
|
+
* test spawns a real child process that writes through the normal
|
|
8
|
+
* EmbeddingFallback mutation path, then asserts the parent's warm searches
|
|
9
|
+
* revalidate against disk. Vectors are pure functions of the text so both
|
|
10
|
+
* processes agree on them.
|
|
11
|
+
*/
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
|
|
19
|
+
import { parseConfig } from "./config.js";
|
|
20
|
+
import { EmbeddingFallback } from "./embedding-fallback.js";
|
|
21
|
+
import {
|
|
22
|
+
clearHostEmbeddingProvidersForTest,
|
|
23
|
+
registerHostEmbeddingProvider,
|
|
24
|
+
type HostEmbeddingProvider,
|
|
25
|
+
} from "./host-embedding-provider.js";
|
|
26
|
+
import type { EmbeddingIndexStoreIo } from "./embedding-index-storage.js";
|
|
27
|
+
|
|
28
|
+
/** Deterministic across processes: both sides embed with this exact function. */
|
|
29
|
+
function vectorFor(text: string): number[] {
|
|
30
|
+
const vec = [0, 0, 0, 0];
|
|
31
|
+
for (let i = 0; i < text.length; i++) vec[i % 4] += text.charCodeAt(i) % 13;
|
|
32
|
+
return vec;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hostProvider(model: string): HostEmbeddingProvider {
|
|
36
|
+
return {
|
|
37
|
+
id: model,
|
|
38
|
+
model,
|
|
39
|
+
embed: async (text: string) => vectorFor(text),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PARENT_TEXT = "parent fact about database indexes";
|
|
44
|
+
const CHILD_TEXT = "child fact about queue consumers";
|
|
45
|
+
|
|
46
|
+
/** Spawn a real second process that indexes one memory under the given identity. */
|
|
47
|
+
function runChildPeer(options: {
|
|
48
|
+
memoryDir: string;
|
|
49
|
+
hostModel: string;
|
|
50
|
+
memoryId: string;
|
|
51
|
+
text: string;
|
|
52
|
+
relPath: string;
|
|
53
|
+
}): Promise<void> {
|
|
54
|
+
const moduleUrl = (name: string) => new URL(name, import.meta.url).href;
|
|
55
|
+
const childSource = [
|
|
56
|
+
"const [fallbackUrl, configUrl, hostUrl, memoryDir, hostModel, memoryId, text, relPath] = process.argv.slice(1);",
|
|
57
|
+
`const vectorFor = (text) => { const vec = [0, 0, 0, 0]; for (let i = 0; i < text.length; i++) vec[i % 4] += text.charCodeAt(i) % 13; return vec; };`,
|
|
58
|
+
"const { EmbeddingFallback } = await import(fallbackUrl);",
|
|
59
|
+
"const { parseConfig } = await import(configUrl);",
|
|
60
|
+
"const { registerHostEmbeddingProvider } = await import(hostUrl);",
|
|
61
|
+
"registerHostEmbeddingProvider(memoryDir, { id: hostModel, model: hostModel, embed: async (t) => vectorFor(t) });",
|
|
62
|
+
'const config = parseConfig({ memoryDir, embeddingFallbackEnabled: true, embeddingFallbackProvider: "openai", openaiApiKey: false });',
|
|
63
|
+
"const fallback = new EmbeddingFallback(config);",
|
|
64
|
+
"await fallback.indexFile(memoryId, text, relPath);",
|
|
65
|
+
'process.stdout.write("CHILD_DONE\\n");',
|
|
66
|
+
"",
|
|
67
|
+
].join("\n");
|
|
68
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
69
|
+
const child = spawn(
|
|
70
|
+
process.execPath,
|
|
71
|
+
[
|
|
72
|
+
"--import", "tsx",
|
|
73
|
+
"-e", childSource,
|
|
74
|
+
moduleUrl("./embedding-fallback.js"),
|
|
75
|
+
moduleUrl("./config.js"),
|
|
76
|
+
moduleUrl("./host-embedding-provider.js"),
|
|
77
|
+
options.memoryDir,
|
|
78
|
+
options.hostModel,
|
|
79
|
+
options.memoryId,
|
|
80
|
+
options.text,
|
|
81
|
+
options.relPath,
|
|
82
|
+
],
|
|
83
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
84
|
+
);
|
|
85
|
+
let stdout = "";
|
|
86
|
+
let stderr = "";
|
|
87
|
+
child.stdout.setEncoding("utf8");
|
|
88
|
+
child.stderr.setEncoding("utf8");
|
|
89
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
90
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
91
|
+
child.on("error", reject);
|
|
92
|
+
child.on("close", (code) => {
|
|
93
|
+
if (code === 0 && stdout.split("\n").includes("CHILD_DONE")) resolve();
|
|
94
|
+
else reject(new Error(`peer exited ${code}: ${stderr || stdout}`));
|
|
95
|
+
});
|
|
96
|
+
return promise;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeCountingIo() {
|
|
100
|
+
let reads = 0;
|
|
101
|
+
const io: EmbeddingIndexStoreIo = {
|
|
102
|
+
readUtf8: async (filePath) => {
|
|
103
|
+
reads += 1;
|
|
104
|
+
return readFile(filePath, "utf-8");
|
|
105
|
+
},
|
|
106
|
+
writeUtf8: async (filePath, contents) => {
|
|
107
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
108
|
+
await writeFile(filePath, contents, "utf-8");
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return { io, reads: () => reads };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function newMemoryDir(): Promise<string> {
|
|
115
|
+
return await mkdtemp(path.join(tmpdir(), `embed-cross-${process.pid}-`));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parentConfig(memoryDir: string) {
|
|
119
|
+
return parseConfig({
|
|
120
|
+
memoryDir,
|
|
121
|
+
embeddingFallbackEnabled: true,
|
|
122
|
+
embeddingFallbackProvider: "openai",
|
|
123
|
+
openaiApiKey: false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("a peer's identity swap invalidates the warm cache instead of serving the old generation", async (t) => {
|
|
128
|
+
const memoryDir = await newMemoryDir();
|
|
129
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
130
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
131
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
132
|
+
|
|
133
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir));
|
|
134
|
+
await fallback.indexFile("mem-a", PARENT_TEXT, "namespaces/alpha/facts/parent-a.md");
|
|
135
|
+
const warm = await fallback.search(PARENT_TEXT, 5);
|
|
136
|
+
assert.equal(warm[0]?.id, "mem-a", "baseline: own entry is searchable");
|
|
137
|
+
|
|
138
|
+
await runChildPeer({
|
|
139
|
+
memoryDir,
|
|
140
|
+
hostModel: "host-model-b",
|
|
141
|
+
memoryId: "mem-b",
|
|
142
|
+
text: CHILD_TEXT,
|
|
143
|
+
relPath: "namespaces/alpha/facts/child-b.md",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const hits = await fallback.search(PARENT_TEXT, 5);
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
hits.map((hit) => hit.id),
|
|
149
|
+
[],
|
|
150
|
+
"stale host-model-a entries must not be served after the disk identity swapped to host-model-b",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a peer's same-identity write becomes visible to warm searches, via one revalidation read", async (t) => {
|
|
155
|
+
const memoryDir = await newMemoryDir();
|
|
156
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
157
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
158
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
159
|
+
|
|
160
|
+
const counting = makeCountingIo();
|
|
161
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir), counting.io);
|
|
162
|
+
await fallback.indexFile("mem-a", PARENT_TEXT, "namespaces/alpha/facts/parent-a.md");
|
|
163
|
+
assert.equal((await fallback.search(PARENT_TEXT, 5))[0]?.id, "mem-a");
|
|
164
|
+
|
|
165
|
+
const readsAfterWarmup = counting.reads();
|
|
166
|
+
await fallback.search(PARENT_TEXT, 5);
|
|
167
|
+
await fallback.search(PARENT_TEXT, 5);
|
|
168
|
+
assert.equal(
|
|
169
|
+
counting.reads(),
|
|
170
|
+
readsAfterWarmup,
|
|
171
|
+
"warm searches with an unchanged disk marker perform no index reads",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await runChildPeer({
|
|
175
|
+
memoryDir,
|
|
176
|
+
hostModel: "host-model-a",
|
|
177
|
+
memoryId: "mem-b",
|
|
178
|
+
text: CHILD_TEXT,
|
|
179
|
+
relPath: "namespaces/alpha/facts/child-b.md",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const hits = await fallback.search(CHILD_TEXT, 5);
|
|
183
|
+
assert.equal(
|
|
184
|
+
hits[0]?.id,
|
|
185
|
+
"mem-b",
|
|
186
|
+
"a peer's same-identity entry must be visible to the next warm search (merged, not replacing mem-a)",
|
|
187
|
+
);
|
|
188
|
+
assert.ok(
|
|
189
|
+
counting.reads() > readsAfterWarmup,
|
|
190
|
+
"the peer write must trigger revalidation reads (the marker changed)",
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a peer's dirty-shard write into a published generation moves the stamp without a directory swap", async (t) => {
|
|
195
|
+
const memoryDir = await newMemoryDir();
|
|
196
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
197
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
198
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
199
|
+
const prevLimit = process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT;
|
|
200
|
+
process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT = "1024";
|
|
201
|
+
t.after(() => {
|
|
202
|
+
if (prevLimit === undefined) delete process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT;
|
|
203
|
+
else process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT = prevLimit;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const counting = makeCountingIo();
|
|
207
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir), counting.io);
|
|
208
|
+
for (let i = 0; i < 14; i++) {
|
|
209
|
+
await fallback.indexFile(
|
|
210
|
+
`mem-a-${i}`,
|
|
211
|
+
`${PARENT_TEXT} variant number ${i}`,
|
|
212
|
+
`namespaces/alpha/facts/parent-a-${i}.md`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const shardDir = path.join(memoryDir, "state", "embeddings");
|
|
216
|
+
const seeded = await lstat(shardDir);
|
|
217
|
+
assert.equal(seeded.isDirectory(), true, "precondition: seed migrated to the published sharded generation");
|
|
218
|
+
|
|
219
|
+
const readsAfterSeed = counting.reads();
|
|
220
|
+
await fallback.search(PARENT_TEXT, 5);
|
|
221
|
+
assert.equal(counting.reads(), readsAfterSeed, "warm search reads nothing while the marker is unchanged");
|
|
222
|
+
const inoBefore = (await lstat(shardDir)).ino;
|
|
223
|
+
|
|
224
|
+
await runChildPeer({
|
|
225
|
+
memoryDir,
|
|
226
|
+
hostModel: "host-model-a",
|
|
227
|
+
memoryId: "mem-b",
|
|
228
|
+
text: CHILD_TEXT,
|
|
229
|
+
relPath: "namespaces/alpha/facts/child-b.md",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
assert.equal(
|
|
233
|
+
(await lstat(shardDir)).ino,
|
|
234
|
+
inoBefore,
|
|
235
|
+
"the peer rewrote shards inside the existing directory (no swap): the stamp must still move so the warm cache revalidates",
|
|
236
|
+
);
|
|
237
|
+
const hits = await fallback.search(CHILD_TEXT, 5);
|
|
238
|
+
assert.equal(hits[0]?.id, "mem-b", "the peer's entry is visible to the parent's warm search");
|
|
239
|
+
assert.ok(
|
|
240
|
+
counting.reads() > readsAfterSeed,
|
|
241
|
+
"the in-place peer write must trigger revalidation reads",
|
|
242
|
+
);
|
|
243
|
+
assert.match(
|
|
244
|
+
(await fallback.search(PARENT_TEXT, 5))[0]?.id ?? "",
|
|
245
|
+
/^mem-a-/,
|
|
246
|
+
"parent entries survive the peer's dirty-shard write (merge, not replacement)",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("an identity probe that raced an in-flight peer publication revalidates at the completion marker", async (t) => {
|
|
251
|
+
const memoryDir = await newMemoryDir();
|
|
252
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
253
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
254
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
255
|
+
|
|
256
|
+
// The peer's io gate freezes its publication after the marker moved but
|
|
257
|
+
// before the index bytes are replaced: the window an unlocked reader sees.
|
|
258
|
+
const { promise: release, resolve: unblock } = Promise.withResolvers<void>();
|
|
259
|
+
const { promise: atWrite, resolve: reachedWrite } = Promise.withResolvers<void>();
|
|
260
|
+
let peerGated = false;
|
|
261
|
+
const peerIo: EmbeddingIndexStoreIo = {
|
|
262
|
+
readUtf8: async (filePath) => readFile(filePath, "utf-8"),
|
|
263
|
+
writeUtf8: async (filePath, contents) => {
|
|
264
|
+
if (!peerGated) {
|
|
265
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
266
|
+
await writeFile(filePath, contents, "utf-8");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
peerGated = false;
|
|
270
|
+
reachedWrite();
|
|
271
|
+
await release;
|
|
272
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
273
|
+
await writeFile(filePath, contents, "utf-8");
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
const counting = makeCountingIo();
|
|
277
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir), counting.io);
|
|
278
|
+
await fallback.indexFile("mem-a", PARENT_TEXT, "namespaces/alpha/facts/parent-a.md");
|
|
279
|
+
assert.equal((await fallback.search(PARENT_TEXT, 5))[0]?.id, "mem-a");
|
|
280
|
+
|
|
281
|
+
// The peer's io gate freezes its publication after the marker moved but
|
|
282
|
+
// before the index bytes are replaced: the window an unlocked reader sees.
|
|
283
|
+
const peer = new EmbeddingFallback(parentConfig(memoryDir), peerIo);
|
|
284
|
+
peerGated = true;
|
|
285
|
+
const peerWrite = peer.indexFile("mem-b", CHILD_TEXT, "namespaces/alpha/facts/child-b.md");
|
|
286
|
+
await atWrite;
|
|
287
|
+
|
|
288
|
+
// Park the parent's probe on the in-flight window: its identity probe
|
|
289
|
+
// reads the old bytes, then its locked content load blocks on the peer's
|
|
290
|
+
// generation lock. Unblock only once the probe read has landed.
|
|
291
|
+
const { promise: probeRead, resolve: probeReadLanded } = Promise.withResolvers<void>();
|
|
292
|
+
const countingReadUtf8 = counting.io.readUtf8;
|
|
293
|
+
counting.io.readUtf8 = async (filePath) => {
|
|
294
|
+
const raw = await countingReadUtf8(filePath);
|
|
295
|
+
probeReadLanded();
|
|
296
|
+
return raw;
|
|
297
|
+
};
|
|
298
|
+
const parentSearch = fallback.search(CHILD_TEXT, 5);
|
|
299
|
+
await probeRead;
|
|
300
|
+
unblock();
|
|
301
|
+
const hits = await parentSearch;
|
|
302
|
+
await peerWrite;
|
|
303
|
+
assert.equal(hits[0]?.id, "mem-b", "the peer's entry is visible once the in-flight publication completes");
|
|
304
|
+
|
|
305
|
+
const readsAfterBarrierSearch = counting.reads();
|
|
306
|
+
await fallback.search(CHILD_TEXT, 5);
|
|
307
|
+
assert.ok(
|
|
308
|
+
counting.reads() > readsAfterBarrierSearch,
|
|
309
|
+
"the probe cached during the in-flight window must be re-read at the completion marker; a mid-flight stamp must never stay stable",
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a failed completion marker keeps every probe revalidating until a publication completes", async (t) => {
|
|
314
|
+
const memoryDir = await newMemoryDir();
|
|
315
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
316
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
317
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
318
|
+
const stateDir = path.join(memoryDir, "state");
|
|
319
|
+
|
|
320
|
+
// The peer's shard writes go through this shadow io so the injected
|
|
321
|
+
// state-dir permission failure hits ONLY the plain-fs completion marker
|
|
322
|
+
// write, never the content publication itself.
|
|
323
|
+
const shadow: Record<string, string> = {};
|
|
324
|
+
const { promise: release, resolve: unblock } = Promise.withResolvers<void>();
|
|
325
|
+
const { promise: atWrite, resolve: reachedWrite } = Promise.withResolvers<void>();
|
|
326
|
+
let peerGated = false;
|
|
327
|
+
const peerIo: EmbeddingIndexStoreIo = {
|
|
328
|
+
readUtf8: async (filePath) => shadow[filePath] ?? readFile(filePath, "utf-8"),
|
|
329
|
+
writeUtf8: async (filePath, contents) => {
|
|
330
|
+
if (!peerGated) {
|
|
331
|
+
shadow[filePath] = contents;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
peerGated = false;
|
|
335
|
+
reachedWrite();
|
|
336
|
+
await release;
|
|
337
|
+
shadow[filePath] = contents;
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const counting = makeCountingIo();
|
|
342
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir), counting.io);
|
|
343
|
+
await fallback.indexFile("mem-a", PARENT_TEXT, "namespaces/alpha/facts/parent-a.md");
|
|
344
|
+
assert.equal((await fallback.search(PARENT_TEXT, 5))[0]?.id, "mem-a");
|
|
345
|
+
|
|
346
|
+
// Real publication whose completion marker write fails: the peer freezes
|
|
347
|
+
// at its first content write (marker already in-flight), the test makes
|
|
348
|
+
// the state dir unwritable, and the peer's completion write then fails.
|
|
349
|
+
const peer = new EmbeddingFallback(parentConfig(memoryDir), peerIo);
|
|
350
|
+
peerGated = true;
|
|
351
|
+
const peerWrite = peer.indexFile("mem-b", CHILD_TEXT, "namespaces/alpha/facts/child-b.md");
|
|
352
|
+
await atWrite;
|
|
353
|
+
|
|
354
|
+
// Park the parent's probe on the in-flight window: its identity probe
|
|
355
|
+
// caches the in-flight stamp, then its locked load blocks on the peer.
|
|
356
|
+
const { promise: probeRead, resolve: probeReadLanded } = Promise.withResolvers<void>();
|
|
357
|
+
const countingReadUtf8 = counting.io.readUtf8;
|
|
358
|
+
counting.io.readUtf8 = async (filePath) => {
|
|
359
|
+
const raw = await countingReadUtf8(filePath);
|
|
360
|
+
probeReadLanded();
|
|
361
|
+
return raw;
|
|
362
|
+
};
|
|
363
|
+
const parentSearch = fallback.search(CHILD_TEXT, 5);
|
|
364
|
+
await probeRead;
|
|
365
|
+
const prevMode = (await stat(stateDir)).mode;
|
|
366
|
+
await chmod(stateDir, 0o550);
|
|
367
|
+
unblock();
|
|
368
|
+
await peerWrite;
|
|
369
|
+
await chmod(stateDir, prevMode);
|
|
370
|
+
await parentSearch;
|
|
371
|
+
|
|
372
|
+
// With the completion write failed, the marker stays in-flight: the probe
|
|
373
|
+
// cached above must never match again — every search revalidates.
|
|
374
|
+
const readsBeforeProbe = counting.reads();
|
|
375
|
+
await fallback.search(CHILD_TEXT, 5);
|
|
376
|
+
assert.ok(
|
|
377
|
+
counting.reads() > readsBeforeProbe,
|
|
378
|
+
"an in-flight stamp must never repeat: a probe that cached it must revalidate on the next search",
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// The next healthy publication completes its marker; the stamp then
|
|
382
|
+
// stabilizes and warm searches return to zero index reads.
|
|
383
|
+
const healthyPeer = new EmbeddingFallback(parentConfig(memoryDir));
|
|
384
|
+
await healthyPeer.indexFile("mem-c", CHILD_TEXT, "namespaces/alpha/facts/child-c.md");
|
|
385
|
+
const settled = await fallback.search(CHILD_TEXT, 5);
|
|
386
|
+
assert.equal(settled[0]?.id, "mem-c", "the healthy publication's entry is served");
|
|
387
|
+
const readsAfterSettled = counting.reads();
|
|
388
|
+
const warm = await fallback.search(CHILD_TEXT, 5);
|
|
389
|
+
assert.equal(warm[0]?.id, "mem-c");
|
|
390
|
+
assert.equal(
|
|
391
|
+
counting.reads(),
|
|
392
|
+
readsAfterSettled,
|
|
393
|
+
"a completed publication's stamp is stable: warm searches perform no index reads",
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("a negative empty cache does not pin the store against a peer's first publication", async (t) => {
|
|
398
|
+
const memoryDir = await newMemoryDir();
|
|
399
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
400
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
401
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
402
|
+
|
|
403
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir));
|
|
404
|
+
// Searching the empty store caches a fresh empty index (loadedFromDisk
|
|
405
|
+
// false): the negative-cache shape the invalidation block used to skip.
|
|
406
|
+
assert.deepEqual(await fallback.search(PARENT_TEXT, 5), []);
|
|
407
|
+
|
|
408
|
+
await runChildPeer({
|
|
409
|
+
memoryDir,
|
|
410
|
+
hostModel: "host-model-a",
|
|
411
|
+
memoryId: "mem-b",
|
|
412
|
+
text: CHILD_TEXT,
|
|
413
|
+
relPath: "namespaces/alpha/facts/child-b.md",
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const hits = await fallback.search(CHILD_TEXT, 5);
|
|
417
|
+
assert.equal(
|
|
418
|
+
hits[0]?.id,
|
|
419
|
+
"mem-b",
|
|
420
|
+
"the peer's first generation must be visible to a store that was empty at first search",
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("a no-op removal after recovery leaves a stable stamp and no warm reload", async (t) => {
|
|
425
|
+
const memoryDir = await newMemoryDir();
|
|
426
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
427
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
428
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
429
|
+
const prevLimit = process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT;
|
|
430
|
+
process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT = "1024";
|
|
431
|
+
t.after(() => {
|
|
432
|
+
if (prevLimit === undefined) delete process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT;
|
|
433
|
+
else process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT = prevLimit;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const counting = makeCountingIo();
|
|
437
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir), counting.io);
|
|
438
|
+
for (let i = 0; i < 14; i++) {
|
|
439
|
+
await fallback.indexFile(
|
|
440
|
+
`mem-a-${i}`,
|
|
441
|
+
`${PARENT_TEXT} variant number ${i}`,
|
|
442
|
+
`namespaces/alpha/facts/parent-a-${i}.md`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
await fallback.search(PARENT_TEXT, 5);
|
|
446
|
+
|
|
447
|
+
// Crash after the in-flight marker, before the staging publication.
|
|
448
|
+
const stateDir = path.join(memoryDir, "state");
|
|
449
|
+
const shardDir = path.join(stateDir, "embeddings");
|
|
450
|
+
await rename(shardDir, path.join(stateDir, "embeddings.pre-replace.tmp"));
|
|
451
|
+
await writeFile(path.join(stateDir, "embeddings.generation"), "in-flight");
|
|
452
|
+
|
|
453
|
+
// A no-op removal performs no persist: recovery alone must finalize the
|
|
454
|
+
// marker, or every search reloads the whole index forever.
|
|
455
|
+
await fallback.removeFromIndex("mem-absent");
|
|
456
|
+
const marker = (await readFile(path.join(stateDir, "embeddings.generation"), "utf-8")).trim();
|
|
457
|
+
assert.notEqual(marker, "in-flight", "recovery with a no-op mutation must still finalize the marker");
|
|
458
|
+
|
|
459
|
+
const settled = await fallback.search(PARENT_TEXT, 5);
|
|
460
|
+
assert.match(settled[0]?.id ?? "", /^mem-a-/, "the restored generation is served");
|
|
461
|
+
const readsAfterSettled = counting.reads();
|
|
462
|
+
const warm = await fallback.search(PARENT_TEXT, 5);
|
|
463
|
+
assert.match(warm[0]?.id ?? "", /^mem-a-/);
|
|
464
|
+
assert.equal(
|
|
465
|
+
counting.reads(),
|
|
466
|
+
readsAfterSettled,
|
|
467
|
+
"the recovered generation's stamp is stable: warm searches perform no index reads",
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("a peer write that aliases the warm stamp's mtime tick still invalidates the warm cache", async (t) => {
|
|
472
|
+
const memoryDir = await newMemoryDir();
|
|
473
|
+
t.after(() => rm(memoryDir, { recursive: true, force: true }));
|
|
474
|
+
t.after(() => clearHostEmbeddingProvidersForTest());
|
|
475
|
+
t.after(registerHostEmbeddingProvider(memoryDir, hostProvider("host-model-a")));
|
|
476
|
+
const prevLimit = process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT;
|
|
477
|
+
process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT = "1024";
|
|
478
|
+
t.after(() => {
|
|
479
|
+
if (prevLimit === undefined) delete process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT;
|
|
480
|
+
else process.env.REMNIC_EMBEDDING_INDEX_FILE_CHAR_LIMIT = prevLimit;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const counting = makeCountingIo();
|
|
484
|
+
const fallback = new EmbeddingFallback(parentConfig(memoryDir), counting.io);
|
|
485
|
+
for (let i = 0; i < 14; i++) {
|
|
486
|
+
await fallback.indexFile(
|
|
487
|
+
`mem-a-${i}`,
|
|
488
|
+
`${PARENT_TEXT} variant number ${i}`,
|
|
489
|
+
`namespaces/alpha/facts/parent-a-${i}.md`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const shardDir = path.join(memoryDir, "state", "embeddings");
|
|
493
|
+
assert.equal((await lstat(shardDir)).isDirectory(), true, "precondition: seed migrated to the published sharded generation");
|
|
494
|
+
// Pin the directory mtime to a whole-millisecond tick, warm the cache on
|
|
495
|
+
// it, then restore the SAME tick after the peer's write: on a coarse-tick
|
|
496
|
+
// filesystem the pre- and post-write stats are indistinguishable, so the
|
|
497
|
+
// revalidation stamp must still move through something that cannot alias.
|
|
498
|
+
const aliasedTickSec = 1_700_000_000.5;
|
|
499
|
+
await utimes(shardDir, aliasedTickSec, aliasedTickSec);
|
|
500
|
+
assert.match(
|
|
501
|
+
(await fallback.search(PARENT_TEXT, 5))[0]?.id ?? "",
|
|
502
|
+
/^mem-a-/,
|
|
503
|
+
"baseline: own entries are searchable",
|
|
504
|
+
);
|
|
505
|
+
const readsAfterWarmup = counting.reads();
|
|
506
|
+
await fallback.search(PARENT_TEXT, 5);
|
|
507
|
+
assert.equal(counting.reads(), readsAfterWarmup, "warm search reads nothing while the stamp is unchanged");
|
|
508
|
+
const inoBefore = (await lstat(shardDir)).ino;
|
|
509
|
+
|
|
510
|
+
await runChildPeer({
|
|
511
|
+
memoryDir,
|
|
512
|
+
hostModel: "host-model-a",
|
|
513
|
+
memoryId: "mem-b",
|
|
514
|
+
text: CHILD_TEXT,
|
|
515
|
+
relPath: "namespaces/alpha/facts/child-b.md",
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
assert.equal((await lstat(shardDir)).ino, inoBefore, "precondition: the peer rewrote shards in place (no directory swap)");
|
|
519
|
+
await utimes(shardDir, aliasedTickSec, aliasedTickSec);
|
|
520
|
+
|
|
521
|
+
const hits = await fallback.search(CHILD_TEXT, 5);
|
|
522
|
+
assert.equal(
|
|
523
|
+
hits[0]?.id,
|
|
524
|
+
"mem-b",
|
|
525
|
+
"a peer write that lands in an aliased stat tick must still invalidate the warm cache",
|
|
526
|
+
);
|
|
527
|
+
assert.ok(counting.reads() > readsAfterWarmup, "the aliased peer write must trigger revalidation reads");
|
|
528
|
+
assert.match(
|
|
529
|
+
(await fallback.search(PARENT_TEXT, 5))[0]?.id ?? "",
|
|
530
|
+
/^mem-a-/,
|
|
531
|
+
"parent entries survive the peer's write (merge, not replacement)",
|
|
532
|
+
);
|
|
533
|
+
});
|
|
@@ -175,8 +175,9 @@ export type EmbedMode = "lookup" | "index";
|
|
|
175
175
|
export class EmbeddingFallback {
|
|
176
176
|
private readonly store: EmbeddingIndexFileStore;
|
|
177
177
|
private loaded: EmbeddingIndexFile | null = null;
|
|
178
|
-
/** True when
|
|
178
|
+
/** True when the cached index came from a published generation. */
|
|
179
179
|
private loadedFromDisk = false;
|
|
180
|
+
private identityProbe: { stamp: string; identity: EmbeddingIndexIdentity | null } | null = null;
|
|
180
181
|
private mutationQueue: Promise<void> = Promise.resolve();
|
|
181
182
|
|
|
182
183
|
/** The canonical state dir scoping this fallback's generation mutation lock. */
|
|
@@ -900,13 +901,20 @@ export class EmbeddingFallback {
|
|
|
900
901
|
}
|
|
901
902
|
|
|
902
903
|
private async readIndexIdentityFromDisk(): Promise<EmbeddingIndexIdentity | null> {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
904
|
+
const stamp = await this.store.identityStamp();
|
|
905
|
+
if (this.loaded) {
|
|
906
|
+
if (this.identityProbe?.stamp === stamp) {
|
|
907
|
+
return { provider: this.loaded.provider, model: this.loaded.model };
|
|
908
|
+
}
|
|
909
|
+
this.loaded = null;
|
|
910
|
+
this.loadedFromDisk = false;
|
|
908
911
|
}
|
|
909
|
-
|
|
912
|
+
if (this.identityProbe?.stamp === stamp) {
|
|
913
|
+
return this.identityProbe.identity;
|
|
914
|
+
}
|
|
915
|
+
const identity = await this.store.identityFromDisk();
|
|
916
|
+
this.identityProbe = { stamp, identity };
|
|
917
|
+
return identity;
|
|
910
918
|
}
|
|
911
919
|
|
|
912
920
|
/**
|
|
@@ -927,6 +935,10 @@ export class EmbeddingFallback {
|
|
|
927
935
|
}
|
|
928
936
|
this.loaded = index;
|
|
929
937
|
this.loadedFromDisk = true;
|
|
938
|
+
this.identityProbe = {
|
|
939
|
+
stamp: await this.store.identityStamp(),
|
|
940
|
+
identity: { provider: index.provider, model: index.model },
|
|
941
|
+
};
|
|
930
942
|
}
|
|
931
943
|
}
|
|
932
944
|
|