Skip to content

Commit 59fdcc9

Browse files
Iris Alexandrescuclaude
andcommitted
fix(serenity): guard fails closed only on genuine errors; async begin-sites reconcile stale attempts (LLMO-7418 external-review Findings 1, 9, 11)
Finding 1: guardAgainstConcurrentProvisioning's read (getBrandProvisioningState) threw a bare Error on ANY PostgREST failure, including "column does not exist" — meaning if this service ever deploys even briefly ahead of the mysticat-data-service migrations that add the provisioning columns, every synchronous createMarket/activate call 500s on an endpoint that worked a moment ago. Preserve the underlying SQLSTATE on the thrown error and degrade to a no-op specifically for 42703 (undefined_column) — no column means no attempt could possibly be in flight, so proceeding is correct, not just convenient. Any other read error still fails closed, unchanged. Finding 11 (adjacent, same function): a missing/unparseable updated_at yielded NaN, which is never < the staleness threshold, so it fell through to "reconcile as stale" — capable of tearing down a genuinely fresh, healthy attempt. Treat NaN as "assume fresh" instead (409, never silently reconciled away). Finding 9: beginProvisioningAttempt's own CAS has no staleness awareness, so a stuck pending row (crashed worker, DLQ'd message) 409s forever once a brand's callers are async-only — there is no scheduled sweep (architecturally impossible; Semrush only accepts user-token auth). Call guardAgainstConcurrentProvisioning immediately before beginProvisioningAttempt at the two async begin-sites in this branch (createMarket, activate's project-activation batch) that operate on a pre-existing brand id — reusing the guard's own tested reconcile-or-409 logic rather than inventing new logic. The brand-create async sites (createBrandForOrg) are NOT touched: they mint a brand-new, freshly-generated UUID in the same request, so no prior attempt could exist for it — adding the guard there would be a pure no-op read on every call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f3050ab commit 59fdcc9

4 files changed

Lines changed: 112 additions & 6 deletions

File tree

src/controllers/serenity.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ import { computeWriteDeadline } from '../support/serenity/intent-classification.
9898
import AccessControlUtil from '../support/access-control-util.js';
9999
import { isServicePrincipal, resolveBrandUuid } from '../support/prompts-storage.js';
100100
import {
101-
getBrandAliases, getBrandUrlSources, getBrandCompetitors, updateBrand, getBrandBaseSiteId,
101+
getBrandAliases, getBrandBaseSiteId,
102102
cancelProvisioningAttempt,
103103
guardAgainstConcurrentProvisioning, beginProvisioningAttempt,
104104
} from '../support/brands-storage.js';
@@ -1048,6 +1048,19 @@ function SerenityController(context, log, env) {
10481048
// brand's already-canonical workspace rather than provisioning a new one — every
10491049
// brand reaching this branch already has one (`auth.mode === 'subworkspace'` IS that
10501050
// invariant; see `authorize`).
1051+
//
1052+
// LLMO-7418 external-review Finding 9: beginProvisioningAttempt's own CAS has no
1053+
// staleness awareness — a stuck `pending` row (a crashed worker, a DLQ'd message) would
1054+
// 409 here forever, with nothing else to ever reconcile it once every caller has
1055+
// migrated to async (a scheduled sweep is architecturally impossible — Semrush only
1056+
// accepts user-token auth). Reuse the sync guard's own reconcile-or-409 logic first: a
1057+
// stale attempt is reconciled to `failed` here (so the CAS below then succeeds), a
1058+
// genuinely fresh one still 409s (via the guard's own throw, same shape as `!began`'s).
1059+
await guardAgainstConcurrentProvisioning(
1060+
auth.brandUuid,
1061+
ctx.dataAccess.services.postgrestClient,
1062+
log,
1063+
);
10511064
const attemptId = randomUUID();
10521065
const began = await beginProvisioningAttempt({
10531066
brandId: auth.brandUuid,
@@ -1753,6 +1766,14 @@ function SerenityController(context, log, env) {
17531766
// in-request settle-poll + project-create/publish sequence), slated for removal once every
17541767
// known caller has migrated to `async: true`.
17551768
if (validateAsync(body)) {
1769+
// LLMO-7418 external-review Finding 9: see the createMarket async branch above for the
1770+
// full rationale — reconcile a stale in-flight attempt before minting a new one, since
1771+
// beginProvisioningAttempt's own CAS has no staleness awareness.
1772+
await guardAgainstConcurrentProvisioning(
1773+
brandUuid,
1774+
ctx.dataAccess.services.postgrestClient,
1775+
log,
1776+
);
17561777
const attemptId = randomUUID();
17571778
const began = await beginProvisioningAttempt({
17581779
brandId: brandUuid,

src/support/brands-storage.js

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1927,7 +1927,13 @@ export async function getBrandProvisioningState(brandId, postgrestClient) {
19271927
.maybeSingle();
19281928

19291929
if (error) {
1930-
throw new Error(`Failed to read brand provisioning state: ${error.message}`);
1930+
// Preserve the underlying Postgres SQLSTATE (e.g. `42703` undefined_column) on the thrown
1931+
// error — LLMO-7418 external-review Finding 1: guardAgainstConcurrentProvisioning needs this
1932+
// to tell "the async-provisioning migrations haven't landed yet" apart from a genuine
1933+
// transient read failure, and a bare `new Error(message)` here erases it.
1934+
const err = new Error(`Failed to read brand provisioning state: ${error.message}`);
1935+
err.code = error.code;
1936+
throw err;
19311937
}
19321938
if (!data) {
19331939
return null;
@@ -2316,16 +2322,43 @@ export const PROVISIONING_STALE_THRESHOLD_MS = 10 * 60 * 1000;
23162322
* @param {object} postgrestClient
23172323
* @param {object} [log]
23182324
* @throws when a fresh attempt is genuinely in flight (`err.status = 409`,
2319-
* `err.code = 'semrush_provisioning_in_progress'`).
2325+
* `err.code = 'semrush_provisioning_in_progress'`), or when the provisioning-state read itself
2326+
* fails for a reason OTHER than the columns not existing yet (see below) — an unreadable state
2327+
* must never be silently treated as an empty one.
23202328
*/
23212329
export async function guardAgainstConcurrentProvisioning(brandId, postgrestClient, log) {
2322-
const state = await getBrandProvisioningState(brandId, postgrestClient);
2330+
// POSTGRES_UNDEFINED_COLUMN (LLMO-7418 external-review Finding 1): this guard defends against
2331+
// a race between a synchronous caller and a LIVE async provisioning attempt — a race that
2332+
// cannot exist until the async-provisioning columns (mysticat-data-service migrations
2333+
// 1037/1040) are actually deployed. If this API ships even briefly ahead of those migrations,
2334+
// EVERY synchronous createMarket/activate call would otherwise 500 on a column that doesn't
2335+
// exist yet, on the exact endpoints every current caller already depends on. Degrade to a
2336+
// no-op instead: no column means no attempt could possibly be in flight, so proceeding is
2337+
// correct, not just convenient. Any OTHER read failure (a genuine transient DB error) still
2338+
// fails closed below, unchanged.
2339+
const POSTGRES_UNDEFINED_COLUMN = '42703';
2340+
let state;
2341+
try {
2342+
state = await getBrandProvisioningState(brandId, postgrestClient);
2343+
} catch (error) {
2344+
if (error?.code === POSTGRES_UNDEFINED_COLUMN) {
2345+
log?.warn?.('brands-storage: async-provisioning columns not present yet; guard is a no-op', {
2346+
brandId, error: error.message,
2347+
});
2348+
return;
2349+
}
2350+
throw error;
2351+
}
23232352
if (!state || state.provisioningStatus !== 'pending') {
23242353
return;
23252354
}
23262355

23272356
const ageMs = Date.now() - new Date(state.updatedAt).getTime();
2328-
if (ageMs < PROVISIONING_STALE_THRESHOLD_MS) {
2357+
// A missing/unparseable updatedAt yields NaN, and NaN is never < the threshold — falling
2358+
// through to "stale, reconcile" would then kill a genuinely fresh, healthy attempt (LLMO-7418
2359+
// external-review Finding 11). Treat an unparseable age as "assume fresh" (the safer
2360+
// direction: at worst a later request is briefly 409'd, never a live attempt torn down).
2361+
if (Number.isNaN(ageMs) || ageMs < PROVISIONING_STALE_THRESHOLD_MS) {
23292362
// ErrorWithStatusCode (not a plain Error+.status, unlike this file's other 409s): this guard
23302363
// is called from BOTH serenity.js's activate (whose mapError only special-cases `instanceof
23312364
// ErrorWithStatusCode`) and brands.js's createBrandForOrg (whose createErrorResponse accepts

test/controllers/serenity.test.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1657,6 +1657,10 @@ describe('SerenityController', () => {
16571657

16581658
expect(response.status).to.equal(202);
16591659
expect(body).to.include({ jobId: 'job-abc', status: 'IN_PROGRESS' });
1660+
// LLMO-7418 external-review Finding 9: reconciles a stale in-flight attempt (reusing the
1661+
// sync guard's own logic) before minting a new one — beginProvisioningAttempt's own CAS
1662+
// has no staleness awareness on its own.
1663+
expect(guardAgainstConcurrentProvisioningStub).to.have.been.calledOnceWith(BRAND);
16601664
expect(beginProvisioningAttemptStub).to.have.been.calledOnce;
16611665
expect(beginProvisioningAttemptStub.firstCall.args[0]).to.include({
16621666
brandId: BRAND, updatedBy: 'serenity-create-market',
@@ -2206,7 +2210,9 @@ describe('SerenityController', () => {
22062210

22072211
expect(response.status).to.equal(202);
22082212
expect(orchestrateActivateMarketsStub).to.not.have.been.called;
2209-
expect(guardAgainstConcurrentProvisioningStub).to.not.have.been.called;
2213+
// LLMO-7418 external-review Finding 9: the async begin-site now reconciles a stale
2214+
// in-flight attempt first, reusing the sync guard's own logic, before minting a new one.
2215+
expect(guardAgainstConcurrentProvisioningStub).to.have.been.calledOnceWith(BRAND);
22102216
expect(beginProvisioningAttemptStub).to.have.been.calledOnce;
22112217
expect(beginProvisioningAttemptStub.firstCall.args[0]).to.include({
22122218
brandId: BRAND, updatedBy: 'serenity-activate',

test/support/brands-storage.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4938,6 +4938,52 @@ describe('brands-storage', () => {
49384938
}]);
49394939
});
49404940

4941+
it('degrades to a no-op (does not throw) when the provisioning columns do not exist yet (LLMO-7418 external-review Finding 1)', async () => {
4942+
const postgrestClient = createTableMockClient({
4943+
brands: {
4944+
data: null,
4945+
error: { message: 'column brands.semrush_provisioning_status does not exist', code: '42703' },
4946+
},
4947+
});
4948+
const warn = sinon.stub();
4949+
4950+
const call = guardAgainstConcurrentProvisioning(BRAND_ID, postgrestClient, { warn });
4951+
await expect(call).to.not.be.rejected;
4952+
expect(warn).to.have.been.calledOnce;
4953+
});
4954+
4955+
it('still fails closed (rethrows) on any OTHER read error, e.g. a transient DB failure', async () => {
4956+
const postgrestClient = createTableMockClient({
4957+
brands: { data: null, error: { message: 'connection reset', code: 'ECONNRESET' } },
4958+
});
4959+
4960+
let caught;
4961+
try {
4962+
await guardAgainstConcurrentProvisioning(BRAND_ID, postgrestClient);
4963+
} catch (e) {
4964+
caught = e;
4965+
}
4966+
4967+
expect(caught).to.exist;
4968+
expect(caught.message).to.include('Failed to read brand provisioning state');
4969+
});
4970+
4971+
it('treats an unparseable updated_at (NaN age) as fresh rather than reconciling it away (LLMO-7418 external-review Finding 11)', async () => {
4972+
const postgrestClient = createTableMockClient({
4973+
brands: { data: stateWith({ updated_at: 'not-a-real-timestamp' }), error: null },
4974+
});
4975+
4976+
let caught;
4977+
try {
4978+
await guardAgainstConcurrentProvisioning(BRAND_ID, postgrestClient);
4979+
} catch (e) {
4980+
caught = e;
4981+
}
4982+
4983+
expect(caught).to.exist;
4984+
expect(caught.status).to.equal(409);
4985+
});
4986+
49414987
it('does not throw when the stale-reconciliation CAS is itself rejected (already reconciled)', async () => {
49424988
const staleAgeMs = PROVISIONING_STALE_THRESHOLD_MS + 1000;
49434989
const staleUpdatedAt = new Date(Date.now() - staleAgeMs).toISOString();

0 commit comments

Comments
 (0)