|
| 1 | +# Runbook: Serenity zombie sub-workspace recovery (dynamic AI allocation) |
| 2 | + |
| 3 | +**Last validated:** 2026-07-14 (LLMO-6191 rollout-hardening). |
| 4 | + |
| 5 | +How to recognize and recover a Semrush sub-workspace that is stuck "not ready" after a |
| 6 | +partially-applied dynamic-allocation transfer, and how to read the paging signals for the JIT |
| 7 | +top-up allocator (`SERENITY_DYNAMIC_ALLOCATION`). |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## When to use this |
| 12 | + |
| 13 | +- A brand's metered writes (create/publish prompts, add markets, model updates) are returning |
| 14 | + `503 Sub-workspace is provisioning, retry` (`workspaceBusy`) repeatedly for the **same brand**, |
| 15 | + well past a normal settle window. |
| 16 | +- `AllocationRejection{Reason=workspaceBusy}` or a `NotReadyRetry` streak (see |
| 17 | + [Observability](#observability--paging-signals) below) is elevated for one workspace and not |
| 18 | + clearing on its own. |
| 19 | +- A support ticket reports one brand permanently unable to add prompts/markets while other brands |
| 20 | + are unaffected (scoped to one workspace — not a broad outage; see the release-outage runbook for |
| 21 | + that case instead). |
| 22 | + |
| 23 | +## Prerequisites |
| 24 | + |
| 25 | +- **Access:** an IMS user token with standing on the affected org's Semrush workspace (same |
| 26 | + requirement as any `/serenity/*` call — see `docs/serenity.md`; there is **no service-account |
| 27 | + path** to Semrush in this repo). `mysticat auth token --ims`. |
| 28 | +- **The brand's ids:** the SpaceCat brand id and its `semrushSubWorkspaceId` (read via |
| 29 | + `GET /v2/orgs/:org/brands/:brandId`, or directly from the `brands` table). |
| 30 | +- Familiarity with `src/support/serenity/resource-manager.js` (`ensureAiHeadroom`, |
| 31 | + `releaseAiSurplus`) and `workspace-lifecycle.js` (`pollUntilCreated`) helps but is not required to |
| 32 | + follow the steps below. |
| 33 | + |
| 34 | +## TL;DR fast path |
| 35 | + |
| 36 | +```bash |
| 37 | +export API_BASE=https://spacecat.experiencecloud.live/api/ci |
| 38 | +export IMS=$(mysticat auth token --ims) |
| 39 | + |
| 40 | +# 1. Confirm the workspace's live status |
| 41 | +curl -s -H "Authorization: Bearer ${IMS}" \ |
| 42 | + "${API_BASE}/v2/orgs/${ORG_ID}/brands/${BRAND_ID}/serenity/markets" | jq . |
| 43 | + |
| 44 | +# 2. Retry the metered op that's failing (the transfer is idempotent — a retry after settling |
| 45 | +# finds it already applied and proceeds normally). Most "zombie" cases self-heal on the very |
| 46 | +# next customer-facing retry once the async workspace lock actually clears. |
| 47 | + |
| 48 | +# 3. If it does NOT clear after a few minutes, escalate — this repo has no admin endpoint to force |
| 49 | +# a workspace out of "not ready" (see Step 3 below). |
| 50 | +``` |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +## Step 1 — Confirm the symptom, not just the alarm |
| 55 | + |
| 56 | +`workspaceBusy` (503) is the allocator's **expected, retryable** signal when a transfer hits the |
| 57 | +Semrush async "workspace not ready" lock (`isWorkspaceNotReady`, `src/support/serenity/errors.js`). |
| 58 | +A single 503 is normal — the transfer is absolute + idempotent, so the client/UI is expected to |
| 59 | +retry. This runbook is for when the SAME workspace keeps returning it well past a normal settle |
| 60 | +window (minutes, not seconds), i.e. the async lock itself appears stuck. |
| 61 | + |
| 62 | +Grep Splunk for the specific workspace (see `docs/serenity.md`'s Observability section for the |
| 63 | +index/sourcetype): |
| 64 | + |
| 65 | +```spl |
| 66 | +index=dx_aem_engineering sourcetype=dx_aem_sites_spacecat_backend_<env> service=api-service |
| 67 | + "SERENITY_ALLOC" "<subWorkspaceId>" |
| 68 | +``` |
| 69 | + |
| 70 | +Look for a repeated `SERENITY_ALLOC transfer never cleared workspace-not-ready` line |
| 71 | +(`resource-manager.js`, `transferAndSettle`) or repeated `SERENITY_ALLOC workspace not ready on |
| 72 | +transfer — returning 503` (`transferOnce`, the fail-fast hot path) for the SAME workspace id across |
| 73 | +multiple, separated requests. |
| 74 | + |
| 75 | +## Step 2 — Rule out an in-flight partial transfer (the actual "zombie" cause) |
| 76 | + |
| 77 | +A sub-workspace can be left "not ready" when a transfer was applied upstream but the |
| 78 | +Adobe/Semrush-side async settle never completed — the write went out, but the workspace's public |
| 79 | +status keeps reporting a transitional state. Confirm live status: |
| 80 | + |
| 81 | +```bash |
| 82 | +curl -s -H "Authorization: Bearer ${IMS}" \ |
| 83 | + "${API_BASE}/v2/orgs/${ORG_ID}/brands/${BRAND_ID}/serenity/markets" | jq '.[].status' |
| 84 | +``` |
| 85 | + |
| 86 | +If markets report `status` other than the expected live value, or the brand's activation state |
| 87 | +(`GET /v2/orgs/:org/brands/:brandId`) shows `pending` when you expect `active`, the workspace is |
| 88 | +likely still mid-settle upstream, not a bug in this codebase. |
| 89 | + |
| 90 | +## Step 3 — Attempt recovery |
| 91 | + |
| 92 | +There is **no admin/automation endpoint in this repo** to force a stuck workspace's state — every |
| 93 | +`/serenity/*` call is a normal customer-facing operation gated on a live IMS user token (see |
| 94 | +`docs/serenity.md`'s "IMS-user only" caveat). Recovery options, in order: |
| 95 | + |
| 96 | +1. **Wait and retry the original failing operation.** The transfer is idempotent |
| 97 | + (`transferOnce`/`transferAndSettle`, `resource-manager.js`) — a retry once the async lock clears |
| 98 | + upstream simply finds the workspace already at the intended total and proceeds. Most cases |
| 99 | + resolve within the `LOCK_TIMEOUT_MS`/poll-attempt windows already built into the allocator |
| 100 | + (`DEFAULT_POLL`, 12 attempts × 1s, plus `NOT_READY_RETRIES` backoff — see `resource-manager.js`). |
| 101 | +2. **Re-run `ensureAiHeadroom` for the workspace manually** (e.g. via a one-off invocation in a |
| 102 | + Node REPL against the target env, using `createSerenityTransport` the same way |
| 103 | + `scripts/serenity-rightsizing-sweep.mjs` does) if you need to confirm the transfer settles |
| 104 | + outside of a live customer request. This performs no destructive action — it only re-reads and, |
| 105 | + if still short, re-tops-up. |
| 106 | +3. **If the workspace remains stuck for longer than ~30 minutes**, this is very likely an |
| 107 | + upstream (Semrush) issue, not something this codebase can resolve — escalate to the Semrush/ |
| 108 | + Project-Engine API owners with the workspace id and the Splunk trace from Step 1. There is no |
| 109 | + decommission-and-recreate path for a sub-workspace short of the destructive |
| 110 | + `POST /serenity/deactivate` (which deletes every project in it — see `docs/serenity.md`'s |
| 111 | + Activate/deactivate caveats) followed by a fresh `activate`; treat that as a last resort, and |
| 112 | + confirm with the brand owner first since it is data-destructive. |
| 113 | + |
| 114 | +## Observability — paging signals |
| 115 | + |
| 116 | +See `src/support/serenity/allocation-metrics.js` for the full metric catalog |
| 117 | +(`Mysticat/SerenityAllocation` CloudWatch namespace). The two classes that matter for THIS runbook: |
| 118 | + |
| 119 | +| Metric | Dashboard-only or pager-worthy | Why | |
| 120 | +|---|---|---| |
| 121 | +| `AllocationRejection{Reason=orgPoolExhausted\|brandAiLimit}` | Dashboard-only | Expected under normal load on a small pool — not a bug. | |
| 122 | +| `AllocationRejection{Reason=workspaceBusy}` | **Pager-worthy** | The transfer never cleared the async lock — JIT top-up itself is degraded, not just short on quota. Sustained/repeated for one workspace is this runbook's trigger. | |
| 123 | +| `NotReadyRetry` | **Pager-worthy** if a run exhausts all retries (correlate with the `workspaceBusy` counter, which fires exactly when a retry run exhausts) | A rising rate without exhaustion is just gateway latency; exhaustion is the actionable signal. | |
| 124 | +| `ReleaseOutcome{Reason=requires-decommission}` | Dashboard-only, but track the trend | A standing pool-leak signal — surplus that can never be reclaimed short of workspace delete. Not urgent per-occurrence, but a rising trend means the rightsizing sweep (`scripts/serenity-rightsizing-sweep.mjs`) or a decommission pass is due. | |
| 125 | +| `ReleaseOutcome{Reason=error}` / `AllocationRejection` generally | Dashboard-only | Expected fleet noise on a live allocator. | |
| 126 | + |
| 127 | +**Alerting caveat:** this repo has no alerting-as-code file to declare a CloudWatch/Coralogix alarm |
| 128 | +in — the metrics above are emitted (EMF → CloudWatch), but wiring an actual alarm/pager rule for |
| 129 | +`AllocationRejection{Reason=workspaceBusy}` and retry-exhaustion is a **manual step** an operator |
| 130 | +must still take in the CloudWatch/Coralogix console (or via `mcp__coralogix__manage_alerts` if |
| 131 | +you're doing it from an agent session) — this PR does not invent a new alerting pipeline to do it |
| 132 | +automatically, per LLMO-6191's scope guidance. Do this before turning `SERENITY_DYNAMIC_ALLOCATION` |
| 133 | +on for real traffic. |
| 134 | + |
| 135 | +## Command quick-reference |
| 136 | + |
| 137 | +```bash |
| 138 | +# check a brand's live market/status |
| 139 | +curl -s -H "Authorization: Bearer ${IMS}" \ |
| 140 | + "${API_BASE}/v2/orgs/${ORG_ID}/brands/${BRAND_ID}/serenity/markets" | jq . |
| 141 | + |
| 142 | +# Splunk trace for one workspace |
| 143 | +index=dx_aem_engineering sourcetype=dx_aem_sites_spacecat_backend_<env> service=api-service |
| 144 | + "SERENITY_ALLOC" "<subWorkspaceId>" |
| 145 | + |
| 146 | +# dry-run the rightsizing sweep against one brand (also a safe way to probe a workspace's state) |
| 147 | +POSTGREST_URL=... SEMRUSH_IMS_TOKEN=... node scripts/serenity-rightsizing-sweep.mjs \ |
| 148 | + --dry-run --org-ids ${ORG_ID} --brand-ids ${BRAND_ID} |
| 149 | +``` |
0 commit comments