LearnNewsExamplesServices
Frontmatter
id16861
titleNative Ollama embedding has no admission control, so concurrency is emergent
stateClosed
labels
enhancementaiarchitecture
assigneesneo-opus-ada, neo-gpt
createdAtAug 10, 2026, 10:34 AM
updatedAtAug 10, 2026, 2:17 PM
githubUrlhttps://github.com/neomjs/neo/issues/16861
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 10, 2026, 2:17 PM

Native Ollama embedding has no admission control, so concurrency is emergent

Closed Backlog/active-chunk-14 enhancementaiarchitecture
neo-opus-ada
neo-opus-ada commented on Aug 10, 2026, 10:34 AM

Context

#16780 observed a plane holding ~4 cores of continuous inference against an empty Knowledge Base. Its AC-5 asks that in-flight embedding concurrency be declared, enforced and reported, on the reasoning that "how many embedding requests Neo may have in flight against one provider is emergent from how many callers exist."

Running the falsifier on that sentence partly refuted it, and the asymmetry is what this ticket delivers.

Live latest-open sweep: latest 20 open issues at 2026-08-10T08:33:41Z. A2A claim sweep over the herd window: #16860, #16859, #16856, #16855, #16853 — all adjacent, none overlapping (see Out of Scope). No equivalent found.

The Problem

Path Enforced Declared Reported
openAiCompatible yes, at 1#enqueueOpenAiCompatiblePost#drainOpenAiCompatiblePostQueue runs posts one at a time, interactive-first no no
ollama no mechanism at all no no

The openAiCompatible path is already serialized. Its own comment states the intent: "keeps TextEmbeddingService from creating competing local-provider concurrency." So the defect there is not unbounded work — it is an undeclared number. Nothing names the cap, no fixture pins it, and a refactor could raise it to 4 with nothing failing.

The ollama path has no admission control. It reaches the provider through observeUnqueuedProviderActivity (ai/services/shared/providerActivityLedger.mjs:322), which builds its lifecycle with queueDisposition: 'not-applicable' and then simply await task(). It observes; it does not admit. Concurrency there is a function of how many callers happen to exist.

That is the path the observed incident ran on. #16853 has since measured a single early-aborted ollama request holding ~4 cores on a controlled reproduction — so "one stuck request versus four" is the difference between a slow plane and a saturated one.

The Architectural Reality

ai/services/memory-core/TextEmbeddingService.mjs. #embedOllama is the single choke point — both the per-text (embedText) and batch (embedTexts) dispatches funnel through it, so one admission gate covers the path.

The declaration belongs with the existing ollama leaf group at ai/configBase.mjs:653 beside embeddingTimeoutMs and requireParallelModels, per ADR-0019 (leaf(default, env, type), read at the use site).

One timing constraint that is not obvious. An await before dispatch — even one that resolves immediately — returns control to the caller. A caller that aborts on the next line then cancels before the provider is reached. The provider-neutral cancellation contract (#15694) asserts the signal arrives at the provider, and an unconditional await breaks it: the request is never dispatched at all. Admission must be free when there is room, or it silently re-times every caller's cancellation.

The Fix

  • Declare ollama.maxInFlightEmbeddings (default 1, matching the openAiCompatible path's long-standing effective behaviour, so no deployment's shape changes).
  • Admit in #embedOllama: synchronous when a slot is free, awaited only when the cap binds. Release in a finally.
  • Report {cap, inFlight, waiting}.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
ollama.maxInFlightEmbeddings (new leaf) ai/configBase.mjs admission cap for native ollama embedding none — a value below 1 throws rather than admitting nothing forever leaf comment cap-of-1 and cap-of-2 fixtures
getOllamaEmbeddingAdmission() (new) TextEmbeddingService {cap, inFlight, waiting} n/a JSDoc asserted in the serialization fixture
#embedOllama dispatch timing TextEmbeddingService unchanged when uncontended n/a JSDoc the #15694 cancellation contract stays green

Decision Record impact

aligned-with ADR 0019 — a plain leaf(default, env, type) read at its use site; no helper, no env-check, no hidden default.

Acceptance Criteria

Delivered by PR #16862 at faf2e579e0. 26 passed on TextEmbeddingService.spec.mjs; 5917 passed across test/playwright/unit/ai/services/ + test/playwright/unit/ai/daemons/.

  • A declared leaf names the cap; read at the use site on every admission, so an operator override applies to the next request rather than the next process start. — ai/configBase.mjs ollama group; #tryAcquireOllamaEmbeddingSlot reads aiConfig.ollama.maxInFlightEmbeddings per attempt.

    This criterion had no falsifier until the pre-push AC walk. The peak-overlap fixtures all set the cap before any call, so a value captured once at construction satisfied every one of them — the clause about an override applying to the next request was ticked on a behaviour nothing tested. Now covered by "a RAISED cap applies to the next admission, not the next process start": one request in flight, cap raised with nothing released, next caller admitted concurrently. Red-proofed by freezing the cap at first read, which reddens exactly that test.

    The AC's earlier wording also over-claimed — it said a raised cap "can wake waiters it now has room for". It cannot: nothing watches the config, so queued waiters are only re-checked when a release wakes them. Corrected here and in the source comment.

  • With the cap at 1, three concurrent native ollama embeds produce a peak overlap of 1. — "the default cap SERIALIZES native Ollama embedding"; peak asserted both mid-sequence and after the full drain.

  • Control: with the cap at 2, peak overlap is exactly 2. — "the declared cap ADMITS at its number". Deliberately written and run first: without it, accidentally-serial code satisfies the criterion above and the suite certifies a cap that does nothing.

  • A failing embed returns its slot; N consecutive failures leave the path as open as it started. — "a FAILING embed returns its slot"; release is in a finally.

  • A cap below 1 fails loud rather than admitting nothing forever. — "a cap below 1 fails LOUD".

  • The uncontended path does not await before dispatch. — #tryAcquireOllamaEmbeddingSlot is synchronous; only #awaitOllamaEmbeddingSlot awaits, and only when the cap binds. The provider-neutral cancellation contract stays green — it red-proved this: the first implementation awaited unconditionally and the contract test caught that the request was never dispatched.

  • {cap, inFlight, waiting} is reportable. — getOllamaEmbeddingAdmission(), asserted as {cap: 1, inFlight: 1, waiting: 2} inside the serialization fixture.

  • The leaf actually SHIPS to every service that runs the capped path. (added after CI — see below.) NEO_OLLAMA_MAX_INFLIGHT_EMBEDDINGS is passed through to kb-server, mc-server and orchestrator in ai/deploy/docker-compose.yml, with a per-service disposition row and a reviewable reason in OllamaProviderEnvCoordinates.spec.mjs, plus the compose census updated.

    Added because CI caught what local runs did not. OllamaProviderEnvCoordinates.spec.mjs has a gate whose comment reads "the next added leaf cannot silently fail to ship" — a new NEO_OLLAMA_* leaf lands in the declared set immediately and reddens until someone decides, per service, whether it ships. It caught me exactly as designed. My original AC list declared a cap and never asked whether it reached a container, which is the same "declared but inert" failure the cap itself exists to prevent, one layer up.

    Required on all three services rather than only where embedding is heaviest: Orchestrator.mjs calls TextEmbeddingService.embedTexts for Dream and TenantRepoSyncService calls embedText, so the re-embed sweeps that hold the heavy-maintenance lease run through this gate. An unshipped cap reads as enforced from every surface while admitting without limit — strictly worse than no cap.

  • Coverage fails against today's code and passes against the repair. — replacing the cap read with a constant reddens both cap tests and nothing else: 2 failed, 24 passed. This spec is not mode: 'serial', so both failures are reported rather than the run stopping at the first.

Defect found in review — a QUEUED caller cannot observe its own abort

@neo-gpt measured it and I confirmed it structurally against c204c4dff5:

  • #awaitOllamaEmbeddingSlot() takes no signal parameter — the signal never enters its scope.
  • Its waiter promise has exactly two resolvers, both on the release path (#releaseOllamaEmbeddingSlot, and the wake handoff when an invalid cap throws).
  • So a caller queued behind the cap that aborts stays hung until the in-flight request releases. Not a timing artifact — the only possible outcome.

The admission gate introduced a wait point and did not carry cancellation through it. The synchronous/asynchronous split in this ticket exists because of cancellation — an unconditional await before dispatch re-times every caller's abort, which the provider-neutral contract test caught. So the reasoning covered the caller who does not wait and never asked about the one who does.

Recorded here rather than only in the PR thread because it changes what this ticket must deliver: an admission cap is not correct merely because it bounds concurrency; it must also not swallow a cancellation it created the opportunity for.

@neo-gpt has claimed the bounded repair under the operator delivery policy, plus the positiveInt leaf type now that #16847 has merged. I remain author and will independently falsify the repaired head — specifically whether an aborted queued caller forfeits its place and wakes the caller behind it, since my own invalid-cap handoff had exactly that bug.

Out of Scope

  • A second limiter on the openAiCompatible path. That queue is correct and its interactive-first ordering is load-bearing. This declares the cap that exists there; it does not re-implement it.
  • How a request ENDS#16853 (early abort strands the runner), #16849 (Ollama.stream() has no timeout/abort), #16860 (a slow plane cannot raise its own deadlines). This ticket governs whether a request is admitted.
  • Cross-process concurrency. The cap is per process. N processes each holding their cap is a deployment-topology question, and naming it here would over-claim what an in-process gate can enforce.

Avoided Traps

  • An unconditional await on admission. Breaks the #15694 cancellation contract by handing control back before dispatch. Caught by an existing test, not by review.
  • Releasing only on success. Walks the cap to zero after N failures and stalls the path permanently while every surface reports an idle service.
  • A cap-of-1 assertion with no cap-of-2 control. Accidentally-serial code passes it, and the suite then certifies a cap that does nothing.

Related

#16780 (parent — AC-5), #16853, #16849, #16860, #16850.

Deployment dependency: #16850 reports that 5 of 6 NEO_OLLAMA_* vars never reach the containers. Until that lands, this leaf resolves locally and is inert on deployed planes — so post-merge validation must assert the resolved value inside a container, never the declared default. Also consistent with @neo-opus-grace's finding on #16860 that ollama's server-side NUM_PARALLEL does not need raising: a client-side cap of 1 is the matching shape, not a workaround for it.

Origin Session ID: 87f453f9-aa80-4487-9ed1-b5d91e052c43

Retrieval Hint: ollama embedding admission control in-flight cap maxInFlightEmbeddings observeUnqueuedProviderActivity

Authored by Ada (Claude Opus 5, Claude Code).

tobiu referenced in commit 7ef07a7 - "feat(memory-core): declare and enforce native Ollama embedding admission (#16861) (#16862) on Aug 10, 2026, 2:17 PM
tobiu closed this issue on Aug 10, 2026, 2:17 PM