feat(serenity): async bulk-delete to stop edge-timeout 503s on large brands - #3288
aliciadriani wants to merge 5 commits into
Conversation
…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>
|
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
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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, { |
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
nit: the handler tests cover the flat path; add one for subworkspace === true so the handleBulkDeletePromptsSubworkspace branch (different arg shape) is exercised too.
Codecov Report❌ Patch coverage is
📢 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>
|
This PR will trigger a minor release when merged. |
aliciadriani
left a comment
There was a problem hiding this comment.
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 atMAX_STORED_FAILURES(200) with afailedTotalcount andfailedTruncatedflag, 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_DELETEtying 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.
|
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). |
dzehnder
left a comment
There was a problem hiding this comment.
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
storedResultis overwritten bysetResult(undefined ?? null)→ the job persists withresult: 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
FAILEDis overwritten bysetStatus('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 whereerror.statusis 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.
handleBulkDeletePromptsfolds a publish failure intofailed[]as astatus:502pseudo-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 supportsIdempotency-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: semrushbefore enqueue.requirePair: PROMISE_PAIR_SEMRUSHis actually stricter than bulk-tags. - Idempotency verified:
deleteProjectBatchescountsisUpstreamGoneas deleted (prompts.js:1023-1024), so a transient re-run is safe — already-deleted ids aren't reported as failures. MAX_STORED_FAILUREScap (200) to keep the job record under the store size limit is a thoughtful touch.- The "reuses
handleBulkDeletePromptsverbatim" 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 hitsdeletePromptsByIds→prompts/delete, a different, cheaper endpoint). #3288 fixes the delete-503 UX but does not reduce theby_tagsload; 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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
… into feat/serenity-async-bulk-delete
|
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 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 Re-requesting your review. |
|
Filed the publish-retry observation as its own follow-up: #3290 — add a publish-resume checkpoint to the async delete job (reuse bulk-tags' |
…-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>
# [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)
1. Abstract
Moves the Serenity Prompt Library bulk-delete off the synchronous request path onto the existing job runner: the endpoint returns
202 + jobIdand the delete + publish run in the worker, with the UI polling for the outcome. Gated behindSERENITY_ASYNC_BULK_DELETE(default off).2. Reasoning
POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts/bulk-deleteruns the Semrush delete +publishProjectsynchronously. 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
src/support/serenity/handlers/bulk-delete-job.js:acceptBulkDelete(producer) validates the request andcreateAndEnqueueJobs it, returning202 { jobId, status, jobType: 'bulkDelete' }.bulkDeleteHandler(consumer) runs the existinghandleBulkDeletePrompts/handleBulkDeletePromptsSubworkspacewith 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.BULK_DELETE_JOB_TYPEin the job-runnerHANDLERS(exchange-first, no lease).bulkDeletePrompts: behindSERENITY_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 typebulkDelete; its{ deleted, failed }result passes through the existing poll endpoint.4. Required information
6. Affected / used mysticat-workspace projects
202 { jobId }on delete and pollserenity/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.jshandler returns 200/failed[]and never throws the observed 503;mapErrordoesn't emit it; Lambda timeout 900s inpackage.jsonvs 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:
SERENITY_ASYNC_BULK_DELETE=true, POST a bulk-delete withx-promise-token+x-promise-audience: semrush, confirm a202 { jobId }and thatGET .../serenity/prompts/jobs/{jobId}reportsbulkDeleteprogressing toCOMPLETEDwith{ deleted, failed }.9. Deployment & merge order
SERENITY_ASYNC_BULK_DELETEon in prod until the project-elmo-ui polling change ships; the default-off flag makes merge safe ahead of that.🤖 Generated with review-kit
Change Management
Correction (from review)
GET .../serenity/marketspromptsCount), not ~9.7k — that earlier figure was the unrelated Postgrespromptstable (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), a different store/path./aio/prompts/by_tags) is the read/list walk (listPromptsByTags), fixed in fix(serenity): cap faceted prompt corpus walk to fail fast on large brands #3285 — not delete retries. Delete hitsdeletePromptsByIds(prompts/delete), a different, cheaper endpoint. This PR fixes the delete-503 UX (moves it off the edge-bounded path); it does not reduceby_tagsload.