Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions packages/content/content-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap
} else {
// slug-canonical: caller-canonicalizes
const b = await SELECT.one.from(ContentCurrent).where({ slug }).columns('content');
buffer = b ? await toBuffer(b.content) : null;
// #3103: toBuffer(null) throws — guard the raw column (same race as serveStoredSlug).
buffer = b?.content != null ? await toBuffer(b.content) : null;
}
return { contentHash: cur.contentHash, mimeType: cur.mimeType, version: cur.sourceVersion, buffer, source: 'current' };
}
Expand All @@ -340,7 +341,8 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap
} else {
// slug-canonical: caller-canonicalizes
const b = await SELECT.one.from(ContentFiles).where({ slug, version: meta.version }).columns('content');
buffer = b ? await toBuffer(b.content) : null;
// #3103: toBuffer(null) throws — guard the raw column (same race as serveStoredSlug).
buffer = b?.content != null ? await toBuffer(b.content) : null;
}
return { contentHash: meta.contentHash, mimeType: meta.mimeType, version: meta.version, buffer, source: 'files' };
}
Expand Down Expand Up @@ -942,7 +944,9 @@ export function createContentHandlers({ namespace = 'com.sap.developers.ims', ap

res.status(status);
res.setHeader('Content-Type', `${resolved.mimeType}; charset=utf-8`);
res.setHeader('Cache-Control', 'public, max-age=60');
// #3103: never let the edge cache a 5xx — a transient DB blip served as a
// styled 500 must not be pinned for 60s to later users. 404s stay cacheable.
res.setHeader('Cache-Control', status >= 500 ? 'no-store' : 'public, max-age=60');
res.setHeader('X-Content-Source', resolved.source === 'current' ? 'db-current' : 'db');
return res.send(decompressed);
} catch (err) {
Expand Down Expand Up @@ -1070,12 +1074,28 @@ 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;
// Raw SQL returns CONTENT as a Buffer (see comment above), but coerce
// defensively via toBuffer so a stream/string LOB isn't mis-flagged as a
// miss by the Buffer.isBuffer guard below — symmetric with the else branch.
contentBuf = blobRow?.CONTENT != null ? await toBuffer(blobRow.CONTENT) : undefined;
} 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);
// Guard the raw column before toBuffer — toBuffer(null) itself throws,
// which would bubble past the Buffer.isBuffer check below.
contentBuf = blobRow?.content != null ? await toBuffer(blobRow.content) : undefined;
}
// Resilience (#3103): the metadata SELECT above hit, but this SEPARATE BLOB
// read came back empty — the inconsistent-read window when a concurrent
// publish/delta-write to ContentCurrent (overwrite-in-place, no version
// pin) lands between the two queries, or a transient HANA locator blip.
// Treat a missing/non-Buffer BLOB as a soft miss → caller serves the styled
// 404, rather than letting `gunzipSync(undefined)` throw into the raw-500
// catch and show the user "Internal Server Error".
if (!Buffer.isBuffer(contentBuf) || contentBuf.length === 0) {
metrics.counter('content.blob.missing'); // observability for the race
return 'not-found';
}
const decompressed = gunzipSync(contentBuf);
const advisory = await computeAdvisory(slug);
Expand Down Expand Up @@ -1339,7 +1359,12 @@ 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' });
metrics.counter('render.error'); // #3103: align with catalog-branch observability
// #3103: serve the styled error page (with 500 status for monitoring)
// instead of raw JSON "Content retrieval failed" — mirrors the catalog
// branch (line ~1260) and the fail-open pages path. A transient DB blip
// should never surface a bare "Internal Server Error" to the user.
return serveNotFound(res, slug, 500);
}
}

Expand Down
20 changes: 20 additions & 0 deletions test/lib/content-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,26 @@ describe('content-store', () => {
expect(res.headers['x-content-source']).toBe('cache');
});

// Resilience: metadata row present but the BLOB column is empty/missing —
// the "metadata hit, BLOB read came back empty" race against a concurrent
// publish/delta-write to ContentCurrent. Must degrade to the styled 404,
// NOT bubble a TypeError into a raw 500 "Internal Server Error". See #3103.
it('returns the styled 404 (not a raw 500) when the BLOB is missing but metadata exists', async () => {
// Null out the content BLOB while leaving contentHash/version intact,
// reproducing the inconsistent-read window.
await UPDATE(ContentFiles).set({ content: null }).where({ slug: 'served-tut' });
// Flush the LRU so the read hits the DB, not the warm cache.
const { invalidateContentCache } = await import('../../srv/lib/content-store.js');
invalidateContentCache();

const res = await project.axios.get('/content/tutorials/served-tut', {
validateStatus: () => true
});

expect(res.status).not.toBe(500);
expect(res.status).toBe(404);
});

it('redirects /tutorials/<slug>.html to canonical /tutorials/<slug> with 301', async () => {
const res = await project.axios.get('/content/tutorials/served-tut.html', {
maxRedirects: 0,
Expand Down
Loading