Skip to content

fix(memory): measure the embedding width instead of waiting for a probe - #12180

Merged
diegosouzapw merged 6 commits into
diegosouzapw:release/v3.8.51from
ntdatt812:fix/12154-embedding-lazy-probe
Sep 2, 2026
Merged

diegosouzapw merged 6 commits into
diegosouzapw:release/v3.8.51from
ntdatt812:fix/12154-embedding-lazy-probe

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

Fixes #12154. Your diagnosis is right, and tracing it showed the two paths are deadlocked on the same null in opposite ways.

A lazy probe nobody performs

EmbeddingResolution.dimensions is documented as "null antes da 1ª chamada (lazy probe)" — null until the first call. Nothing ever performs that call:

path what it did
scheduleVectorUpsert (store.ts) called ensureReady() with the null resolution — which declines to create vec_memories — then ignored the {ready:false} answer and upserted anyway, straight into the catch. Memory stored, needs_reindex set, never vectorized.
runReindexBatch (reindex.ts) gated on ensureReady() before embedding, so it returned {processed:0, errors:0} and the queue never drained.

So the width could only come from an embedding, and neither path would embed until it had the width. That is why your instances A and B sat at rowCount: 0 with 24 pending, and why C only worked from a signature an older build had already written. Nothing surfaced it because the upsert is fire-and-forget: POST /api/memory still returns 200 and health still reports working: true.

The fix: measure it

withMeasuredDimensions(resolution, width) fills the pending null in from a vector that has actually come back, and rebuilds the signature the way the resolution built it — identity first, then model — so two endpoints serving the same model id still get separate signatures and reindex independently. A width the registry already supplied is never overwritten, and a nonsense measurement (0, negative, fractional, NaN) is ignored rather than written into a signature.

  • store.ts already holds a finished vector when it calls ensureReady, so it just passes vector.length. It now also honours a {ready:false} answer instead of upserting into a table that is not there.
  • reindex.ts spends one embedding up front to measure, and reuses that vector for its own item rather than paying for it twice.

Registry-described models are unaffected: their dimensions is already non-null, so the helper returns the resolution unchanged and neither path changes behaviour.

Tests

tests/unit/memory-vec-lazy-probe-12154.test.ts, 7 tests: the pending null is filled; the signature keeps the endpoint identity and gains the width; a resolution without an identity signs by model; a known width is never overwritten; four kinds of nonsense measurement are ignored; a source-less resolution stays unusable; and two endpoints serving the same model id do not collide.

mutation result
helper becomes a no-op 4 failed
sign by model, ignoring identity 2 failed

Commands run

node --import tsx/esm --test tests/unit/memory-vec-lazy-probe-12154.test.ts    7 pass, 0 fail
eslint <4 changed files> --suppressions-location config/quality/eslint-suppressions.json
                                                                              No issues found
tsc --noEmit -p tsconfig.json                                                  no errors in the changed files

Changed test files: tests/unit/memory-vec-lazy-probe-12154.test.ts (new).

The unit tests cover the resolution/signature logic, which is where the null originated. I have not run a self-hosted TEI server against this build, so the end-to-end claim — that vec_memories is now created and rowCount climbs — is reasoned from the two call sites above rather than observed. If you have one of the affected instances handy, that is the check worth doing before merge.

@ntdatt812
ntdatt812 force-pushed the fix/12154-embedding-lazy-probe branch from 3c289e1 to 56513ba Compare August 31, 2026 10:10
@diegosouzapw
diegosouzapw changed the base branch from main to release/v3.8.51 August 31, 2026 17:07
@diegosouzapw

Copy link
Copy Markdown
Owner

Retargeted from main to release/v3.8.51 (the active development branch). Heads-up on why: main only receives work at the release squash, so it currently carries an inherited base-red (#12133) whose failing checks — unit-test/pack-artifact gates killed on timeout — have nothing to do with your diff. Against the release branch your CI runs clean. No action needed from you; thanks for the fix!

@diegosouzapw

Copy link
Copy Markdown
Owner

Heads-up @ntdatt812 — I retargeted this from main to release/v3.8.51 (the active development branch), but that surfaced a second issue the retarget alone cannot fix.

Because the branch was cut from main, the diff now carries 28 files: your actual fix is only 4 of them —

  • src/lib/memory/embedding/index.ts
  • src/lib/memory/reindex.ts
  • src/lib/memory/store.ts
  • tests/unit/memory-vec-lazy-probe-12154.test.ts

— while the other 24 are main-only infra that already lives on the release branch through separate main-twin PRs (CI workflows, Dockerfile/Dockerfile.bun, the electron lockfile, several changelog.d entries). Merging as-is would replay that infra back over the release branch.

Could you re-derive the branch from the current release/v3.8.51 tip and keep just your 4 files?

git remote add upstream https://github.com/diegosouzapw/OmniRoute.git
git fetch upstream
git switch -c fix/12154-embedding-lazy-probe upstream/release/v3.8.51
git checkout <your-branch> -- src/lib/memory/embedding/index.ts src/lib/memory/reindex.ts src/lib/memory/store.ts tests/unit/memory-vec-lazy-probe-12154.test.ts

The fix itself looks right and #12154 is a genuine bug (memories stored but never vectorized, while /api/memory/health reports working: true) — we want this landed. Also note the CI failures currently shown on this PR are from the pre-retarget runs against the red main, not your code.

@ntdatt812

Copy link
Copy Markdown
Contributor Author

CI is red on this PR and neither failure is the diff. Evidence rather than a re-run request.

Build — not a build failure at all:

##[error]The runner has received a shutdown signal. This can happen when the runner
service is stopped, or a manually started runner is canceled.
##[error]The operation was canceled.

The same thing hit #12177 in the same window, and main's own CI history shows the pattern independently — of the last twelve runs on main, seven are cancelled. Nothing was compiled and nothing failed.

Vitest — one test, tests/unit/ui/use-provider-models-auto-fetch.test.tsx > synchronizes upstream models only when autoFetchModels is explicitly true, in a file this branch does not touch. It passes locally on this branch and on origin/main, both PASS (2) FAIL (0).

The mechanism looks like a fixed-sleep race rather than luck. The helper is:

async function flushQueuedSync() {
  await new Promise<void>((resolve) => setTimeout(resolve, 10));
  await Promise.resolve();
}

The hook has to complete /api/providers, queue the sync, then issue /api/providers/connection-1/sync-models?mode=sync — two awaited round-trips through the mock plus the effect flushes between them — and the assertion is toHaveBeenCalledWith on that second call. Ten milliseconds of wall clock is the whole budget. That run reported environment 1375.26s and import 908.89s, so the runner was heavily contended; on a quiet machine 10ms is plenty and on that one it was not.

If it is worth fixing rather than re-running, the smallest change is to wait on the condition instead of the clock:

await vi.waitFor(() =>
  expect(fetchMock).toHaveBeenCalledWith("/api/providers/connection-1/sync-models?mode=sync", expect.anything()),
);

which keeps the negative test above it honest — that one asserts the call is never made, so it still needs a bounded wait rather than a condition. Happy to send it as its own PR if you would like; it is unrelated to this branch and should not ride on it.

This branch's own suite, tests/unit/memory-vec-lazy-probe-12154.test.ts, is 7 passed.

resolveEmbeddingSource() reports dimensions: null for any source the
hard-coded registry does not describe, and a self-hosted endpoint is by
definition absent from it. Both write paths then deadlocked on that null:

- scheduleVectorUpsert called ensureReady() with the null resolution, which
  declines to create vec_memories, and then ignored the {ready:false} answer
  and upserted anyway -- straight into the catch, so every memory was stored,
  marked needs_reindex, and never vectorized;
- reindexPending refused to embed until the width was known, and the width
  could only ever come from an embedding.

Nothing surfaced it: POST /api/memory returned 200 and the health check
stayed green while rowCount stayed at 0.

The comment on EmbeddingResolution.dimensions already calls this a lazy
probe; nobody performed the probe. The upsert path holds a finished vector
when it calls ensureReady, so measure it there, and let reindex spend one
embedding up front to measure -- reusing that vector rather than paying for
it twice. withMeasuredDimensions rebuilds the signature the same way the
resolution did, identity first, so two endpoints serving the same model id
still reindex independently.

scheduleVectorUpsert now also honours a {ready:false} answer instead of
upserting into a table that is not there.

Fixes diegosouzapw#12154
@diegosouzapw
diegosouzapw force-pushed the fix/12154-embedding-lazy-probe branch from 56513ba to 4436689 Compare September 1, 2026 12:41
diegosouzapw and others added 2 commits September 1, 2026 11:33
…green

runReindexBatch grew past max-lines-per-function and cognitive-complexity
when the lazy-probe path landed. Split measure/ready/item helpers without
changing the diegosouzapw#12154 behavior.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw

Copy link
Copy Markdown
Owner

Babysit (/sweep-reds round 4) — not merging. Did not merge origin/release/v3.8.51 (#12335 still open).

Reds before on f3d6afbd56: Fast Quality Gates (complexity-ratchets), Unit Tests fast-path 3/4 + 4/4, No new ESLint warnings.

Own complexity (this PR's diff): already extracted in f3d6afbd56 (measureUnknownWidth / ensureReindexStoreReady / reindexOneItem in src/lib/memory/reindex.ts). Against merge-base 9d04995950 this PR touches 3 source files in scope (embedding/index.ts, reindex.ts, store.ts) — none of them are in the ratchet regression list.

Remaining Fast Quality Gates hit is inherited, not this diff:

[complexity] REGRESSÃO — open-sse/services/autoCombo/strictZeroCostFilter.ts: 0 → 1
[cognitive-complexity] REGRESSÃO — open-sse/services/autoCombo/strictZeroCostFilter.ts: 0 → 1

That file is not in this PR. The 0→1 is #12319 (2f33f2c20d) on the red release/v3.8.51 tip, visible because CI new-code mode diffs the GitHub merge-ref against stale base.sha 9d04995950. Not chasing it here.

Units 3/4 + 4/4 + ESLint are inherited (drain in #12327 / #12331):

  • settings-i18n-keys / locale __MISSING__ → radarPage.colLimits / trainsOnPrompts*
  • createSSEStream passthrough usage-only chunks
  • unused collectSSE / stream / writable in tests/unit/stream-passthrough-usage-estimation.test.ts

Local: tests/unit/memory-vec-lazy-probe-12154.test.ts 7/7 pass. No push.

HOLD until the base-red drain lands. HEAD remains f3d6afbd56.

@diegosouzapw

Copy link
Copy Markdown
Owner

Merge-refresh after drain #12327 (1586476183). Clean merge.

Embedding lazy-probe delta unchanged (reindex.ts helpers). Remaining FQG hit on strictZeroCostFilter.ts from #12319 is still inherited if that file's ratchet is on the tip — not this PR's 5 files.

New HEAD: a257aa26f5. Not merging this PR.

@diegosouzapw
diegosouzapw merged commit 9327990 into diegosouzapw:release/v3.8.51 Sep 2, 2026
15 of 16 checks passed
@diegosouzapw

Copy link
Copy Markdown
Owner

Merged into release/v3.8.51 as 9327990be6 — thanks @ntdatt812. It lands in the next release notes.

@diegosouzapw

Copy link
Copy Markdown
Owner

Merging with --admin: the frozen red on this PR (Unit fast-path 4/4) ran against a merge-ref cut before #12327 drained the 2026-09-01 base-red window (settings-i18n-keys / placeholder-fallback lived in that shard). The fork is now locked (maintainerCanModify=false), so a refresh push to re-trigger CI is not possible. Evidence: the rebuilt branch's own test suite passed on push day (7/7 memory-vec-lazy-probe + typecheck + eslint), and the merge result against today's tip re-validated locally just now — 7/7 again. Closes the #12154 fix loop.

muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…be (diegosouzapw#12180)

* fix(memory): measure the embedding width instead of waiting for a probe

resolveEmbeddingSource() reports dimensions: null for any source the
hard-coded registry does not describe, and a self-hosted endpoint is by
definition absent from it. Both write paths then deadlocked on that null:

- scheduleVectorUpsert called ensureReady() with the null resolution, which
  declines to create vec_memories, and then ignored the {ready:false} answer
  and upserted anyway -- straight into the catch, so every memory was stored,
  marked needs_reindex, and never vectorized;
- reindexPending refused to embed until the width was known, and the width
  could only ever come from an embedding.

Nothing surfaced it: POST /api/memory returned 200 and the health check
stayed green while rowCount stayed at 0.

The comment on EmbeddingResolution.dimensions already calls this a lazy
probe; nobody performed the probe. The upsert path holds a finished vector
when it calls ensureReady, so measure it there, and let reindex spend one
embedding up front to measure -- reusing that vector rather than paying for
it twice. withMeasuredDimensions rebuilds the signature the same way the
resolution did, identity first, so two endpoints serving the same model id
still reindex independently.

scheduleVectorUpsert now also honours a {ready:false} answer instead of
upserting into a table that is not there.

Fixes diegosouzapw#12154

* chore(changelog): point the fragment at the real PR number

* fix(memory): extract reindex helpers so the complexity ratchet stays green

runReindexBatch grew past max-lines-per-function and cognitive-complexity
when the lazy-probe path landed. Split measure/ready/item helpers without
changing the diegosouzapw#12154 behavior.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(backend): vec_memories never created for self-hosted embedding endpoints — memories stored but never vectorized, health stays green

2 participants