Repository navigation
Conversation
… query path Two gaps showed up on a CPU embed backend (Qwen3-Embedding-8B, the documented ~23 tok/s) with a ~10k-token block (issue damienmoon/ctx#1): 1. backfillOneEmbedding was called only at the top of runDreamLoop, so with dream.enabled=false (the default) or Dream switched off at runtime nothing embedded pending blocks in the background. runEmbedBackfillLoop is now its own goroutine, started regardless of Dream: a successful pick loops at once, an empty pick waits embed_backfill.interval (default 15 s), 0 = arm off, polled for a hot re-enable. The dream loop keeps its inline call; the tx-wrapped FOR UPDATE SKIP LOCKED pick makes two pickers safe (Welle-49). 2. backfillPending had a count cap (sync_cap) but no time budget: one slow block consumed the caller's whole deadline (120 s on the CLI client), the question's own embed was never admitted and the query answered 500, and the failure was memoized as class wire although the backend was only slow for this caller. embed_backfill.sync_budget (seconds, default 30, 0 = unbounded) bounds the whole pre-search loop per request; each wire call runs under what is left. A block that overruns it, or dies with the request context, is memoized with the new class caller_timeout: next_attempt_at = now(), the query path never picks it again (EmbedFailureQueryPathExcludedPredicate), the background arm picks it at once, and a later wire failure there reclassifies it as wire with the ordinary backoff. Also: the "german detected" log line is now "non-english heuristic matched" (the heuristic fires on Swedish, Danish and Finnish too; the translation was right, the label was not). Tests (Rot/Grün through the production functions): - handler: TestBackfillPending_SyncBudget_Integration — SyncBudget=0 holds the request for the backend's full delay (red); SyncBudget=300ms returns inside the budget with the parent context intact, the backend sees the cancel, the memo is caller_timeout and due now, a second backfillPending makes no wire call, the background predicate still sees the block; a request cancel mid-embed classifies caller_timeout, not wire. - events: TestEmbedBackfillArm_RunsWithoutDream_Integration — the arm embeds a pending block with DreamEnabled=false, picks a caller_timeout block at once, and Interval=0 turns it off. - compose and description gates: TestComposeDeclaresEveryRegistryKey and TestKeyDescriptionsComplete pass with the two new keys. Run on 2026-10-09 in golang:1.27-alpine with CTX_TEST_PG_IMAGE=localhost/ ctx-db:38fdb74f: go build ./..., go vet -tags=integration, go test -short for config/store/handler/events (one pre-existing failure, TestRebuildViaWorker_ TimeoutKillsHangingChild, fails identically on the unpatched checkout in this container), and the backfill integration suites of handler, events and store all green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The comments and the operations paragraph said "issue damienmoon/ctx#1", which on GitHub reads as a reference into a repository that does not exist there. The issue lives on the author's Forgejo, a read-only mirror of the GitHub fork: https://force-push.me/damienmoon/ctx/issues/1. Comments only; no code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
embed_backfill.sync_budget and embed_backfill.interval are two new typDuration keys. TestValidateRejectsNegativeOnEveryDurationKey pins the count as a drift guard (45 before this branch) and failed in CI. Both keys are covered by the generic V17 sign walk, which the test exercises per key; the pin moves to 47 in the commit that explains why, as the comment above it asks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tracking issue, with the full server and llama.cpp logs: https://force-push.me/damienmoon/ctx/issues/1 (my Forgejo;
damienmoon/ctxthere is a read-only mirror of this fork).Summary
On a fresh v5.21.1 install with the documented CPU embed backend (Qwen3-Embedding-8B on llama.cpp, about 23 tokens per second), storing one block of about 10k tokens made every following
ctx queryfail with HTTP 500 after 120 seconds. Two things combine to produce that:backfillOneEmbeddingis called only at the top ofrunDreamLoop. Withdream.enabled=false, the registry default, or with Dream switched toDreamModeOffat runtime, no goroutine embeds pending blocks in the background. The query path'sbackfillPendingis the only embedder, and it runs inside the caller's deadline.backfillPendinghas a count cap but no time budget.embed_backfill.sync_capbounds how many blocks a request embeds inline, not how long. One block that needs seven minutes consumes the CLI client's entire 120-second deadline; the question's own embed is never admitted (embedcache: admission: context canceled) and the query answers 500 instead of degrading. The failure is memoized as classwire, which means "backend down", although the backend was healthy and only slow for this caller. Every query that lands after the backoff lapses repeats the burn.This PR adds a dedicated backfill goroutine that runs regardless of Dream, a wall-clock budget for the query path's inline backfill, and a memo class that hands an overrunning block to the background arm instead of parking it on the wire backoff curve.
Observed
Server log of the failing request (
POST /api/querythrough the CLI client, default timeout 120 s):llama.cpp on the same block:
prompt processing, n_tokens = 3072, progress = 0.30, t = 110.30 s / 27.85 tokens per second, thensrv stop: cancel task. The same query over/mcp, which carries no deadline, ran 317 seconds, embedded the block and answered 200. Turning Dream on made the scheduler embed the next pending block within 27 seconds, which is what pointed at the gating.Change
internal/events/scheduler.gorunEmbedBackfillLoop, started unconditionally inRun. It does the same work as the call at the top of the dream loop,backfillOneEmbeddingunder the iterated tenant's snapshot and router, on its own cadence: a successful pick loops at once, an empty pick waitsembed_backfill.interval, an error waits 10 s. Under!enforcing()it yields to interactive demand exactly like the dream loop; under Enforcing the wire call waits at its target in its background lease.interval <= 0turns the arm off; it then polls the config every 15 s so a hot enable needs no restart. Each pick runs under its ownguardPanic. The dream loop keeps its inline call; the tx-wrappedFOR UPDATE SKIP LOCKEDpick makes two pickers safe (Welle-49).internal/handler/query.gobackfillPendinggets a wall-clock budget. Each wire call runs undercontext.WithDeadline(ctx, budgetDeadline), and the loop head checks the budget before the next pick. A call cut by the budget or by the request context is classifiedcaller_timeout; the embed context's error is read before its cancel, so a genuine wire error on a live context still classifieswireoroversize. The pick uses the query-path predicate below.internal/store/embed_failures.goEmbedFailureCallerTimeout = "caller_timeout".RecordEmbedFailurewrites it withnext_attempt_at = now(), so the block is eligible for the background arm at once whileattemptsstill counts. NewEmbedFailureQueryPathExcludedPredicate: the shared backoff exclusion pluslast_class = 'caller_timeout', so the query path never re-picks such a block; the background arm keeps the plain predicate. A later wire failure in the arm reclassifies the block aswirewith the ordinary curve.last_classis unconstrainedTEXT(migration 113, line 11286), so there is no schema change.internal/config/config.go,descriptions.goembed_backfill.sync_budget(seconds, default 30,0= unbounded, hot, global-only) andembed_backfill.interval(seconds, default 15,0= arm off, hot, global-only). Both follow the explicit opt-out convention ofsync_cap.docker-compose.ymlembed_backfill.*block, soTestComposeDeclaresEveryRegistryKeyholds.docs/operations.mdinternal/handler/query.go, one log linegerman detected, translatingbecomesnon-english heuristic matched, translating. The heuristic fires on umlauts and stopwords, so Swedish, Danish and Finnish questions land there as well; the translation was right, the label was not.The branch has three commits: the fix (
8e11ca79), a comments-only follow-up that names the tracking issue by URL (83a0aca3), and the bump of the duration-key drift guard invalidate_test.gothat CI asked for (218d5f84).Behavior before and after
intervalDreamModeOff)wire, repeated after the backoffsync_budget, memocaller_timeout, the question embed runs, the query answers; the arm embeds the blockwire, exponential backoffoversize, infinitysync_budget=0,interval=0The defaults change observable behavior in two ways: a background goroutine now exists in every deployment, and a query spends at most 30 seconds on inline backfill. A deployment that relies on the inline loop draining a backlog in one request can set
sync_budget=0.Tests
Rot/Grün through the production functions, in the style of
backfill_synccap_integration_test.goandbackfill_headofline_integration_test.go:internal/handler/backfill_syncbudget_integration_test.go,TestBackfillPending_SyncBudget_Integrationred_unbudgeted_waits_for_the_slow_backend:SyncBudget=0holds the request for the backend's full 1.5 s delay.budget_cuts_the_slow_block_and_hands_it_to_the_arm:SyncBudget=300msagainst a 5 s backend returns inside the budget with the parent context intact; the backend sees the cancel; the memo iscaller_timeoutwithnext_attempt_at <= now()andattempts = 1; a secondbackfillPendingwith a 10 s budget makes no wire call; the shared predicate still returns the block for the arm.request_cancel_is_caller_timeout_not_wire: a request cancelled mid-embed classifiescaller_timeout.internal/events/backfill_arm_integration_test.go,TestEmbedBackfillArm_RunsWithoutDream_Integrationarm_embeds_a_pending_block_with_dream_off: withStartupConfig{DreamEnabled: false}the arm alone embeds a pending block within 5 s atInterval=100ms.arm_picks_a_caller_timeout_block_at_once: acaller_timeoutmemo does not hold the arm back.interval_zero_turns_the_arm_off: a pending block stays pending.TestBackfillPending_SyncCap_Integration,TestBackfillPending_MemoSurvivesRequestCancel,TestBackfillPending_ExcludedType,TestBackfillHeadOfLine_*,TestBackfillQI3_Integration,TestBackfillExcludedType_Integration, thestoreEmbedFailure and ClearEmbedding suites, and theconfiggatesTestComposeDeclaresEveryRegistryKeyandTestKeyDescriptionsComplete.Run in
golang:1.27-alpinewithCTX_TEST_PG_IMAGEpointing at a locally built PostgreSQL 18 + pgvector + TimescaleDB image:go build ./...,go vet -tags=integration ./internal/{config,store,handler,events}/...,go test -shorton the same packages, and the integration suites above. One pre-existing-shortfailure in that container,TestRebuildViaWorker_TimeoutKillsHangingChild, fails identically on unpatched38fdb74f(the helper process reports a Go runtime deadlock instead of being killed) and is unrelated to this change.Live confirmation
Deployed on the host that hit the issue at the fix commit as first pushed,
a677b2d7, whose tree is identical to8e11ca79on this branch (the branch was re-signed before opening this PR) (48 vCPU VPS, Qwen3-Embedding-8B Q8_0 on CPU,--threads 16, Dream on) and exercised on the path that failed originally, the CLI client with its 120 s timeout:ctx saveof a 29,163-character block (7,117 tokens)ctx querythrough the same clientcaller_timeout(embed-backfillllmlog row: 29,998 ms, no tokens); question embed served; search ranscheduler: backfilling embedding) 6 s after the memoscheduler: embedding backfilled, 7,117 tokens in 276,893 ms (25.9 tokens per second)The same sequence on
38fdb74fanswered 500 after 120 s. llama.cpp shows the inline attempt cancelled at 1,024 tokens, so a cut attempt no longer occupies the backend for the full deadline.Out of scope, noted on the way
MaxBlockChars = 1500ininternal/llm/synthesize.gotruncates every source in the synthesis prompt. A block of the kinddocs/using-ctx-effectively.mdrecommends (a dedicated RLHF-warnings block, loaded withctx query) is retrieved but not readable throughqueryonce it passes 1,500 characters;getby id is the working path. Worth a sentence in that doc or a key. I can file it separately if you prefer.embed.go, "No artificial deadline"), so the new arm, like the dream loop's inline call today, has no bound on one embed call. Abackground_timeoutwould need a memo path that survives a dead context, since the memo is written in the same transaction as the pick. Left out of this PR on purpose.I am glad to adjust naming, defaults or the test shape to the project's conventions.