Skip to content

feat(serenity): async bulk-delete to stop edge-timeout 503s on large brands - #3288

Open
aliciadriani wants to merge 5 commits into
mainfrom
feat/serenity-async-bulk-delete
Open

aliciadriani wants to merge 5 commits into
mainfrom
feat/serenity-async-bulk-delete

Conversation

@aliciadriani

@aliciadriani aliciadriani commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

1. Abstract

Moves the Serenity Prompt Library bulk-delete off the synchronous request path onto the existing job runner: the endpoint returns 202 + jobId and the delete + publish run in the worker, with the UI polling for the outcome. Gated behind SERENITY_ASYNC_BULK_DELETE (default off).

2. Reasoning

POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts/bulk-delete runs the Semrush delete + publishProject synchronously. On a large brand (Adobe Helpx, ~9.7k prompts) the publish can outlive the ~15s Fastly edge budget while the Lambda itself has a 900s budget, so the client gets a 503 ({"message":"Service Unavailable"}) even though the delete keeps running and likely completes server-side. The UI then retries, which piles more requests onto Semrush. This matches the two root causes found on the incident: (1) too many Semrush requests for the large brand, amplified by (2) the retry loop. Full RCA: #3287.

3. High-level overview of the changes

  • New src/support/serenity/handlers/bulk-delete-job.js:
    • acceptBulkDelete (producer) validates the request and createAndEnqueueJobs it, returning 202 { jobId, status, jobType: 'bulkDelete' }.
    • bulkDeleteHandler (consumer) runs the existing handleBulkDeletePrompts / handleBulkDeletePromptsSubworkspace with the worker's exchanged Semrush access token, then records the same { deleted, failed } result on the job. The delete logic is reused verbatim, so idempotent already-gone deletes, per-target failures, and publish reconciliation are unchanged.
  • Worker: register BULK_DELETE_JOB_TYPE in the job-runner HANDLERS (exchange-first, no lease).
  • Controller bulkDeletePrompts: behind SERENITY_ASYNC_BULK_DELETE (default off), exchange the promise token exactly as the async bulk-tags path does and return 202; the synchronous path is unchanged and remains the default.
  • getPromptsJobStatus: reports the delete job as public type bulkDelete; its { deleted, failed } result passes through the existing poll endpoint.
  • Behaviour is identical to today until the flag is switched on (and the polling UI ships).

4. Required information

6. Affected / used mysticat-workspace projects

  • project-elmo-ui (consumer, follow-up): to realise the fix end to end, the Prompt Library must handle the 202 { jobId } on delete and poll serenity/prompts/jobs/{jobId} instead of blocking on the response. Separately, the incident also involves a UI bug that persistently shows the deletion error — that is a project-elmo-ui fix, independent of this PR.

7. Additional information outside the code

Root cause established from code (prompts.js handler returns 200/failed[] and never throws the observed 503; mapError doesn't emit it; Lambda timeout 900s in package.json vs the ~15s edge) — the 503 is an edge timeout on a mutation that keeps running. Splunk confirmation of the exact edge layer is tracked as an open item in #3287 (Splunk MCP was unavailable during triage).

8. Test plan

(a) Local: unit tests for the producer validation and the consumer (delegates to the existing delete logic, completes the job with the result, marks FAILED on a deterministic error, rethrows a retryable job error on a transient 5xx). Ran the serenity controller and job-runner suites; both green with the flag default-off (sync path unchanged).
(b) Per-env:

  • dev: set SERENITY_ASYNC_BULK_DELETE=true, POST a bulk-delete with x-promise-token + x-promise-audience: semrush, confirm a 202 { jobId } and that GET .../serenity/prompts/jobs/{jobId} reports bulkDelete progressing to COMPLETED with { deleted, failed }.
  • prod: keep the flag off until the polling UI ships; then enable and verify the Adobe Helpx delete completes without a client 503.

9. Deployment & merge order

🤖 Generated with review-kit

Change Management

cm-assessment: v1
changeType: standard
impact: unnoticeable
risk: minor
scope: single-repo
relatedPRs: ["adobe/spacecat-api-service#3287", "adobe/spacecat-api-service#3285"]
rationale: "New async job path for bulk-delete behind a default-off flag; the synchronous path and all behaviour are unchanged until the flag is enabled, the delete logic is reused verbatim, deletes stay soft/idempotent, and the change is reversible by flag or redeploy."
recommendations: "Enable SERENITY_ASYNC_BULK_DELETE in dev first and only in prod once the project-elmo-ui polling UI ships; watch the job-runner queue and Semrush error rate after enabling."
backout: "Set SERENITY_ASYNC_BULK_DELETE=false (instant revert to the synchronous path) or redeploy the previous release."

Correction (from review)

…brands

The Prompt Library bulk-delete (serenity/prompts/bulk-delete) runs the Semrush
delete + publishProject synchronously. On a large brand the publish can outlive
the ~15s Fastly edge budget (the Lambda has 900s), so the client 503s while the
work keeps running; the UI then retries, hammering Semrush further (#3287).

Move the work to the existing serenity job runner, mirroring the async bulk-tags
path:
- New bulk-delete-job.js: acceptBulkDelete (producer -> createAndEnqueueJob,
  returns 202 + jobId) and bulkDeleteHandler (consumer -> runs the existing
  handleBulkDeletePrompts / -Subworkspace with the worker's exchanged Semrush
  token, records { deleted, failed } on the job). Delete logic is reused verbatim,
  so idempotent already-gone deletes, per-target failures, and publish
  reconciliation are unchanged.
- Register BULK_DELETE_JOB_TYPE in the job-runner HANDLERS.
- Controller bulkDeletePrompts: behind SERENITY_ASYNC_BULK_DELETE (default off),
  exchange the promise token (as bulk-tags does) and return 202; the synchronous
  path stays the default until the flag and the polling UI roll out together.
- getPromptsJobStatus reports the delete job as public type 'bulkDelete'.
- OpenAPI: document the 202 accepted shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

UI consumer follow-up (handle 202 + poll, and fix the persistent deletion-error display): https://github.com/adobe/project-elmo-ui/issues/3185 — roll the SERENITY_ASYNC_BULK_DELETE flag on in lockstep with the polling change there.

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aliciadriani,

Verdict: Comment - self-review, non-binding (GitHub blocks author approval); no blockers, two should-fixes and two nits inline.
Complexity: MEDIUM - new async job path (producer + consumer + worker registration + controller branch), but flag-gated and reusing the existing delete logic.
Changes: enqueue serenity bulk-delete as a job (202 + poll) behind SERENITY_ASYNC_BULK_DELETE, reusing handleBulkDeletePrompts in the worker (5 files).

Must fix before merge

None.

Non-blocking (4): should-fix and nits
  • suggestion: [Should] unbounded failed[] on the job result risks the AsyncJob item-size limit for a large delete - src/support/serenity/handlers/bulk-delete-job.js:138 (details inline)
  • suggestion: [Should] no idempotency key, so a double-submit enqueues duplicate delete jobs and re-hammers Semrush - src/support/serenity/handlers/bulk-delete-job.js:71 (details inline)
  • nit: enabling the flag before the UI handles 202 breaks the grid (reads res.deleted) - src/controllers/serenity.js:954 (details inline)
  • nit: add a subworkspace-path handler test - src/support/serenity/handlers/bulk-delete-job.js:109 (details inline)

log,
{ orgId, env: context.env, callerId },
);
job.setResult(result);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (should): job.setResult(result) stores the full { deleted, failed } verbatim. For a large brand a delete with many upstream failures could produce a failed[] big enough to push the AsyncJob record past the store's item-size limit, so job.save() fails and the whole job is marked failed even though the deletes landed. The sibling bulk-tags job already guards this with pageBulkFailures / BULK_FAILURE_PAGE_LIMIT. Bound or page the stored failures the same way (and expose them page-wise via the poller) rather than storing an unbounded array.

);
}

const job = await createAndEnqueueJob(context, {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (should): acceptBulkDelete enqueues a fresh job on every call - no idempotency key / deterministic job id. The exact failure mode this PR targets is a retry storm (the UI re-submitting a delete that 503'd), and without dedupe each re-submit enqueues another delete+publish job, re-hammering Semrush for the same work. It's harmless for correctness (the delete is idempotent), but it defeats part of the goal. bulk-tags-job derives a deterministic job id from an idempotency-key header and reuses the in-flight job; consider the same here so a retried submit joins the existing job instead of spawning a new one.

// keeps running. When enabled, enqueue the delete as a job and return 202 +
// jobId for the UI to poll; the sync path below stays the default until the
// flag and the polling UI are rolled out together.
if ((ctx.env || env)?.SERENITY_ASYNC_BULK_DELETE === 'true') {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: once SERENITY_ASYNC_BULK_DELETE is on, the endpoint returns 202 { jobId } with no deleted field, but today's UI reads res.deleted on the delete response - so flipping the flag before project-elmo-ui#3185 / #3184 ships the 202-handling would render a broken success toast. The PR body already says to roll them in lockstep; worth a comment on the flag itself (or a guard) so a future operator doesn't enable it standalone.

* @param {string} accessToken - Semrush access token exchanged by the runner.
* @param {object} [injectedTransport] - test seam.
*/
export async function bulkDeleteHandler(context, job, accessToken, injectedTransport) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the handler tests cover the flat path; add one for subworkspace === true so the handleBulkDeletePromptsSubworkspace branch (different arg shape) is exercised too.

@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.37037% with 72 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/controllers/serenity.js 26.66% 44 Missing ⚠️
src/support/serenity/handlers/bulk-delete-job.js 84.26% 28 Missing ⚠️

📢 Thoughts on this report? Let us know!

Address self-review on the PR:
- Cap the failures persisted on the job record at MAX_STORED_FAILURES (200) with
  a failedTotal count, so a large delete with many upstream failures cannot push
  the AsyncJob item past the store's size limit and fail an otherwise-landed
  delete. New test covers the truncation + count.
- Comment the SERENITY_ASYNC_BULK_DELETE flag to keep it off until the UI ships
  202/poll handling (project-elmo-ui#3185/#3184), since 202 has no deleted field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aliciadriani,

Verdict: Comment - re-review of the update; self-review, non-binding. No open blockers.
Complexity: MEDIUM - one commit addressing the prior self-review.
Changes: bound the job's stored failures (with a true-count) and guard the async flag against pre-UI enablement (3 files).

Must fix before merge

None.

Previously flagged, now resolved

  • Unbounded failed[] on the job result: now capped at MAX_STORED_FAILURES (200) with a failedTotal count and failedTruncated flag, so a large delete can't push the AsyncJob record past the store's item-size limit. Covered by a new truncation test.
  • Flag could be enabled before the UI understands 202: added a comment on SERENITY_ASYNC_BULK_DELETE tying it to project-elmo-ui#3185/#3184.
Deferred (2), with rationale
  • [Should] idempotency-key dedupe: deferred to its own follow-up. It needs extracting bulk-tags' deterministic-jobId + race-recheck machinery (private to that handler) into a shared helper - too much for this PR. It's correctness-safe to defer: the delete is idempotent (already-gone → success), so a duplicate job wastes Semrush calls but does no harm, and the retry storm that would cause duplicates is what project-elmo-ui#3184/#3185 remove. Worth a ticket if we want the server-side guard regardless.
  • [Nit] subworkspace-path handler test: minor; the flat path is covered and the subworkspace branch is a thin delegate to the existing (separately-tested) handleBulkDeletePromptsSubworkspace. Follow-up.

@aliciadriani

Copy link
Copy Markdown
Collaborator Author

Filed the deferred idempotency-key dedupe as its own tracked issue: #3289 (reuse the bulk-tags idempotency pattern; correctness-safe to do separately since the delete is idempotent).

@aliciadriani
aliciadriani enabled auto-merge (squash) September 15, 2026 16:54

@dzehnder dzehnder left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — async bulk-delete

Verdict: Request changes. The async approach is the right call and correctly scoped to the delete path (complementary to #3285, doesn't touch the read walk; sync path stays byte-for-byte unchanged while the flag is off). But there's a confirmed Critical integration bug that makes the feature non-functional as written and, worse, silently reports failed hard-deletes as successes. The unit tests pass only because they exercise the handler in isolation against the wrong contract.

Critical

1. The handler self-manages job state and returns nothing; the runner then overwrites it — the result is lost and failures are masked as success. (details inline on bulk-delete-job.js)

bulkDeleteHandler sets the job state itself and returns undefined:

  • success → job.setResult(storedResult); job.setStatus('COMPLETED'); await job.save() (bulk-delete-job.js:153-155)
  • deterministic error → job.setStatus('FAILED'); job.setError(...); await job.save() (bulk-delete-job.js:167-173), no throw, no return

But the runner invokes handlers on a return-value contract, unconditionally, after the handler returns (serenity-prompt-classification/index.js:325-327):

const result = await handler(context, job, accessToken);
job.setStatus('COMPLETED');
job.setResult(result ?? null);

So in production:

  • Success: the handler's storedResult is overwritten by setResult(undefined ?? null) → the job persists with result: null. The poller / UI (project-elmo-ui#3185) never receives { deleted, failed } — the point of the async path is defeated.
  • Deterministic failure: the handler's FAILED is overwritten by setStatus('COMPLETED') + result: null. A delete that failed with nothing deleted is reported to the UI as COMPLETED (success) — on an irreversible Semrush hard-delete, the worst direction to be wrong in (a sync 503 at least signals failure). Any unexpected throw where error.status is undefined (< 500 → treated as deterministic) hits this path.

Only the transient branch (throw retryableJobError(...), bulk-delete-job.js:165) is wired correctly, because throwing is what the runner's catch expects.

Fix: follow the exact contract bulkTagsHandler already uses — return storedResult on success (drop the setResult/setStatus/save at 153-155) and throw the deterministic error (replace 167-173 with throw error;) so the runner's catch sets FAILED. Keep the transient throw retryableJobError as-is.

Important

2. Missing // @ts-check — the new file is silently untyped. bulk-delete-job.js:13 goes straight from the license block to import, with no // @ts-check. src/support/serenity/CLAUDE.md rule 1 requires it as the first non-license line, and the sibling bulk-tags-job.js:14 has it. Without it the whole file is excluded from the type-check gate — which would likely have caught finding 1. Add the pragma and fix anything it surfaces.

3. Tests encode the wrong contract and mask finding 1. The consumer tests call bulkDeleteHandler in isolation and assert on the mock job's self-set status/result — they never dispatch through the runner (index.js:325-327), so they pass while production overwrites the result. After fixing finding 1, assert on the handler's return value (success) and on a thrown error (deterministic failure), ideally plus one through-the-runner integration test so the persisted result is actually verified.

Minor / observations

  • No publish-retry. handleBulkDeletePrompts folds a publish failure into failed[] as a status:502 pseudo-entry and returns normally (prompts.js:2668-2680), so the job completes with deletes applied-but-unpublished and no recovery — unlike bulk-tags' checkpoint/resume. Inherited from the sync handler, not new, but the async move was the chance to close it. Worth a note that an unpublished delete has no automatic retry.
  • No per-job lease / no idempotency key. A token-bearing hard-delete gets neither a lease nor a deterministic jobId (bulk-tags supports Idempotency-Key). Consistent with the bulk-tags precedent and mitigated by the terminal-state guard + verified already-gone idempotency, so low risk — but a client POST retry spawns a second job that re-runs (and re-publishes) the whole batch.

What's genuinely good

  • Correct scoping (delete path only, complementary to #3285, read path untouched); default-off gating leaves the sync path unchanged and the 202 branch validates promise-token + x-promise-audience: semrush before enqueue. requirePair: PROMISE_PAIR_SEMRUSH is actually stricter than bulk-tags.
  • Idempotency verified: deleteProjectBatches counts isUpstreamGone as deleted (prompts.js:1023-1024), so a transient re-run is safe — already-deleted ids aren't reported as failures.
  • MAX_STORED_FAILURES cap (200) to keep the job record under the store size limit is a thoughtful touch.
  • The "reuses handleBulkDeletePrompts verbatim" claim holds — reused-handler signatures match the callers exactly.

Factual corrections for the PR / RCA (#3287) description

  • Corpus size: the brand holds 38,764 serenity prompts (confirmed live via GET .../serenity/markets, promptsCount), not ~9.7k (that figure is the unrelated Postgres table from #3279).
  • Semrush load attribution: Semrush's "10x load on /aio/prompts/by_tags" is the read/list walk (listPromptsByTags), fixed in #3285 — not delete retries (delete hits deletePromptsByIdsprompts/delete, a different, cheaper endpoint). #3288 fixes the delete-503 UX but does not reduce the by_tags load; recommend noting the dominant load source is the read path.

Net: fix finding 1 (return/throw instead of self-managing job state), add // @ts-check, rework the tests to the corrected contract — after that this is a solid, well-scoped fix for the delete-503 half.

: result;
job.setResult(storedResult);
job.setStatus('COMPLETED');
await job.save();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): This handler self-manages job state and returns undefined, but the job runner treats handlers on a RETURN-VALUE contract and unconditionally overwrites state afterward (serenity-prompt-classification/index.js:325-327: const result = await handler(...); job.setStatus('COMPLETED'); job.setResult(result ?? null);).

So this job.setResult(storedResult); setStatus('COMPLETED'); save() (and the FAILED branch at 167-173) get clobbered: on success the real {deleted, failed} becomes null (UI poller gets nothing); on a deterministic error the FAILED becomes COMPLETED with null result - a failed hard-delete reported as success. Only the transient throw retryableJobError branch is wired correctly.

Fix (match bulkTagsHandler): RETURN storedResult on success (drop the setResult/setStatus/save here), and THROW the deterministic error (replace the FAILED branch with throw error;) so the runner's catch sets FAILED. Keep the transient throw as-is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0e9e890. bulkDeleteHandler now returns the (bounded) result on success and throws the deterministic error, so the runner's return-value contract (index.js:325-327) sets COMPLETED+setResult / FAILED correctly; the transient retryableJobError throw is unchanged. Added // @ts-check (which is what would have caught this), and reworked the tests to assert the RETURN value + a through-runner-contract test, a non-retryable throw on deterministic failure, and a retryable throw on a transient 5xx. Thanks for the precise catch.

Alicia Adriani and others added 2 commits September 15, 2026 19:09
…ract

Address review (Critical): the job runner invokes handlers on a return-value
contract (serenity-prompt-classification/index.js:325-327) — it sets COMPLETED +
setResult(returnValue) on success and FAILED on a non-retryable throw, AFTER the
handler returns. The handler was self-managing job state and returning undefined,
so the runner overwrote it: success persisted result:null (poller/UI got
nothing), and a deterministic failure was flipped to COMPLETED with null — a
failed hard-delete reported as success.

- bulkDeleteHandler now RETURNS the (bounded) result on success and THROWS the
  deterministic error (runner records FAILED); the transient retryableJobError
  throw is unchanged. It no longer calls setResult/setStatus/save.
- Add the required // @ts-check pragma (src/support/serenity/CLAUDE.md rule 1) —
  the file was silently untyped, which is what let this slip; type-check passes.
- Rework the consumer tests to the real contract: assert the RETURN value on
  success (plus a through-runner-contract test that persists it), a non-retryable
  THROW on a deterministic error, and a retryable throw on a transient 5xx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

Thanks @dzehnder — all addressed in 0e9e890:

Critical (1) — fixed: handler now RETURNs the bounded result / THROWs the deterministic error per the runner's return-value contract; transient retryableJobError throw unchanged. No more self-managed setResult/setStatus/save.
Important (2) — added // @ts-check; type-check passes (and yes, it would have caught #1 — that's why the file slipping past the gate mattered).
Important (3) — tests reworked to the real contract: assert the RETURN value on success + a through-runner-contract test that persists it; a non-retryable THROW on deterministic failure; a retryable throw on transient 5xx.

Minor/observations:

Factual corrections applied to this PR's description and #3287: corpus is 38,764 serenity prompts (not the ~9.7k Postgres figure), and the dominant Semrush by_tags load is the read/list path (#3285), not delete — this PR is the delete-503 UX fix, not a by_tags load reduction.

Re-requesting your review.

@aliciadriani

Copy link
Copy Markdown
Collaborator Author

Filed the publish-retry observation as its own follow-up: #3290 — add a publish-resume checkpoint to the async delete job (reuse bulk-tags' recoverFailedPublish / publishRecoveryPending), so a delete that applied but failed to publish recovers instead of sitting deleted-but-unpublished.

dzehnder added a commit that referenced this pull request Sep 17, 2026
…-walk fallback (#3304)

<!-- mysticat-pr-skill -->

## 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 a `text ->
existing prompt` dedup index by walking the entire project corpus via
`buildExistingPromptIndex` — up to 20 sequential 1,000-item `POST
/aio/prompts/by_tags` calls — 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_count` but 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
- New `buildTargetedPromptIndex` (default, kill-switch
`SERENITY_TARGETED_CREATE_LOOKUP` ON): one `search` call 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_tags` load Semrush flagged for small adds. Phase 0
verified live (2026-09-17) that upstream `search` is 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
serial `for` loop to a bounded-concurrency page fetch
(`BULK_CREATE_CONCURRENCY = 8`), and `MAX_PROMPT_INDEX_PAGES` raised 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
(#3279). Flag read per-request; unset/any non-`'false'` = targeted
(default ON), explicit `'false'` = walk.
- Whole-project degradation: any lookup failure (a `search` error, 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. `isIndexError` is per-project; no per-input
isolation.
- Deadline threaded into the index build + fan-out: `writeDeadline` was
only on `classifyPromptIntents`; 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.
- Write-path observability (in this PR): one structured end-of-request
`log.info` on both publish branches — `{brandId/workspaceId, created,
updated, skipped, failed, published, upstreamCallCount, elapsedMs,
targetedLookup}`. Serenity writes are otherwise invisible in `cdn_prod`
and silent in `backend_prod` (why this incident was
code-only-diagnosable); this line is the rollback detector and
validation evidence.
- Covers both `handleCreatePrompts` (flat) and
`handleCreatePromptsSubworkspace` at the shared
`buildPromptIndexByProject`.

## 4. Required information
- Jira / issue: LLMO-7615
- Related issues: #3283 (read-path RCA), #3287 (delete-path RCA)
- Related PRs: #3285 (read-path fix, merged), #3288 (delete-path async,
in review); frontend adobe/project-elmo-ui#3185
- Spec:
mysticat-workspace/local/serenity-prompts-create-path-corpus-walk-fix-spec.md
(5-persona architecture review + live Phase 0 verification 2026-09-17)

## 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). `search` load caveat: A
reduces `by_tags` load for the common small-add case, but a max 500-item
import is up to 500 light `search` calls 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
(same `semrushPromptId`, 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; a
`search` failure 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-check` and 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

```yaml
cm-assessment: v1
changeType: standard
impact: unnoticeable
risk: minor
scope: single-repo
relatedPRs: ["#3285", "#3288"]
rationale: "Reshapes an internal create/upsert dedup read from a full-corpus walk to a per-input search lookup behind a default-ON kill-switch; the response contract is unchanged, the dangerous failure direction (treating an existing prompt as new -> silent tag-stack) is closed by whole-project degradation, and disabling the flag lands on a corrected bounded/capped walk, never the pre-fix serial walk."
recommendations: "Enable on a dev brand with a seeded large corpus first; canary on Helpx via a flag-gated per-brand allowlist checking id+tags before/after; watch the new create-prompts completed log (upstreamCallCount, elapsedMs, targetedLookup) and the Semrush by_tags load."
backout: "Set SERENITY_TARGETED_CREATE_LOOKUP=false (instant revert to the corrected bounded walk) or redeploy the previous release."
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
solaris007 pushed a commit that referenced this pull request Sep 17, 2026
# [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)
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.

2 participants