fix(serenity): scope create-path dedup to input via search, add fixed-walk fallback - #3304
Conversation
…-walk fallback The create/upsert path built its existing-prompt dedup index by walking the whole Semrush project corpus (buildExistingPromptIndex: up to 20 serial 1000-item by_tags calls) before any write. On a large brand (Adobe Helpx, ~38.7k prompts) that blows the ~15s Fastly edge budget, so adding prompts (single add and CSV import) fails regardless of input size. Third instance of the serial-corpus-walk class (read #3285, delete #3288, now create). Default (kill-switch SERENITY_TARGETED_CREATE_LOOKUP ON): buildTargetedPromptIndex looks up ONLY the incoming texts via one `search` call per distinct (projectId, text), exact-match only — cost scales with input, not corpus. Phase 0 verified live (2026-09-17) that upstream `search` is literal substring matching, exact text returns the row at pos 0, metacharacters literal. Kill-switch OFF falls back to the walk, now bounded-concurrency and capped at 100k (was 20k) so a large brand is fully indexed — never the pre-fix serial walk. Failure/ambiguity degrades the whole PROJECT to an index error (inputs fail itemized, HTTP 200), never a false "new" (which would silently tag-stack an existing prompt via the create endpoint's existing_count+tag-attach behavior). writeDeadline is now threaded into the index build and fan-out, returning a 2xx partial before the edge kill. A structured end-of-request log (upstreamCallCount, elapsedMs, targetedLookup) makes create/upsert diagnosable from logs. Covers both flat and subworkspace create handlers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…lose review gaps Addresses the PR #3304 review findings: - Thread writeDeadline into buildTargetedPromptIndex/buildExistingPromptIndex so a large single-project add short-circuits mid-build (degrades to itemized 503) instead of running the edge budget out before the fan-out. - Guard an exact-name match with no usable id: degrade the project rather than silently falling through to create-as-new (tag-stack). - Make the full-page/falsy-id degrade client message redaction-safe (generic 'existing-prompt lookup unavailable'); keep specifics in the log. - Update stale @PARAM docs for env/writeDeadline on both create handlers; add the vendor-search residual-contract note, the short-text false-degrade note, the flag-removal note, the .env.example entry, and the batch-short-page doc fix. - Tests: per-item deadline guard (guard 2), build-phase deadline guard (both build fns), falsy-id degrade, 20->100 cap recognition + cap-hit warn, duplicate-text cost dedup, redacted-message assertions; subworkspace parity for full-page, falsy-id, deadline, and flag-off walk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
This PR will trigger a patch release when merged. |
There was a problem hiding this comment.
Hey @dzehnder,
Verdict: Request changes - two issues to address before merge.
Complexity: HIGH - large diff; API handler changes.
Changes: Scopes create-path dedup lookup to incoming texts via targeted search instead of walking the whole corpus, with a bounded-concurrency walk fallback behind a kill-switch (5 files).
Note: CI build and it-postgres checks are still pending - resolve before merge.
Must fix before merge
- [Important] Case-insensitive dedup regression in targeted lookup - the strict
===exact-match misses case variants that the walk path catches viabyLower-src/support/serenity/handlers/prompts.js:1960(details inline) - [Important] New env var
SERENITY_TARGETED_CREATE_LOOKUPnot documented indocs/serenity.mdoperator guide -.env.example:96(details inline)
Non-blocking (3): minor issues and suggestions
- nit:
if (exact && exact.id)is redundant after the precedingif (exact && !exact.id)throw - a plainif (exact)suffices -src/support/serenity/handlers/prompts.js:1978 - suggestion:
TARGETED_LOOKUP_LIMIT = 25may be low for short/common texts with many substring siblings - consider raising to 50 or making it env-configurable to reduce false-degrades without a deploy -src/support/serenity/handlers/prompts.js:1754 - nit: The kill-switch is marked temporary but no removal ticket is linked - create one now so it does not calcify -
.env.example:96
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 15s | Cost: $12.87 | Commit: 37aac9952cc430c4e42092b160f57315d76af03a
If this code review was useful, please react with 👍. Otherwise, react with 👎.
| } | ||
| onUpstreamCall?.(); | ||
| const resp = await transport.listPromptsByTags(semrushWorkspaceId, projectId, { | ||
| tag_ids: [], search: text, page: 1, limit: TARGETED_LOOKUP_LIMIT, |
There was a problem hiding this comment.
issue (blocking): The === exact-match check is case-sensitive, but the corpus walk path (buildExistingPromptIndex) catches case variants via byLower - and the code comments at line ~1797 explain exactly why: "case-sensitive where upstream's own dedupe is not would send a case variant back down the create path and straight into the additive attach."
If a stored prompt is "Best Shoes" and the input is "best shoes", upstream search (likely case-insensitive substring) returns the row, but === misses it. On a non-full page the function concludes "not found", the input takes the create path, and upstream's case-insensitive dedupe silently stacks a second tag - the exact bug this PR fixes on the corpus-size axis.
Fix: Add a case-insensitive fallback after the strict check:
const exact = items.find((it) => String(it?.name ?? '').trim() === text)
?? items.find((it) => String(it?.name ?? '').trim().toLowerCase() === text.toLowerCase());When the fallback matches, populate both byText (keyed on the input text) and byLower as normal. The full-page guard still applies if neither match hits on a full page.
There was a problem hiding this comment.
Fixed in b70c5d4. buildTargetedPromptIndex now matches case-insensitively (name.trim().toLowerCase() === text.trim().toLowerCase()) and keys byText/byLower by the matched stored name, mirroring the walk path's dedup so a case variant (stored "Best Shoes" vs input "best shoes") upserts instead of falling through to create-as-new. Added a case-variance regression test to both the flat and subworkspace suites.
| # Generation model sent to DRS (canonical contract default; override per env). | ||
| DRS_PROMPT_GENERATION_MODEL=gpt-5-nano | ||
| SERENITY_ASYNC_PROMPT_GEN=false | ||
| # Create-path dedup kill-switch. DEFAULT ON (unset, or anything but 'false', uses |
There was a problem hiding this comment.
issue (blocking): This kill-switch controls production dedup behavior and is called out for per-brand canary rollouts (PR description section 7), but the operator guide (docs/serenity.md, referenced as "the Serenity operator guide" in the repo's CLAUDE.md and README) has no entry for it. Operators troubleshooting a canary or rollback need to find this flag in the docs, not in code comments.
Fix: Add a row to the environment configuration table in docs/serenity.md documenting SERENITY_TARGETED_CREATE_LOOKUP, its default, its effect, and that it is temporary.
There was a problem hiding this comment.
Fixed in b70c5d4. Added a SERENITY_TARGETED_CREATE_LOOKUP entry to the Serenity operator guide (docs/serenity.md, Environment configuration): default ON, 'false' falls back to the corrected bounded/capped walk (never the pre-fix serial walk), per-brand canary/rollback lever, and the targetedLookup/upstreamCallCount signals to watch.
…witch Address MysticatBot review on #3304: - buildTargetedPromptIndex now matches existing prompts case-insensitively (keyed by stored name into byText/byLower), mirroring the walk path's dedup so a case variant (stored "Best Shoes" vs input "best shoes") upserts instead of falling through to create-as-new and silently tag-stacking. - Document SERENITY_TARGETED_CREATE_LOOKUP in the Serenity operator guide (docs/serenity.md): default ON, 'false' fallback to the corrected walk, per-brand canary/rollback, and the targetedLookup/upstreamCallCount signals. - Add case-variance regression tests to both flat and subworkspace suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MysticatBot
left a comment
There was a problem hiding this comment.
Mysticat could not complete a verified review of this pull request.
This is a service-side gap, not a finding about your code: no binding APPROVE or REQUEST_CHANGES review could be produced and verified for the current commit.
- Current head:
b70c5d47e7742b4f4783d655a7862ca51bda21d3 - Failure category:
claude_crash - Request ID:
2e35c6e0-b29a-11f1-8bdc-dc491fde0d68
Please re-request a review (e.g. push a new commit or re-request from the configured reviewer) to try again.
MysticatBot
left a comment
There was a problem hiding this comment.
Mysticat could not complete a verified review of this pull request.
This is a service-side gap, not a finding about your code: no binding APPROVE or REQUEST_CHANGES review could be produced and verified for the current commit.
- Current head:
95329c00ce634f1b36bd998aa39777cac52f1449 - Failure category:
claude_crash - Request ID:
58cd2370-b29b-11f1-87b5-480a70a2afa0
Please re-request a review (e.g. push a new commit or re-request from the configured reviewer) to try again.
MysticatBot
left a comment
There was a problem hiding this comment.
Mysticat could not complete a verified review of this pull request.
This is a service-side gap, not a finding about your code: no binding APPROVE or REQUEST_CHANGES review could be produced and verified for the current commit.
- Current head:
95329c00ce634f1b36bd998aa39777cac52f1449 - Failure category:
claude_crash - Request ID:
a6f17e50-b29d-11f1-8e42-bc25acb5e06a
Please re-request a review (e.g. push a new commit or re-request from the configured reviewer) to try again.
# [1.823.0](v1.822.0...v1.823.0) (2026-09-17) ### Bug Fixes * **serenity:** scope create-path dedup to input via search, add fixed-walk fallback ([#3304](#3304)) ([a7e2ee4](a7e2ee4)), closes [#3285](#3285) [#3288](#3288) [3283/#3287](#3287) [Hi#level](https://github.com/Hi/issues/level) [#3279](#3279) [#3283](#3283) [#3287](#3287) [#3285](#3285) [#3288](#3288) [adobe/project-elmo-ui#3185](https://github.com/adobe/project-elmo-ui/issues/3185) ### Features * GET /sites/:siteId/deployed-opportunities (deployed-opportunities timeline) | LLMO-7669 ([#3299](#3299)) ([8b26421](8b26421)), closes [#2501](#2501)
|
🎉 This PR is included in version 1.823.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
1. Abstract
Fixes adding prompts failing (single "Add prompt" and CSV import) for large-corpus brands by scoping the create/upsert dedup lookup to the incoming texts instead of walking the whole Semrush project corpus on every write. Ships behind a default-ON kill-switch whose OFF path is a bounded, fully-capped corpus walk (never the pre-fix serial walk).
2. Reasoning
handleCreatePrompts(and its subworkspace twin) built atext -> existing promptdedup index by walking the entire project corpus viabuildExistingPromptIndex— up to 20 sequential 1,000-itemPOST /aio/prompts/by_tagscalls — before any prompt is written. The cost scales with the number of existing prompts, not the input, so on Adobe Helpx (~38,764 prompts) the walk hits the page cap every time and the ~20 serial upstream calls exceed the ~15s Fastly edge budget → the request fails whether the customer adds 1 prompt or 16. Third instance of the serial-corpus-walk class in this incident (read #3285 merged, delete #3288, now create); RCA #3283/#3287, customer thread since 2026-09-16.The dedup index exists to decide upsert-vs-create per input: an already-present text must have its tags replaced via the existing prompt's id, not go down the create path — where the upstream create folds a repeated text into
existing_countbut still attaches the given tags, silently stacking a second tag on the live prompt (the additive-tag bug #3219/LLMO-7422, "could never be undone").3. High-level overview of the changes
buildTargetedPromptIndex(default, kill-switchSERENITY_TARGETED_CREATE_LOOKUPON): onesearchcall per distinct(projectId, text), exact-match only (name.trim() === text.trim()), building the same{byText, byLower}index. Cost scales with input, not corpus (1 add = 1 call, 16-row CSV = 16 calls), and it reduces the/aio/prompts/by_tagsload Semrush flagged for small adds. Phase 0 verified live (2026-09-17) that upstreamsearchis literal substring matching, an exact text returns the row at position 0 (total=1) across unicode/punctuation, and metacharacters are literal (no operator injection).buildExistingPromptIndex(kill-switch OFF fallback): reworked from a serialforloop to a bounded-concurrency page fetch (BULK_CREATE_CONCURRENCY = 8), andMAX_PROMPT_INDEX_PAGESraised 20 → 100 (100k prompts) so a large brand is fully indexed. An incomplete index is a correctness bug (beyond-cap rows resolve as "new" and tag-stack), not just a perf one; the biggest real corpus is ~76k (Prompt Library: bulk delete issues one round-trip per prompt id, and the list read materializes the whole collection — both 503 at the Fastly edge for large brands #3279). Flag read per-request; unset/any non-'false'= targeted (default ON), explicit'false'= walk.searcherror, a full page with no exact hit, or a budget timeout) degrades that whole project to an index error → its inputs fail itemized (HTTP 200), never a silent "new"/tag-stack.isIndexErroris per-project; no per-input isolation.writeDeadlinewas only onclassifyPromptIntents; the index build and create fan-out ran ungated. Now once the budget is spent, remaining inputs fail itemized (503) so the request returns a 2xx partial before the edge terminates the Lambda mid-flush.log.infoon both publish branches —{brandId/workspaceId, created, updated, skipped, failed, published, upstreamCallCount, elapsedMs, targetedLookup}. Serenity writes are otherwise invisible incdn_prodand silent inbackend_prod(why this incident was code-only-diagnosable); this line is the rollback detector and validation evidence.handleCreatePrompts(flat) andhandleCreatePromptsSubworkspaceat the sharedbuildPromptIndexByProject.4. Required information
7. Additional information outside the code
The kill-switch is default ON — Option A (targeted lookup) is the shipped behavior, not gated off. Turning the flag OFF lands on the fixed bounded/capped walk (a correct, fast state), never the pre-fix serial-20-page walk that caused the incident. Rollout can be per-brand via env allowlisting if a canary is wanted; the flag is a temporary kill-switch (add a removal ticket once soaked).
searchload caveat: A reducesby_tagsload for the common small-add case, but a max 500-item import is up to 500 lightsearchcalls vs the walk's ~40 — quantify by regime for the Semrush conversation rather than claiming a one-directional win.8. Test plan
(a) Local unit tests (
prompts.test.js+prompts-subworkspace.test.js, both handlers): existing text → upsert (samesemrushPromptId, tags replaced not stacked); new text → create; cost assertion — targeted call count == distinct(projectId, text)pairs; exact-match rejects a same-project substring over-match; asearchfailure degrades the whole project (inputs fail itemized, never treated as new); a full page with no exact hit degrades the project; write-budget-exhausted marks inputs failed (503); fixed-walk (flag off) pages concurrently and recognises a page-2 prompt in one batch. 260 passing across the two suites; 736 passing across all serenity handler suites;npm run type-checkand eslint clean.(b) Per-env (recommended before enabling widely): seed a large synthetic corpus on a dev brand, add a single prompt + import the 16-row
sample-run-for-acrobat-prompt-upload.csv+ a full 500-item request; assert 2xx within the edge budget and that a known-existing text upserts (same id, tags replaced not stacked). Prod: a flag-gated canary on Helpx with a disposable prompt, id+tags checked before/after, then cleanup.Change Management
🤖 Generated with Claude Code