Skip to content

Commit 76a0ada

Browse files
Iris Alexandrescuclaude
andcommitted
feat(serenity): opt-in async provisioning for the 3 remaining skip-mode call sites (LLMO-7352/LLMO-7418 Phase 4)
Extends the opt-in `async: true` contract (PR-C, #3246) to the last 3 ensureSubworkspace call sites that were still fully synchronous: createBrandForOrg's bare-create (no semrushMarket) branch, and activate's pending->active and bare-reactivation branches. These 3 sites use ensureSubworkspace's `createReadiness: 'skip'` mode (persist the workspace pointer immediately after a single create call, without polling for settle) rather than 'poll' mode, so they never had the in-request settle-poll latency/timeout problem PR-A/PR-B/PR-C fixed for the other 2 sites. This is deliberate hardening for consistency and defense against a slow/hanging single Semrush create call, not a fix for a still-open incident — the original LLMO-7352 symptom is already closed by PR-A alone, since any workspace pointer's first real use (e.g. adding a market) always runs the now-fail-fast pollUntilCreated first. - createBrandForOrg's bare-create branch mirrors its sibling hasSemrushMarket branch exactly: async:true persists the row first, then hands off to provision-workspace-job with no chained job (no project to create). - activate's two skip-mode branches each mint a provisioning attempt and chain to a new activate-brand-workspace-job, which does the one piece of business logic the generic worker doesn't: flipping (or re-affirming) the brand's status to active once the workspace is confirmed ready. One job handler covers both branches; `wasPending` in its metadata distinguishes a real pending->active transition (502 on save failure) from a no-op reactivation re-affirm (207). - Existing guardAgainstConcurrentProvisioning calls on the sync branches are unchanged (still needed to protect a sync caller against a concurrent async attempt from another endpoint). Stacked on PR-C (#3246). 30 new unit tests; 954 passing overall, 0 regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 553f05a commit 76a0ada

9 files changed

Lines changed: 589 additions & 29 deletions

File tree

docs/openapi/brands-v2-api.yaml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,14 @@ v2-brands-for-org:
8181
$ref: './schemas.yaml#/V2Brand'
8282
'202':
8383
description: |
84-
PR-C (LLMO-7352/LLMO-7418): the request set `async: true` alongside
85-
`semrushMarket`. The brand row is persisted immediately (visible,
84+
The request set `async: true` (PR-C, LLMO-7352/LLMO-7418, alongside
85+
`semrushMarket`; or Phase 4, same epic, on a bare create with no
86+
market). The brand row is persisted immediately (visible,
8687
non-active — "Setting up", no workspace pointer yet); the
87-
sub-workspace ready-check and the initial market's project create/
88-
publish are enqueued to a background job chain instead of running
89-
inline. Poll the returned `jobId` at `GET .../serenity/jobs/{jobId}`
90-
for status + result.
88+
sub-workspace ready-check and, when `semrushMarket` was supplied,
89+
the initial market's project create/publish — are enqueued to a
90+
background job chain instead of running inline. Poll the returned
91+
`jobId` at `GET .../serenity/jobs/{jobId}` for status + result.
9192
content:
9293
application/json:
9394
schema:
@@ -117,7 +118,7 @@ v2-brands-for-org:
117118
organization (`code: brand_site_org_mismatch`).
118119
- The brand's primary URL is already the primary URL (base site) of
119120
another brand in this organization.
120-
- (PR-C, `async: true` only) a provisioning attempt is already in flight
121+
- (`async: true` only) a provisioning attempt is already in flight
121122
for this brand (`error: semrushProvisioningInProgress`).
122123
headers:
123124
X-Error:

docs/openapi/serenity-api.yaml

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,12 +1203,12 @@ v2-serenity-activate:
12031203
intact; retry converges idempotently). An already-active brand re-supplying
12041204
markets is never downgraded (a partial failure is reported as 207).
12051205
1206-
PR-C (LLMO-7352/LLMO-7418): when a primary URL/domain and markets are
1207-
present (the branch described above — NOT the sub-workspace-only pending
1208-
or bare-reactivation paths, which always stay synchronous), the caller may
1209-
set `async: true` to enqueue the whole batch to a background job chain
1210-
instead of running it inline; see the `202` response. Absent/false runs
1211-
synchronously as described above — this is opt-in only and not yet the
1206+
LLMO-7352/LLMO-7418: the caller may set `async: true` on ANY of the 3
1207+
branches — project activation (PR-C), the sub-workspace-only
1208+
pending→active flip, and bare reactivation (both Phase 4) — to enqueue
1209+
that branch's work to a background job chain instead of running it
1210+
inline; see the `202` response. Absent/false runs synchronously as
1211+
described above on every branch — this is opt-in only and not yet the
12121212
default.
12131213
operationId: activateSerenityBrand
12141214
security:
@@ -1226,17 +1226,20 @@ v2-serenity-activate:
12261226
schema: { $ref: './schemas.yaml#/SerenityActivateResponse' }
12271227
'202':
12281228
description: |
1229-
PR-C (LLMO-7352/LLMO-7418): the request set `async: true` on the
1230-
project-activation branch (primary URL + markets present). The whole
1231-
batch (sub-workspace ready-check, every market's project create/
1232-
publish, the site link, and the active flip) is enqueued to a
1233-
background job chain instead of running inline. Poll the returned
1234-
`jobId` at `GET .../serenity/jobs/{jobId}` for status + result — the
1235-
polling endpoint transparently follows the job chain to its
1236-
EFFECTIVE terminal hop, so `status` only reports `COMPLETED` once
1237-
the actual batch has run, not merely once the sub-workspace became
1238-
ready; on COMPLETED, `result` is the same body a synchronous
1239-
`200`/`207` would have returned.
1229+
The request set `async: true`. On the project-activation branch
1230+
(primary URL + markets present — PR-C), the whole batch
1231+
(sub-workspace ready-check, every market's project create/publish,
1232+
the site link, and the active flip) is enqueued to a background job
1233+
chain. On the sub-workspace-only pending→active or bare-reactivation
1234+
branches (Phase 4), only the sub-workspace ready-check + the status
1235+
flip are enqueued — there is no project/market work on those
1236+
branches. Either way, poll the returned `jobId` at
1237+
`GET .../serenity/jobs/{jobId}` for status + result — the polling
1238+
endpoint transparently follows the job chain to its EFFECTIVE
1239+
terminal hop, so `status` only reports `COMPLETED` once the actual
1240+
batch (or status flip) has run, not merely once the sub-workspace
1241+
became ready; on COMPLETED, `result` is the same body the branch's
1242+
own synchronous `200`/`207`/`502` would have returned.
12401243
content:
12411244
application/json:
12421245
schema: { $ref: './schemas.yaml#/SerenityPromptsJobAccepted' }
@@ -1258,8 +1261,9 @@ v2-serenity-activate:
12581261
outcomes — the operator must choose a different primary URL; (b)
12591262
misconfiguration: the brand's sub-workspace equals the organization parent
12601263
workspace (refused to avoid acting on the shared parent pool); or (c)
1261-
(PR-C, `async: true` only) a provisioning attempt is already in flight for
1262-
this brand (`error: semrush_provisioning_in_progress`) — retry shortly.
1264+
(`async: true` only, any branch) a provisioning attempt is already in
1265+
flight for this brand (`error: semrush_provisioning_in_progress`) —
1266+
retry shortly.
12631267
content:
12641268
application/json:
12651269
schema:

src/controllers/brands.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,6 +1729,12 @@ function BrandsController(ctx, log, env) {
17291729
// hoisted so the catch can mark that persisted-but-not-yet-provisioning row visibly failed if
17301730
// starting the attempt itself throws (see the catch's compensation).
17311731
let asyncMarketProvisioning = null;
1732+
// Phase 4 (LLMO-7352/LLMO-7418): the bare-create (no semrushMarket) sibling of
1733+
// asyncMarketProvisioning above — same "non-null after the row is persisted means start an
1734+
// attempt" contract, just with no chained job (a bare sub-workspace has no project to create
1735+
// afterward). Mutually exclusive with asyncMarketProvisioning (they're set in sibling
1736+
// if/else branches of the same hasSemrushMarket check).
1737+
let asyncBareProvisioning = null;
17321738

17331739
try {
17341740
if (!hasText(spaceCatId)) {
@@ -1928,6 +1934,28 @@ function BrandsController(ctx, log, env) {
19281934
languageCode: provisioned.languageCode,
19291935
};
19301936
}
1937+
} else if (validateAsync(brandData)) {
1938+
// B (LLMO-6405): sub-workspace-only active create — no market supplied, so
1939+
// no project is provisioned. Markets are added afterwards from the Markets
1940+
// tab. The brand is anchored by its primary site (baseSiteId, persisted by
1941+
// upsertBrand below) AND by its Semrush sub-workspace.
1942+
//
1943+
// Phase 4 (LLMO-7352/LLMO-7418): opt-in only (mirrors the hasSemrushMarket branch's own
1944+
// `async` flag above). Absent/false runs the EXACT bespoke synchronous
1945+
// provisionBrandSubworkspaceBare call this branch has always run. `async: true` persists
1946+
// the brand row FIRST (visible, active, no workspace pointer yet), then hands the bare
1947+
// sub-workspace provisioning off to provision-workspace-job — no chained job, since a
1948+
// bare create has no project to create once the workspace is ready.
1949+
//
1950+
// Resolved and validated HERE, before any write — same rationale as the
1951+
// hasSemrushMarket branch: a missing org workspace config must never leave a
1952+
// persisted, permanently-inert brand row behind.
1953+
const parentWorkspaceId = await resolveWorkspaceId(context, spaceCatId);
1954+
if (!parentWorkspaceId || !hasText(parentWorkspaceId)) {
1955+
return badRequest('Organization has no Semrush workspace configured');
1956+
}
1957+
provisionedBrandId = randomUUID();
1958+
asyncBareProvisioning = { parentWorkspaceId };
19311959
} else {
19321960
// B (LLMO-6405): sub-workspace-only active create — no market supplied, so
19331961
// no project is provisioned. Markets are added afterwards from the Markets
@@ -2126,6 +2154,76 @@ function BrandsController(ctx, log, env) {
21262154
}
21272155
}
21282156

2157+
// Phase 4 (LLMO-7352/LLMO-7418): bare-create's async sibling of the block above — same
2158+
// shape, no chained job (no project to create once the workspace is ready).
2159+
if (asyncBareProvisioning) {
2160+
const asyncBrandId = /** @type {string} */ (provisionedBrandId);
2161+
const { parentWorkspaceId } = asyncBareProvisioning;
2162+
const attemptId = randomUUID();
2163+
let began;
2164+
try {
2165+
began = await beginProvisioningAttempt({
2166+
brandId: asyncBrandId,
2167+
attemptId,
2168+
postgrestClient,
2169+
updatedBy,
2170+
});
2171+
} catch (beginError) {
2172+
log.error('brands: failed to begin the provisioning attempt after brand row was persisted', {
2173+
brandId: asyncBrandId, error: beginError?.message,
2174+
});
2175+
await recordFreshBrandProvisioningStartFailure({
2176+
brandId: asyncBrandId,
2177+
error: 'Failed to start Semrush provisioning',
2178+
postgrestClient,
2179+
}).catch((failError) => {
2180+
log.error('brands: failed to record the provisioning-start failure itself', {
2181+
brandId: asyncBrandId, error: failError?.message,
2182+
});
2183+
});
2184+
throw beginError;
2185+
}
2186+
if (!began) {
2187+
return createResponse(
2188+
{
2189+
error: 'semrushProvisioningInProgress',
2190+
message: 'Unable to start Semrush provisioning for the new brand',
2191+
},
2192+
409,
2193+
);
2194+
}
2195+
try {
2196+
const job = await createAndEnqueueJob(context, {
2197+
jobType: PROVISION_WORKSPACE_JOB_TYPE,
2198+
metadata: {
2199+
brandId: asyncBrandId,
2200+
attemptId,
2201+
parentWorkspaceId,
2202+
title: brandData.name,
2203+
},
2204+
});
2205+
return createResponse(
2206+
{ ...withSerenityState(created, serenityScopes), status: 'pending', jobId: job.getId() },
2207+
202,
2208+
);
2209+
} catch (enqueueError) {
2210+
log.error('brands: failed to start async Semrush provisioning after brand row was persisted', {
2211+
brandId: asyncBrandId, error: enqueueError?.message,
2212+
});
2213+
await promoteProvisioningFailed({
2214+
brandId: asyncBrandId,
2215+
attemptId,
2216+
error: 'Failed to start Semrush provisioning',
2217+
postgrestClient,
2218+
}).catch((failError) => {
2219+
log.error('brands: failed to record the provisioning-start failure itself', {
2220+
brandId: asyncBrandId, error: failError?.message,
2221+
});
2222+
});
2223+
throw enqueueError;
2224+
}
2225+
}
2226+
21292227
return createResponse(withSerenityState(created, serenityScopes), 201);
21302228
} catch (error) {
21312229
if (error.code === 'brand_status_demotion_not_allowed') {

src/controllers/serenity.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { CLASSIFY_PROMPTS_JOB_TYPE } from '../support/serenity/handlers/classify
4747
import { PROVISION_WORKSPACE_JOB_TYPE } from '../support/serenity/handlers/provision-workspace-job.js';
4848
import { CREATE_MARKET_JOB_TYPE } from '../support/serenity/handlers/create-market-job.js';
4949
import { ACTIVATE_MARKETS_JOB_TYPE } from '../support/serenity/handlers/activate-markets-job.js';
50+
import { ACTIVATE_BRAND_WORKSPACE_JOB_TYPE } from '../support/serenity/handlers/activate-brand-workspace-job.js';
5051
import { ORIGIN_VALUE } from '../support/serenity/prompt-tags.js';
5152
import {
5253
BULK_TAGS_JOB_TYPE,
@@ -1617,6 +1618,41 @@ function SerenityController(context, log, env) {
16171618
if (!existingSiteId) {
16181619
throw new ErrorWithStatusCode(`Brand has no onboarded primary site: ${brandUuid}`, 400);
16191620
}
1621+
// Phase 4 (LLMO-7352/LLMO-7418): opt-in only (mirrors this endpoint's own
1622+
// project-activation branch below, and createMarket's/createBrandForOrg's `async` flag).
1623+
// Absent/false runs the EXACT synchronous pending->active flip this branch has always
1624+
// run. `async: true` hands the sub-workspace-ensure + status flip off to the
1625+
// `provision-workspace-job` ->
1626+
// `serenity-activate-brand-workspace` job chain instead.
1627+
if (validateAsync(body)) {
1628+
const attemptId = randomUUID();
1629+
const began = await beginProvisioningAttempt({
1630+
brandId: brandUuid,
1631+
attemptId,
1632+
postgrestClient: ctx.dataAccess.services.postgrestClient,
1633+
updatedBy: 'serenity-activate',
1634+
});
1635+
if (!began) {
1636+
const err = new ErrorWithStatusCode(
1637+
'A Semrush sub-workspace provisioning attempt is already in progress for this '
1638+
+ 'brand; please retry shortly.',
1639+
409,
1640+
);
1641+
err.code = 'semrush_provisioning_in_progress';
1642+
throw err;
1643+
}
1644+
const job = await createAndEnqueueJob(ctx, {
1645+
jobType: PROVISION_WORKSPACE_JOB_TYPE,
1646+
metadata: {
1647+
brandId: brandUuid,
1648+
attemptId,
1649+
parentWorkspaceId: auth.parentWorkspaceId ?? '',
1650+
chainedJobType: ACTIVATE_BRAND_WORKSPACE_JOB_TYPE,
1651+
chainedJobMetadata: { brandId: brandUuid, wasPending: true },
1652+
},
1653+
});
1654+
return accepted({ jobId: job.getId(), status: job.getStatus() });
1655+
}
16201656
// PR-C guard (LLMO-7352/LLMO-7418): this branch stays synchronous, but a market-creating
16211657
// endpoint may have an async provisioning attempt in flight for this SAME brand — without
16221658
// this check, ensureSubworkspace below could independently create a second workspace.
@@ -1694,6 +1730,40 @@ function SerenityController(context, log, env) {
16941730
if (generatePrompts) {
16951731
throw new ErrorWithStatusCode('A primary URL is required to generate prompts', 400);
16961732
}
1733+
// Phase 4 (LLMO-7352/LLMO-7418): opt-in only — see the wasPending branch above for the
1734+
// full rationale. `wasPending: false` in the chained metadata distinguishes this
1735+
// already-active no-op re-affirm from a real pending->active transition, so
1736+
// activate-brand-workspace-job.js's save-divergence handling matches this branch's own
1737+
// 207-not-502 contract.
1738+
if (validateAsync(body)) {
1739+
const attemptId = randomUUID();
1740+
const began = await beginProvisioningAttempt({
1741+
brandId: brandUuid,
1742+
attemptId,
1743+
postgrestClient: ctx.dataAccess.services.postgrestClient,
1744+
updatedBy: 'serenity-activate',
1745+
});
1746+
if (!began) {
1747+
const err = new ErrorWithStatusCode(
1748+
'A Semrush sub-workspace provisioning attempt is already in progress for this '
1749+
+ 'brand; please retry shortly.',
1750+
409,
1751+
);
1752+
err.code = 'semrush_provisioning_in_progress';
1753+
throw err;
1754+
}
1755+
const job = await createAndEnqueueJob(ctx, {
1756+
jobType: PROVISION_WORKSPACE_JOB_TYPE,
1757+
metadata: {
1758+
brandId: brandUuid,
1759+
attemptId,
1760+
parentWorkspaceId: auth.parentWorkspaceId ?? '',
1761+
chainedJobType: ACTIVATE_BRAND_WORKSPACE_JOB_TYPE,
1762+
chainedJobMetadata: { brandId: brandUuid, wasPending: false },
1763+
},
1764+
});
1765+
return accepted({ jobId: job.getId(), status: job.getStatus() });
1766+
}
16971767
// PR-C guard (LLMO-7352/LLMO-7418): see the wasPending branch above for rationale.
16981768
await guardAgainstConcurrentProvisioning(
16991769
brandUuid,

src/serenity-prompt-classification/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ import {
4747
activateMarketsJobHandler,
4848
ACTIVATE_MARKETS_JOB_TYPE,
4949
} from '../support/serenity/handlers/activate-markets-job.js';
50+
import {
51+
activateBrandWorkspaceJobHandler,
52+
ACTIVATE_BRAND_WORKSPACE_JOB_TYPE,
53+
} from '../support/serenity/handlers/activate-brand-workspace-job.js';
5054
import {
5155
isRateLimited,
5256
isSemrushTransportError,
@@ -119,7 +123,9 @@ export const vaultOpts = {
119123
* sub-workspace provisioning lives in `../support/serenity/handlers/provision-workspace-job.js`;
120124
* and PR-C's two chained market-creation phases (workspace ready -> create the market/run the
121125
* activate batch against it) live in `../support/serenity/handlers/create-market-job.js` and
122-
* `../support/serenity/handlers/activate-markets-job.js`. All five are registered below.
126+
* `../support/serenity/handlers/activate-markets-job.js`; Phase 4's brand-status-flip half of
127+
* `activate`'s two remaining "skip"-mode branches lives in
128+
* `../support/serenity/handlers/activate-brand-workspace-job.js`. All six are registered below.
123129
*
124130
* @type {Record<string, (context: object, job: object,
125131
* accessToken: string) => Promise<object>>}
@@ -130,6 +136,7 @@ const HANDLERS = {
130136
[PROVISION_WORKSPACE_JOB_TYPE]: provisionWorkspaceHandler,
131137
[CREATE_MARKET_JOB_TYPE]: createMarketJobHandler,
132138
[ACTIVATE_MARKETS_JOB_TYPE]: activateMarketsJobHandler,
139+
[ACTIVATE_BRAND_WORKSPACE_JOB_TYPE]: activateBrandWorkspaceJobHandler,
133140
};
134141

135142
const TRANSIENT_NETWORK_ERROR_CODES = new Set([

0 commit comments

Comments
 (0)