@remnic/core 9.3.765 → 9.3.766

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.
Files changed (99) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -1
  2. package/dist/access-admin-ops-surface.js +9 -9
  3. package/dist/access-boundary.d.ts +1 -1
  4. package/dist/access-boundary.js +10 -10
  5. package/dist/access-cli.js +18 -18
  6. package/dist/access-http.d.ts +13 -2
  7. package/dist/access-http.js +14 -14
  8. package/dist/access-identity-continuity-surface.js +9 -9
  9. package/dist/access-lcm-surface.d.ts +1 -1
  10. package/dist/access-lcm-surface.js +9 -9
  11. package/dist/access-mcp.d.ts +1 -1
  12. package/dist/access-mcp.js +13 -13
  13. package/dist/access-observe-write-surface.d.ts +1 -1
  14. package/dist/access-observe-write-surface.js +9 -9
  15. package/dist/access-operations-batch.js +11 -11
  16. package/dist/access-operations.d.ts +5 -5
  17. package/dist/access-operations.js +12 -12
  18. package/dist/access-recall-surface.d.ts +1 -1
  19. package/dist/access-recall-surface.js +9 -9
  20. package/dist/access-schema.d.ts +68 -68
  21. package/dist/{access-service-CAoJ4qN8.d.ts → access-service-CdW6B2qQ.d.ts} +3 -5
  22. package/dist/access-service.d.ts +1 -1
  23. package/dist/access-service.js +9 -9
  24. package/dist/access-surface-catalog.d.ts +1 -1
  25. package/dist/{chunk-NKP6II6G.js → chunk-4HYFKIGH.js} +6 -6
  26. package/dist/{chunk-CGLRKKMF.js → chunk-4JG55YV3.js} +3 -3
  27. package/dist/{chunk-BRYPUVJU.js → chunk-CKNAVW34.js} +2 -2
  28. package/dist/{chunk-WRFKZEO6.js → chunk-DMKMQTBH.js} +10 -2
  29. package/dist/chunk-DMKMQTBH.js.map +1 -0
  30. package/dist/{chunk-RJCQPRLB.js → chunk-GLERZDM7.js} +3 -3
  31. package/dist/{chunk-SKT66QI5.js → chunk-HBOPSFQQ.js} +6 -6
  32. package/dist/{chunk-TOF25WKK.js → chunk-IGVKWKR2.js} +2 -2
  33. package/dist/{chunk-422OELQ3.js → chunk-J3UJJZKI.js} +44 -7
  34. package/dist/chunk-J3UJJZKI.js.map +1 -0
  35. package/dist/{chunk-5CEJH5ZN.js → chunk-K2I3AWBR.js} +10 -2
  36. package/dist/chunk-K2I3AWBR.js.map +1 -0
  37. package/dist/{chunk-5JF4DIKZ.js → chunk-MSGPLITO.js} +2 -2
  38. package/dist/{chunk-K43PI6DQ.js → chunk-MYNRJGOY.js} +2 -2
  39. package/dist/{chunk-UQEE4H6Z.js → chunk-OAREUC7N.js} +13 -7
  40. package/dist/chunk-OAREUC7N.js.map +1 -0
  41. package/dist/{chunk-TUXRWLIQ.js → chunk-PA5ALD3S.js} +6 -6
  42. package/dist/{chunk-P6ZZ4GX4.js → chunk-TVLN5EZZ.js} +3 -3
  43. package/dist/{chunk-KXG7HHIQ.js → chunk-U5GFYMV7.js} +2 -2
  44. package/dist/{chunk-YNQ6DFSV.js → chunk-WU46YPY6.js} +8 -1
  45. package/dist/chunk-WU46YPY6.js.map +1 -0
  46. package/dist/{chunk-LQ6JI4VH.js → chunk-XG4NBBRW.js} +10 -2
  47. package/dist/chunk-XG4NBBRW.js.map +1 -0
  48. package/dist/{chunk-SANZHXY2.js → chunk-ZZMRXJFY.js} +29 -3
  49. package/dist/chunk-ZZMRXJFY.js.map +1 -0
  50. package/dist/{cli-WRmxvfc_.d.ts → cli-Dfnc66gs.d.ts} +1 -1
  51. package/dist/cli.d.ts +2 -2
  52. package/dist/cli.js +17 -17
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +18 -18
  55. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  56. package/dist/namespaces/migrate.js +9 -9
  57. package/dist/namespaces/search.js +8 -8
  58. package/dist/operator-toolkit.js +10 -10
  59. package/dist/orchestrator.js +18 -18
  60. package/dist/qmd.js +2 -2
  61. package/dist/schemas.d.ts +84 -84
  62. package/dist/search/factory.js +7 -7
  63. package/dist/search/index.js +7 -7
  64. package/dist/search/lancedb-backend.js +2 -2
  65. package/dist/search/meilisearch-backend.js +2 -2
  66. package/dist/search/orama-backend.js +2 -2
  67. package/dist/search/port.d.ts +4 -3
  68. package/dist/search/port.js +3 -1
  69. package/dist/search/remote-backend.js +2 -1
  70. package/dist/shared-context/manager.d.ts +8 -8
  71. package/dist/transfer/types.d.ts +66 -66
  72. package/package.json +2 -2
  73. package/src/access-http-cancellation.test.ts +240 -0
  74. package/src/access-http.ts +54 -2
  75. package/src/access-recall-surface.ts +1 -0
  76. package/src/access-service.ts +10 -10
  77. package/src/search/lancedb-backend.ts +8 -0
  78. package/src/search/meilisearch-backend.ts +8 -0
  79. package/src/search/orama-backend.ts +8 -0
  80. package/src/search/port.ts +15 -2
  81. package/src/search/remote-backend.ts +32 -3
  82. package/dist/chunk-422OELQ3.js.map +0 -1
  83. package/dist/chunk-5CEJH5ZN.js.map +0 -1
  84. package/dist/chunk-LQ6JI4VH.js.map +0 -1
  85. package/dist/chunk-SANZHXY2.js.map +0 -1
  86. package/dist/chunk-UQEE4H6Z.js.map +0 -1
  87. package/dist/chunk-WRFKZEO6.js.map +0 -1
  88. package/dist/chunk-YNQ6DFSV.js.map +0 -1
  89. /package/dist/{chunk-NKP6II6G.js.map → chunk-4HYFKIGH.js.map} +0 -0
  90. /package/dist/{chunk-CGLRKKMF.js.map → chunk-4JG55YV3.js.map} +0 -0
  91. /package/dist/{chunk-BRYPUVJU.js.map → chunk-CKNAVW34.js.map} +0 -0
  92. /package/dist/{chunk-RJCQPRLB.js.map → chunk-GLERZDM7.js.map} +0 -0
  93. /package/dist/{chunk-SKT66QI5.js.map → chunk-HBOPSFQQ.js.map} +0 -0
  94. /package/dist/{chunk-TOF25WKK.js.map → chunk-IGVKWKR2.js.map} +0 -0
  95. /package/dist/{chunk-5JF4DIKZ.js.map → chunk-MSGPLITO.js.map} +0 -0
  96. /package/dist/{chunk-K43PI6DQ.js.map → chunk-MYNRJGOY.js.map} +0 -0
  97. /package/dist/{chunk-TUXRWLIQ.js.map → chunk-PA5ALD3S.js.map} +0 -0
  98. /package/dist/{chunk-P6ZZ4GX4.js.map → chunk-TVLN5EZZ.js.map} +0 -0
  99. /package/dist/{chunk-KXG7HHIQ.js.map → chunk-U5GFYMV7.js.map} +0 -0
@@ -0,0 +1,240 @@
1
+ import assert from "node:assert/strict";
2
+ import { request as httpRequest } from "node:http";
3
+ import test from "node:test";
4
+
5
+ import { EngramAccessHttpServer } from "./access-http.js";
6
+ import { EngramAccessService, type EngramAccessRecallRequest } from "./access-service.js";
7
+
8
+ function deferred<T = void>(): {
9
+ promise: Promise<T>;
10
+ resolve: (value: T | PromiseLike<T>) => void;
11
+ reject: (reason?: unknown) => void;
12
+ } {
13
+ let resolve!: (value: T | PromiseLike<T>) => void;
14
+ let reject!: (reason?: unknown) => void;
15
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
16
+ resolve = resolvePromise;
17
+ reject = rejectPromise;
18
+ });
19
+ return { promise, resolve, reject };
20
+ }
21
+
22
+ function waitFor<T>(promise: Promise<T>, timeoutMs = 1_000): Promise<T> {
23
+ return Promise.race([
24
+ promise,
25
+ new Promise<never>((_resolve, reject) => {
26
+ const timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
27
+ timer.unref?.();
28
+ }),
29
+ ]);
30
+ }
31
+
32
+ test("HTTP recall aborts in-flight work when the client disconnects", async () => {
33
+ const recallStarted = deferred<void>();
34
+ const recallSettled = deferred<void>();
35
+ const forceRelease = deferred<void>();
36
+ let observedSignal: AbortSignal | undefined;
37
+ const service = {
38
+ recall: async (input: EngramAccessRecallRequest) => {
39
+ observedSignal = input.abortSignal;
40
+ recallStarted.resolve();
41
+ try {
42
+ await Promise.race([
43
+ forceRelease.promise,
44
+ new Promise<void>((_resolve, reject) => {
45
+ input.abortSignal?.addEventListener(
46
+ "abort",
47
+ () => reject(input.abortSignal?.reason),
48
+ { once: true },
49
+ );
50
+ }),
51
+ ]);
52
+ } finally {
53
+ recallSettled.resolve();
54
+ }
55
+ if (input.abortSignal?.aborted) {
56
+ throw input.abortSignal.reason;
57
+ }
58
+ return {} as never;
59
+ },
60
+ } as unknown as EngramAccessService;
61
+ const server = new EngramAccessHttpServer({
62
+ service,
63
+ port: 0,
64
+ authToken: "test-token",
65
+ adminConsoleEnabled: false,
66
+ });
67
+ const status = await server.start();
68
+
69
+ try {
70
+ const client = httpRequest({
71
+ host: "127.0.0.1",
72
+ port: status.port,
73
+ path: "/engram/v1/recall",
74
+ method: "POST",
75
+ headers: {
76
+ authorization: "Bearer test-token",
77
+ "content-type": "application/json",
78
+ },
79
+ });
80
+ client.on("error", () => {});
81
+ client.end(JSON.stringify({ query: "slow recall" }));
82
+
83
+ await waitFor(recallStarted.promise);
84
+ assert.ok(observedSignal, "the HTTP request signal must reach the recall service");
85
+ client.destroy();
86
+
87
+ await waitFor(recallSettled.promise);
88
+ assert.equal(observedSignal.aborted, true);
89
+ } finally {
90
+ forceRelease.resolve();
91
+ await server.stop();
92
+ }
93
+ });
94
+
95
+ test("a disconnected recall queued on the principal budget lock never starts", async () => {
96
+ const firstStarted = deferred<void>();
97
+ const releaseFirst = deferred<void>();
98
+ let secondStarted = false;
99
+ const service = Object.create(EngramAccessService.prototype) as EngramAccessService;
100
+ const lockHost = service as unknown as {
101
+ budgetLocks: Map<string, Promise<void>>;
102
+ withBudgetLock<T>(
103
+ principal: string,
104
+ abortSignal: AbortSignal | undefined,
105
+ operation: () => Promise<T>,
106
+ ): Promise<T>;
107
+ };
108
+ lockHost.budgetLocks = new Map();
109
+
110
+ const first = lockHost.withBudgetLock("principal", undefined, async () => {
111
+ firstStarted.resolve();
112
+ await releaseFirst.promise;
113
+ });
114
+ await firstStarted.promise;
115
+
116
+ const controller = new AbortController();
117
+ const second = lockHost.withBudgetLock("principal", controller.signal, async () => {
118
+ secondStarted = true;
119
+ });
120
+ controller.abort();
121
+ releaseFirst.resolve();
122
+
123
+ await first;
124
+ await assert.rejects(waitFor(second), (error: Error) => error.name === "AbortError");
125
+ assert.equal(secondStarted, false);
126
+ });
127
+
128
+ test("an aborted queued recall does not poison the principal budget lock", async () => {
129
+ const firstStarted = deferred<void>();
130
+ const releaseFirst = deferred<void>();
131
+ let secondStarted = false;
132
+ let thirdStarted = false;
133
+ const service = Object.create(EngramAccessService.prototype) as EngramAccessService;
134
+ const lockHost = service as unknown as {
135
+ budgetLocks: Map<string, Promise<void>>;
136
+ withBudgetLock<T>(
137
+ principal: string,
138
+ abortSignal: AbortSignal | undefined,
139
+ operation: () => Promise<T>,
140
+ ): Promise<T>;
141
+ };
142
+ lockHost.budgetLocks = new Map();
143
+
144
+ const first = lockHost.withBudgetLock("principal", undefined, async () => {
145
+ firstStarted.resolve();
146
+ await releaseFirst.promise;
147
+ });
148
+ await firstStarted.promise;
149
+
150
+ const controller = new AbortController();
151
+ const second = lockHost.withBudgetLock("principal", controller.signal, async () => {
152
+ secondStarted = true;
153
+ });
154
+ const third = lockHost.withBudgetLock("principal", undefined, async () => {
155
+ thirdStarted = true;
156
+ });
157
+ controller.abort();
158
+ releaseFirst.resolve();
159
+
160
+ await first;
161
+ await assert.rejects(waitFor(second), (error: Error) => error.name === "AbortError");
162
+ await waitFor(third);
163
+ assert.equal(secondStarted, false);
164
+ assert.equal(thirdStarted, true);
165
+ });
166
+
167
+ function makeRecallServiceProbe(): {
168
+ service: EngramAccessService;
169
+ setExecuteRecall: (execute: () => Promise<void>) => void;
170
+ requestFingerprint: () => unknown;
171
+ stored: () => boolean;
172
+ } {
173
+ const service = Object.create(EngramAccessService.prototype) as EngramAccessService;
174
+ let executeRecall = async () => {};
175
+ let capturedFingerprint: unknown;
176
+ let didStore = false;
177
+ const host = service as unknown as {
178
+ budgetLocks: Map<string, Promise<void>>;
179
+ orchestrator: { config: Record<string, unknown> };
180
+ resolveRequestPrincipal: () => string;
181
+ executeRecall: () => Promise<{ response: Record<string, never>; budgetRecordPrincipal: null }>;
182
+ handleIdempotentRead: (options: {
183
+ requestFingerprint: unknown;
184
+ execute: () => Promise<Record<string, never>>;
185
+ }) => Promise<Record<string, never>>;
186
+ };
187
+ host.budgetLocks = new Map();
188
+ host.orchestrator = { config: {} };
189
+ host.resolveRequestPrincipal = () => "principal";
190
+ host.executeRecall = async () => {
191
+ await executeRecall();
192
+ return { response: {}, budgetRecordPrincipal: null };
193
+ };
194
+ host.handleIdempotentRead = async (options) => {
195
+ capturedFingerprint = options.requestFingerprint;
196
+ const response = await options.execute();
197
+ didStore = true;
198
+ return response;
199
+ };
200
+ return {
201
+ service,
202
+ setExecuteRecall: (execute) => {
203
+ executeRecall = execute;
204
+ },
205
+ requestFingerprint: () => capturedFingerprint,
206
+ stored: () => didStore,
207
+ };
208
+ }
209
+
210
+ test("recall excludes its abort signal from the idempotency fingerprint", async () => {
211
+ const probe = makeRecallServiceProbe();
212
+ await probe.service.recall({
213
+ query: "same payload",
214
+ idempotencyKey: "same-key",
215
+ abortSignal: new AbortController().signal,
216
+ });
217
+
218
+ assert.equal(
219
+ Object.hasOwn(probe.requestFingerprint() as object, "abortSignal"),
220
+ false,
221
+ );
222
+ });
223
+
224
+ test("recall does not store a result when the pipeline consumes an abort", async () => {
225
+ const probe = makeRecallServiceProbe();
226
+ const controller = new AbortController();
227
+ probe.setExecuteRecall(async () => {
228
+ controller.abort();
229
+ });
230
+
231
+ await assert.rejects(
232
+ probe.service.recall({
233
+ query: "cancelled",
234
+ idempotencyKey: "cancelled-key",
235
+ abortSignal: controller.signal,
236
+ }),
237
+ (error: Error) => error.name === "AbortError",
238
+ );
239
+ assert.equal(probe.stored(), false);
240
+ });
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath, URL } from "node:url";
8
8
  import { gunzipSync } from "node:zlib";
9
9
  import { log } from "./logger.js";
10
+ import { abortError, isAbortError } from "./abort-error.js";
10
11
  import { EngramAccessInputError, type EngramAccessService, type EngramAccessMemoryResponse, type EngramAccessWriteResponse } from "./access-service.js";
11
12
  import { CorrectionContractError } from "./correction/correction-contract.js";
12
13
  import { WearablesInputError } from "./wearables/errors.js";
@@ -38,6 +39,12 @@ import { handleChatMessage, handleChatEventsSSE } from "./chat/chat-http.js";
38
39
  // server lifecycle (issue #1685 item 1 / #1687 Thread 21).
39
40
  import { cleanupExpiredChatSessions } from "./chat/chat-session.js";
40
41
 
42
+ export interface AccessHttpReadinessState {
43
+ ready: boolean;
44
+ warmupAttempts: number;
45
+ lastError?: string | null;
46
+ }
47
+
41
48
  export interface EngramAccessHttpServerOptions {
42
49
  service: EngramAccessService;
43
50
  host?: string;
@@ -66,6 +73,11 @@ export interface EngramAccessHttpServerOptions {
66
73
  emitLegacyTools?: boolean;
67
74
  /** Optional authenticated admin dashboard/config controls supplied by the host server. */
68
75
  adminControls?: RemnicAdminControls;
76
+ /**
77
+ * Standalone readiness state. Defaults to ready so embedded hosts keep their
78
+ * existing health behavior.
79
+ */
80
+ readiness?: () => AccessHttpReadinessState;
69
81
  }
70
82
 
71
83
  export interface EngramAccessHttpServerStatus {
@@ -279,6 +291,7 @@ export class EngramAccessHttpServer {
279
291
  private readonly adminControls?: RemnicAdminControls;
280
292
  private readonly trustPrincipalHeader: boolean;
281
293
  private readonly adapterRegistry: AdapterRegistry | null;
294
+ private readonly readiness: () => AccessHttpReadinessState;
282
295
  private readonly writeRequestTimestamps: number[] = [];
283
296
  private readonly mcpServer: EngramMcpServer;
284
297
  private server: Server | null = null;
@@ -319,6 +332,7 @@ export class EngramAccessHttpServer {
319
332
  this.adminConsolePrefillToken = options.adminConsolePrefillToken === true ? this.authToken : undefined;
320
333
  this.adminControls = options.adminControls;
321
334
  this.trustPrincipalHeader = options.trustPrincipalHeader === true;
335
+ this.readiness = options.readiness ?? (() => ({ ready: true, warmupAttempts: 0 }));
322
336
  this.adapterRegistry = options.enableAdapters !== false
323
337
  ? (options.adapterRegistry ?? new AdapterRegistry())
324
338
  : null;
@@ -344,8 +358,22 @@ export class EngramAccessHttpServer {
344
358
 
345
359
  const server = createServer((req, res) => {
346
360
  const correlationId = randomUUID();
361
+ const abortController = new AbortController();
362
+ const abortDisconnectedRequest = () => {
363
+ if (!res.writableFinished && !abortController.signal.aborted) {
364
+ abortController.abort(abortError("HTTP client disconnected"));
365
+ }
366
+ };
367
+ req.once("aborted", abortDisconnectedRequest);
368
+ res.once("close", abortDisconnectedRequest);
347
369
  correlationIdStore.run(correlationId, () => {
348
- void this.handle(req, res, correlationId).catch((err) => {
370
+ void this.handle(req, res, correlationId, abortController.signal).catch((err) => {
371
+ if (isAbortError(err)) {
372
+ if (!res.destroyed && !res.writableEnded) {
373
+ res.end();
374
+ }
375
+ return;
376
+ }
349
377
  log.debug(`engram access HTTP request failed [${correlationId}]: ${err}`);
350
378
  if (err instanceof HttpError) {
351
379
  const payload: Record<string, unknown> = { error: err.message, code: err.code };
@@ -370,6 +398,9 @@ export class EngramAccessHttpServer {
370
398
  err,
371
399
  );
372
400
  this.respondJson(res, 500, { error: "internal_error", code: "internal_error" });
401
+ }).finally(() => {
402
+ req.off("aborted", abortDisconnectedRequest);
403
+ res.off("close", abortDisconnectedRequest);
373
404
  });
374
405
  });
375
406
  });
@@ -583,7 +614,12 @@ export class EngramAccessHttpServer {
583
614
  return queryDisclosure;
584
615
  }
585
616
 
586
- private async handle(req: IncomingMessage, res: ServerResponse, correlationId: string): Promise<void> {
617
+ private async handle(
618
+ req: IncomingMessage,
619
+ res: ServerResponse,
620
+ correlationId: string,
621
+ abortSignal: AbortSignal,
622
+ ): Promise<void> {
587
623
  const parsed = new URL(req.url ?? "/", `http://${hostToUrlAuthority(this.host)}`);
588
624
  const pathname = parsed.pathname;
589
625
 
@@ -591,6 +627,21 @@ export class EngramAccessHttpServer {
591
627
  return;
592
628
  }
593
629
 
630
+ if (req.method === "GET" && pathname === "/engram/v1/health") {
631
+ const readiness = this.readiness();
632
+ if (!readiness.ready) {
633
+ this.respondJson(res, 503, {
634
+ ok: false,
635
+ ready: false,
636
+ warmupAttempts: readiness.warmupAttempts,
637
+ lastError: readiness.lastError ?? null,
638
+ code: "not_ready",
639
+ });
640
+ return;
641
+ }
642
+
643
+ }
644
+
594
645
  if (!this.isAuthorized(req, pathname)) {
595
646
  const body = JSON.stringify({ error: "unauthorized", code: "unauthorized" });
596
647
  res.writeHead(401, {
@@ -767,6 +818,7 @@ export class EngramAccessHttpServer {
767
818
  ...(tags !== undefined ? { tags } : {}),
768
819
  ...(tagMatch !== undefined ? { tagMatch } : {}),
769
820
  ...(includeLowConfidence ? { includeLowConfidence: true } : {}),
821
+ abortSignal,
770
822
  });
771
823
  this.respondJson(res, 200, response);
772
824
  return;
@@ -823,6 +823,7 @@ export class AccessRecallSurface {
823
823
  ...(authenticatedPrincipal ? { principalOverride: authenticatedPrincipal } : {}),
824
824
  ...(asOf !== undefined ? { asOf } : {}),
825
825
  ...(request.includeLowConfidence === true ? { includeLowConfidence: true } : {}),
826
+ ...(request.abortSignal ? { abortSignal: request.abortSignal } : {}),
826
827
  };
827
828
  const startedAt = Date.now();
828
829
  const context = await this.deps.orchestrator.recall(query, request.sessionKey, recallOptions);
@@ -5,6 +5,7 @@ import { readdirSync, unlinkSync } from "node:fs";
5
5
  import { createHash } from "node:crypto";
6
6
  import { ZodError } from "zod";
7
7
  import { AccessIdempotencyStore, hashAccessIdempotencyPayload } from "./access-idempotency.js";
8
+ import { throwIfAborted } from "./abort-error.js";
8
9
  import { resolveNamespaceCapabilities,
9
10
  resolveMemoryLifecycleCapabilities,
10
11
  resolveQmdCapabilities,
@@ -401,12 +402,10 @@ export interface EngramAccessRecallRequest {
401
402
  topK?: number;
402
403
  mode?: RecallPlanMode | "auto";
403
404
  includeDebug?: boolean;
405
+ abortSignal?: AbortSignal;
404
406
  /**
405
- * Recall disclosure depth (issue #677). Selects how much content each
406
- * result returns: `"chunk"` (default), `"section"`, or `"raw"`. Omitting
407
- * this field is equivalent to passing `"chunk"` and preserves pre-#677
408
- * behavior. Surfaces (CLI / HTTP / MCP) and per-level token telemetry
409
- * are wired in subsequent PRs of #677.
407
+ * Recall disclosure depth. Omitting it preserves the `"chunk"` default.
408
+ * Other accepted values are `"section"` and `"raw"`.
410
409
  */
411
410
  disclosure?: RecallDisclosure;
412
411
  /**
@@ -2421,7 +2420,7 @@ export class EngramAccessService {
2421
2420
  }
2422
2421
  }
2423
2422
 
2424
- private async withBudgetLock<T>(principal: string, fn: () => Promise<T>): Promise<T> {
2423
+ private async withBudgetLock<T>(principal: string, abortSignal: AbortSignal | undefined, fn: () => Promise<T>): Promise<T> {
2425
2424
  const key = principal || "__anonymous__";
2426
2425
  const previous = this.budgetLocks.get(key) ?? Promise.resolve();
2427
2426
  let release!: () => void;
@@ -2430,9 +2429,9 @@ export class EngramAccessService {
2430
2429
  });
2431
2430
  const queued = previous.then(() => current, () => current);
2432
2431
  this.budgetLocks.set(key, queued);
2433
-
2434
2432
  await previous.catch(() => {});
2435
2433
  try {
2434
+ throwIfAborted(abortSignal);
2436
2435
  return await fn();
2437
2436
  } finally {
2438
2437
  release();
@@ -2736,6 +2735,7 @@ export class EngramAccessService {
2736
2735
  throw new EngramAccessInputError("query is required");
2737
2736
  }
2738
2737
  const normalizedRequest = { ...request, query };
2738
+ const { abortSignal: _abortSignal, ...requestFingerprint } = normalizedRequest;
2739
2739
  const authenticatedPrincipal = request.authenticatedPrincipal?.trim();
2740
2740
  const principal = this.resolveRequestPrincipal(request.sessionKey, authenticatedPrincipal);
2741
2741
  if (resolveNamespaceCapabilities(this.orchestrator.config).namespaces && !principal) {
@@ -2743,15 +2743,15 @@ export class EngramAccessService {
2743
2743
  "authentication required: namespaces are enabled and no principal was supplied",
2744
2744
  );
2745
2745
  }
2746
- const budgetLockPrincipal = principal ?? "default";
2747
- return this.withBudgetLock(budgetLockPrincipal, async () => {
2746
+ return this.withBudgetLock(principal ?? "default", request.abortSignal, async () => {
2748
2747
  let budgetRecordPrincipal: string | null = null;
2749
2748
  const response = await this.handleIdempotentRead({
2750
2749
  operation: "recall",
2751
2750
  idempotencyKey: request.idempotencyKey,
2752
- requestFingerprint: normalizedRequest,
2751
+ requestFingerprint,
2753
2752
  execute: async () => {
2754
2753
  const result = await this.executeRecall(normalizedRequest);
2754
+ throwIfAborted(request.abortSignal);
2755
2755
  budgetRecordPrincipal = result.budgetRecordPrincipal;
2756
2756
  return result.response;
2757
2757
  },
@@ -1,5 +1,6 @@
1
1
  import { log } from "../logger.js";
2
2
  import {
3
+ reportSearchDegradation,
3
4
  resolveEnsureCollectionArgs,
4
5
  type SearchBackend,
5
6
  type SearchExecutionOptions,
@@ -491,6 +492,13 @@ export class LanceDbBackend implements SearchBackend {
491
492
  }
492
493
  } catch (err) {
493
494
  log.debug(`LanceDbBackend search (${mode}) failed: ${err}`);
495
+ if (!isSearchAborted(execution)) {
496
+ reportSearchDegradation(execution, {
497
+ backend: "lancedb",
498
+ code: "backend_error",
499
+ detail: err instanceof Error ? err.name : typeof err,
500
+ });
501
+ }
494
502
  return [];
495
503
  }
496
504
  }
@@ -1,5 +1,6 @@
1
1
  import { log } from "../logger.js";
2
2
  import {
3
+ reportSearchDegradation,
3
4
  resolveEnsureCollectionArgs,
4
5
  type SearchBackend,
5
6
  type SearchExecutionOptions,
@@ -282,6 +283,13 @@ export class MeilisearchBackend implements SearchBackend {
282
283
  } catch (err) {
283
284
  log.debug(`MeilisearchBackend search failed: ${err}`);
284
285
  if (rethrow) throw err;
286
+ if (!isSearchAborted(execution)) {
287
+ reportSearchDegradation(execution, {
288
+ backend: "meilisearch",
289
+ code: "backend_error",
290
+ detail: err instanceof Error ? err.name : typeof err,
291
+ });
292
+ }
285
293
  return [];
286
294
  }
287
295
  }
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { log } from "../logger.js";
4
4
  import {
5
+ reportSearchDegradation,
5
6
  resolveEnsureCollectionArgs,
6
7
  type SearchBackend,
7
8
  type SearchExecutionOptions,
@@ -581,6 +582,13 @@ export class OramaBackend implements SearchBackend {
581
582
  }));
582
583
  } catch (err) {
583
584
  log.debug(`OramaBackend search (${mode}) failed: ${err}`);
585
+ if (!isSearchAborted(execution)) {
586
+ reportSearchDegradation(execution, {
587
+ backend: "orama",
588
+ code: "backend_error",
589
+ detail: err instanceof Error ? err.name : typeof err,
590
+ });
591
+ }
584
592
  return [];
585
593
  }
586
594
  }
@@ -19,13 +19,15 @@ export interface SearchQueryOptions {
19
19
  * rule 34).
20
20
  */
21
21
  export interface SearchDegradation {
22
- backend: "qmd";
22
+ backend: "qmd" | "remote" | "meilisearch" | "orama" | "lancedb";
23
23
  code:
24
24
  | "backend_unavailable"
25
+ | "backend_error"
25
26
  | "daemon_timeout"
26
27
  | "daemon_loading"
27
28
  | "subprocess_error"
28
- | "deadline_exceeded";
29
+ | "deadline_exceeded"
30
+ | "remote_error";
29
31
  detail?: string;
30
32
  }
31
33
 
@@ -41,6 +43,17 @@ export interface SearchExecutionOptions {
41
43
  onDegradation?: (degradation: SearchDegradation) => void;
42
44
  }
43
45
 
46
+ export function reportSearchDegradation(
47
+ execution: SearchExecutionOptions | undefined,
48
+ degradation: SearchDegradation,
49
+ ): void {
50
+ try {
51
+ execution?.onDegradation?.(degradation);
52
+ } catch {
53
+ // Observability must never break search.
54
+ }
55
+ }
56
+
44
57
  export function resolveEnsureCollectionArgs(
45
58
  collectionOrExecution?: string | SearchExecutionOptions,
46
59
  execution?: SearchExecutionOptions,
@@ -1,5 +1,11 @@
1
1
  import { log } from "../logger.js";
2
- import type { SearchBackend, SearchExecutionOptions, SearchQueryOptions, SearchResult } from "./port.js";
2
+ import {
3
+ reportSearchDegradation,
4
+ type SearchBackend,
5
+ type SearchExecutionOptions,
6
+ type SearchQueryOptions,
7
+ type SearchResult,
8
+ } from "./port.js";
3
9
 
4
10
  export interface RemoteSearchBackendOptions {
5
11
  baseUrl: string;
@@ -103,7 +109,13 @@ export class RemoteSearchBackend implements SearchBackend {
103
109
  body: Record<string, unknown>,
104
110
  execution?: SearchExecutionOptions,
105
111
  ): Promise<SearchResult[]> {
106
- if (!this.available) return [];
112
+ if (!this.available) {
113
+ reportSearchDegradation(execution, {
114
+ backend: "remote",
115
+ code: "backend_unavailable",
116
+ });
117
+ return [];
118
+ }
107
119
  try {
108
120
  const res = await fetch(`${this.baseUrl}${endpoint}`, {
109
121
  method: "POST",
@@ -115,13 +127,30 @@ export class RemoteSearchBackend implements SearchBackend {
115
127
  });
116
128
  if (!res.ok) {
117
129
  log.debug(`RemoteSearchBackend ${endpoint} returned ${res.status}`);
130
+ reportSearchDegradation(execution, {
131
+ backend: "remote",
132
+ code: "remote_error",
133
+ detail: `HTTP ${res.status}`,
134
+ });
118
135
  return [];
119
136
  }
120
137
  const data = await res.json();
121
- if (!Array.isArray(data)) return [];
138
+ if (!Array.isArray(data)) {
139
+ reportSearchDegradation(execution, {
140
+ backend: "remote",
141
+ code: "remote_error",
142
+ detail: "invalid response body",
143
+ });
144
+ return [];
145
+ }
122
146
  return data as SearchResult[];
123
147
  } catch (err) {
124
148
  log.debug(`RemoteSearchBackend ${endpoint} failed: ${err}`);
149
+ reportSearchDegradation(execution, {
150
+ backend: "remote",
151
+ code: "remote_error",
152
+ detail: err instanceof Error ? err.name : typeof err,
153
+ });
125
154
  return [];
126
155
  }
127
156
  }