@vellumai/assistant 0.10.4-dev.202607010743.70cd9f0 → 0.10.4-dev.202607011113.fffc936
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/openapi.yaml +2 -1
- package/package.json +1 -1
- package/src/__tests__/context-search-conversations-source.test.ts +38 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/daemon/lifecycle.ts +2 -24
- package/src/persistence/__tests__/conversation-queries-search.test.ts +42 -0
- package/src/persistence/checkpoints.ts +45 -0
- package/src/persistence/conversation-queries.ts +35 -4
- package/src/persistence/embeddings/embedding-runtime-manager.ts +31 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +12 -3
- package/src/plugins/defaults/memory/job-handlers/__tests__/backfill-lexical-index.test.ts +217 -4
- package/src/plugins/defaults/memory/job-handlers/backfill-lexical-index.ts +85 -1
- package/src/plugins/defaults/memory/routes/messages-lexical-routes.ts +14 -3
- package/src/plugins/defaults/memory/startup.ts +9 -0
- package/src/runtime/migrations/origin-mode.ts +6 -6
- package/src/runtime/__tests__/runtime-mode.test.ts +0 -62
- package/src/runtime/runtime-mode.ts +0 -38
package/openapi.yaml
CHANGED
|
@@ -18822,7 +18822,8 @@ paths:
|
|
|
18822
18822
|
description:
|
|
18823
18823
|
"Enqueues the cursor-checkpointed backfill job that indexes existing messages into the Qdrant lexical
|
|
18824
18824
|
(BM25-style) collection in batches. Resumable and idempotent — re-running continues from the last checkpoint.
|
|
18825
|
-
Pass `force: true` to reset the cursor and re-index from the beginning.
|
|
18825
|
+
Pass `force: true` to reset the cursor and re-index from the beginning. The same backfill is also auto-enqueued
|
|
18826
|
+
once per instance on upgrade at assistant startup."
|
|
18826
18827
|
tags:
|
|
18827
18828
|
- memory
|
|
18828
18829
|
requestBody:
|
package/package.json
CHANGED
|
@@ -52,6 +52,11 @@ mock.module(
|
|
|
52
52
|
}),
|
|
53
53
|
);
|
|
54
54
|
|
|
55
|
+
import {
|
|
56
|
+
deleteMemoryCheckpoint,
|
|
57
|
+
LEXICAL_BACKFILL_COMPLETE_KEY,
|
|
58
|
+
setMemoryCheckpoint,
|
|
59
|
+
} from "../persistence/checkpoints.js";
|
|
55
60
|
import { getDb } from "../persistence/db-connection.js";
|
|
56
61
|
import { initializeDb } from "../persistence/db-init.js";
|
|
57
62
|
import { rawRun } from "../persistence/raw-query.js";
|
|
@@ -402,12 +407,16 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
402
407
|
getDb().run("DELETE FROM conversations");
|
|
403
408
|
lexicalCalls = [];
|
|
404
409
|
suppressIndexing = false;
|
|
410
|
+
// These tests exercise the populated-index (post-backfill) qdrant path, so
|
|
411
|
+
// mark the backfill complete. The completion gate is covered by its own test.
|
|
412
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
405
413
|
setOverridesForTesting({ "messages-search-backend": true });
|
|
406
414
|
});
|
|
407
415
|
|
|
408
416
|
afterEach(() => {
|
|
409
417
|
setOverridesForTesting({});
|
|
410
418
|
suppressIndexing = false;
|
|
419
|
+
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
411
420
|
lexicalMockImpl = () => {
|
|
412
421
|
throw new Error(
|
|
413
422
|
"searchMessageIdsLexical mock not configured for this test",
|
|
@@ -442,6 +451,35 @@ describe("searchConversationSource with the qdrant backend", () => {
|
|
|
442
451
|
]);
|
|
443
452
|
});
|
|
444
453
|
|
|
454
|
+
test("falls back to FTS until the backfill completion checkpoint is set, even with the qdrant flag on", async () => {
|
|
455
|
+
const match = await seedConversation({
|
|
456
|
+
title: "Pre-backfill notes",
|
|
457
|
+
content: "The prebackfilltoken decision is recorded here.",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// On an upgraded instance the historical messages are still being indexed
|
|
461
|
+
// by the background backfill, so the lexical collection is only partially
|
|
462
|
+
// populated. Until the completion checkpoint is set, the source must use
|
|
463
|
+
// the FTS path and never query Qdrant.
|
|
464
|
+
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
465
|
+
lexicalMockImpl = async () => {
|
|
466
|
+
throw new Error(
|
|
467
|
+
"searchMessageIdsLexical must not run before the backfill completes",
|
|
468
|
+
);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const result = await searchConversationSource(
|
|
472
|
+
"prebackfilltoken",
|
|
473
|
+
makeContext(),
|
|
474
|
+
5,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
expect(lexicalCalls).toHaveLength(0);
|
|
478
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
479
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
480
|
+
]);
|
|
481
|
+
});
|
|
482
|
+
|
|
445
483
|
test("routes short/punctuation-only queries to the exact LIKE path, not Qdrant", async () => {
|
|
446
484
|
const match = await seedConversation({
|
|
447
485
|
title: "C++ notes",
|
|
@@ -82,6 +82,7 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
82
82
|
"../../../../../config/types.js",
|
|
83
83
|
"../../../../../messaging/providers/slack/message-metadata.js",
|
|
84
84
|
"../../../../../persistence/auto-analysis-constants.js",
|
|
85
|
+
"../../../../../persistence/checkpoints.js",
|
|
85
86
|
"../../../../../persistence/conversation-queries.js",
|
|
86
87
|
"../../../../../persistence/conversation-search-lexical.js",
|
|
87
88
|
"../../../../../persistence/db-connection.js",
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "../persistence/conversation-crud.js";
|
|
32
32
|
import { getDb } from "../persistence/db-connection.js";
|
|
33
33
|
import { initializeDb } from "../persistence/db-init.js";
|
|
34
|
+
import { startEmbeddingRuntimeManager } from "../persistence/embeddings/embedding-runtime-manager.js";
|
|
34
35
|
import { startConsentRefresh } from "../platform/consent-cache.js";
|
|
35
36
|
import { syncWorkspaceIdentityToPlatform } from "../platform/sync-identity.js";
|
|
36
37
|
import { runMemoryStartup } from "../plugins/defaults/memory/startup.js";
|
|
@@ -879,30 +880,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
879
880
|
log.warn({ err }, "Assistant symlink installation failed — continuing");
|
|
880
881
|
}
|
|
881
882
|
|
|
882
|
-
|
|
883
|
-
// If download fails, local embeddings gracefully fall back to cloud backends.
|
|
884
|
-
void (async () => {
|
|
885
|
-
try {
|
|
886
|
-
const { EmbeddingRuntimeManager } =
|
|
887
|
-
await import("../persistence/embeddings/embedding-runtime-manager.js");
|
|
888
|
-
const runtimeManager = new EmbeddingRuntimeManager();
|
|
889
|
-
if (!runtimeManager.isReady()) {
|
|
890
|
-
log.info("Downloading embedding runtime in background...");
|
|
891
|
-
await runtimeManager.ensureInstalled();
|
|
892
|
-
// Reset the sticky local-backend failure flag so auto mode retries
|
|
893
|
-
// local embeddings without evicting a worker that may already be live.
|
|
894
|
-
const { resetLocalEmbeddingFailureState } =
|
|
895
|
-
await import("../persistence/embeddings/embedding-backend.js");
|
|
896
|
-
resetLocalEmbeddingFailureState();
|
|
897
|
-
log.info("Embedding runtime download complete");
|
|
898
|
-
}
|
|
899
|
-
} catch (err) {
|
|
900
|
-
log.warn(
|
|
901
|
-
{ err },
|
|
902
|
-
"Embedding runtime download failed — local embeddings will use cloud fallback",
|
|
903
|
-
);
|
|
904
|
-
}
|
|
905
|
-
})();
|
|
883
|
+
startEmbeddingRuntimeManager();
|
|
906
884
|
|
|
907
885
|
startWorkspaceHeartbeatService();
|
|
908
886
|
|
|
@@ -53,6 +53,11 @@ mock.module("../jobs-store.js", () => ({
|
|
|
53
53
|
}));
|
|
54
54
|
|
|
55
55
|
import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
|
|
56
|
+
import {
|
|
57
|
+
deleteMemoryCheckpoint,
|
|
58
|
+
LEXICAL_BACKFILL_COMPLETE_KEY,
|
|
59
|
+
setMemoryCheckpoint,
|
|
60
|
+
} from "../checkpoints.js";
|
|
56
61
|
import { createConversation } from "../conversation-crud.js";
|
|
57
62
|
import { searchConversations } from "../conversation-queries.js";
|
|
58
63
|
import { getDb } from "../db-connection.js";
|
|
@@ -61,6 +66,17 @@ import { rawRun } from "../raw-query.js";
|
|
|
61
66
|
|
|
62
67
|
await initializeDb();
|
|
63
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Mark the messages lexical-index backfill complete. The qdrant read path is
|
|
71
|
+
* gated on this: an upgraded instance whose backfill has not finished stays on
|
|
72
|
+
* fts5 so it never reads from a partially-populated `messages_lexical`. The
|
|
73
|
+
* qdrant-path tests below index candidates as if the backfill has drained, so
|
|
74
|
+
* they set this marker; the gate itself is covered by its own test.
|
|
75
|
+
*/
|
|
76
|
+
function markBackfillComplete(): void {
|
|
77
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
function resetTables(): void {
|
|
65
81
|
const db = getDb();
|
|
66
82
|
db.run(`DELETE FROM messages`);
|
|
@@ -118,6 +134,8 @@ describe("searchConversations · qdrant backend", () => {
|
|
|
118
134
|
beforeEach(() => {
|
|
119
135
|
resetTables();
|
|
120
136
|
memoryEnabled = true;
|
|
137
|
+
// These tests exercise the populated-index (post-backfill) qdrant path.
|
|
138
|
+
markBackfillComplete();
|
|
121
139
|
searchMessageIdsLexicalMock.mockClear();
|
|
122
140
|
searchMessageIdsLexicalMock.mockImplementation(async () => []);
|
|
123
141
|
setOverridesForTesting({ "messages-search-backend": "qdrant" });
|
|
@@ -313,6 +331,27 @@ describe("searchConversations · qdrant backend", () => {
|
|
|
313
331
|
]);
|
|
314
332
|
});
|
|
315
333
|
|
|
334
|
+
test("uses the fts5 path (not qdrant) until the backfill completion checkpoint is set", async () => {
|
|
335
|
+
// On an upgraded instance the historical messages are indexed by a
|
|
336
|
+
// background backfill. Until it drains, the lexical collection is only
|
|
337
|
+
// partially populated, so a qdrant read would silently miss older content.
|
|
338
|
+
// With the flag on but the completion checkpoint UNSET, the read must stay
|
|
339
|
+
// on the always-populated fts5 path and never query the lexical index.
|
|
340
|
+
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
341
|
+
const conv = createConversation("Notes");
|
|
342
|
+
insertMessage("m-1", conv.id, "the flux capacitor needs recalibration");
|
|
343
|
+
lexicalReturns(["m-1"]);
|
|
344
|
+
|
|
345
|
+
const results = await searchConversations("flux capacitor");
|
|
346
|
+
|
|
347
|
+
expect(searchMessageIdsLexicalMock).not.toHaveBeenCalled();
|
|
348
|
+
// The fts5 content match still finds the conversation and its message.
|
|
349
|
+
expect(results.map((r) => r.conversationId)).toEqual([conv.id]);
|
|
350
|
+
expect(results[0]!.matchingMessages.map((m) => m.messageId)).toEqual([
|
|
351
|
+
"m-1",
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
|
|
316
355
|
test("non-tokenizable queries use the LIKE fallback without hitting the index", async () => {
|
|
317
356
|
// Single-char / non-ASCII queries produce no tokens, so neither FTS nor the
|
|
318
357
|
// sparse encoder yields terms — both backends use the LIKE content scan.
|
|
@@ -338,6 +377,9 @@ describe("searchConversations · qdrant backend", () => {
|
|
|
338
377
|
describe("searchConversations · fts5 backend (default) ignores the lexical index", () => {
|
|
339
378
|
beforeEach(() => {
|
|
340
379
|
resetTables();
|
|
380
|
+
// Backfill completion is irrelevant on the default backend, but clear it so
|
|
381
|
+
// this suite does not depend on the qdrant suite's marker leaking across.
|
|
382
|
+
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
341
383
|
searchMessageIdsLexicalMock.mockClear();
|
|
342
384
|
lexicalReturns(["should-not-be-used"]);
|
|
343
385
|
// Default backend: flag unset ⇒ fts5.
|
|
@@ -63,3 +63,48 @@ export function resetMessageCursorCheckpoint(
|
|
|
63
63
|
messageId: "",
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Completion sentinel for the messages lexical-index backfill. Set once the
|
|
69
|
+
* backfill has drained the entire `messages` table into the Qdrant lexical
|
|
70
|
+
* index (`messages_lexical`); cleared on a `force` re-run.
|
|
71
|
+
*
|
|
72
|
+
* Lives here — in `persistence/` — so it is the single source of truth for the
|
|
73
|
+
* backfill handler (plugin layer, which writes it) AND the message-search read
|
|
74
|
+
* sites (persistence + plugin layers, which gate the read backend on it).
|
|
75
|
+
* Keeping the key and its reader here lets `conversation-queries.ts` (also
|
|
76
|
+
* `persistence/`) read it without a persistence -> plugin import.
|
|
77
|
+
*/
|
|
78
|
+
export const LEXICAL_BACKFILL_COMPLETE_KEY =
|
|
79
|
+
"lexical:messages:backfill_complete";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True once the messages lexical-index backfill has fully drained on this
|
|
83
|
+
* instance.
|
|
84
|
+
*
|
|
85
|
+
* Gates two things: the one-time startup auto-enqueue (skip when already
|
|
86
|
+
* complete) and — critically — the message-search read backend. An upgraded
|
|
87
|
+
* instance whose backfill has not finished must keep reading from SQLite FTS,
|
|
88
|
+
* because a `qdrant`-backed read against the still-filling `messages_lexical`
|
|
89
|
+
* collection returns an empty result (not a throw), so the Qdrant-error degrade
|
|
90
|
+
* path never fires and content search would silently return nothing. Switch
|
|
91
|
+
* reads to Qdrant only once this marker confirms the index is fully populated.
|
|
92
|
+
*/
|
|
93
|
+
export function isLexicalBackfillComplete(): boolean {
|
|
94
|
+
return getMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY) === "1";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear the lexical-backfill completion sentinel so {@link
|
|
99
|
+
* isLexicalBackfillComplete} reads false until a run re-marks it.
|
|
100
|
+
*
|
|
101
|
+
* Called when a `force` re-index is requested — both at enqueue time (so reads
|
|
102
|
+
* fall back to SQLite FTS the instant the rebuild is queued, rather than serving
|
|
103
|
+
* from a stale/emptying `messages_lexical` collection in the window before the
|
|
104
|
+
* worker claims the job) and again inside the backfill handler (idempotent).
|
|
105
|
+
* Co-located with the key and its reader here so callers clear it without
|
|
106
|
+
* re-declaring the key string.
|
|
107
|
+
*/
|
|
108
|
+
export function clearLexicalBackfillComplete(): void {
|
|
109
|
+
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
110
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
wrapUntrustedContent,
|
|
10
10
|
} from "../security/untrusted-content.js";
|
|
11
11
|
import { getLogger } from "../util/logger.js";
|
|
12
|
+
import { isLexicalBackfillComplete } from "./checkpoints.js";
|
|
12
13
|
import type { ConversationRow } from "./conversation-crud.js";
|
|
13
14
|
import { parseConversation } from "./conversation-crud.js";
|
|
14
15
|
import { ensureDisplayOrderMigration } from "./conversation-display-order-migration.js";
|
|
@@ -553,11 +554,36 @@ function likeContentMatchMessages(
|
|
|
553
554
|
);
|
|
554
555
|
}
|
|
555
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Resolve the effective message-search backend for the persistence read site.
|
|
559
|
+
*
|
|
560
|
+
* Starts from the `messages-search-backend` feature flag, then downgrades to
|
|
561
|
+
* `fts5` in two cases where the Qdrant `messages_lexical` collection is not a
|
|
562
|
+
* safe source (each would return an empty result — not a throw — so the
|
|
563
|
+
* Qdrant-error degrade never fires):
|
|
564
|
+
* 1. Memory is disabled — the per-message write path is suppressed, so the
|
|
565
|
+
* collection is never forward-filled. `!isMemoryEnabled()` is the
|
|
566
|
+
* layer-clean check available from `persistence/`.
|
|
567
|
+
* 2. The one-time upgrade backfill has not finished — the collection is only
|
|
568
|
+
* partially populated, so older content is missing until it drains.
|
|
569
|
+
*
|
|
570
|
+
* The recall read site applies the same completion gate (via the shared
|
|
571
|
+
* {@link isLexicalBackfillComplete}) on top of its own, stricter suppression
|
|
572
|
+
* check (`isMemoryIndexingSuppressed`, which also covers a disabled plugin).
|
|
573
|
+
*/
|
|
574
|
+
function resolveMessageSearchBackend(): "fts5" | "qdrant" {
|
|
575
|
+
if (!isMemoryEnabled()) return "fts5";
|
|
576
|
+
const flagBackend = getMessagesSearchBackend(getConfig());
|
|
577
|
+
if (flagBackend === "qdrant" && !isLexicalBackfillComplete()) return "fts5";
|
|
578
|
+
return flagBackend;
|
|
579
|
+
}
|
|
580
|
+
|
|
556
581
|
/**
|
|
557
582
|
* Full-text search across message content.
|
|
558
583
|
*
|
|
559
584
|
* The lexical backend is selected by the `messages-search-backend` feature
|
|
560
|
-
* flag (see {@link getMessagesSearchBackend})
|
|
585
|
+
* flag (see {@link getMessagesSearchBackend}) and gated by
|
|
586
|
+
* {@link resolveMessageSearchBackend}:
|
|
561
587
|
* - `fts5` (default): the `messages_fts` virtual table for tokenized matching.
|
|
562
588
|
* - `qdrant`: the sparse `messages_lexical` Qdrant index (BM25-style).
|
|
563
589
|
* Both apply the same visibility/archived SQL filtering, merge with a `LIKE`
|
|
@@ -600,9 +626,14 @@ export async function searchConversations(
|
|
|
600
626
|
// `isMemoryIndexingSuppressed` predicate) can't be reached from here without a
|
|
601
627
|
// persistence -> plugin import, and is covered at flip time by requiring the
|
|
602
628
|
// backfill/population check before enabling the flag.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
629
|
+
//
|
|
630
|
+
// The backfill completion gate is the second half of that population guard,
|
|
631
|
+
// for the common (memory-enabled) case: on an upgraded instance the historical
|
|
632
|
+
// messages are indexed by a background backfill, so until it has fully drained
|
|
633
|
+
// the `messages_lexical` collection is only partially populated and a `qdrant`
|
|
634
|
+
// read would silently miss older content. Stay on `fts5` until
|
|
635
|
+
// `isLexicalBackfillComplete()` confirms the index is whole, then switch.
|
|
636
|
+
const backend = resolveMessageSearchBackend();
|
|
606
637
|
|
|
607
638
|
// LIKE pattern for title matching (message-content indexes don't cover titles).
|
|
608
639
|
const titlePattern = likeContainsPattern(query);
|
|
@@ -571,3 +571,34 @@ export class EmbeddingRuntimeManager {
|
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Download the local embedding runtime in the background (non-blocking).
|
|
577
|
+
*
|
|
578
|
+
* Pre-warms the runtime (bun binary + ONNX worker scripts) so the first local
|
|
579
|
+
* embed doesn't pay the download cost inline. A no-op when the runtime is
|
|
580
|
+
* already installed. Failures are swallowed with a warning: local embeddings
|
|
581
|
+
* fall back to cloud backends, so a failed download must never block or crash
|
|
582
|
+
* daemon startup. Returns immediately; the download proceeds on its own.
|
|
583
|
+
*/
|
|
584
|
+
export function startEmbeddingRuntimeManager(): void {
|
|
585
|
+
void (async () => {
|
|
586
|
+
try {
|
|
587
|
+
const runtimeManager = new EmbeddingRuntimeManager();
|
|
588
|
+
if (runtimeManager.isReady()) return;
|
|
589
|
+
log.info("Downloading embedding runtime in background...");
|
|
590
|
+
await runtimeManager.ensureInstalled();
|
|
591
|
+
// Reset the sticky local-backend failure flag so auto mode retries
|
|
592
|
+
// local embeddings without evicting a worker that may already be live.
|
|
593
|
+
const { resetLocalEmbeddingFailureState } =
|
|
594
|
+
await import("./embedding-backend.js");
|
|
595
|
+
resetLocalEmbeddingFailureState();
|
|
596
|
+
log.info("Embedding runtime download complete");
|
|
597
|
+
} catch (err) {
|
|
598
|
+
log.warn(
|
|
599
|
+
{ err },
|
|
600
|
+
"Embedding runtime download failed — local embeddings will use cloud fallback",
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getMessagesSearchBackend } from "../../../../../config/assistant-feature-flags.js";
|
|
2
2
|
import { readSlackMetadata } from "../../../../../messaging/providers/slack/message-metadata.js";
|
|
3
3
|
import { AUTO_ANALYSIS_SOURCE } from "../../../../../persistence/auto-analysis-constants.js";
|
|
4
|
+
import { isLexicalBackfillComplete } from "../../../../../persistence/checkpoints.js";
|
|
4
5
|
import {
|
|
5
6
|
buildFtsMatchQuery,
|
|
6
7
|
buildRecallEvidenceExcerpt,
|
|
@@ -128,9 +129,17 @@ export async function searchConversationSource(
|
|
|
128
129
|
// (memory disabled or the memory plugin disabled) the collection is never
|
|
129
130
|
// populated, so a qdrant-backed read would silently return nothing while FTS
|
|
130
131
|
// still works — force the FTS path regardless of the flag in that case.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
//
|
|
133
|
+
// Second gate: until the one-time upgrade backfill has fully drained, the
|
|
134
|
+
// collection holds only messages written since the write path went live —
|
|
135
|
+
// older history is missing. Reading from Qdrant then silently misses that
|
|
136
|
+
// history (an empty candidate set, not a throw), so stay on FTS until
|
|
137
|
+
// `isLexicalBackfillComplete()` confirms the index is whole. Shared with the
|
|
138
|
+
// persistence read site via the same checkpoint helper.
|
|
139
|
+
const backend =
|
|
140
|
+
isMemoryIndexingSuppressed() || !isLexicalBackfillComplete()
|
|
141
|
+
? "fts5"
|
|
142
|
+
: getMessagesSearchBackend(context.config);
|
|
134
143
|
|
|
135
144
|
// Tokenize once. Short/punctuation-only queries (`C++`, CJK) produce no
|
|
136
145
|
// usable ≥2-char match shape, and both backends must route those straight to
|
|
@@ -69,12 +69,24 @@ mock.module(
|
|
|
69
69
|
// `generateSparseEmbedding` is a pure local TF-IDF encoder (no provider call),
|
|
70
70
|
// so it runs unmocked — mocking `embedding-backend.js` wholesale would starve
|
|
71
71
|
// its other named exports that the db-init import graph pulls in.
|
|
72
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
73
|
+
import { join } from "node:path";
|
|
74
|
+
|
|
72
75
|
import { resetDbForTesting } from "../../../../../__tests__/db-test-helpers.js";
|
|
73
76
|
import { DEFAULT_CONFIG } from "../../../../../config/defaults.js";
|
|
77
|
+
import {
|
|
78
|
+
invalidateConfigCache,
|
|
79
|
+
loadRawConfig,
|
|
80
|
+
saveRawConfig,
|
|
81
|
+
} from "../../../../../config/loader.js";
|
|
74
82
|
import type { AssistantConfig } from "../../../../../config/types.js";
|
|
75
83
|
import {
|
|
84
|
+
deleteMemoryCheckpoint,
|
|
85
|
+
isLexicalBackfillComplete,
|
|
86
|
+
LEXICAL_BACKFILL_COMPLETE_KEY,
|
|
76
87
|
readMessageCursorCheckpoint,
|
|
77
88
|
resetMessageCursorCheckpoint,
|
|
89
|
+
setMemoryCheckpoint,
|
|
78
90
|
writeMessageCursorCheckpoint,
|
|
79
91
|
} from "../../../../../persistence/checkpoints.js";
|
|
80
92
|
import {
|
|
@@ -89,7 +101,17 @@ import {
|
|
|
89
101
|
memoryJobs,
|
|
90
102
|
messages,
|
|
91
103
|
} from "../../../../../persistence/schema/index.js";
|
|
92
|
-
import {
|
|
104
|
+
import type {
|
|
105
|
+
RouteDefinition,
|
|
106
|
+
RouteHandlerArgs,
|
|
107
|
+
} from "../../../../../runtime/routes/types.js";
|
|
108
|
+
import { getWorkspacePluginsDir } from "../../../../../util/platform.js";
|
|
109
|
+
import memoryPkg from "../../package.json" with { type: "json" };
|
|
110
|
+
import { ROUTES as MESSAGES_LEXICAL_ROUTES } from "../../routes/messages-lexical-routes.js";
|
|
111
|
+
import {
|
|
112
|
+
backfillLexicalIndexJob,
|
|
113
|
+
maybeEnqueueLexicalBackfillOnUpgrade,
|
|
114
|
+
} from "../backfill-lexical-index.js";
|
|
93
115
|
|
|
94
116
|
const TEST_CONFIG: AssistantConfig = DEFAULT_CONFIG;
|
|
95
117
|
|
|
@@ -147,6 +169,55 @@ function pendingBackfillJobCount(): number {
|
|
|
147
169
|
return rows.length;
|
|
148
170
|
}
|
|
149
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Invoke the `messages_lexical_backfill` route handler directly through its
|
|
174
|
+
* shared `ROUTES` entry (transport-agnostic), the same way the HTTP/IPC adapters
|
|
175
|
+
* call it. Returns the handler result (`{ jobId }`).
|
|
176
|
+
*/
|
|
177
|
+
async function callBackfillRoute(
|
|
178
|
+
body: Record<string, unknown>,
|
|
179
|
+
): Promise<{ jobId: string }> {
|
|
180
|
+
const route = MESSAGES_LEXICAL_ROUTES.find(
|
|
181
|
+
(r: RouteDefinition) => r.operationId === "messages_lexical_backfill",
|
|
182
|
+
);
|
|
183
|
+
if (!route) throw new Error("messages_lexical_backfill route not found");
|
|
184
|
+
const args: RouteHandlerArgs = { body };
|
|
185
|
+
return (await route.handler(args)) as { jobId: string };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Toggle `memory.enabled` in the real workspace config so the startup hook's
|
|
190
|
+
* `isMemoryEnabled()` (which reads `getConfig()`) sees the change. Driving the
|
|
191
|
+
* real config instead of mocking `jobs-store` avoids a process-global
|
|
192
|
+
* `mock.module` leaking `isMemoryEnabled` into sibling test files.
|
|
193
|
+
*/
|
|
194
|
+
function setMemoryEnabled(enabled: boolean): void {
|
|
195
|
+
const raw = loadRawConfig();
|
|
196
|
+
const memory =
|
|
197
|
+
raw.memory && typeof raw.memory === "object"
|
|
198
|
+
? (raw.memory as Record<string, unknown>)
|
|
199
|
+
: {};
|
|
200
|
+
saveRawConfig({ ...raw, memory: { ...memory, enabled } });
|
|
201
|
+
invalidateConfigCache();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Toggle the memory plugin's `.disabled` sentinel in the real workspace plugins
|
|
206
|
+
* dir so `isMemoryIndexingSuppressed()` (via `isPluginDisabled(memoryPkg.name)`)
|
|
207
|
+
* sees the change. Driving the real sentinel — not a process-global mock —
|
|
208
|
+
* exercises the same suppression path the write/recall gates use and avoids
|
|
209
|
+
* leaking a mock into sibling test files.
|
|
210
|
+
*/
|
|
211
|
+
function setMemoryPluginDisabled(disabled: boolean): void {
|
|
212
|
+
const dir = join(getWorkspacePluginsDir(), memoryPkg.name);
|
|
213
|
+
if (disabled) {
|
|
214
|
+
mkdirSync(dir, { recursive: true });
|
|
215
|
+
writeFileSync(join(dir, ".disabled"), "");
|
|
216
|
+
} else {
|
|
217
|
+
rmSync(dir, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
150
221
|
describe("backfillLexicalIndexJob", () => {
|
|
151
222
|
// initializeDb runs the full migration chain; under parallel CI load it can
|
|
152
223
|
// exceed bun's default 5s hook timeout, so allow more.
|
|
@@ -157,6 +228,8 @@ describe("backfillLexicalIndexJob", () => {
|
|
|
157
228
|
beforeEach(async () => {
|
|
158
229
|
upsertBatchCalls.length = 0;
|
|
159
230
|
resetLexicalSingleton(true);
|
|
231
|
+
setMemoryEnabled(true);
|
|
232
|
+
setMemoryPluginDisabled(false);
|
|
160
233
|
resetDbForTesting();
|
|
161
234
|
await initializeDb();
|
|
162
235
|
// The template-restore path leaves stale WAL sidecars, so rows can bleed
|
|
@@ -166,6 +239,7 @@ describe("backfillLexicalIndexJob", () => {
|
|
|
166
239
|
getDb().delete(conversations).run();
|
|
167
240
|
getMemoryDb()!.delete(memoryJobs).run();
|
|
168
241
|
resetMessageCursorCheckpoint(CHECKPOINT_KEY, CHECKPOINT_ID_KEY);
|
|
242
|
+
deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
|
|
169
243
|
}, 30_000);
|
|
170
244
|
|
|
171
245
|
test("indexes a batch of messages and advances the checkpoint", async () => {
|
|
@@ -202,8 +276,9 @@ describe("backfillLexicalIndexJob", () => {
|
|
|
202
276
|
);
|
|
203
277
|
expect(cursor).toEqual({ createdAt: 2_000, messageId: "msg-b" });
|
|
204
278
|
|
|
205
|
-
// A short batch does not re-enqueue.
|
|
279
|
+
// A short batch does not re-enqueue, and marks the backfill complete.
|
|
206
280
|
expect(pendingBackfillJobCount()).toBe(0);
|
|
281
|
+
expect(isLexicalBackfillComplete()).toBe(true);
|
|
207
282
|
});
|
|
208
283
|
|
|
209
284
|
test("resumes from the persisted cursor, skipping already-indexed rows", async () => {
|
|
@@ -251,12 +326,18 @@ describe("backfillLexicalIndexJob", () => {
|
|
|
251
326
|
expect(upsertBatchCalls[0]).toHaveLength(200);
|
|
252
327
|
// Exactly one follow-up job enqueued to process the remaining rows.
|
|
253
328
|
expect(pendingBackfillJobCount()).toBe(1);
|
|
329
|
+
// A full batch means more rows may remain, so the backfill is NOT yet
|
|
330
|
+
// complete — the completion marker must stay unset until a short batch.
|
|
331
|
+
expect(isLexicalBackfillComplete()).toBe(false);
|
|
254
332
|
});
|
|
255
333
|
|
|
256
|
-
test("does nothing (no upsert, no re-enqueue) when there are no messages", async () => {
|
|
334
|
+
test("does nothing (no upsert, no re-enqueue) when there are no messages, and marks complete", async () => {
|
|
257
335
|
await backfillLexicalIndexJob(makeJob({}), TEST_CONFIG);
|
|
258
336
|
expect(upsertBatchCalls).toHaveLength(0);
|
|
259
337
|
expect(pendingBackfillJobCount()).toBe(0);
|
|
338
|
+
// An empty batch is a drained backfill (an already-empty or fully-indexed
|
|
339
|
+
// table) — the completion marker is written.
|
|
340
|
+
expect(isLexicalBackfillComplete()).toBe(true);
|
|
260
341
|
});
|
|
261
342
|
|
|
262
343
|
test("force resets the cursor and re-indexes from the beginning", async () => {
|
|
@@ -268,11 +349,13 @@ describe("backfillLexicalIndexJob", () => {
|
|
|
268
349
|
createdAt: 5_000,
|
|
269
350
|
});
|
|
270
351
|
|
|
271
|
-
// A stale cursor past every row would normally skip everything
|
|
352
|
+
// A stale cursor past every row would normally skip everything, and a prior
|
|
353
|
+
// run had already marked the backfill complete.
|
|
272
354
|
writeMessageCursorCheckpoint(CHECKPOINT_KEY, CHECKPOINT_ID_KEY, {
|
|
273
355
|
createdAt: 9_999,
|
|
274
356
|
messageId: "msg-zzz",
|
|
275
357
|
});
|
|
358
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
276
359
|
|
|
277
360
|
await backfillLexicalIndexJob(makeJob({ force: true }), TEST_CONFIG);
|
|
278
361
|
|
|
@@ -286,6 +369,32 @@ describe("backfillLexicalIndexJob", () => {
|
|
|
286
369
|
CHECKPOINT_ID_KEY,
|
|
287
370
|
);
|
|
288
371
|
expect(cursor).toEqual({ createdAt: 5_000, messageId: "msg-x" });
|
|
372
|
+
|
|
373
|
+
// force cleared the completion marker; the short re-index batch then drained
|
|
374
|
+
// and re-marked it complete.
|
|
375
|
+
expect(isLexicalBackfillComplete()).toBe(true);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("force clears the completion marker even when the re-run does not drain in one batch", async () => {
|
|
379
|
+
insertConversation("conv-force-full");
|
|
380
|
+
// A full batch on the forced run: the marker must be CLEARED (re-armed) and
|
|
381
|
+
// stay unset because more rows may remain past this batch.
|
|
382
|
+
for (let i = 0; i < 200; i++) {
|
|
383
|
+
insertMessage({
|
|
384
|
+
id: `fm-${String(i).padStart(4, "0")}`,
|
|
385
|
+
conversationId: "conv-force-full",
|
|
386
|
+
content: `forced message ${i}`,
|
|
387
|
+
createdAt: 1_000 + i,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
391
|
+
|
|
392
|
+
await backfillLexicalIndexJob(makeJob({ force: true }), TEST_CONFIG);
|
|
393
|
+
|
|
394
|
+
expect(upsertBatchCalls[0]).toHaveLength(200);
|
|
395
|
+
expect(pendingBackfillJobCount()).toBe(1);
|
|
396
|
+
// Marker was cleared by force and not re-set (batch was full → more remain).
|
|
397
|
+
expect(isLexicalBackfillComplete()).toBe(false);
|
|
289
398
|
});
|
|
290
399
|
|
|
291
400
|
test("lazily initializes the index when not yet initialized (worker process)", async () => {
|
|
@@ -307,4 +416,108 @@ describe("backfillLexicalIndexJob", () => {
|
|
|
307
416
|
expect(upsertBatchCalls).toHaveLength(1);
|
|
308
417
|
expect(upsertBatchCalls[0].map((p) => p.messageId)).toEqual(["msg-w"]);
|
|
309
418
|
});
|
|
419
|
+
|
|
420
|
+
// The one-time startup auto-enqueue. Inherits the outer beforeEach, which
|
|
421
|
+
// resets the DB, clears the memory_jobs table and completion marker, and sets
|
|
422
|
+
// memoryEnabled = true — so each test starts from a fresh, memory-enabled,
|
|
423
|
+
// never-backfilled instance.
|
|
424
|
+
describe("maybeEnqueueLexicalBackfillOnUpgrade", () => {
|
|
425
|
+
test("enqueues exactly one backfill job when memory is enabled and the checkpoint is unset", () => {
|
|
426
|
+
maybeEnqueueLexicalBackfillOnUpgrade();
|
|
427
|
+
|
|
428
|
+
const rows = getMemoryDb()!
|
|
429
|
+
.select({ type: memoryJobs.type })
|
|
430
|
+
.from(memoryJobs)
|
|
431
|
+
.all();
|
|
432
|
+
expect(rows).toHaveLength(1);
|
|
433
|
+
expect(rows[0].type).toBe("backfill_lexical_index");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("does NOT enqueue when the completion checkpoint is already set", () => {
|
|
437
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
438
|
+
|
|
439
|
+
maybeEnqueueLexicalBackfillOnUpgrade();
|
|
440
|
+
|
|
441
|
+
expect(pendingBackfillJobCount()).toBe(0);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("does NOT enqueue when memory is disabled", () => {
|
|
445
|
+
setMemoryEnabled(false);
|
|
446
|
+
|
|
447
|
+
maybeEnqueueLexicalBackfillOnUpgrade();
|
|
448
|
+
|
|
449
|
+
expect(pendingBackfillJobCount()).toBe(0);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Indexing is suppressed by the memory plugin's `.disabled` sentinel even
|
|
453
|
+
// while `memory.enabled` is true. The hook gates on the same
|
|
454
|
+
// `isMemoryIndexingSuppressed` signal as the write/recall paths, so it must
|
|
455
|
+
// skip: enqueuing (and later setting the completion marker) would leave a
|
|
456
|
+
// stale index that a lexical-backed read could serve while per-message writes
|
|
457
|
+
// stay suppressed.
|
|
458
|
+
test("does NOT enqueue when the memory plugin is disabled but memory.enabled is true", () => {
|
|
459
|
+
setMemoryEnabled(true);
|
|
460
|
+
setMemoryPluginDisabled(true);
|
|
461
|
+
|
|
462
|
+
maybeEnqueueLexicalBackfillOnUpgrade();
|
|
463
|
+
|
|
464
|
+
expect(pendingBackfillJobCount()).toBe(0);
|
|
465
|
+
// The completion marker must stay unset so reads stay on FTS.
|
|
466
|
+
expect(isLexicalBackfillComplete()).toBe(false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("does NOT enqueue a duplicate when a backfill job is already pending", () => {
|
|
470
|
+
// First call enqueues the one-time job.
|
|
471
|
+
maybeEnqueueLexicalBackfillOnUpgrade();
|
|
472
|
+
expect(pendingBackfillJobCount()).toBe(1);
|
|
473
|
+
|
|
474
|
+
// A second call (e.g. a restart mid-backfill) must not pile on a duplicate
|
|
475
|
+
// while the prior job is still pending.
|
|
476
|
+
maybeEnqueueLexicalBackfillOnUpgrade();
|
|
477
|
+
expect(pendingBackfillJobCount()).toBe(1);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// The manual backfill route (`handleBackfillLexicalIndex`). Inherits the outer
|
|
482
|
+
// beforeEach, which resets the DB, clears the memory_jobs table and completion
|
|
483
|
+
// marker, and enables memory — so each test starts from a fresh instance.
|
|
484
|
+
describe("handleBackfillLexicalIndex route", () => {
|
|
485
|
+
test("force: true clears the completion marker synchronously at enqueue, before the job runs", async () => {
|
|
486
|
+
// Pretend a prior backfill already fully drained on this instance, so
|
|
487
|
+
// reads would be serving from the lexical index.
|
|
488
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
489
|
+
expect(isLexicalBackfillComplete()).toBe(true);
|
|
490
|
+
|
|
491
|
+
const { jobId } = await callBackfillRoute({ force: true });
|
|
492
|
+
expect(jobId).toBeTruthy();
|
|
493
|
+
|
|
494
|
+
// The marker is cleared the instant the forced rebuild is enqueued —
|
|
495
|
+
// before the worker claims the job — so reads immediately fall back to
|
|
496
|
+
// FTS instead of serving from the stale/emptying lexical index.
|
|
497
|
+
expect(isLexicalBackfillComplete()).toBe(false);
|
|
498
|
+
// The job was enqueued with the force payload for the worker to process.
|
|
499
|
+
expect(pendingBackfillJobCount()).toBe(1);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("non-force enqueue does NOT clear the completion marker", async () => {
|
|
503
|
+
// A completed instance stays on the lexical read path for a resume-style
|
|
504
|
+
// (non-force) enqueue — only a forced rebuild re-arms the FTS fallback.
|
|
505
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
506
|
+
expect(isLexicalBackfillComplete()).toBe(true);
|
|
507
|
+
|
|
508
|
+
const { jobId } = await callBackfillRoute({});
|
|
509
|
+
expect(jobId).toBeTruthy();
|
|
510
|
+
|
|
511
|
+
expect(isLexicalBackfillComplete()).toBe(true);
|
|
512
|
+
expect(pendingBackfillJobCount()).toBe(1);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("force: false is treated as non-force and leaves the marker set", async () => {
|
|
516
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
517
|
+
|
|
518
|
+
await callBackfillRoute({ force: false });
|
|
519
|
+
|
|
520
|
+
expect(isLexicalBackfillComplete()).toBe(true);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
310
523
|
});
|
|
@@ -2,8 +2,12 @@ import { and, asc, eq, gt, or } from "drizzle-orm";
|
|
|
2
2
|
|
|
3
3
|
import type { AssistantConfig } from "../../../../config/types.js";
|
|
4
4
|
import {
|
|
5
|
+
clearLexicalBackfillComplete,
|
|
6
|
+
isLexicalBackfillComplete,
|
|
7
|
+
LEXICAL_BACKFILL_COMPLETE_KEY,
|
|
5
8
|
readMessageCursorCheckpoint,
|
|
6
9
|
resetMessageCursorCheckpoint,
|
|
10
|
+
setMemoryCheckpoint,
|
|
7
11
|
writeMessageCursorCheckpoint,
|
|
8
12
|
} from "../../../../persistence/checkpoints.js";
|
|
9
13
|
import { getDb } from "../../../../persistence/db-connection.js";
|
|
@@ -11,10 +15,17 @@ import { generateSparseEmbedding } from "../../../../persistence/embeddings/embe
|
|
|
11
15
|
import { withQdrantBreaker } from "../../../../persistence/embeddings/qdrant-circuit-breaker.js";
|
|
12
16
|
import {
|
|
13
17
|
enqueueMemoryJob,
|
|
18
|
+
hasActiveJobOfType,
|
|
14
19
|
type MemoryJob,
|
|
15
20
|
} from "../../../../persistence/jobs-store.js";
|
|
16
21
|
import { messages } from "../../../../persistence/schema/index.js";
|
|
17
|
-
import {
|
|
22
|
+
import { getLogger } from "../../../../util/logger.js";
|
|
23
|
+
import {
|
|
24
|
+
isMemoryIndexingSuppressed,
|
|
25
|
+
resolveLexicalIndex,
|
|
26
|
+
} from "./index-message-lexical.js";
|
|
27
|
+
|
|
28
|
+
const log = getLogger("lexical-backfill");
|
|
18
29
|
|
|
19
30
|
/**
|
|
20
31
|
* Cursor checkpoint keys for the lexical-index backfill. Kept distinct from the
|
|
@@ -25,6 +36,66 @@ import { resolveLexicalIndex } from "./index-message-lexical.js";
|
|
|
25
36
|
const LEXICAL_BACKFILL_CHECKPOINT_KEY = "lexical:messages:last_created_at";
|
|
26
37
|
const LEXICAL_BACKFILL_CHECKPOINT_ID_KEY = "lexical:messages:last_id";
|
|
27
38
|
|
|
39
|
+
/**
|
|
40
|
+
* One-time, self-healing auto-enqueue of the messages lexical-index backfill,
|
|
41
|
+
* invoked once from `runMemoryStartup` (startup.ts) on daemon boot. Ensures each
|
|
42
|
+
* instance populates its Qdrant lexical index (`messages_lexical`) exactly once
|
|
43
|
+
* on upgrade — in the background — so a later read-path flip to the lexical
|
|
44
|
+
* backend does not open onto an empty index.
|
|
45
|
+
*
|
|
46
|
+
* Deliberate, narrow exception to the "not run at daemon startup" note carried by
|
|
47
|
+
* the manual backfill route (`messages-lexical-routes.ts`, added in the FTS→Qdrant
|
|
48
|
+
* PR 5): this call only *enqueues* a memory job — it does no indexing, no LLM
|
|
49
|
+
* work, and no other heavy lifting on the boot thread. The backfill itself runs
|
|
50
|
+
* off the event loop via the existing memory job worker, in small
|
|
51
|
+
* cursor-checkpointed batches. It is a one-time, checkpoint-guarded, no-LLM data
|
|
52
|
+
* backfill (migration-like), so it is consistent with the daemon-startup
|
|
53
|
+
* philosophy of never blocking or doing expensive work at boot.
|
|
54
|
+
*
|
|
55
|
+
* Guards (all must pass to enqueue):
|
|
56
|
+
* - Memory indexing must not be suppressed — memory enabled AND the memory
|
|
57
|
+
* plugin not disabled — using the same {@link isMemoryIndexingSuppressed}
|
|
58
|
+
* signal as the write/recall paths. When `memory.enabled === false` the memory
|
|
59
|
+
* job worker drains nothing (`runMemoryJobsOnce` returns early), so an enqueued
|
|
60
|
+
* backfill would sit pending forever and only accumulate dead rows. When the
|
|
61
|
+
* memory plugin is disabled via its `.disabled` sentinel, per-message index
|
|
62
|
+
* writes are suppressed, so completing a backfill would leave a stale index
|
|
63
|
+
* that a lexical-backed read could serve; gating on the same signal keeps the
|
|
64
|
+
* completion marker unset while writes are suppressed, so every read path stays
|
|
65
|
+
* on FTS in that state (the marker unifies them).
|
|
66
|
+
* - The completion sentinel must be unset. Once the backfill has fully drained
|
|
67
|
+
* on this instance there is nothing to do; the marker makes this idempotent
|
|
68
|
+
* across restarts.
|
|
69
|
+
* - No backfill job may already be pending or running. Without this, every
|
|
70
|
+
* restart during a long backfill would pile up a duplicate job. (The batches
|
|
71
|
+
* are idempotent, but redundant jobs waste worker cycles.)
|
|
72
|
+
*
|
|
73
|
+
* An instance that was already manually backfilled via the route but predates
|
|
74
|
+
* the completion sentinel re-enqueues once here and re-runs the idempotent
|
|
75
|
+
* backfill (no duplicate points; ~minutes) — an acceptable one-time cost.
|
|
76
|
+
*
|
|
77
|
+
* Best-effort and lightweight: any failure (e.g. the memory database is briefly
|
|
78
|
+
* unavailable at boot) is swallowed and logged so a memory hiccup never blocks
|
|
79
|
+
* daemon startup, matching the surrounding startup steps.
|
|
80
|
+
*/
|
|
81
|
+
export function maybeEnqueueLexicalBackfillOnUpgrade(): void {
|
|
82
|
+
try {
|
|
83
|
+
if (isMemoryIndexingSuppressed()) return;
|
|
84
|
+
if (isLexicalBackfillComplete()) return;
|
|
85
|
+
if (hasActiveJobOfType("backfill_lexical_index")) return;
|
|
86
|
+
const jobId = enqueueMemoryJob("backfill_lexical_index", {});
|
|
87
|
+
log.info(
|
|
88
|
+
{ jobId },
|
|
89
|
+
"Enqueued one-time messages lexical-index backfill on startup",
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.warn(
|
|
93
|
+
{ err },
|
|
94
|
+
"Failed to enqueue startup lexical-index backfill (non-fatal)",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
28
99
|
/** Messages indexed per job invocation before the job re-enqueues itself. */
|
|
29
100
|
const BATCH_SIZE = 200;
|
|
30
101
|
|
|
@@ -51,6 +122,12 @@ export async function backfillLexicalIndexJob(
|
|
|
51
122
|
LEXICAL_BACKFILL_CHECKPOINT_KEY,
|
|
52
123
|
LEXICAL_BACKFILL_CHECKPOINT_ID_KEY,
|
|
53
124
|
);
|
|
125
|
+
// Clearing the completion sentinel lets a forced re-run drain and re-mark
|
|
126
|
+
// as complete, and re-arms the one-time startup auto-enqueue for this run.
|
|
127
|
+
// The route also clears this at enqueue time (so reads fall back to FTS
|
|
128
|
+
// before the worker claims the job); clearing again here is idempotent and
|
|
129
|
+
// keeps the forced-run semantics self-contained in the handler.
|
|
130
|
+
clearLexicalBackfillComplete();
|
|
54
131
|
}
|
|
55
132
|
|
|
56
133
|
const cursor = readMessageCursorCheckpoint(
|
|
@@ -104,5 +181,12 @@ export async function backfillLexicalIndexJob(
|
|
|
104
181
|
// happened this invocation, and re-resetting would loop over the same head.
|
|
105
182
|
if (batch.length === BATCH_SIZE) {
|
|
106
183
|
enqueueMemoryJob("backfill_lexical_index", {});
|
|
184
|
+
} else {
|
|
185
|
+
// A short batch (including an empty one) means the cursor has reached the
|
|
186
|
+
// end of the `messages` table — the backfill has fully drained. Record the
|
|
187
|
+
// completion sentinel so the one-time startup auto-enqueue never schedules
|
|
188
|
+
// it again on this instance. Idempotent: re-running after completion draws
|
|
189
|
+
// an empty batch and simply re-sets the same marker.
|
|
190
|
+
setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
|
|
107
191
|
}
|
|
108
192
|
}
|
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* Deliberately NOT gated on `memory.v2.enabled`: the lexical index is a
|
|
6
6
|
* BM25-style full-text replacement for message search that is independent of
|
|
7
7
|
* the v2 concept-page machinery. The backfill runs off the event loop as a
|
|
8
|
-
* cursor-checkpointed memory job
|
|
9
|
-
*
|
|
8
|
+
* cursor-checkpointed memory job. This route enqueues it on demand (operator or
|
|
9
|
+
* client); the one-time, checkpoint-guarded startup auto-enqueue in
|
|
10
|
+
* `runMemoryStartup` (see `maybeEnqueueLexicalBackfillOnUpgrade`) enqueues the
|
|
11
|
+
* same job once per instance on upgrade.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { z } from "zod";
|
|
13
15
|
|
|
16
|
+
import { clearLexicalBackfillComplete } from "../../../../persistence/checkpoints.js";
|
|
14
17
|
import {
|
|
15
18
|
enqueueMemoryJob,
|
|
16
19
|
type MemoryJobType,
|
|
@@ -42,6 +45,14 @@ async function handleBackfillLexicalIndex({
|
|
|
42
45
|
body = {},
|
|
43
46
|
}: RouteHandlerArgs): Promise<MessagesLexicalBackfillResult> {
|
|
44
47
|
const { force } = MessagesLexicalBackfillParams.parse(body);
|
|
48
|
+
if (force === true) {
|
|
49
|
+
// Clear the completion sentinel at enqueue time so `isLexicalBackfillComplete()`
|
|
50
|
+
// flips false immediately — the read paths fall back to SQLite FTS in the window
|
|
51
|
+
// between enqueue and the worker claiming the job, instead of serving from the
|
|
52
|
+
// stale/emptying `messages_lexical` collection the forced rebuild is about to
|
|
53
|
+
// reset. The handler clears it again when it runs (idempotent).
|
|
54
|
+
clearLexicalBackfillComplete();
|
|
55
|
+
}
|
|
45
56
|
const payload: Record<string, unknown> =
|
|
46
57
|
force === true ? { force: true } : {};
|
|
47
58
|
const jobId = enqueueMemoryJob(BACKFILL_LEXICAL_INDEX_JOB, payload);
|
|
@@ -60,7 +71,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
60
71
|
handler: handleBackfillLexicalIndex,
|
|
61
72
|
summary: "Enqueue a resumable backfill of messages into the lexical index",
|
|
62
73
|
description:
|
|
63
|
-
"Enqueues the cursor-checkpointed backfill job that indexes existing messages into the Qdrant lexical (BM25-style) collection in batches. Resumable and idempotent — re-running continues from the last checkpoint. Pass `force: true` to reset the cursor and re-index from the beginning.
|
|
74
|
+
"Enqueues the cursor-checkpointed backfill job that indexes existing messages into the Qdrant lexical (BM25-style) collection in batches. Resumable and idempotent — re-running continues from the last checkpoint. Pass `force: true` to reset the cursor and re-index from the beginning. The same backfill is also auto-enqueued once per instance on upgrade at assistant startup.",
|
|
64
75
|
tags: ["memory"],
|
|
65
76
|
requestBody: MessagesLexicalBackfillParams,
|
|
66
77
|
},
|
|
@@ -30,6 +30,7 @@ import { startMemoryJobsWorker } from "../../../persistence/jobs-worker.js";
|
|
|
30
30
|
import { getLogger } from "../../../util/logger.js";
|
|
31
31
|
import { getWorkspaceDir } from "../../../util/platform.js";
|
|
32
32
|
import { resolveQdrantUrl } from "./embeddings.js";
|
|
33
|
+
import { maybeEnqueueLexicalBackfillOnUpgrade } from "./job-handlers/backfill-lexical-index.js";
|
|
33
34
|
import { sweepConceptPageFrontmatter } from "./v2/frontmatter-sweep.js";
|
|
34
35
|
import {
|
|
35
36
|
maybeRebuildMemoryV2Concepts,
|
|
@@ -206,6 +207,14 @@ export async function runMemoryStartup(config: AssistantConfig): Promise<void> {
|
|
|
206
207
|
registerMemoryJobHandlers();
|
|
207
208
|
startMemoryJobsWorker();
|
|
208
209
|
|
|
210
|
+
// One-time, self-healing backfill of existing messages into the Qdrant
|
|
211
|
+
// lexical index (`messages_lexical`) on upgrade, so a later read-path flip to
|
|
212
|
+
// the lexical backend never opens onto an empty index. Enqueue-only and
|
|
213
|
+
// checkpoint-guarded — the indexing runs off the event loop via the memory
|
|
214
|
+
// worker started just above; see the function's docstring for the guards and
|
|
215
|
+
// the deliberate exception it makes to the "no work at daemon startup" rule.
|
|
216
|
+
maybeEnqueueLexicalBackfillOnUpgrade();
|
|
217
|
+
|
|
209
218
|
// Seed capability graph nodes (new memory graph system)
|
|
210
219
|
try {
|
|
211
220
|
const { seedCliGraphNodes } = await import("./graph/capability-seed.js");
|
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
* - `hasManagedProxyPrereqs()` — true when the daemon has the credentials
|
|
8
8
|
* it needs to act as a managed-proxy client, i.e. this is a managed
|
|
9
9
|
* deployment.
|
|
10
|
-
* - `
|
|
11
|
-
* where the daemon process is running.
|
|
10
|
+
* - `getIsContainerized()` — true when the daemon runs inside a container
|
|
11
|
+
* (`IS_CONTAINERIZED`), identifying where the daemon process is running.
|
|
12
12
|
*
|
|
13
13
|
* Folding both into a single helper keeps callers from repeating the
|
|
14
14
|
* combination logic.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { getIsContainerized } from "../../config/env-registry.js";
|
|
17
18
|
import { hasManagedProxyPrereqs } from "../../providers/platform-proxy/context.js";
|
|
18
|
-
import { getDaemonRuntimeMode } from "../runtime-mode.js";
|
|
19
19
|
|
|
20
20
|
export type VBundleOriginMode =
|
|
21
21
|
| "managed"
|
|
@@ -26,14 +26,14 @@ export type VBundleOriginMode =
|
|
|
26
26
|
* Returns the origin mode for the current daemon.
|
|
27
27
|
*
|
|
28
28
|
* Managed-proxy prereqs win first (a managed deployment is always
|
|
29
|
-
* "managed" regardless of where the daemon process runs); otherwise
|
|
30
|
-
* → "self-hosted-remote", bare-metal → "self-hosted-local".
|
|
29
|
+
* "managed" regardless of where the daemon process runs); otherwise
|
|
30
|
+
* containerized → "self-hosted-remote", bare-metal → "self-hosted-local".
|
|
31
31
|
*/
|
|
32
32
|
export async function getOriginMode(): Promise<VBundleOriginMode> {
|
|
33
33
|
if (await hasManagedProxyPrereqs()) {
|
|
34
34
|
return "managed";
|
|
35
35
|
}
|
|
36
|
-
if (
|
|
36
|
+
if (getIsContainerized()) {
|
|
37
37
|
return "self-hosted-remote";
|
|
38
38
|
}
|
|
39
39
|
return "self-hosted-local";
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for `getDaemonRuntimeMode()` — ensures the helper reflects
|
|
3
|
-
* `IS_CONTAINERIZED` environment state via the existing truthy-check
|
|
4
|
-
* semantics shared with `getIsContainerized()`.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
|
|
9
|
-
import { getDaemonRuntimeMode } from "../runtime-mode.js";
|
|
10
|
-
|
|
11
|
-
describe("getDaemonRuntimeMode", () => {
|
|
12
|
-
let savedIsContainerized: string | undefined;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
savedIsContainerized = process.env.IS_CONTAINERIZED;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
if (savedIsContainerized === undefined) {
|
|
20
|
-
delete process.env.IS_CONTAINERIZED;
|
|
21
|
-
} else {
|
|
22
|
-
process.env.IS_CONTAINERIZED = savedIsContainerized;
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("returns 'docker' when IS_CONTAINERIZED=true", () => {
|
|
27
|
-
process.env.IS_CONTAINERIZED = "true";
|
|
28
|
-
expect(getDaemonRuntimeMode()).toBe("docker");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("returns 'docker' when IS_CONTAINERIZED=1", () => {
|
|
32
|
-
// Matches the existing truthy-check in getIsContainerized(), which
|
|
33
|
-
// treats "1" as equivalent to "true" for boolean env flags.
|
|
34
|
-
process.env.IS_CONTAINERIZED = "1";
|
|
35
|
-
expect(getDaemonRuntimeMode()).toBe("docker");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("returns 'bare-metal' when IS_CONTAINERIZED is unset", () => {
|
|
39
|
-
delete process.env.IS_CONTAINERIZED;
|
|
40
|
-
expect(getDaemonRuntimeMode()).toBe("bare-metal");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("returns 'bare-metal' when IS_CONTAINERIZED=false", () => {
|
|
44
|
-
process.env.IS_CONTAINERIZED = "false";
|
|
45
|
-
expect(getDaemonRuntimeMode()).toBe("bare-metal");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("returns 'bare-metal' when IS_CONTAINERIZED=0", () => {
|
|
49
|
-
process.env.IS_CONTAINERIZED = "0";
|
|
50
|
-
expect(getDaemonRuntimeMode()).toBe("bare-metal");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("returns 'bare-metal' when IS_CONTAINERIZED is an empty string", () => {
|
|
54
|
-
process.env.IS_CONTAINERIZED = "";
|
|
55
|
-
expect(getDaemonRuntimeMode()).toBe("bare-metal");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("returns 'bare-metal' for arbitrary non-truthy values", () => {
|
|
59
|
-
process.env.IS_CONTAINERIZED = "yes";
|
|
60
|
-
expect(getDaemonRuntimeMode()).toBe("bare-metal");
|
|
61
|
-
});
|
|
62
|
-
});
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Daemon runtime-mode detection.
|
|
3
|
-
*
|
|
4
|
-
* Exposes a single well-named helper `getDaemonRuntimeMode()` that returns
|
|
5
|
-
* `"docker"` when the daemon is running inside a container and
|
|
6
|
-
* `"bare-metal"` otherwise.
|
|
7
|
-
*
|
|
8
|
-
* Under the hood this delegates to `getIsContainerized()` from the env
|
|
9
|
-
* registry, which accepts the standard truthy values for `IS_CONTAINERIZED`
|
|
10
|
-
* (`"true"` or `"1"`). Keeping the check in the registry avoids duplicating
|
|
11
|
-
* the env-parsing semantics across modules.
|
|
12
|
-
*
|
|
13
|
-
* The mode-named API (rather than a boolean) exists to make downstream
|
|
14
|
-
* switch/branch code read naturally — e.g. `if (mode === "docker") { ... }`
|
|
15
|
-
* is clearer than `if (isContainerized) { ... }` when the behavior depends
|
|
16
|
-
* on the specific deployment shape, and it leaves room for additional
|
|
17
|
-
* runtime modes in the future without renaming every callsite.
|
|
18
|
-
*
|
|
19
|
-
* The `DaemonRuntimeMode` type is declared here, alongside the resolver
|
|
20
|
-
* that produces it. `"docker"` describes a daemon running inside a
|
|
21
|
-
* container; `"bare-metal"` describes a daemon running directly on the
|
|
22
|
-
* host.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { getIsContainerized } from "../config/env-registry.js";
|
|
26
|
-
|
|
27
|
-
export type DaemonRuntimeMode = "bare-metal" | "docker";
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Returns the deployment mode the daemon is currently running under.
|
|
31
|
-
*
|
|
32
|
-
* - `"docker"` when `IS_CONTAINERIZED` is set to a truthy value
|
|
33
|
-
* (`"true"` or `"1"`).
|
|
34
|
-
* - `"bare-metal"` otherwise.
|
|
35
|
-
*/
|
|
36
|
-
export function getDaemonRuntimeMode(): DaemonRuntimeMode {
|
|
37
|
-
return getIsContainerized() ? "docker" : "bare-metal";
|
|
38
|
-
}
|