@remnic/core 10.0.101 → 10.0.102
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 +2 -2
- package/dist/access-cli.js +2 -2
- package/dist/access-extraction-force-flush.js +2 -2
- package/dist/access-http-briefing.js +2 -2
- package/dist/access-http-query.js +2 -2
- package/dist/access-http.js +2 -2
- package/dist/access-identity-continuity-surface.js +2 -2
- package/dist/access-lcm-surface.js +2 -2
- package/dist/access-mcp.js +2 -2
- package/dist/access-observe-write-surface.js +2 -2
- package/dist/access-operations-batch.js +2 -2
- package/dist/access-operations.js +2 -2
- package/dist/access-recall-concurrency.js +2 -2
- package/dist/access-recall-response.js +2 -2
- package/dist/access-recall-surface.js +2 -2
- package/dist/access-service.js +2 -2
- package/dist/{chunk-QIPDTAWZ.js → chunk-D77I4MD4.js} +2 -2
- package/dist/{chunk-PL36IQPL.js → chunk-KCRHFW3N.js} +69 -20
- package/dist/chunk-KCRHFW3N.js.map +1 -0
- package/dist/{chunk-MMOXCCHF.js → chunk-T3X4CCHC.js} +2 -2
- package/dist/cli.js +2 -2
- package/dist/explicit-capture.js +2 -2
- package/dist/harmonic-retrieval.d.ts +14 -4
- package/dist/harmonic-retrieval.d.ts.map +1 -1
- package/dist/harmonic-retrieval.js +5 -3
- package/dist/index.js +2 -2
- package/dist/orchestration/backlog-scheduler.js +3 -3
- package/dist/orchestration/buffer-drain-runner.js +3 -3
- package/dist/orchestrator.js +2 -2
- package/dist/recall-navigation.js +2 -2
- package/package.json +2 -2
- package/src/harmonic-graph-cache.test.ts +152 -7
- package/src/harmonic-retrieval.ts +125 -27
- package/dist/chunk-PL36IQPL.js.map +0 -1
- /package/dist/{chunk-QIPDTAWZ.js.map → chunk-D77I4MD4.js.map} +0 -0
- /package/dist/{chunk-MMOXCCHF.js.map → chunk-T3X4CCHC.js.map} +0 -0
|
@@ -25,13 +25,20 @@ export interface ProjectedHarmonicGraph {
|
|
|
25
25
|
nodes: AbstractionNode[];
|
|
26
26
|
anchors: CueAnchor[];
|
|
27
27
|
}
|
|
28
|
-
/** Counters for tests and diagnostics. `
|
|
28
|
+
/** Counters for tests and diagnostics. `stale` hits also count as `hits`. */
|
|
29
29
|
export declare const harmonicGraphCacheStats: {
|
|
30
30
|
hits: number;
|
|
31
31
|
misses: number;
|
|
32
|
+
stale: number;
|
|
32
33
|
shared: number;
|
|
33
34
|
};
|
|
34
35
|
export declare function clearHarmonicGraphCache(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Resolves once every build running now has settled, and after the failure bookkeeping of a background
|
|
38
|
+
* refresh (that handler was attached when the refresh started, so it runs first). For tests: a time-based
|
|
39
|
+
* wait races a slow build.
|
|
40
|
+
*/
|
|
41
|
+
export declare function settleHarmonicGraphBuilds(): Promise<void>;
|
|
35
42
|
/**
|
|
36
43
|
* `readProjectedHarmonicGraph` with an opt-in in-memory cache. Every recall used
|
|
37
44
|
* to re-read all nodes, anchors and source memories from disk (about 4.7 s on a
|
|
@@ -40,9 +47,12 @@ export declare function clearHarmonicGraphCache(): void;
|
|
|
40
47
|
* cold-write version sentinels (bumped by every memory write, in any process) and
|
|
41
48
|
* the graph version sentinel (bumped by node and anchor writes). A stamp read is
|
|
42
49
|
* four stats, not a scan of the memories. The TTL bounds the one input a sentinel
|
|
43
|
-
* cannot see, which is wall-clock validity expiry
|
|
44
|
-
*
|
|
45
|
-
*
|
|
50
|
+
* cannot see, which is wall-clock validity expiry; past it the stale graph is served
|
|
51
|
+
* while one refresh runs (up to GRAPH_CACHE_MAX_STALE_MS). A changed stamp always
|
|
52
|
+
* waits for a fresh build, because a stale graph could carry governed metadata of a
|
|
53
|
+
* memory that left the active set. Concurrent misses share one build. An explicit
|
|
54
|
+
* `nowMs` bypasses the cache so tests stay deterministic. The cached arrays are
|
|
55
|
+
* frozen: callers must not mutate them.
|
|
46
56
|
*/
|
|
47
57
|
export declare function readProjectedHarmonicGraph(options: {
|
|
48
58
|
memoryDir: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"harmonic-retrieval.d.ts","sourceRoot":"","sources":["../src/harmonic-retrieval.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"harmonic-retrieval.d.ts","sourceRoot":"","sources":["../src/harmonic-retrieval.ts"],"names":[],"mappings":"AAOC,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAC;AACjC,OAAO,EAA+C,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AA+PnH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,aAAa,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,eAAe,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,cAAc,EAAE,qBAAqB,EAAE,CAAC;CACzC;AA+ED,wBAAsB,oBAAoB,CAAC,OAAO,EAAE;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAY7B;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAavB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,OAAO,EAAE,SAAS,EAAE,CAAC;CACtB;AAqKD,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB;;;;;CAA8C,CAAC;AAWnF,wBAAgB,uBAAuB,IAAI,IAAI,CAI9C;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,IAAI,OAAO,CAAC,IAAI,CAAC,CAE/D;AAiDD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,0BAA0B,CAAC,OAAO,EAAE;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAyClC;AAED,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC,yHAAyH;IACzH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,GAAG,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAoFrC"}
|
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
readAbstractionNodes,
|
|
5
5
|
readCueAnchors,
|
|
6
6
|
readProjectedHarmonicGraph,
|
|
7
|
-
searchHarmonicRetrieval
|
|
8
|
-
|
|
7
|
+
searchHarmonicRetrieval,
|
|
8
|
+
settleHarmonicGraphBuilds
|
|
9
|
+
} from "./chunk-KCRHFW3N.js";
|
|
9
10
|
import "./chunk-HUDPXTXW.js";
|
|
10
11
|
import "./chunk-AZEZSQ5R.js";
|
|
11
12
|
import "./chunk-FL3O34XF.js";
|
|
@@ -132,6 +133,7 @@ export {
|
|
|
132
133
|
readAbstractionNodes,
|
|
133
134
|
readCueAnchors,
|
|
134
135
|
readProjectedHarmonicGraph,
|
|
135
|
-
searchHarmonicRetrieval
|
|
136
|
+
searchHarmonicRetrieval,
|
|
137
|
+
settleHarmonicGraphBuilds
|
|
136
138
|
};
|
|
137
139
|
//# sourceMappingURL=harmonic-retrieval.js.map
|
package/dist/index.js
CHANGED
|
@@ -259,7 +259,7 @@ import {
|
|
|
259
259
|
writeManifest,
|
|
260
260
|
writeMeetingEpisodeMemory,
|
|
261
261
|
writeTimelineState
|
|
262
|
-
} from "./chunk-
|
|
262
|
+
} from "./chunk-T3X4CCHC.js";
|
|
263
263
|
import "./chunk-6P7U3HWG.js";
|
|
264
264
|
import "./chunk-PQM3B54K.js";
|
|
265
265
|
import {
|
|
@@ -717,7 +717,7 @@ import {
|
|
|
717
717
|
import "./chunk-UOBPK2OS.js";
|
|
718
718
|
import "./chunk-2YA5N2BY.js";
|
|
719
719
|
import "./chunk-3HSRWJTC.js";
|
|
720
|
-
import "./chunk-
|
|
720
|
+
import "./chunk-KCRHFW3N.js";
|
|
721
721
|
import "./chunk-HUDPXTXW.js";
|
|
722
722
|
import "./chunk-AZEZSQ5R.js";
|
|
723
723
|
import "./chunk-YAZNBMNF.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BufferDrainRunner
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-D77I4MD4.js";
|
|
4
|
+
import "../chunk-T3X4CCHC.js";
|
|
5
5
|
import "../chunk-6P7U3HWG.js";
|
|
6
6
|
import "../chunk-PQM3B54K.js";
|
|
7
7
|
import "../chunk-B3DR2JBZ.js";
|
|
@@ -174,7 +174,7 @@ import "../chunk-MDPT6QFK.js";
|
|
|
174
174
|
import "../chunk-UOBPK2OS.js";
|
|
175
175
|
import "../chunk-2YA5N2BY.js";
|
|
176
176
|
import "../chunk-3HSRWJTC.js";
|
|
177
|
-
import "../chunk-
|
|
177
|
+
import "../chunk-KCRHFW3N.js";
|
|
178
178
|
import "../chunk-HUDPXTXW.js";
|
|
179
179
|
import "../chunk-AZEZSQ5R.js";
|
|
180
180
|
import "../chunk-YAZNBMNF.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BufferDrainRunner,
|
|
3
3
|
ClaimBufferStorage
|
|
4
|
-
} from "../chunk-
|
|
5
|
-
import "../chunk-
|
|
4
|
+
} from "../chunk-D77I4MD4.js";
|
|
5
|
+
import "../chunk-T3X4CCHC.js";
|
|
6
6
|
import "../chunk-6P7U3HWG.js";
|
|
7
7
|
import "../chunk-PQM3B54K.js";
|
|
8
8
|
import "../chunk-B3DR2JBZ.js";
|
|
@@ -175,7 +175,7 @@ import "../chunk-MDPT6QFK.js";
|
|
|
175
175
|
import "../chunk-UOBPK2OS.js";
|
|
176
176
|
import "../chunk-2YA5N2BY.js";
|
|
177
177
|
import "../chunk-3HSRWJTC.js";
|
|
178
|
-
import "../chunk-
|
|
178
|
+
import "../chunk-KCRHFW3N.js";
|
|
179
179
|
import "../chunk-HUDPXTXW.js";
|
|
180
180
|
import "../chunk-AZEZSQ5R.js";
|
|
181
181
|
import "../chunk-YAZNBMNF.js";
|
package/dist/orchestrator.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
mergeGraphExpandedResults,
|
|
7
7
|
qmdStartupCollectionChecksWithTimeout,
|
|
8
8
|
resolveEffectiveIdentityInjectionMode
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-T3X4CCHC.js";
|
|
10
10
|
import "./chunk-6P7U3HWG.js";
|
|
11
11
|
import "./chunk-PQM3B54K.js";
|
|
12
12
|
import "./chunk-B3DR2JBZ.js";
|
|
@@ -183,7 +183,7 @@ import "./chunk-MDPT6QFK.js";
|
|
|
183
183
|
import "./chunk-UOBPK2OS.js";
|
|
184
184
|
import "./chunk-2YA5N2BY.js";
|
|
185
185
|
import "./chunk-3HSRWJTC.js";
|
|
186
|
-
import "./chunk-
|
|
186
|
+
import "./chunk-KCRHFW3N.js";
|
|
187
187
|
import "./chunk-HUDPXTXW.js";
|
|
188
188
|
import "./chunk-AZEZSQ5R.js";
|
|
189
189
|
import "./chunk-YAZNBMNF.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
runRecallNavigation
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-T3X4CCHC.js";
|
|
4
4
|
import "./chunk-6P7U3HWG.js";
|
|
5
5
|
import "./chunk-PQM3B54K.js";
|
|
6
6
|
import "./chunk-B3DR2JBZ.js";
|
|
@@ -173,7 +173,7 @@ import "./chunk-MDPT6QFK.js";
|
|
|
173
173
|
import "./chunk-UOBPK2OS.js";
|
|
174
174
|
import "./chunk-2YA5N2BY.js";
|
|
175
175
|
import "./chunk-3HSRWJTC.js";
|
|
176
|
-
import "./chunk-
|
|
176
|
+
import "./chunk-KCRHFW3N.js";
|
|
177
177
|
import "./chunk-HUDPXTXW.js";
|
|
178
178
|
import "./chunk-AZEZSQ5R.js";
|
|
179
179
|
import "./chunk-YAZNBMNF.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remnic/core",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.102",
|
|
4
4
|
"description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -3386,7 +3386,7 @@
|
|
|
3386
3386
|
"core"
|
|
3387
3387
|
],
|
|
3388
3388
|
"peerDependencies": {
|
|
3389
|
-
"@remnic/coding-graph": "^10.0.
|
|
3389
|
+
"@remnic/coding-graph": "^10.0.102"
|
|
3390
3390
|
},
|
|
3391
3391
|
"peerDependenciesMeta": {
|
|
3392
3392
|
"@remnic/coding-graph": {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ProjectedHarmonicGraph,
|
|
12
12
|
readProjectedHarmonicGraph,
|
|
13
13
|
searchHarmonicRetrieval,
|
|
14
|
+
settleHarmonicGraphBuilds,
|
|
14
15
|
} from "./harmonic-retrieval.js";
|
|
15
16
|
import { bumpMemoryCorpusVersionForDir } from "./memory-corpus-version.js";
|
|
16
17
|
import { StorageManager } from "./storage.js";
|
|
@@ -232,6 +233,14 @@ test("pruneOrphanCueAnchors alone invalidates the cached graph", async () => {
|
|
|
232
233
|
// Recall waits for the graph build, so a cache miss costs every recall that lands on it. Production: a
|
|
233
234
|
// 2106 node, 9174 anchor graph rebuilt in 5 s standalone, 7 s inside the daemon, and 17 to 32 s when two
|
|
234
235
|
// recalls overlapped and each ran its own build.
|
|
236
|
+
async function until(condition: () => Promise<boolean>) {
|
|
237
|
+
for (let i = 0; i < 400; i += 1) {
|
|
238
|
+
if (await condition()) return;
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
240
|
+
}
|
|
241
|
+
assert.fail("condition not reached in 2 s");
|
|
242
|
+
}
|
|
243
|
+
|
|
235
244
|
test("concurrent reads on a cold cache share one build", async () => {
|
|
236
245
|
await withDir(async (memoryDir, storage) => {
|
|
237
246
|
const memory = await storage.writeMemory("fact", "theta shared fact", { source: "test" });
|
|
@@ -257,6 +266,73 @@ test("a caller that aborts leaves the shared build for the others", async () =>
|
|
|
257
266
|
});
|
|
258
267
|
});
|
|
259
268
|
|
|
269
|
+
test("a read past the TTL answers from the stale graph and refreshes behind it", async (t) => {
|
|
270
|
+
await withDir(async (memoryDir, storage) => {
|
|
271
|
+
const memory = await storage.writeMemory("fact", "zeta ttl fact", { source: "test" });
|
|
272
|
+
const nodeFile = await recordAbstractionNode({ memoryDir, node: node("n1", "zeta ttl fact", [memory.id]) });
|
|
273
|
+
const start = Date.now();
|
|
274
|
+
const clock = t.mock.method(Date, "now", () => start);
|
|
275
|
+
assert.equal((await read(memoryDir)).nodes.length, 1);
|
|
276
|
+
await rm(nodeFile, { force: true });
|
|
277
|
+
clock.mock.mockImplementation(() => start + 29_000);
|
|
278
|
+
assert.equal((await read(memoryDir)).nodes.length, 1, "still cached inside the TTL");
|
|
279
|
+
const missesBefore = harmonicGraphCacheStats.misses;
|
|
280
|
+
clock.mock.mockImplementation(() => start + 31_000);
|
|
281
|
+
assert.equal((await read(memoryDir)).nodes.length, 1, "past the TTL the caller does not wait for a rebuild");
|
|
282
|
+
assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "a refresh was started");
|
|
283
|
+
await until(async () => (await read(memoryDir)).nodes.length === 0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a graph past the stale limit is rebuilt before it is served", async (t) => {
|
|
288
|
+
await withDir(async (memoryDir, storage) => {
|
|
289
|
+
const memory = await storage.writeMemory("fact", "kappa limit fact", { source: "test" });
|
|
290
|
+
const nodeFile = await recordAbstractionNode({ memoryDir, node: node("n1", "kappa limit fact", [memory.id]) });
|
|
291
|
+
const start = Date.now();
|
|
292
|
+
const clock = t.mock.method(Date, "now", () => start);
|
|
293
|
+
assert.equal((await read(memoryDir)).nodes.length, 1);
|
|
294
|
+
await rm(nodeFile, { force: true });
|
|
295
|
+
clock.mock.mockImplementation(() => start + 330_000);
|
|
296
|
+
assert.equal((await read(memoryDir)).nodes.length, 0, "the stale window ends 300 s after the TTL, at 330 s");
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("a graph is still served stale one millisecond before the stale window ends", async (t) => {
|
|
301
|
+
await withDir(async (memoryDir, storage) => {
|
|
302
|
+
const memory = await storage.writeMemory("fact", "kappa edge fact", { source: "test" });
|
|
303
|
+
const nodeFile = await recordAbstractionNode({ memoryDir, node: node("n1", "kappa edge fact", [memory.id]) });
|
|
304
|
+
const start = Date.now();
|
|
305
|
+
const clock = t.mock.method(Date, "now", () => start);
|
|
306
|
+
assert.equal((await read(memoryDir)).nodes.length, 1);
|
|
307
|
+
await rm(nodeFile, { force: true });
|
|
308
|
+
clock.mock.mockImplementation(() => start + 329_999);
|
|
309
|
+
assert.equal((await read(memoryDir)).nodes.length, 1, "up to 300 s past the TTL the stale graph is served");
|
|
310
|
+
await settleHarmonicGraphBuilds();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("an ineligible source's invalid_at does not end the cache entry", async (t) => {
|
|
315
|
+
await withDir(async (memoryDir, storage) => {
|
|
316
|
+
const start = Date.now();
|
|
317
|
+
const clock = t.mock.method(Date, "now", () => start);
|
|
318
|
+
const live = await storage.writeMemory("fact", "pi eligible fact", { source: "test" });
|
|
319
|
+
const gone = await storage.writeMemory("fact", "pi quarantined fact", {
|
|
320
|
+
source: "test",
|
|
321
|
+
invalidAt: new Date(start + 10_000).toISOString(),
|
|
322
|
+
});
|
|
323
|
+
const gonePath = (await storage.getMemoryById(gone.id))!.path;
|
|
324
|
+
await writeFile(gonePath, (await readFile(gonePath, "utf8")).replace("category: fact", "category: fact\nstatus: quarantined"));
|
|
325
|
+
bumpMemoryCorpusVersionForDir(memoryDir); // the out-of-band status edit needs the documented bump, as in the status test above
|
|
326
|
+
await recordAbstractionNode({ memoryDir, node: node("n1", "pi mixed fact", [live.id, gone.id]) });
|
|
327
|
+
assert.equal((await read(memoryDir)).nodes.length, 1);
|
|
328
|
+
const before = { ...harmonicGraphCacheStats };
|
|
329
|
+
clock.mock.mockImplementation(() => start + 20_000);
|
|
330
|
+
assert.equal((await read(memoryDir)).nodes.length, 1);
|
|
331
|
+
assert.equal(harmonicGraphCacheStats.misses, before.misses, "the quarantined source's invalid_at passing starts no rebuild");
|
|
332
|
+
assert.equal(harmonicGraphCacheStats.hits, before.hits + 1);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
260
336
|
// Unreadable node store: a file where the nodes directory belongs is not ENOENT, so the strict listing throws
|
|
261
337
|
// (a corrupt node file would not: readAbstractionNodes skips those on purpose).
|
|
262
338
|
async function breakNodeStore(memoryDir: string) {
|
|
@@ -270,6 +346,24 @@ async function breakNodeStore(memoryDir: string) {
|
|
|
270
346
|
};
|
|
271
347
|
}
|
|
272
348
|
|
|
349
|
+
test("a source whose invalid_at passes inside the stale window is never served", async (t) => {
|
|
350
|
+
await withDir(async (memoryDir, storage) => {
|
|
351
|
+
const start = Date.now();
|
|
352
|
+
const clock = t.mock.method(Date, "now", () => start);
|
|
353
|
+
const memory = await storage.writeMemory("fact", "lambda expiry fact", {
|
|
354
|
+
source: "test",
|
|
355
|
+
invalidAt: new Date(start + 60_000).toISOString(),
|
|
356
|
+
});
|
|
357
|
+
await recordAbstractionNode({ memoryDir, node: node("n1", "lambda expiry fact", [memory.id]) });
|
|
358
|
+
assert.equal((await read(memoryDir)).nodes.length, 1);
|
|
359
|
+
clock.mock.mockImplementation(() => start + 31_000);
|
|
360
|
+
assert.equal((await read(memoryDir)).nodes.length, 1, "past the TTL but before the invalid_at: stale is fine");
|
|
361
|
+
await until(async () => harmonicGraphCacheStats.stale >= 1);
|
|
362
|
+
clock.mock.mockImplementation(() => start + 61_000);
|
|
363
|
+
assert.equal((await read(memoryDir)).nodes.length, 0, "after the invalid_at the graph is rebuilt before it is served");
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
273
367
|
test("callers that joined a failing build all reject, and the next read retries and succeeds", async () => {
|
|
274
368
|
await withDir(async (memoryDir, storage) => {
|
|
275
369
|
const memory = await storage.writeMemory("fact", "mu retry fact", { source: "test" });
|
|
@@ -284,18 +378,69 @@ test("callers that joined a failing build all reject, and the next read retries
|
|
|
284
378
|
});
|
|
285
379
|
});
|
|
286
380
|
|
|
287
|
-
test("
|
|
381
|
+
test("a failing refresh is retried at most once per TTL, not on every stale read", async (t) => {
|
|
288
382
|
await withDir(async (memoryDir, storage) => {
|
|
289
|
-
const memory = await storage.writeMemory("fact", "
|
|
290
|
-
|
|
383
|
+
const memory = await storage.writeMemory("fact", "nu backoff fact", { source: "test" });
|
|
384
|
+
await recordAbstractionNode({ memoryDir, node: node("n1", "nu backoff fact", [memory.id]) });
|
|
291
385
|
const start = Date.now();
|
|
292
386
|
const clock = t.mock.method(Date, "now", () => start);
|
|
293
387
|
assert.equal((await read(memoryDir)).nodes.length, 1);
|
|
294
|
-
|
|
295
|
-
clock.mock.mockImplementation(() => start + 29_000);
|
|
296
|
-
assert.equal((await read(memoryDir)).nodes.length, 1, "still cached inside the TTL");
|
|
388
|
+
const restore = await breakNodeStore(memoryDir);
|
|
297
389
|
clock.mock.mockImplementation(() => start + 31_000);
|
|
298
|
-
|
|
390
|
+
const missesBefore = harmonicGraphCacheStats.misses;
|
|
391
|
+
assert.equal((await read(memoryDir)).nodes.length, 1, "the stale graph is still served");
|
|
392
|
+
// The failing build bumps `misses` before it reads the broken store: wait for it to settle, not for a time.
|
|
393
|
+
await settleHarmonicGraphBuilds();
|
|
394
|
+
assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "one refresh was started");
|
|
395
|
+
clock.mock.mockImplementation(() => start + 40_000);
|
|
396
|
+
for (let i = 0; i < 5; i += 1) await read(memoryDir);
|
|
397
|
+
await settleHarmonicGraphBuilds();
|
|
398
|
+
assert.equal(harmonicGraphCacheStats.misses, missesBefore + 1, "no new build inside the backoff");
|
|
399
|
+
await restore();
|
|
400
|
+
clock.mock.mockImplementation(() => start + 62_000);
|
|
401
|
+
await read(memoryDir);
|
|
402
|
+
await until(async () => harmonicGraphCacheStats.misses === missesBefore + 2);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// The build's own clock reading is taken before it reads the anchors, so an invalid_at can pass while a
|
|
407
|
+
// build is still running. Every caller that joined that build must still get an unexpired graph.
|
|
408
|
+
test("a source that expires while the build is running is not served from that build", async () => {
|
|
409
|
+
await withDir(async (memoryDir, storage) => {
|
|
410
|
+
const sources: Array<{ id: string; path: string }> = [];
|
|
411
|
+
for (let i = 0; i < 120; i += 1) {
|
|
412
|
+
const memory = await storage.writeMemory("fact", `xi race fact ${i}`, { source: "test" });
|
|
413
|
+
await recordAbstractionNode({ memoryDir, node: node(`n${i}`, `xi race fact ${i}`, [memory.id]) });
|
|
414
|
+
sources.push({ id: memory.id, path: (await storage.getMemoryById(memory.id))!.path });
|
|
415
|
+
}
|
|
416
|
+
// Anchors are read after the sources are projected, so many of them make the tail the deadline falls in.
|
|
417
|
+
await upsertCueAnchors({
|
|
418
|
+
memoryDir,
|
|
419
|
+
anchors: Array.from({ length: 1500 }, (_, i) => anchor(`xi-a${i}`, `n${i % 120}`, sources[i % 120]!.id)),
|
|
420
|
+
});
|
|
421
|
+
// Calibrate: how long one cold build takes on this machine.
|
|
422
|
+
const calibrationStart = Date.now();
|
|
423
|
+
await read(memoryDir);
|
|
424
|
+
const buildMs = Date.now() - calibrationStart;
|
|
425
|
+
clearHarmonicGraphCache();
|
|
426
|
+
// n0's source expires near the end of the next build, which started before the deadline.
|
|
427
|
+
const first = sources[0]!;
|
|
428
|
+
const deadlineMs = Date.now() + Math.max(2, Math.floor(buildMs * 0.85));
|
|
429
|
+
await writeFile(
|
|
430
|
+
first.path,
|
|
431
|
+
(await readFile(first.path, "utf8")).replace("category: fact", `category: fact\ninvalidAt: ${new Date(deadlineMs).toISOString()}`),
|
|
432
|
+
);
|
|
433
|
+
// The storage layer caches parsed memories by corpus version, so an out-of-band edit needs the documented bump.
|
|
434
|
+
bumpMemoryCorpusVersionForDir(memoryDir);
|
|
435
|
+
const graph = await read(memoryDir);
|
|
436
|
+
const returnedAtMs = Date.now();
|
|
437
|
+
const served = graph.nodes.some((candidate) => candidate.nodeId === "n0");
|
|
438
|
+
// A build that finished before the deadline may legitimately include n0 (a 25 ms margin covers the
|
|
439
|
+
// hand-off); one that returned after it must not.
|
|
440
|
+
if (returnedAtMs >= deadlineMs + 25) {
|
|
441
|
+
assert.equal(served, false, `expired source served; build took about ${buildMs} ms`);
|
|
442
|
+
assert.equal(graph.nodes.length, 119);
|
|
443
|
+
}
|
|
299
444
|
});
|
|
300
445
|
});
|
|
301
446
|
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { realpath } from "node:fs/promises";
|
|
3
3
|
import { listJsonFilesStrict, readJsonFile } from "./json-store.js";
|
|
4
4
|
import { raceAbort, throwIfAborted } from "./abort-error.js";
|
|
5
|
+
import { log } from "./logger.js";
|
|
5
6
|
import { readHarmonicGraphStamp } from "./harmonic-graph-version.js";
|
|
6
7
|
import { inferMemoryStatus, toMemoryPathRel } from "./memory-lifecycle-ledger-utils.js";
|
|
7
8
|
import {
|
|
@@ -170,6 +171,20 @@ function parseSourceMemoryInsertedAt(node: AbstractionNode): Map<string, string>
|
|
|
170
171
|
return insertedAtById;
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Whether a source memory can back a projected node at all. Shared with `earliestInvalidAtMs`, so the cache
|
|
176
|
+
* deadline only follows a source that the projection would use.
|
|
177
|
+
* #2332: a private support-passport record leaves the eligible set the same way a rejected one does — a node
|
|
178
|
+
* title or anchor attribution rebuilt from it would leak governed metadata into the deep-recall frontier (and
|
|
179
|
+
* every other graph reader) before any content check runs.
|
|
180
|
+
*/
|
|
181
|
+
function isEligibleSourceMemory(memory: MemoryFile, memoryDir: string): boolean {
|
|
182
|
+
return (
|
|
183
|
+
!isSupportPassportPrivateMemory(memory) &&
|
|
184
|
+
inferMemoryStatus(memory.frontmatter, toMemoryPathRel(memoryDir, memory.path)) === "active"
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
173
188
|
function projectSourceBackedNode(
|
|
174
189
|
node: AbstractionNode,
|
|
175
190
|
sourceMemories: SourceMemoryMap,
|
|
@@ -184,12 +199,7 @@ function projectSourceBackedNode(
|
|
|
184
199
|
const memory = sourceMemories.get(memoryId);
|
|
185
200
|
return (
|
|
186
201
|
memory !== undefined &&
|
|
187
|
-
|
|
188
|
-
// same way a rejected one does — a node title or anchor attribution
|
|
189
|
-
// rebuilt from it would leak governed metadata into the deep-recall
|
|
190
|
-
// frontier (and every other graph reader) before any content check runs.
|
|
191
|
-
!isSupportPassportPrivateMemory(memory) &&
|
|
192
|
-
inferMemoryStatus(memory.frontmatter, toMemoryPathRel(memoryDir, memory.path)) === "active" &&
|
|
202
|
+
isEligibleSourceMemory(memory, memoryDir) &&
|
|
193
203
|
(temporalExpiredInInjection || !isValidityExpiredNow(memory.frontmatter, nowMs))
|
|
194
204
|
);
|
|
195
205
|
});
|
|
@@ -404,6 +414,22 @@ export interface ProjectedHarmonicGraph {
|
|
|
404
414
|
* one of that node's surviving active sources, and is dropped once no
|
|
405
415
|
* reference survives.
|
|
406
416
|
*/
|
|
417
|
+
interface ExpiringHarmonicGraph extends ProjectedHarmonicGraph {
|
|
418
|
+
/** First instant a source memory's `invalid_at` passes: from then on the graph is wrong without any write. */
|
|
419
|
+
validUntilMs: number;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function earliestInvalidAtMs(sourceMemories: SourceMemoryMap, memoryDir: string, nowMs: number): number {
|
|
423
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
424
|
+
for (const memory of sourceMemories.values()) {
|
|
425
|
+
// An ineligible source drops out of the projection whatever its `invalid_at`, so its deadline changes nothing.
|
|
426
|
+
if (!isEligibleSourceMemory(memory, memoryDir)) continue;
|
|
427
|
+
const invalidAtMs = Date.parse(memory.frontmatter.invalid_at?.trim() ?? "");
|
|
428
|
+
if (Number.isFinite(invalidAtMs) && invalidAtMs > nowMs && invalidAtMs < earliest) earliest = invalidAtMs;
|
|
429
|
+
}
|
|
430
|
+
return earliest;
|
|
431
|
+
}
|
|
432
|
+
|
|
407
433
|
async function buildProjectedHarmonicGraph(options: {
|
|
408
434
|
memoryDir: string;
|
|
409
435
|
abstractionNodeStoreDir?: string;
|
|
@@ -412,6 +438,18 @@ async function buildProjectedHarmonicGraph(options: {
|
|
|
412
438
|
temporalExpiredInInjection?: boolean;
|
|
413
439
|
nowMs?: number;
|
|
414
440
|
}): Promise<ProjectedHarmonicGraph> {
|
|
441
|
+
const { nodes, anchors } = await buildExpiringHarmonicGraph(options);
|
|
442
|
+
return { nodes, anchors };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function buildExpiringHarmonicGraph(options: {
|
|
446
|
+
memoryDir: string;
|
|
447
|
+
abstractionNodeStoreDir?: string;
|
|
448
|
+
anchorsEnabled: boolean;
|
|
449
|
+
abortSignal?: AbortSignal;
|
|
450
|
+
temporalExpiredInInjection?: boolean;
|
|
451
|
+
nowMs?: number;
|
|
452
|
+
}): Promise<ExpiringHarmonicGraph> {
|
|
415
453
|
throwIfAborted(options.abortSignal, "harmonic retrieval aborted");
|
|
416
454
|
const nodes = await readAbstractionNodes(options);
|
|
417
455
|
const sourceMemoryIds = [...new Set(nodes.flatMap((node) => node.sourceMemoryIds ?? []))];
|
|
@@ -424,6 +462,10 @@ async function buildProjectedHarmonicGraph(options: {
|
|
|
424
462
|
})
|
|
425
463
|
: new Map();
|
|
426
464
|
const nowMs = options.nowMs ?? Date.now();
|
|
465
|
+
// With temporalExpiredInInjection the validity filter is off, so no `invalid_at` can age the graph.
|
|
466
|
+
const validUntilMs = options.temporalExpiredInInjection === true
|
|
467
|
+
? Number.POSITIVE_INFINITY
|
|
468
|
+
: earliestInvalidAtMs(sourceMemories, options.memoryDir, nowMs);
|
|
427
469
|
// A node whose full stored source set survived projection keeps legacy
|
|
428
470
|
// anchor attribution (an anchor written before per-node source ids existed).
|
|
429
471
|
const legacyCompatibleNodeRefs = new Set<string>();
|
|
@@ -444,7 +486,7 @@ async function buildProjectedHarmonicGraph(options: {
|
|
|
444
486
|
}
|
|
445
487
|
return projected ? [projected] : [];
|
|
446
488
|
});
|
|
447
|
-
if (!options.anchorsEnabled) return { nodes: projectedNodes, anchors: [] };
|
|
489
|
+
if (!options.anchorsEnabled) return { nodes: projectedNodes, anchors: [], validUntilMs };
|
|
448
490
|
throwIfAborted(options.abortSignal, "harmonic retrieval aborted");
|
|
449
491
|
const nodeIndex = new Map(projectedNodes.map((node) => [node.nodeId, node]));
|
|
450
492
|
const anchors = (await readCueAnchors(options)).flatMap((anchor) => {
|
|
@@ -458,20 +500,25 @@ async function buildProjectedHarmonicGraph(options: {
|
|
|
458
500
|
});
|
|
459
501
|
return nodeRefs.length > 0 ? [{ ...anchor, nodeRefs }] : [];
|
|
460
502
|
});
|
|
461
|
-
return { nodes: projectedNodes, anchors };
|
|
503
|
+
return { nodes: projectedNodes, anchors, validUntilMs };
|
|
462
504
|
}
|
|
463
505
|
|
|
464
506
|
const GRAPH_CACHE_TTL_MS = 30_000;
|
|
507
|
+
/**
|
|
508
|
+
* Past the TTL a graph whose stamp is unchanged is still served while a refresh runs behind it, so no
|
|
509
|
+
* recall waits for a rebuild just because the TTL lapsed. This is how long past the TTL; after it the graph is rebuilt first.
|
|
510
|
+
*/
|
|
511
|
+
const GRAPH_CACHE_MAX_STALE_MS = 300_000;
|
|
465
512
|
const GRAPH_CACHE_MAX_ENTRIES = 8;
|
|
466
513
|
|
|
467
514
|
interface GraphCacheEntry {
|
|
468
515
|
stamp: string;
|
|
469
|
-
|
|
516
|
+
/** When the build finished, so the TTL measures how long the graph has been available. */
|
|
517
|
+
builtAtMs: number;
|
|
518
|
+
validUntilMs: number;
|
|
470
519
|
graph: ProjectedHarmonicGraph;
|
|
471
520
|
}
|
|
472
521
|
|
|
473
|
-
const graphCache = new Map<string, GraphCacheEntry>();
|
|
474
|
-
|
|
475
522
|
interface GraphBuild {
|
|
476
523
|
stamp: string;
|
|
477
524
|
promise: Promise<ProjectedHarmonicGraph>;
|
|
@@ -481,10 +528,41 @@ interface GraphBuild {
|
|
|
481
528
|
waiting: number;
|
|
482
529
|
}
|
|
483
530
|
|
|
531
|
+
const graphCache = new Map<string, GraphCacheEntry>();
|
|
484
532
|
/** One build per cache key at a time: every recall that lands on a miss waits for the same build. */
|
|
485
533
|
const graphBuilds = new Map<string, GraphBuild>();
|
|
486
|
-
/**
|
|
487
|
-
|
|
534
|
+
/** When a background refresh last failed, per key: stale reads retry at most once per TTL. Capped like the cache. */
|
|
535
|
+
const refreshFailedAtMs = new Map<string, number>();
|
|
536
|
+
|
|
537
|
+
function rememberRefreshFailure(key: string): void {
|
|
538
|
+
refreshFailedAtMs.delete(key);
|
|
539
|
+
refreshFailedAtMs.set(key, Date.now());
|
|
540
|
+
while (refreshFailedAtMs.size > GRAPH_CACHE_MAX_ENTRIES) {
|
|
541
|
+
const oldest = refreshFailedAtMs.keys().next().value;
|
|
542
|
+
if (oldest === undefined) break;
|
|
543
|
+
refreshFailedAtMs.delete(oldest);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* A source's `invalid_at` can pass while the build is still reading anchors, which would hand every caller
|
|
549
|
+
* that joined this build a graph that is already expired. Rebuild until the graph is unexpired at completion.
|
|
550
|
+
* Each round drops the sources that expired, so the set of future deadlines shrinks and the loop ends.
|
|
551
|
+
*/
|
|
552
|
+
async function buildUnexpiredHarmonicGraph(options: GraphReadOptions, abortSignal: AbortSignal): Promise<ExpiringHarmonicGraph> {
|
|
553
|
+
for (;;) {
|
|
554
|
+
const built = await buildExpiringHarmonicGraph({
|
|
555
|
+
memoryDir: options.memoryDir,
|
|
556
|
+
abstractionNodeStoreDir: options.abstractionNodeStoreDir,
|
|
557
|
+
anchorsEnabled: options.anchorsEnabled,
|
|
558
|
+
temporalExpiredInInjection: options.temporalExpiredInInjection,
|
|
559
|
+
abortSignal,
|
|
560
|
+
});
|
|
561
|
+
if (Date.now() < built.validUntilMs) return built;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** Counters for tests and diagnostics. `stale` hits also count as `hits`. */
|
|
565
|
+
export const harmonicGraphCacheStats = { hits: 0, misses: 0, stale: 0, shared: 0 };
|
|
488
566
|
|
|
489
567
|
/** Freeze records and their nested arrays/objects so a caller cannot corrupt the shared cache. */
|
|
490
568
|
function deepFreeze<T extends object>(value: T): T {
|
|
@@ -498,6 +576,16 @@ function deepFreeze<T extends object>(value: T): T {
|
|
|
498
576
|
export function clearHarmonicGraphCache(): void {
|
|
499
577
|
graphCache.clear();
|
|
500
578
|
graphBuilds.clear();
|
|
579
|
+
refreshFailedAtMs.clear();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Resolves once every build running now has settled, and after the failure bookkeeping of a background
|
|
584
|
+
* refresh (that handler was attached when the refresh started, so it runs first). For tests: a time-based
|
|
585
|
+
* wait races a slow build.
|
|
586
|
+
*/
|
|
587
|
+
export async function settleHarmonicGraphBuilds(): Promise<void> {
|
|
588
|
+
await Promise.allSettled([...graphBuilds.values()].map((build) => build.promise));
|
|
501
589
|
}
|
|
502
590
|
|
|
503
591
|
type GraphReadOptions = Parameters<typeof buildProjectedHarmonicGraph>[0];
|
|
@@ -508,21 +596,15 @@ type GraphReadOptions = Parameters<typeof buildProjectedHarmonicGraph>[0];
|
|
|
508
596
|
*/
|
|
509
597
|
function startGraphBuild(key: string, stamp: string, options: GraphReadOptions): GraphBuild {
|
|
510
598
|
harmonicGraphCacheStats.misses++;
|
|
511
|
-
const startedAtMs = Date.now();
|
|
512
599
|
const controller = new AbortController();
|
|
513
|
-
const promise: Promise<ProjectedHarmonicGraph> =
|
|
514
|
-
memoryDir: options.memoryDir,
|
|
515
|
-
abstractionNodeStoreDir: options.abstractionNodeStoreDir,
|
|
516
|
-
anchorsEnabled: options.anchorsEnabled,
|
|
517
|
-
temporalExpiredInInjection: options.temporalExpiredInInjection,
|
|
518
|
-
abortSignal: controller.signal,
|
|
519
|
-
})
|
|
600
|
+
const promise: Promise<ProjectedHarmonicGraph> = buildUnexpiredHarmonicGraph(options, controller.signal)
|
|
520
601
|
.then((built) => {
|
|
521
602
|
const graph = { nodes: deepFreeze(built.nodes), anchors: deepFreeze(built.anchors) };
|
|
522
603
|
// A cleared cache or a newer build for this key has replaced this one: do not store a stale graph.
|
|
523
604
|
if (graphBuilds.get(key)?.promise !== promise) return graph;
|
|
605
|
+
refreshFailedAtMs.delete(key);
|
|
524
606
|
graphCache.delete(key);
|
|
525
|
-
graphCache.set(key, { stamp,
|
|
607
|
+
graphCache.set(key, { stamp, builtAtMs: Date.now(), validUntilMs: built.validUntilMs, graph });
|
|
526
608
|
while (graphCache.size > GRAPH_CACHE_MAX_ENTRIES) {
|
|
527
609
|
const oldest = graphCache.keys().next().value;
|
|
528
610
|
if (oldest === undefined) break;
|
|
@@ -561,9 +643,12 @@ function joinGraphBuild(key: string, build: GraphBuild, signal: AbortSignal | un
|
|
|
561
643
|
* cold-write version sentinels (bumped by every memory write, in any process) and
|
|
562
644
|
* the graph version sentinel (bumped by node and anchor writes). A stamp read is
|
|
563
645
|
* four stats, not a scan of the memories. The TTL bounds the one input a sentinel
|
|
564
|
-
* cannot see, which is wall-clock validity expiry
|
|
565
|
-
*
|
|
566
|
-
*
|
|
646
|
+
* cannot see, which is wall-clock validity expiry; past it the stale graph is served
|
|
647
|
+
* while one refresh runs (up to GRAPH_CACHE_MAX_STALE_MS). A changed stamp always
|
|
648
|
+
* waits for a fresh build, because a stale graph could carry governed metadata of a
|
|
649
|
+
* memory that left the active set. Concurrent misses share one build. An explicit
|
|
650
|
+
* `nowMs` bypasses the cache so tests stay deterministic. The cached arrays are
|
|
651
|
+
* frozen: callers must not mutate them.
|
|
567
652
|
*/
|
|
568
653
|
export async function readProjectedHarmonicGraph(options: {
|
|
569
654
|
memoryDir: string;
|
|
@@ -587,12 +672,25 @@ export async function readProjectedHarmonicGraph(options: {
|
|
|
587
672
|
// The stamp is read BEFORE the build, so a write that lands mid-build leaves the
|
|
588
673
|
// entry stale and the next call rebuilds.
|
|
589
674
|
const stamp = readHarmonicGraphStamp(options.memoryDir, nodeStoreDir);
|
|
590
|
-
const
|
|
675
|
+
const nowMs = Date.now();
|
|
591
676
|
const hit = graphCache.get(key);
|
|
592
|
-
|
|
677
|
+
// A source's `invalid_at` passing changes the projection without bumping the stamp, so it ends the entry
|
|
678
|
+
// at the exact instant, however fresh the entry is.
|
|
679
|
+
if (hit && hit.stamp === stamp && nowMs < hit.validUntilMs && nowMs - hit.builtAtMs < GRAPH_CACHE_TTL_MS + GRAPH_CACHE_MAX_STALE_MS) {
|
|
593
680
|
harmonicGraphCacheStats.hits++;
|
|
594
681
|
graphCache.delete(key);
|
|
595
682
|
graphCache.set(key, hit);
|
|
683
|
+
if (nowMs - hit.builtAtMs >= GRAPH_CACHE_TTL_MS) {
|
|
684
|
+
harmonicGraphCacheStats.stale++;
|
|
685
|
+
const failedAtMs = refreshFailedAtMs.get(key);
|
|
686
|
+
const backingOff = failedAtMs !== undefined && nowMs - failedAtMs < GRAPH_CACHE_TTL_MS;
|
|
687
|
+
if (graphBuilds.get(key)?.stamp !== stamp && !backingOff) {
|
|
688
|
+
startGraphBuild(key, stamp, options).promise.catch((err) => {
|
|
689
|
+
rememberRefreshFailure(key);
|
|
690
|
+
log.debug(`harmonic graph refresh failed: ${err}`);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
596
694
|
return hit.graph;
|
|
597
695
|
}
|
|
598
696
|
const running = graphBuilds.get(key);
|