Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/memos-local-plugin/core/embedding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ export { GeminiEmbeddingProvider } from "./providers/gemini.js";
export { CohereEmbeddingProvider } from "./providers/cohere.js";
export { VoyageEmbeddingProvider } from "./providers/voyage.js";
export { MistralEmbeddingProvider } from "./providers/mistral.js";
export {
enterForeground,
isForegroundPending,
yieldIfForegroundPending,
} from "./priority-gate.js";
56 changes: 56 additions & 0 deletions apps/memos-local-plugin/core/embedding/priority-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Foreground-priority gate for the embedding layer.
*
* Problem (#2186): the local ONNX embedding provider runs CPU-bound inference
* sequentially on the main thread. When the background capture pipeline is
* embedding trace rows, a foreground retrieval request (turn.start) must wait
* for the entire batch to finish — often exceeding the host's 8 s prefetch
* timeout.
*
* Solution: a lightweight cooperative yield mechanism. Foreground callers
* signal "I need the embedder NOW" via `enterForeground()`. The local provider
* checks `isForegroundPending()` between individual texts in its batch loop
* and yields the event loop (via `setImmediate`) so the foreground request can
* proceed. Background callers wrap their work in `withBackground(fn)` which
* automatically yields when foreground is pending.
*
* This is intentionally minimal — no queues, no worker threads, no interface
* changes to `Embedder`. It simply gives the single-threaded ONNX inference a
* cooperative scheduling point.
*/

let foregroundCount = 0;

/**
* Signal that a foreground (user-facing) embedding request is about to start.
* Returns a release function that MUST be called when the request completes.
*/
export function enterForeground(): () => void {
foregroundCount++;
let released = false;
return () => {
if (!released) {
released = true;
foregroundCount = Math.max(0, foregroundCount - 1);
}
};
}

/**
* True when at least one foreground embedding request is pending.
* Background batch loops should check this between items and yield.
*/
export function isForegroundPending(): boolean {
return foregroundCount > 0;
}

/**
* Yield the event loop if a foreground request is waiting.
* Call this between expensive synchronous operations (e.g. between
* individual ONNX inference calls in a batch).
*/
export async function yieldIfForegroundPending(): Promise<void> {
if (foregroundCount > 0) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
}
4 changes: 4 additions & 0 deletions apps/memos-local-plugin/core/embedding/providers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
EmbeddingProviderName,
ProviderCallCtx,
} from "../types.js";
import { yieldIfForegroundPending } from "../priority-gate.js";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Extractor = (text: string, options?: Record<string, unknown>) => Promise<any>;
Expand Down Expand Up @@ -74,6 +75,9 @@ export class LocalEmbeddingProvider implements EmbeddingProvider {
const out: number[][] = [];
for (let i = 0; i < texts.length; i++) {
if (ctx.signal?.aborted) throw new DOMException("Aborted", "AbortError");
// Cooperative yield: when a foreground retrieval is waiting, give it a
// chance to proceed between individual inference calls (#2186).
if (i > 0) await yieldIfForegroundPending();
const result = await ext(texts[i]!, { pooling: "mean", normalize: true });
const arr = (result as { data?: Float32Array }).data;
if (!arr) {
Expand Down
36 changes: 20 additions & 16 deletions apps/memos-local-plugin/core/retrieval/retrieve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
import { ERROR_CODES } from "../../agent-contract/errors.js";
import { ids } from "../id.js";
import { rootLogger } from "../logger/index.js";
import { enterForeground } from "../embedding/priority-gate.js";
import { collectDecisionGuidance } from "./decision-guidance.js";
import { buildQuery, type CompiledQuery } from "./query-builder.js";
import type { RetrievalEventBus } from "./events.js";
Expand Down Expand Up @@ -254,22 +255,25 @@ async function runAll(
degraded: false,
};
const queryVec = compiled.text
? await deps.embedder.embed(compiled.text, "query").then((vec) => {
embeddingStats.ok = true;
return vec;
}).catch((err) => {
const code = (err as { code?: string })?.code;
const message = err instanceof Error ? err.message : String(err);
embeddingStats.degraded = true;
embeddingStats.errorCode = code;
embeddingStats.errorMessage = message;
log.warn("embed_failed", {
reason: ctx.reason,
code,
err: message,
});
return null;
})
? await (() => {
const release = enterForeground();
return deps.embedder.embed(compiled.text, "query").then((vec) => {
embeddingStats.ok = true;
return vec;
}).catch((err) => {
const code = (err as { code?: string })?.code;
const message = err instanceof Error ? err.message : String(err);
embeddingStats.degraded = true;
embeddingStats.errorCode = code;
embeddingStats.errorMessage = message;
log.warn("embed_failed", {
reason: ctx.reason,
code,
err: message,
});
return null;
}).finally(release);
})()
: null;

// The keyword channels (FTS + pattern) work even without an embedder,
Expand Down
67 changes: 67 additions & 0 deletions apps/memos-local-plugin/tests/unit/embedding/priority-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
enterForeground,
isForegroundPending,
yieldIfForegroundPending,
} from "../../../core/embedding/priority-gate.js";

describe("priority-gate", () => {
beforeEach(() => {
// Drain any leftover foreground counts from prior tests.
while (isForegroundPending()) {
// Force-release by entering and releasing.
const r = enterForeground();
r();
// If still pending after one release, something is wrong — break to
// avoid infinite loop in test.
break;
}
});

it("starts with no foreground pending", () => {
expect(isForegroundPending()).toBe(false);
});

it("enterForeground increments and release decrements", () => {
const release = enterForeground();
expect(isForegroundPending()).toBe(true);
release();
expect(isForegroundPending()).toBe(false);
});

it("multiple foreground calls stack", () => {
const r1 = enterForeground();
const r2 = enterForeground();
expect(isForegroundPending()).toBe(true);
r1();
expect(isForegroundPending()).toBe(true);
r2();
expect(isForegroundPending()).toBe(false);
});

it("release is idempotent", () => {
const release = enterForeground();
release();
release(); // double-release should not go negative
expect(isForegroundPending()).toBe(false);
});

it("yieldIfForegroundPending resolves immediately when no foreground", async () => {
const t0 = Date.now();
await yieldIfForegroundPending();
expect(Date.now() - t0).toBeLessThan(50);
});

it("yieldIfForegroundPending yields via setImmediate when foreground pending", async () => {
const release = enterForeground();
let yielded = false;
const yieldPromise = yieldIfForegroundPending().then(() => {
yielded = true;
});
// Before the microtask/setImmediate fires, yielded should be false.
expect(yielded).toBe(false);
await yieldPromise;
expect(yielded).toBe(true);
release();
});
});
Loading