Repository navigation
feat: cap the text sent to enrichment providers [ENG-3908] - #151
Conversation
Hub accepts value_text up to 30,000 characters and sent all of it, uncut, to every enrichment. That is ~7.5k tokens in English and far more in CJK, on every call and again on every edit — and for embeddings it is worse than expensive: an embedding model refuses an over-long input, which since ENG-2375 is a terminal failure, so the record never gets a vector and never becomes searchable, while the provider's rejection is only discovered after paying for the call. Each enrichment now sends at most its own configured number of bytes (EMBEDDING_MAX_INPUT_BYTES default 12,000; TRANSLATION_/SENTIMENT_/EMOTIONS _MAX_INPUT_BYTES default 24,000; 0 disables the cap), checked before the provider call: - Embeddings, sentiment and emotions trim to the cap and count the cut in hub_enrichment_text_trimmed_total, because a vector or a label for the first part of a record beats none at all. - Translation skips and returns a terminal 'length' failure, because a translation of the first N bytes stored as if it were the whole text is wrong. This reuses the marker, status and retry machinery as-is: the record shows up as failed_terminal, the reconciler leaves it alone, and an edit that brings it back under the cap heals it with no cleanup. Bytes rather than runes or tokens: no tokenizer is right for every deployment (OpenAI, Gemini and a self-hosted TEI endpoint each tokenize differently, and counting provider-side costs a request on Gemini and does not exist on OpenAI), and bytes bound tokens within ~2x across scripts where the same budget in runes bounds them within ~4x. The embedding cap is applied inside BuildEmbeddingInputFromValues, not at its call sites, because that string is both what is sent to the provider and what the write-time supersession guard compares against.
Follow-up to the cap itself, from the review pass: - openapi.yaml, search_handler.go and models/embeddings.go all described a search result's value_text as "the text that was embedded". After the cap that is false for exactly the records this ticket exists for, and it is the published contract (it generates the API reference and the SDK docs), so the description now carries the caveat the docs-site note already had. - failed_terminal in the OpenAPI schema, and the reconciliation section of the env-var reference, described a terminal failure only as something a provider refused. A translation over TRANSLATION_MAX_INPUT_BYTES is refused before any provider call, so retrying it costs nothing — and, more usefully, raising the variable later does NOT clear the markers already written; the same correction applies to .env.example and the config comment, which had implied a tenant could simply retry after an edit. - The data-model note promised that raising EMBEDDING_MAX_INPUT_BYTES "embeds more of it". It does not: the backfill fills missing rows and never overwrites an existing vector, so already-embedded records keep the short vector until their rows are deleted and rebuilt. The note now names the lever that exists. Also: the trimmed counter's description says it counts once per attempt (a retry of a trimmed call counts again); the builder comment no longer claims a cap always leaves room for the label ahead of the answer, which is false for a large multi-byte label at a small cap.
… failure [ENG-3908]
The translation refusal runs before any provider call, but the shared
worker's classify-error path counted it as `translation_api_failed` and
logged "provider failed permanently" at Error. That pages whoever alerts on
provider errors for a call that never happened, and leaves the skip
indistinguishable from a provider's own length rejection, which shares the
`hub_enrichment_terminal_total{reason="length"}` series.
The refusal now wraps a package sentinel (`errInputOverCap`) that the shared
worker recognises: it counts under `hub_translation_worker_errors_total
{reason="input_over_cap"}` (the per-enrichment skip counter the ticket asks
for) and logs a Warn that says no call was made. Terminal handling, the
durable `length` marker, and JobCancel are unchanged.
Also corrects two comments: the search-result model claimed a vector covers
"at least" the first N bytes (it is at most), and the cap floor's rationale
said a sub-rune cap could only cut to nothing (true only when the text
starts with a longer rune).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe change adds configurable UTF-8 byte caps for embedding, translation, sentiment, and emotions inputs. Embeddings, sentiment, and emotions trim input to the configured limit and record trimming metrics. Translation refuses over-cap input before a provider call and records a terminal failure. Worker wiring, configuration validation, tests, API descriptions, and documentation are updated to reflect these behaviors. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to
|
pandeymangg
left a comment
There was a problem hiding this comment.
1 major — reviewed at d71cd6ac0d
Checked
go teston service, workers, config, observability — pass- Every provider call site goes through the cap (search query excepted)
TextCap.Applynever splits a rune or indexes out of range- Over-cap refusal reaches
JobCancel,lengthmarker,failed_terminal - Integration tests not run locally (no Postgres); CI green on pg16–18
Findings
- 🟠 Major · correctness
internal/workers/input_limit.go:22—input_over_capis exported asreason="other", so the documented series never appears
Reply per finding: the fix, why it's wrong, or a ticket.
Opus 5.5
…other [ENG-3908] The translation worker-errors adapter passes every reason through allowedTranslationWorkerReasons and buckets anything unlisted as "other". d71cd6a recorded the over-cap skip as "input_over_cap" without adding it to that allowlist, so the documented series never appeared: the real exporter emitted reason="other". The unit test missed it because the counting double does not apply the allowlist. The reason is now a constant beside the allowlists (observability.WorkerReasonInputOverCap), the worker uses it, and the translation allowlist names it. A new worker test runs the refusal through the real NewTranslationMetrics and asserts the exported series is exactly {input_over_cap: 1}; it fails with {other: 1} before the allowlist change. AGENTS.md's input-cap recipe now says a refusing pipeline must extend its allowlist and assert through the real metrics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What does this PR do?
Fixes ENG-3908.
Hub accepts
value_textup to 30,000 characters and sent all of it, uncut, to all four enrichments. That is ~7.5k tokens in English and far more in CJK, spent on every call and again on every edit. For embeddings it was worse than expensive: an embedding model refuses an over-long input, which since ENG-2375 is a terminal failure, so the record never got a vector and never became searchable — and the rejection was only discovered after paying for the call.Each enrichment now sends at most its own configured number of bytes, checked before the provider call:
EMBEDDING_MAX_INPUT_BYTES(12000)SENTIMENT_MAX_INPUT_BYTES,EMOTIONS_MAX_INPUT_BYTES(24000)TRANSLATION_MAX_INPUT_BYTES(24000)0disables a cap; a negative value, or one below a single rune, is rejected at startup rather than silently meaning "unlimited".Trimming is counted in a new
hub_enrichment_text_trimmed_total{enrichment}(label-not-name, the convention the newer enrichment families use) — trimming is not a failure, so it is its own family rather than a reason onhub_enrichment_terminal_total. A translation skip is counted ashub_translation_worker_errors_total{reason="input_over_cap"}, so it can be told apart from a provider's own length rejection (the two sharehub_enrichment_terminal_total{reason="length"}). Translation needs no new machinery: returning ahuberrors.TerminalProviderError(TerminalReasonLength)reuses what ENG-2375/ENG-2376 already do end to end — no provider call,JobCancel, a durablelengthmarker,failed_terminalin the status API, exclusion from the reconciler sweep, and a tenant-clearable retry. An edit that brings the text back under the cap heals the record with no cleanup.Decisions worth a reviewer's attention
service.TextCapdocuments this.BuildEmbeddingInputFromValues, not at its call sites. That string is both what is sent to the provider and what the write-time supersession guard compares against; one place keeps them byte-identical (pinned by a test).TRANSLATION_MAX_INPUT_BYTESlater does not clear the markers already written — a tenant clears them withPOST /v1/tenants/{tenant_id}/enrichments/retry, after which the sweep re-runs at the new cap. Documented in.env.example, the env-var reference, the data-model note andtranslated-feedback.mdx.Not in this PR
queryalso reaches the embeddings provider uncapped (bounded only by the 64 KiB body cap). That is a request-field bound, i.e. ENG-1652's scope, and fix(api): bound the remaining unbounded request fields [ENG-1652] #150 caps it at 4,096 characters.value_text, and the docs note, now say that a vector may cover only the first N bytes rather than claiming the returned text is what was embedded. A per-record column would be a schema + spec + SDK change; worth it only if the product needs to show it.Review outcomes (this round)
Three independent passes (correctness, contract, security) ran against the head, each verifying findings against the code rather than the base.
Fixed in
e21104a:openapi.yaml+search_handler.go+models/embeddings.godescribed a search result'svalue_textas "the text that was embedded" — false for exactly the records this ticket exists for, and it is the published contract that generates the API reference and SDK docs.failed_terminal(OpenAPI) and the reconciliation section of the env-var reference described a terminal failure only as something the provider refused; the cap refuses before any call, and the "clear to retry after an edit" phrasing was misleading (an edit re-runs it by itself).EMBEDDING_MAX_INPUT_BYTES"embeds more of it". It does not: the backfill fills missing rows and never overwrites an existing vector, so already-embedded records keep the short vector until their rows are deleted and rebuilt. The note now names the lever that actually exists.Parked, not fixed here (
review-followups.md): the four knobs are not surfaced incharts/hub/values.yaml; the search query has no cap and no rate limit; a tenant-settings write can fan out a translation backfill that races a create and enqueue a second translation job for the same record (reproduced locally — pre-existing, double provider spend, no dedupe on the event path).Review outcomes (second round)
Fixed in
d71cd6a:translation_api_failedand logged "provider failed permanently" at Error. That would page whoever alerts on provider errors for a call that never happened. The refusal now wraps a sentinel the shared worker recognises: it counts underreason="input_over_cap"and logs a Warn saying no call was made. Terminal handling, thelengthmarker andJobCancelare unchanged. The unit test asserts the new reason and fails without the fix (checked by reverting it).models.FeedbackRecordWithScoresaid the vector covers "at least" the first N bytes. It is at most. The config floor's rationale was also tightened.Review outcomes (Anshuman's review)
Fixed in
1d4741b:input_over_capwas exported asreason="other", becauseallowedTranslationWorkerReasonsdid not name it and the counting double in the worker tests skips the allowlist. The reason is nowobservability.WorkerReasonInputOverCap, the translation allowlist names it, and a new worker test runs the refusal through the realNewTranslationMetrics, asserting the exported series is exactly{input_over_cap: 1}(it fails with{other: 1}without the allowlist entry). AGENTS.md's input-cap recipe now names the allowlist.Re-smoked on
1d4741bwith a real OpenTelemetry Collector (0.115.0, debug exporter) receiving the worker's OTLP export:hub_translation_worker_errors_total{reason="input_over_cap"}=2with nootherand notranslation_api_failedseries,hub_enrichment_terminal_total{enrichment="translation",reason="length"}=2, andhub_enrichment_text_trimmed_total=2 for each of embedding, taxonomy_embedding, sentiment and emotions. Provider request sizes, markers, job states and/v1/enrichment-statusmatch the earlier smoke.Parked (pre-existing since ENG-2375):
failure_marker_write_failedis missing from all three classify allowlists, so it too exports asother.How should this be tested?
TestFeedbackTranslation_OverCapSkipIsTerminalAndDurable(tests/) drives the whole chain against Postgres: a 200-byte record with a 50-byte cap is refused with the provider never called, a terminallengthmarker lands,CountEnrichmentStatusreportsfailed_terminal=1/failed=0,ListPendingEnrichmentdoes not return the record, editing the text back under the cap translates it again, and a record exactly at the cap is translated whole.End-to-end smoke (~26 assertions, all passing; re-run on
d71cd6awith a 13,500-byte Japanese record added — every cut lands on a rune boundary and no request exceeds its cap).hub-api+hub-worker+ the workspace'sai-test-serveras the OpenAI-compatible provider, with a capturing proxy in front of it recording the exact bytes of every request, an OTLP/HTTP sink reading the worker's metrics, and a 4,000-bytefrrecord plus a 300-byte one:sentiment/emotionsand both embeddings (raw + taxonomy) — the case that used to be a terminal failure — whilevalue_text_translatedstaysNULL;terminal='length',attempts=1, and the translation job iscancelled;/v1/enrichment-statusreportstranslation.failed_terminal=1,failed=0,done=1;value_text_translatedset from the provider) andfailed_terminalreturns to 0;hub_enrichment_text_trimmed_total{enrichment=embedding|taxonomy_embedding|sentiment|emotions}andhub_enrichment_terminal_total{enrichment=translation,reason=length}— read back from a real protobuf export body, with notranslationseries in the trim counter (it skips, it does not trim).Checklist
Required
make buildmake tests(integration tests intests/)make fmtandmake lint; no new warningsgit pull origin main— branch is cut frommain@ 541af90reason='length')Appreciated
/v1/enrichment-statusalready reportsfailed_terminal)docs/— env-var reference, metrics reference, data-model, translated-feedback,.env.example, and the enrichment-framework section ofAGENTS.mdmake tests-coveragefor meaningful logic changes