Skip to content

Commit e8bdee2

Browse files
authored
Merge branch 'main' into feat/sites-47306-api-contract
2 parents 61e4ac4 + 2b7a199 commit e8bdee2

17 files changed

Lines changed: 1239 additions & 45 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ admin-idp-p*.json
2121
# V1 to V2 migration scripts and test data (local only)
2222
scripts/
2323
!scripts/backfill-rum-config.mjs
24+
!scripts/serenity-rightsizing-sweep.mjs
2425
docs/index.html
2526
mcp-server.log
2627
# Scout MCP local config (per-developer, never commit)

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
## [1.661.1](https://github.com/adobe/spacecat-api-service/compare/v1.661.0...v1.661.1) (2026-07-17)
2+
3+
4+
### Bug Fixes
5+
6+
* Prompt Research prompts count from real per-topic total ([#2843](https://github.com/adobe/spacecat-api-service/issues/2843)) ([5f7f4af](https://github.com/adobe/spacecat-api-service/commit/5f7f4af946e50a824ffc3a5785a494819e989e51))
7+
8+
# [1.661.0](https://github.com/adobe/spacecat-api-service/compare/v1.660.0...v1.661.0) (2026-07-17)
9+
10+
11+
### Features
12+
13+
* **serenity:** rollout hardening — rightsizing sweep, observability, cross-container lock ADR (LLMO-6191) ([#2811](https://github.com/adobe/spacecat-api-service/issues/2811)) ([1d54a1a](https://github.com/adobe/spacecat-api-service/commit/1d54a1a93347b07a90c179faa7b54b825b8a2fe5)), closes [#2764](https://github.com/adobe/spacecat-api-service/issues/2764) [#2764](https://github.com/adobe/spacecat-api-service/issues/2764)
14+
115
# [1.660.0](https://github.com/adobe/spacecat-api-service/compare/v1.659.1...v1.660.0) (2026-07-16)
216

317

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# ADR-007: Cross-container serialization for the dynamic-allocation absolute-set race
2+
3+
## Context
4+
5+
The dynamic (JIT) Semrush AI resource allocator (PR #2764, `SERENITY_DYNAMIC_ALLOCATION`, default
6+
OFF) does an **absolute set** of a sub-workspace's resource `total` — it reads the child's current
7+
totals, computes a new one, and transfers it (`ensureAiHeadroom` and `releaseAiSurplus`, both in
8+
`src/support/serenity/resource-manager.js`). Two operations that read the same child concurrently
9+
and then each write an absolute value can clobber one another: the later write wins with a value
10+
computed from a now-stale read.
11+
12+
`src/support/serenity/resource-lock.js` (`withResourceLock`) serializes same-child mutations via an
13+
in-process promise chain with a `LOCK_TIMEOUT_MS` (10s) safety valve. This PR (LLMO-6191) also wraps
14+
`releaseAiSurplus`'s one production call site (`markets-subworkspace.js`, the model-update seam) in
15+
the same lock, so **both** `ensureAiHeadroom` and `releaseAiSurplus` now serialize against each other
16+
for the same child, within one warm Lambda container. That closes the same-container half of the
17+
race. Across separate warm containers — the normal case for a Lambda-per-request API service — there
18+
is **no serialization at all**, so the race is fully open cross-container. This ADR is about closing
19+
that remaining gap (or deciding, explicitly, not to yet).
20+
21+
Constraints on the options below: no new cloud infrastructure may be provisioned unilaterally by this
22+
PR (no new DynamoDB table, no new Redis/ElastiCache instance — see the LLMO-6191 scope guidance).
23+
This repo has **zero existing DynamoDB usage** (no `@aws-sdk/client-dynamodb` dependency) and no
24+
distributed-lock/conditional-write primitive was found in `@adobe/spacecat-shared` either.
25+
26+
### Corrected failure-mode analysis (staff-engineer review)
27+
28+
The absolute-set mechanic that CAUSES the race is also what BOUNDS it — this matters for how risky
29+
"do nothing yet" actually is:
30+
31+
- **ensure-vs-ensure** (two concurrent top-ups on the same child): each call computes its own
32+
`target = roundUpToBlock(...)`, ceiling-clamped (`resource-manager.js`, `ensureAiHeadroom`). The
33+
last writer wins with **its own legitimate, ceiling-bounded target** — the final total is never
34+
higher than `max(target_a, target_b)`. No ceiling breach, no unbounded over-write, no double-charge
35+
of the parent pool. Worst case: the loser's top-up is silently absorbed and the next metered op
36+
that finds itself still short just re-triggers `ensureAiHeadroom` — self-healing.
37+
- **ensure-vs-release** (an `ensureAiHeadroom` topping up a child racing a `releaseAiSurplus` lowering
38+
the same child — now closed IN-PROCESS by this PR's lock wrap, but still open cross-container): a
39+
stale `release` writing last can set `total` below the now-real `used` (a transient
40+
**oversubscribed child**, rejecting the in-flight metered write until the next `ensure` heals it —
41+
not silent data loss, but not "just re-tops-up" either). A stale `ensure` writing last after a
42+
`release` already lowered the child can draw more from the master than its own advisory pool check
43+
computed (bounded only by the master's terminal `422`, not by the advisory read).
44+
45+
Neither interleaving causes data loss or a permanent ceiling breach. The corrected framing: **worst
46+
case is a transient oversubscription/over-draw window, self-healing on the next `ensureAiHeadroom`,
47+
never corruption or a stuck permanent state.**
48+
49+
## Decision
50+
51+
**Adopt Option C (accept the current risk band, observe, revisit on signal) for the initial ON
52+
rollout — conditional on the LLMO-6191 item-2 observability landing first**, not as an independent
53+
decision made in isolation. `TopUpLatencyMs`, `PoolFreeRatio`, and the `AllocationRejection`/
54+
`ReleaseOutcome` metrics (`src/support/serenity/allocation-metrics.js`) are the concrete signal this
55+
ADR's "revisit if it manifests" promise depends on — this status is NOT "Accepted, ship and forget."
56+
57+
Options considered:
58+
59+
**A — DynamoDB conditional write (new infra).** A lock-holder row per child workspace id, written
60+
with a `ConditionExpression` (only succeeds if unheld or the holder's TTL expired), TTL as the
61+
auto-expiry safety valve mirroring `LOCK_TIMEOUT_MS`. Correct, well-understood pattern. Cost: a new
62+
table, IAM policy, and an operational surface (TTL tuning, hot-partition risk on a busy child) this
63+
repo does not have today. Requires provisioning review — out of scope for this PR per the ticket's
64+
scope guidance.
65+
66+
**B — Push the fix upstream (Semrush optimistic concurrency).** If the Semrush transfer API accepted
67+
an `expected_total` (compare-and-swap) parameter, a stale writer would get a rejection instead of
68+
silently clobbering, and no distributed lock would be needed at all — the upstream call becomes
69+
self-serializing. **Unverified**: whether the live gateway supports this is an open question, not
70+
confirmed against the tenant. If it does, this is the strongest option (no new infra, and it fixes
71+
the root cause rather than working around it). Action item: check with the Semrush/Project-Engine
72+
API owners before ruling this out permanently.
73+
74+
**C — Accept + observe (recommended for now).** Ship with the current risk band: same-container race
75+
closed (this PR), cross-container race open but bounded to a transient, self-healing oversubscription
76+
window (never corruption, never a permanent ceiling breach — see the analysis above). Watch the
77+
item-2 telemetry (`TopUpLatencyMs`, `PoolFreeRatio`, `AllocationRejection`, `ReleaseOutcome`) for
78+
repeated-oversubscription symptoms (e.g. a `workspaceBusy` spike shortly after a top-up, or a
79+
`ReleaseOutcome` pattern suggesting a release clobbered a subsequent ensure) as the live signal of
80+
whether this actually manifests at current traffic. Revisit with Option A, B, or D if it does.
81+
82+
**D — SQS FIFO with `MessageGroupId = childWorkspaceId` (SRE review addition).** This repo already
83+
speaks SQS (`src/support/sqs.js`); a FIFO queue's per-group ordering IS a cross-container
84+
serialization primitive without provisioning new infrastructure CLASS (an existing AWS primitive,
85+
not a new one). It would reframe the hot path from synchronous-locked to enqueue-and-settle, which
86+
also retires the fail-fast/504 tension `ensureAiHeadroom`'s hot path currently has to work around.
87+
Real costs: async top-up latency (the request would have to wait for a queue round-trip, or the
88+
metered write would have to happen optimistically before the top-up is confirmed — a different
89+
correctness contract than today's), a new consumer worker, and reordering the synchronous gate
90+
contract every metered handler currently relies on (`createHeadroomGuard.ensure` is awaited
91+
in-request today). Worth a real look if Option C's telemetry shows the race manifesting, but a bigger
92+
shape change than A or B.
93+
94+
**E — Single-flight coalescing (SRE review addition, NOT cross-container).** Not a serialization
95+
primitive on its own — de-duplicating concurrent identical top-up requests within a container reduces
96+
contention but does not touch the cross-container case. Cheap, but only a partial mitigation; already
97+
effectively achieved in-container by `withResourceLock`.
98+
99+
## Status
100+
101+
Accepted, contingent on the item-2 observability metrics being live and watched (see LLMO-6191 PR).
102+
In-PR mitigation shipped now: `releaseAiSurplus`'s one production call site is wrapped in
103+
`withResourceLock` alongside `ensureAiHeadroom`, closing the same-container ensure/release race —
104+
see `src/support/serenity/handlers/markets-subworkspace.js`. The cross-container gap remains open,
105+
by design, per Option C above. Revisit this ADR (promote to Option A, B, or D) if the item-2 signals
106+
show the race manifesting at real traffic, or if the Semrush API owners confirm Option B is
107+
available — check that before committing engineering time to Option A's new infrastructure.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
```

docs/serenity.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,30 @@ Expected fields in the structured log payload:
299299
- outbound auth: `Authorization: Bearer ...` (the token itself is redacted by the platform)
300300
- the IMS sub of the caller in the `actor` field
301301

302+
## Dynamic AI resource allocation — operations (LLMO-6191)
303+
304+
The JIT top-up allocator (`SERENITY_DYNAMIC_ALLOCATION`, default OFF — see
305+
`src/support/serenity/dynamic-allocation-active.js`) has its own operational surface, separate from
306+
the request-path proxy documented above:
307+
308+
- **Metrics/SLIs:** `src/support/serenity/allocation-metrics.js` emits CloudWatch EMF metrics
309+
(namespace `Mysticat/SerenityAllocation`) — pool-free ratio, top-up latency, rejection/retry/
310+
release-outcome counters, and the hot-path (topped-up vs not) ratio. See that file's module doc
311+
for the full catalog and the pager-worthy/dashboard-only split.
312+
- **Zombie-workspace recovery:** see
313+
[`docs/runbooks/serenity-zombie-workspace-recovery.md`](./runbooks/serenity-zombie-workspace-recovery.md)
314+
for diagnosing and recovering a sub-workspace stuck `workspaceBusy` after a partially-applied
315+
transfer, and for the alerting/paging guidance.
316+
- **Rightsizing sweep:** `scripts/serenity-rightsizing-sweep.mjs` is a one-time backfill that
317+
lowers already-carved sub-workspaces (brands onboarded before the JIT allocator shipped) down to
318+
their actual usage, using `releaseAiSurplus` as the reclaim primitive. Run `--dry-run` first —
319+
see the script's own header comment for full usage and the auth caveat (requires an operator
320+
IMS token; there is no service-account path to Semrush in this repo).
321+
- **Cross-container serialization:** `src/support/serenity/resource-lock.js` only serializes
322+
same-container contention. The cross-container gap and the options considered for closing it are
323+
recorded in
324+
[`docs/decisions/007-cross-container-resource-lock.md`](./decisions/007-cross-container-resource-lock.md).
325+
302326
## Dev environment smoke tests
303327

304328
After the api-service feature branch deploys to dev, exercise the surface against `https://spacecat.experiencecloud.live/api/ci`.

0 commit comments

Comments
 (0)