From d61f30e1abacd555948346d6e6aa3adfa4248821 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:56:40 -0500 Subject: [PATCH] fix(content-serve): degrade missing-BLOB race to styled 404, not raw 500 (#3103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding defects on /tutorials/:slug and /concepts/:slug serve paths: 1. serveStoredSlug read the BLOB metadata and the BLOB itself in separate queries (correct — avoids HANA LOB-locator-expiry). During a concurrent delta-write to mutable ContentCurrent (overwrite-in-place, no version pin) or a transient HANA locator blip the metadata row could survive while the BLOB SELECT returned null/undefined, causing toBuffer(null) or blobRow.CONTENT dereference to throw. 2. serveHandler's catch returned res.status(500).json({error:'Content retrieval failed'}) — raw JSON. All sibling branches (pageServeHandler, authorServeHandler, advocateServeHandler) already call serveNotFound; this path was the outlier. Fix: - Guard the BLOB read: null/undefined/non-Buffer/zero-length contentBuf → return 'not-found' (styled 404). Emits content.blob.missing metric for observability of the race condition. - Align serveHandler catch with siblings: serveNotFound(res, slug, 500) (styled page, status 500 preserved for monitoring). Test: publish a slug, null its content column (metadata intact), assert HTTP 404 not 500. Red before, green after. --- packages/content/content-store.js | 19 ++++++++++++++++--- test/lib/content-store.test.js | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/content/content-store.js b/packages/content/content-store.js index 508ef8f51..fdf331fd0 100644 --- a/packages/content/content-store.js +++ b/packages/content/content-store.js @@ -1070,12 +1070,22 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap `SELECT TOP 1 "CONTENT" FROM "${hanaTableName()}" WHERE "SLUG" = ? AND "VERSION" = ?`, [slug, meta.version] ); - contentBuf = blobRow.CONTENT; + contentBuf = blobRow?.CONTENT; } else { const blobRow = source === 'current' ? await SELECT.one.from(ContentCurrent).where({ slug }).columns('content') // slug-canonical: caller-canonicalizes : await SELECT.one.from(ContentFiles).where({ slug, version: meta.version }).columns('content'); // slug-canonical: caller-canonicalizes - contentBuf = await toBuffer(blobRow.content); + contentBuf = blobRow?.content != null ? await toBuffer(blobRow.content) : null; + } + // #3103: missing-BLOB race. The metadata SELECT above and this BLOB SELECT + // are separate queries (deliberately — CDS QL LOB locators expire). During + // a concurrent delta-write to the mutable ContentCurrent (overwrite-in- + // place, no version pin) or a transient HANA locator blip, the metadata + // row can survive while the BLOB comes back null/empty. Degrade to a styled + // 404 instead of throwing a raw 500 (sibling serve branches all fail open). + if (!Buffer.isBuffer(contentBuf) || contentBuf.length === 0) { + metrics.counter('content.blob.missing'); // #3103 + return 'not-found'; } const decompressed = gunzipSync(contentBuf); const advisory = await computeAdvisory(slug); @@ -1339,7 +1349,10 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap return serveNotFound(res, slug); } catch (err) { console.error('[content/serve]', err instanceof Error ? err.message : String(err)); - res.status(500).json({ error: 'Content retrieval failed' }); + // #3103: align with sibling serve branches (pageServeHandler, authorServeHandler, + // advocateServeHandler) — all call serveNotFound on unexpected errors. Raw JSON + // 500 was the outlier; keep status=500 for monitoring but serve the styled page. + return serveNotFound(res, slug, 500); } } diff --git a/test/lib/content-store.test.js b/test/lib/content-store.test.js index cb6925c26..efbde1b33 100644 --- a/test/lib/content-store.test.js +++ b/test/lib/content-store.test.js @@ -229,6 +229,27 @@ describe('content-store', () => { expect(res.status).toBe(503); }); + // #3103: missing-BLOB race. The serve path reads BLOB metadata and the BLOB + // itself in two separate queries (to dodge the HANA LOB-locator-expiry + // gotcha). A concurrent delta-write to the mutable ContentCurrent (or a + // transient HANA locator blip) can land the metadata SELECT on a row whose + // separate BLOB SELECT comes back null. That must degrade to a styled 404, + // NOT a raw JSON 500 (the tutorial/concept path was the only serve branch + // that threw on this; its siblings all fail open). + it('degrades to a 404 (not a raw 500) when the BLOB is null but metadata survives', async () => { + // Null the content column while leaving the metadata row (contentHash, + // mimeType, version) intact — mimics the inconsistent-read window. + await UPDATE(ContentFiles).set({ content: null }).where({ slug: 'served-tut' }); + + const res = await project.axios.get('/content/tutorials/served-tut', { + validateStatus: () => true + }); + + // Red before the fix: serveStoredSlug throws (toBuffer(null) / gunzipSync), + // serveHandler's catch returns raw 500 JSON. + expect(res.status).toBe(404); + }); + it('serves from cache on second request', async () => { await project.axios.get('/content/tutorials/served-tut'); const res = await project.axios.get('/content/tutorials/served-tut');