feat(serenity): rollout hardening — rightsizing sweep, observability, cross-container lock ADR (LLMO-6191)#2811
feat(serenity): rollout hardening — rightsizing sweep, observability, cross-container lock ADR (LLMO-6191)#2811byteclimber wants to merge 2 commits into
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 👎.
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 files (2 pre-existing unrelated errors onmain, in files this PR does not touch)npm test— 14,613 passing (was 14,609 on unmodifiedmain; net +4 new passing tests), same 7 pre-existing failures on unmodifiedmain(missing optional packages / unrelated test drift, confirmed viagit stashcomparison) — not introduced by this PRtest/it/postgres/serenity.test.js) — could not run in this sandbox: the whole app fails to load at bootstrap due to a pre-existing missing optional package (@adobe/spacecat-shared-ticket-client), reproducible on unmodifiedmain; additionally ECR login failed here (no AWS credentials configured in this environment). Needs a run in an environment with a fullnpm ciand AWS/ECR access before merge.LLMO-6191