feat(serenity): rollout hardening — rightsizing sweep, observability, cross-container lock ADR (LLMO-6191)#2811
Conversation
… cross-container lock ADR (LLMO-6191) Operational follow-up to PR #2764 (dynamic AI resource allocation, dormant behind SERENITY_DYNAMIC_ALLOCATION). Addresses the 4 items in LLMO-6191, right-sized per the ticket's own scope guidance: 1. Rightsizing sweep (real code) — scripts/serenity-rightsizing-sweep.mjs, a one-time backfill lowering already-carved sub-workspaces to their actual usage via the existing releaseAiSurplus reclaim primitive. Dry-run mode, org/brand allow-lists, rate limiting, a reason-aware consecutive-error abort threshold (expected pool/busy outcomes don't trip it), and a resumable checkpoint file (a fleet-wide sweep can take hours and IMS tokens expire mid-run). Requires an operator-supplied SEMRUSH_IMS_TOKEN — there is no service-account path to Semrush in this repo. releaseAiSurplus gained a `dryRun` option (single source of truth for the preview math) and now surfaces a typed `errorCode` on its swallowed-error outcome so a batch caller can distinguish expected pool/limit/busy failures from a genuinely unexpected one. 2. Observability (real code) — src/support/serenity/allocation-metrics.js wraps the existing CloudWatch EMF emitter (metrics-emf.js, already used elsewhere in this repo) with allocator-specific metrics: hot-path ratio, top-up latency, advisory pool-free ratio, typed-rejection counters, not-ready retry rate, release-outcome counters, and a 405-classifier match-ratio metric (wired into isMeteredQuota, which has no production call site yet — documented as a known gap, not fabricated coverage). Wired into resource-manager.js and errors.js at the natural chokepoints. 3. Cross-container serialization — DEFERRED to a design doc, docs/decisions/007-cross-container-resource-lock.md, per the ticket's explicit guidance not to unilaterally provision new infra (no DynamoDB table, no Redis). This repo has zero existing DynamoDB usage and no distributed-lock primitive in spacecat-shared. The ADR lays out 5 options (DynamoDB conditional write, upstream Semrush CAS, accept + observe, SQS FIFO MessageGroupId, single-flight coalescing) and recommends accept + observe for the initial ON rollout, contingent on this PR's item-2 telemetry landing first. One real code fix shipped here: releaseAiSurplus's one production call site (markets-subworkspace.js) is now wrapped in withResourceLock alongside ensureAiHeadroom, closing the same-container ensure/release race that review surfaced (the cross-container gap is what's deferred). 4. Alerting + runbook — docs/runbooks/serenity-zombie-workspace-recovery.md (real doc, matches the existing runbook format), covering diagnosis and recovery of a sub-workspace stuck in a partially-applied-transfer state, plus the pager-worthy vs dashboard-only metric split. No alerting-as-code file exists in this repo to wire an actual alarm into, so paging config itself remains a manual step (documented, not invented) per the ticket's scope guidance. Plan reviewed by senior-staff-engineer (architecture/lock options) and senior-sre (sweep safety, metric design) before implementation; findings folded in (reason-aware abort, dryRun primitive, corrected failure-mode framing for the ADR, added SQS-FIFO/coalescing options, wrapped release in the lock). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
MysticatBot
left a comment
There was a problem hiding this comment.
Hey @byteclimber,
Verdict: Request changes - three observability-correctness issues that would produce misleading metrics from day one.
Complexity: HIGH - large diff; architectural decision record + API surface changes.
Changes: Adds rollout hardening for the dynamic Semrush AI allocator - a rightsizing sweep script, CloudWatch EMF observability, a cross-container lock ADR, and a zombie-workspace runbook (11 files).
Note: Recommend a human read before merge - this change amends an architectural invariant documented in docs/decisions/007-cross-container-resource-lock.md. The bot review is a complement to, not a replacement for, a human read here.
Must fix before merge
- [Important]
isMeteredQuotaemits metric on early-return path for every non-405 error, making the classifier ratio meaningless -src/support/serenity/errors.js:90(details inline) - [Important] Metric emission as side-effect of error-factory construction couples telemetry to object creation -
src/support/serenity/resource-manager.js:169(details inline) - [Important]
TopUpLatencyMshas incomparable semantics betweentransferAndSettle(full retry loop) andtransferOnce(single attempt) with no distinguishing dimension -src/support/serenity/resource-manager.js:208(details inline)
Non-blocking (3): minor issues and suggestions
- suggestion: Checkpoint write is not atomic - a crash mid-write loses the entire progress set. Use write-to-tmp +
renameSyncfor crash-safe checkpointing -scripts/serenity-rightsizing-sweep.mjs:169 - suggestion:
--limit,--rate-limit-ms,--max-consecutive-errorssilently produceNaNon non-numeric input (e.g.--limit foo), causing a silent no-op. Add aNumber.isNaN(...)guard with an error message -scripts/serenity-rightsizing-sweep.mjs:119 - nit:
dryRunpath inreleaseAiSurplusdoes not emitrecordReleaseOutcome('dry-run')- the sweep's dry-run mode is invisible to CloudWatch, so an operator cannot confirm the metric pipeline is functional end-to-end -src/support/serenity/resource-manager.js:453
| * quota (`used + need > total`). Matches ONLY on an explicit quota signal in the body — NOT any | ||
| * `405`, so a legitimate Method-Not-Allowed is not absorbed. The exact disguised-405 body is pinned | ||
| * by the PR-4 live-gateway canary; widen the signal here only from that pinned shape. | ||
| * |
There was a problem hiding this comment.
issue (blocking): recordMeteredQuotaClassifier(false) fires on the early-return path for every error that is not a SerenityTransportError with status 405. When a caller is eventually wired up in a generic catch path, this will emit Matched=false for every TypeError, network timeout, auth failure, etc. - drowning the actual 405-classifier signal.
The metric's stated purpose is "of all 405s, how many are disguised quota rejections." That ratio requires emitting only when status === 405.
Fix: Remove the metric call from the early-return path. Emit only inside the 405 branch, so the metric fires only for actual 405 errors.
| function orgPoolExhausted() { | ||
| const e = new ErrorWithStatusCode('Organization AI resource pool is exhausted', 409); | ||
| e.code = ERROR_CODES.ORG_POOL_EXHAUSTED; | ||
| recordRejection('orgPoolExhausted'); // dashboard-only — expected under normal load on a small pool |
There was a problem hiding this comment.
issue (blocking): orgPoolExhausted(), brandAiLimit(), and workspaceBusy() emit recordRejection(...) at construction time. These are plain factory functions returning Error objects. If any code path ever constructs one without throwing (for comparison, logging, or testing), a phantom rejection metric fires.
This breaks the implicit purity contract: the peer predicates in errors.js (isPoolExhausted, isWorkspaceNotReady) are side-effect-free, so developers will reasonably expect the factories to be pure too.
Fix: Move recordRejection(...) to the throw/catch boundary where the rejection actually surfaces to the caller - e.g. inline at the throw site in transferAndSettle/transferOnce, or in the catch-path of ensureAiHeadroom where the typed e.code is already available.
| @@ -198,32 +207,38 @@ function workspaceBusy() { | |||
| * @returns {Promise<void>} | |||
| */ | |||
There was a problem hiding this comment.
issue (blocking): transferAndSettle records Date.now() - startedAt in the finally block, which includes all retry sleeps (up to NOT_READY_RETRIES * intervalMs * attempt - potentially tens of seconds of idle waiting). transferOnce (line 260) records only a single transport call. Both emit the same TopUpLatencyMs metric with no distinguishing dimension.
This creates a bimodal distribution: p99 spikes could be either a slow gateway or a workspace in a retry loop, making percentile-based alerting unreliable.
Fix: Add a Path dimension (settle vs fail-fast) to recordTopUpLatency(ms, path), so operators can alert on each path independently. Low cost to add now, avoids a redeploy later when the alarm author discovers the bimodal shape.
…mensioning, sweep robustness Addresses CHANGES_REQUESTED on PR #2811 (LLMO-6191): - isMeteredQuota (errors.js) no longer emits MeteredQuotaClassifier on its non-405 early return — only actual 405s count toward the match ratio, so a future generic catch-path caller won't drown the signal with Matched=false for every unrelated error. - orgPoolExhausted/brandAiLimit/workspaceBusy (resource-manager.js) are pure constructors again, matching the peer predicates in errors.js. recordRejection now fires at each actual throw site instead, so constructing an error for comparison/logging/testing never fires a phantom metric. - recordTopUpLatency now takes a Path dimension ('settle' vs 'fail-fast') — transferAndSettle's duration includes retry-loop sleeps and is not comparable to transferOnce's single-call duration; sharing one metric made p99 alerting unreliable. Non-blocking suggestions also addressed: - The sweep's checkpoint write is now write-to-tmp + rename (atomic), so a crash mid-write can't corrupt/lose the whole progress set. - --limit/--rate-limit-ms/--max-consecutive-errors now reject non-numeric input with a clear error instead of silently producing NaN (which would have made the abort threshold a silent no-op). - releaseAiSurplus's dryRun path now emits ReleaseOutcome{Reason: 'dry-run'}, so a sweep dry-run pass is visible on the same dashboard. Verified: type-check clean, lint clean, 14,617 passing (was 14,613; +4 new tests covering the fixes), same 7 pre-existing unrelated failures also present on unmodified main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
There was a problem hiding this comment.
Hey @byteclimber,
Verdict: Approve - all prior findings addressed correctly; no new blocking issues.
Complexity: HIGH - large diff; architectural decision record + API surface changes.
Changes: Adds rollout hardening for the dynamic Semrush AI allocator - a rightsizing sweep script, CloudWatch EMF observability, a cross-container lock ADR, and a zombie-workspace runbook (12 files).
Note: Recommend a human read before merge - this change introduces an architectural decision record (docs/decisions/007-cross-container-resource-lock.md). The bot review is a complement to, not a replacement for, a human read here.
Non-blocking (5): minor issues and suggestions
- suggestion:
PoolFreeRatioonly fires on the topped-up path (the master-resource read only happens there), creating a draw-time-only sample. Consider noting this in the metric catalog so operators know it measures "pool state at draw-time" not "continuous pool health" before relying on it as the Option C escalation trigger -src/support/serenity/allocation-metrics.js:64 - nit:
recordMeteredQuotaClassifier(matched)passes a boolean as theMatcheddimension value while every other dimension in the module uses strings. CloudWatch stringifies booleans anyway, but explicit'true'/'false'would be consistent -src/support/serenity/allocation-metrics.js:119 - suggestion: ADR Option D (SQS FIFO) does not mention the 300 TPS per
MessageGroupIdlimit, which would become the bottleneck if a single child workspace ever sees high concurrency -docs/decisions/007-cross-container-resource-lock.md:100 - nit: The
.tmpcheckpoint path (${checkpointFile}.tmp) is predictable. In a shared/tmpdirectory a co-tenant could race the rename via symlink. UsingO_CREAT | O_EXCLon the temp path would harden against this -scripts/serenity-rightsizing-sweep.mjs:521 - suggestion:
createSerenityTransportreceives the fullprocess.envobject. Consider passing only the required subset ({ SEMRUSH_IMS_TOKEN }) to minimize the reachable secret surface -scripts/serenity-rightsizing-sweep.mjs:495
Previously flagged, now resolved
isMeteredQuotametric emission scoped to actual 405s only (no longer fires on every non-405 error)recordRejectionmoved from error factories to throw sites (factories are now side-effect-free)TopUpLatencyMsnow carries aPathdimension (settlevsfail-fast) distinguishing the two semantics- Checkpoint write uses atomic write-to-tmp +
renameSync - Numeric CLI options validated with
parseNumericOption(exits on NaN instead of silent no-op) dryRunpath now emitsrecordReleaseOutcome('dry-run')for dashboard visibility
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 50s | Cost: $4.74 | Commit: 64855dfa6a3c4ccc22c1d1cba2981618850812c6
If this code review was useful, please react with 👍. Otherwise, react with 👎.
…aAccess
createDataAccess destructures {postgrestUrl, postgrestSchema, postgrestApiKey}
(see @adobe/spacecat-shared-data-access service/index.js), but the sweep passed
{POSTGREST_URL: ...} verbatim — postgrestUrl came out undefined and every run
failed immediately with "postgrestUrl is required to create data access"
before ever reaching a live PostgREST call. Live-verified: with this fix the
script gets past data-access init and reaches PostgREST (blocked only by a
separate dev POSTGREST_API_KEY/Vault credential issue, tracked separately).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live e2e verification (local Postgres/PostgREST + adobe-hackathon.semrush.com sandbox)Ran Setup: brought up the local IT stack (
Along the way, fixed a real bug the previous script had ( Dev-Vault-backed live dry-run against real prod data is still blocked separately ( |
|
@byteclimber I think we documented in the spec that we will not do the right-size sweep if I remember correctly. I also don't understand why the test plan can't be executed. |
|
Two different things got conflated with the same name — worth separating out explicitly:
(There's a separate, correctly-rejected item in the code itself — a comment in On the test plan: I went back and actually ran it live this week rather than leaving it as an open checkbox —
Full writeup in the earlier comment on this PR. The one thing still genuinely open is a live dry-run against real prod org/brand data specifically — that's blocked on the dev |
|
@byteclimber I just think we don't need that. All existing workspaces already have a good amount of projects and prompts. |
|
On "we don't need that" — I don't think this is something I should resolve unilaterally by re-explaining scope again, so a direct question instead: the design doc's own §7 says the stranded-units audit that would tell us whether the pool actually needs rightsizing "has not been run" — so right now neither of us has data, just priors. Your prior is "existing workspaces already have a good amount of headroom, this isn't needed." Mine (from building it) was "cheap insurance for whenever it turns out to be needed." The script itself has zero operational cost either way — it's never scheduled, never auto-invoked, only runs if an operator manually supplies live Semrush credentials and calls it by hand. So the actual question isn't "does the code work," it's "do we want this capability to exist at all right now." Two honest options, your call:
I'll do whichever you pick; not going to keep re-litigating this without your explicit answer. Separately — one more §8 item from the design doc that I noticed got silently dropped rather than addressed either way: "the org-pool check is advisory, and that is deliberate... What is missing is the customer-facing story: two brands topping up at once against a nearly-empty pool means one gets a 409 orgPoolExhausted on an ordinary write. Confirm that is the intended experience before the flip." That's a product sign-off, not code, and nothing in this PR (or the thread) confirms it either way. Can you confirm whether "one brand gets a 409 while another is mid-top-up" is the intended customer experience, or does that need a different UX treatment before the flag ever goes ON? |
|
🎉 This PR is included in version 1.661.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Operational hardening for the dynamic (JIT) Semrush AI resource allocator shipped dormant in #2764 (
SERENITY_DYNAMIC_ALLOCATION, default OFF). Implements LLMO-6191, right-sized per the ticket's own scope guidance — see below for what's real code vs. deferred to a design doc.Note on the ticket's "safety-valve timeout" line item: verified
LOCK_TIMEOUT_MSalready exists insrc/support/serenity/resource-lock.js(shipped in #2764) — struck from scope, no work needed here.1. Rightsizing sweep — real code
scripts/serenity-rightsizing-sweep.mjs: one-time backfill lowering already-carved sub-workspaces to actual usage via the existingreleaseAiSurplusreclaim primitive. Dry-run mode,--org-ids/--brand-idsallow-lists, rate limiting, a reason-aware consecutive-error abort threshold (expected pool/limit/busy outcomes don't trip it — only genuinely unclassified errors do), and a resumable checkpoint file (a fleet-wide sweep can take hours; IMS tokens expire mid-run).Requires an operator-supplied
SEMRUSH_IMS_TOKEN— there is no service-account path to Semrush in this repo (every call forwards a live human's IMS bearer token verbatim). This needs a dry-run against a dev org before being trusted at scale — flagging per the task's own caveat, since I have no live Semrush/IMS credentials to validate it myself.releaseAiSurplusgained adryRunoption (single source of truth for the preview math — the sweep reuses it instead of reimplementing the round-up/floor/to-zero logic) and now surfaces a typederrorCodeon its swallowed-error outcome, so a batch caller can tell an expected pool/limit/busy failure apart from an unexpected one.2. Observability and SLIs — real code
src/support/serenity/allocation-metrics.jswraps the existing CloudWatch EMF emitter (metrics-emf.js, already used elsewhere in this repo — no new metrics pipeline invented) with allocator-specific metrics: hot-path ratio, top-up latency, advisory pool-free ratio, typed-rejection counters, not-ready retry rate, release-outcome counters, and a 405-classifier match-ratio metric wired intoisMeteredQuota(which has no production call site yet in this codebase — documented candidly as a known gap, not fabricated coverage).3. Cross-container serialization — deferred to a design doc
docs/decisions/007-cross-container-resource-lock.md. Per the ticket's explicit guidance, this PR does not unilaterally provision new infrastructure (no DynamoDB table, no Redis). This repo has zero existing DynamoDB usage and no distributed-lock primitive was found inspacecat-shared. The ADR lays out 5 options (DynamoDB conditional write, an upstream Semrush CAS transfer, accept + observe, SQS FIFOMessageGroupId, single-flight coalescing) and recommends accept + observe for the initial ON rollout, contingent on this PR's item-2 telemetry actually landing and being watched.One real code fix shipped here as part of closing the same-container half of the gap:
releaseAiSurplus's one production call site (markets-subworkspace.js, the model-update seam) is now wrapped inwithResourceLockalongsideensureAiHeadroom— a same-container ensure/release race that review surfaced as a gap in the original PR (withResourceLock's doc only covered ensure).4. Alerting + runbook — real doc
docs/runbooks/serenity-zombie-workspace-recovery.md(matches the existing runbook format), covering diagnosis/recovery of a sub-workspace stuck in a partially-applied-transfer state, plus the pager-worthy vs. dashboard-only metric split. No alerting-as-code file exists in this repo to wire an actual alarm into, so paging config itself remains a manual step (documented, not invented) — per the ticket's scope guidance not to build a new alerting pipeline.Review process
Plan reviewed by
senior-staff-engineer(architecture/lock options) andsenior-sre(sweep safety, metric design) before implementation. Findings folded in: reason-aware abort threshold,dryRunon the primitive (not reimplemented in the script), corrected failure-mode framing in the ADR (the absolute-set race is bounded, not unbounded — see the ADR's analysis), added the SQS-FIFO and coalescing options the staff-engineer review flagged as missing, and wrappedreleaseAiSurplusin the lock.Test plan
npm run type-check— cleannpm run lint— clean on all touched filesnpm test— full suite passing, no regressionsserenity-rightsizing-sweep.mjsagainst a real throwaway sandbox sub-workspace seeded via a local Postgres+PostgREST IT stack (db+data-service, real migrations) — see PR comment below for the full writeup. Dry-run made zero mutating calls and computed the correct release target; the real run resized the workspace to exactly the floor and the surplus was confirmed to land back on the master pool by the exact predicted amount. Also fixed a realcreateDataAccesswiring bug the script had (camelCase config keys), pushed in0959efde4.POSTGREST_API_KEY(dx_mysticat/dev/api-service) failing PostgREST's JWT validation ("Unsupported token type") — a separate credential issue, tracked but not resolved here.LLMO-6191