From 8e76e4cbaa50c8656267fbbfaba87dc492f1b57c Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 28 Jul 2026 11:28:04 +0100 Subject: [PATCH 01/12] fix: stop re-fetching property/Connect JSON that is known to 404 The property-tooltip script caches successful JSON fetches in localStorage for 24 hours but never caches failures, so when the referenced attachment does not exist (version drift between release tags and generated JSON), every page view re-requests a URL that is guaranteed to 404 (~17k requests/day). The Bloblang script has the same problem, plus a hardcoded fallback-version chain that multiplies the misses. Property tooltips now store a failure marker (1 hour TTL, versioned by latest-redpanda-tag) and resolve to an empty lookup while it is fresh. The Bloblang loader tracks per-URL failures for 1 hour and skips URLs that recently returned an error response. Preview mode is unaffected. Co-Authored-By: Claude Fable 5 --- src/js/16-bloblang-interactive.js | 43 +++++++++++++++++++++++++++++++ src/js/19-property-tooltips.js | 22 ++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/js/16-bloblang-interactive.js b/src/js/16-bloblang-interactive.js index 6aceb4fc..81986159 100644 --- a/src/js/16-bloblang-interactive.js +++ b/src/js/16-bloblang-interactive.js @@ -72,6 +72,42 @@ ); } + /** + * Track Connect JSON URLs that recently returned an error response, so a + * URL that is known to 404 (for example, a version whose JSON was never + * generated) is not re-requested on every page view. + */ + const FETCH_FAILURE_KEY = 'connect-json-fetch-failures'; + const FETCH_FAILURE_TTL = 60 * 60 * 1000; // 1 hour + + function readFetchFailures() { + try { + const parsed = JSON.parse(localStorage.getItem(FETCH_FAILURE_KEY) || '{}'); + const now = Date.now(); + const fresh = {}; + Object.keys(parsed).forEach((url) => { + if (now - parsed[url] < FETCH_FAILURE_TTL) fresh[url] = parsed[url]; + }); + return fresh; + } catch (e) { + return {}; + } + } + + function hasRecentFetchFailure(url) { + return url in readFetchFailures(); + } + + function markFetchFailure(url) { + try { + const failures = readFetchFailures(); + failures[url] = Date.now(); + localStorage.setItem(FETCH_FAILURE_KEY, JSON.stringify(failures)); + } catch (e) { + // localStorage not available + } + } + /** * Parse a Bloblang snippet into mapping, input, and metadata sections. * Looks for # In: and # Meta: comment directives. @@ -196,11 +232,18 @@ if (!url) { url = `/redpanda-connect/components/_attachments/connect-${version}.json`; } + + if (hasRecentFetchFailure(url)) { + return null; + } } const response = await fetch(url); if (!response.ok) { + if (!isPreviewMode()) { + markFetchFailure(url); + } return null; } diff --git a/src/js/19-property-tooltips.js b/src/js/19-property-tooltips.js index e447c588..ef24a0ef 100644 --- a/src/js/19-property-tooltips.js +++ b/src/js/19-property-tooltips.js @@ -92,6 +92,7 @@ var CACHE_KEY = 'redpanda-properties-cache' var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours + var FAILURE_CACHE_TTL = 60 * 60 * 1000 // 1 hour: retry failed fetches sooner so a fix deploy is picked up // Use latest-redpanda-tag meta tag for cache versioning var cacheVersion = getLatestRedpandaTag() || 'unknown' @@ -101,8 +102,11 @@ var cached = localStorage.getItem(CACHE_KEY) if (cached) { var parsed = JSON.parse(cached) - if (parsed.version === cacheVersion && Date.now() - parsed.timestamp < CACHE_TTL) { - propertiesData = parsed.data + var age = Date.now() - parsed.timestamp + if (parsed.version === cacheVersion && (parsed.failed ? age < FAILURE_CACHE_TTL : age < CACHE_TTL)) { + // failed entries resolve to an empty lookup so a URL known to 404 + // is not re-requested on every page view + propertiesData = parsed.failed ? {} : parsed.data propertiesLoading = false propertiesLoadQueue.forEach(function (resolve) { resolve(propertiesData) @@ -182,6 +186,20 @@ } console.warn('Property tooltips: Failed to load properties data:', error) + if (!isPreviewMode()) { + try { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ + version: cacheVersion, + timestamp: Date.now(), + failed: true, + }) + ) + } catch (cacheError) { + // localStorage full or unavailable + } + } propertiesLoading = false propertiesData = {} propertiesLoadQueue.forEach(function (resolve) { From cc3c4e8c22d7243d300104ce2ae05cb1453d1e0d Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 3 Aug 2026 19:02:31 +0100 Subject: [PATCH 02/12] Isolate the missing-resource marker and scope it to 404/410 Review findings: the failure marker shared CACHE_KEY with successful data and was written from the generic catch, so one transient blip (5xx, offline, parse error) wiped a valid 24h cache and left tooltips dead for an hour for that user. - The marker now lives under its own key (redpanda-properties-missing) and can never overwrite cached data. Valid data is preferred on read. - It is written only for HTTP 404/410 (the resource does not exist for this version); transient failures are not cached and simply retry on the next page view. - A successful load clears the marker. --- src/js/19-property-tooltips.js | 40 +++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/js/19-property-tooltips.js b/src/js/19-property-tooltips.js index ef24a0ef..e6335b3e 100644 --- a/src/js/19-property-tooltips.js +++ b/src/js/19-property-tooltips.js @@ -91,8 +91,14 @@ } var CACHE_KEY = 'redpanda-properties-cache' + // Missing-resource marker lives under its own key so it can never + // overwrite a valid cached dataset, and it is only written for HTTP + // 404/410 (the resource does not exist). Transient failures (5xx, + // offline, parse errors) are not cached at all: the next page view + // simply retries. + var MISSING_CACHE_KEY = 'redpanda-properties-missing' var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours - var FAILURE_CACHE_TTL = 60 * 60 * 1000 // 1 hour: retry failed fetches sooner so a fix deploy is picked up + var MISSING_CACHE_TTL = 60 * 60 * 1000 // 1 hour: re-check missing resources so a fix deploy is picked up // Use latest-redpanda-tag meta tag for cache versioning var cacheVersion = getLatestRedpandaTag() || 'unknown' @@ -102,11 +108,23 @@ var cached = localStorage.getItem(CACHE_KEY) if (cached) { var parsed = JSON.parse(cached) - var age = Date.now() - parsed.timestamp - if (parsed.version === cacheVersion && (parsed.failed ? age < FAILURE_CACHE_TTL : age < CACHE_TTL)) { - // failed entries resolve to an empty lookup so a URL known to 404 - // is not re-requested on every page view - propertiesData = parsed.failed ? {} : parsed.data + if (parsed.version === cacheVersion && Date.now() - parsed.timestamp < CACHE_TTL) { + propertiesData = parsed.data + propertiesLoading = false + propertiesLoadQueue.forEach(function (resolve) { + resolve(propertiesData) + }) + propertiesLoadQueue = [] + return Promise.resolve(propertiesData) + } + } + var missing = localStorage.getItem(MISSING_CACHE_KEY) + if (missing) { + var missingParsed = JSON.parse(missing) + if (missingParsed.version === cacheVersion && Date.now() - missingParsed.timestamp < MISSING_CACHE_TTL) { + // The resource is known not to exist for this version: resolve to + // an empty lookup instead of re-requesting a 404 on every page view + propertiesData = {} propertiesLoading = false propertiesLoadQueue.forEach(function (resolve) { resolve(propertiesData) @@ -123,7 +141,9 @@ return fetch(url) .then(function (response) { if (!response.ok) { - throw new Error('HTTP ' + response.status) + var httpError = new Error('HTTP ' + response.status) + httpError.status = response.status + throw httpError } return response.json() }) @@ -141,6 +161,7 @@ data: propertiesData, }) ) + localStorage.removeItem(MISSING_CACHE_KEY) } catch (e) { // localStorage full or unavailable } @@ -186,14 +207,13 @@ } console.warn('Property tooltips: Failed to load properties data:', error) - if (!isPreviewMode()) { + if (!isPreviewMode() && (error.status === 404 || error.status === 410)) { try { localStorage.setItem( - CACHE_KEY, + MISSING_CACHE_KEY, JSON.stringify({ version: cacheVersion, timestamp: Date.now(), - failed: true, }) ) } catch (cacheError) { From 08f011c8948059a74f91990cd152c64a4251805e Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 10:44:46 +0100 Subject: [PATCH 03/12] Restrict Connect negative caching to 404/410 and add tests Review feedback from kbatuigas: markFetchFailure fired for every non-OK response, so one transient 429/500/503 suppressed Connect JSON fetches for an hour. Now only deterministic missing-resource responses (404/410) are marked, matching the property-tooltips behavior. Also clear the properties missing-marker before the cache write so a quota error on setItem cannot leave a stale marker after a successful fetch. Adds a puppeteer test suite (tests/negative-cache) that serves the page from a fake production hostname via request interception and asserts: 404s are negative-cached and expire after the TTL, 429/5xx are retried on every view, and success clears the marker and populates the cache. Wired into npm run test:all and the Bloblang Tests workflow. Co-Authored-By: Claude Fable 5 --- .../workflows/test-bloblang-playground.yml | 12 + package.json | 3 +- src/js/16-bloblang-interactive.js | 5 +- src/js/19-property-tooltips.js | 4 +- tests/negative-cache/README.md | 26 ++ tests/negative-cache/test-runner.js | 277 ++++++++++++++++++ 6 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 tests/negative-cache/README.md create mode 100644 tests/negative-cache/test-runner.js diff --git a/.github/workflows/test-bloblang-playground.yml b/.github/workflows/test-bloblang-playground.yml index b87f438b..8150ed67 100644 --- a/.github/workflows/test-bloblang-playground.yml +++ b/.github/workflows/test-bloblang-playground.yml @@ -8,10 +8,12 @@ on: - 'src/js/vendor/wasm_exec.js' - 'src/js/16-bloblang-interactive.js' - 'src/js/17-bloblang-yaml.js' + - 'src/js/19-property-tooltips.js' - 'src/css/bloblang-interactive.css' - 'src/static/bloblang-docs.json' - 'tests/bloblang-playground/**' - 'tests/bloblang-interactive/**' + - 'tests/negative-cache/**' - 'gulpfile.js' - '.github/workflows/test-bloblang-playground.yml' pull_request: @@ -22,10 +24,12 @@ on: - 'src/js/vendor/wasm_exec.js' - 'src/js/16-bloblang-interactive.js' - 'src/js/17-bloblang-yaml.js' + - 'src/js/19-property-tooltips.js' - 'src/css/bloblang-interactive.css' - 'src/static/bloblang-docs.json' - 'tests/bloblang-playground/**' - 'tests/bloblang-interactive/**' + - 'tests/negative-cache/**' - 'gulpfile.js' - '.github/workflows/test-bloblang-playground.yml' workflow_dispatch: @@ -99,6 +103,8 @@ jobs: run: npx gulp test:build - name: Test Interactive Features run: npm run test:interactive + - name: Test Negative Cache + run: npm run test:negative-cache - name: Upload playground test results uses: actions/upload-artifact@v4 if: always() @@ -111,4 +117,10 @@ jobs: with: name: test-results-interactive path: test-results-interactive.json + - name: Upload negative-cache test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-negative-cache + path: test-results-negative-cache.json diff --git a/package.json b/package.json index fea318c2..161c99a4 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "test:headless": "node tests/bloblang-playground/test-runner.js", "test:playground": "npm run build:wasm && npm run test:headless", "test:interactive": "node tests/bloblang-interactive/test-runner.js", - "test:all": "npm run test:playground && npm run test:interactive", + "test:negative-cache": "node tests/negative-cache/test-runner.js", + "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache", "build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .", "copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/", "serve:playground": "npx serve ." diff --git a/src/js/16-bloblang-interactive.js b/src/js/16-bloblang-interactive.js index 81986159..d5e462b2 100644 --- a/src/js/16-bloblang-interactive.js +++ b/src/js/16-bloblang-interactive.js @@ -241,7 +241,10 @@ const response = await fetch(url); if (!response.ok) { - if (!isPreviewMode()) { + // Only mark deterministic missing-resource responses (404/410). + // Transient failures (429, 5xx) are not cached, so the next page + // view retries them. + if (!isPreviewMode() && (response.status === 404 || response.status === 410)) { markFetchFailure(url); } return null; diff --git a/src/js/19-property-tooltips.js b/src/js/19-property-tooltips.js index e6335b3e..c6bc70fa 100644 --- a/src/js/19-property-tooltips.js +++ b/src/js/19-property-tooltips.js @@ -153,6 +153,9 @@ // Cache the result (skip in preview mode) if (!isPreviewMode()) { try { + // Clear the missing-marker before the cache write: if setItem + // throws on quota, a stale marker must not outlive a successful fetch + localStorage.removeItem(MISSING_CACHE_KEY) localStorage.setItem( CACHE_KEY, JSON.stringify({ @@ -161,7 +164,6 @@ data: propertiesData, }) ) - localStorage.removeItem(MISSING_CACHE_KEY) } catch (e) { // localStorage full or unavailable } diff --git a/tests/negative-cache/README.md b/tests/negative-cache/README.md new file mode 100644 index 00000000..6865fccb --- /dev/null +++ b/tests/negative-cache/README.md @@ -0,0 +1,26 @@ +# Negative-cache tests + +Verifies the localStorage negative caching for tooltip data fetches in +`src/js/16-bloblang-interactive.js` (Connect JSON) and +`src/js/19-property-tooltips.js` (properties JSON): + +- HTTP `404`/`410` responses are negative-cached for 1 hour, so a missing + JSON file is not re-requested on every page view (the 404-storm fix). +- Transient failures (`429`, `5xx`, network errors) are **not** cached and + are retried on the next page view. +- Markers expire after their TTL, and a successful fetch clears the + properties missing-marker. + +The negative cache is disabled in preview mode (`localhost`, +`docs-ui.netlify.app`), so the runner uses Puppeteer request interception to +serve a synthetic test page from a fake production hostname +(`docs.example.test`) and to control the HTTP status of each JSON response. +No real network requests are made. + +## Run + +```sh +npm run test:negative-cache +``` + +Results are also written to `test-results-negative-cache.json`. diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js new file mode 100644 index 00000000..d58e8e5f --- /dev/null +++ b/tests/negative-cache/test-runner.js @@ -0,0 +1,277 @@ +const puppeteer = require('puppeteer'); +const fs = require('fs'); +const path = require('path'); + +/** + * Test runner for JSON-fetch negative caching + * + * Verifies that: + * - 404/410 responses for Connect JSON and properties JSON are negative-cached + * in localStorage, so they are not re-requested on every page view + * - Transient failures (429, 5xx) are NOT cached and are retried on the next + * page view + * - Markers expire after their TTL and successful fetches clear them + * + * The negative cache is disabled in preview mode (localhost / + * docs-ui.netlify.app), so these tests use Puppeteer request interception to + * serve the page from a fake production hostname without touching the network. + */ + +const HOST = 'http://docs.example.test'; +const CONNECT_PATH = '/redpanda-connect/components/_attachments/connect-9.9.9.json'; +const PROPERTIES_PATH = '/current/reference/properties/_attachments/redpanda-properties-v9.9.9.json'; + +const BLOBLANG_JS = fs.readFileSync(path.resolve(__dirname, '../../src/js/16-bloblang-interactive.js'), 'utf8'); +const PROPERTY_JS = fs.readFileSync(path.resolve(__dirname, '../../src/js/19-property-tooltips.js'), 'utf8'); + +const CONNECT_JSON_BODY = JSON.stringify({ + 'bloblang-functions': [], + 'bloblang-methods': [] +}); + +const PROPERTIES_JSON_BODY = JSON.stringify({ + properties: { + log_retention_ms: { + name: 'log_retention_ms', + type: 'integer', + description: 'Test property' + } + } +}); + +const TEST_PAGE = ` + + + + + + + + + +
+
root = this
+

log_retention_ms

+
+ + + +`; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runTests() { + let browser = null; + const results = []; + let failures = 0; + + function assert(name, condition, detail) { + const passed = !!condition; + if (!passed) failures++; + results.push({ name, passed, detail: detail || '' }); + console.log(` ${passed ? 'āœ… PASS' : 'āŒ FAIL'}: ${name}${detail ? ` (${detail})` : ''}`); + } + + try { + console.log('šŸš€ Launching browser...'); + browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-extensions' + ], + timeout: 30000 + }); + + const page = await browser.newPage(); + + // Mutable per-scenario response statuses and request counters + const state = { + connectStatus: 404, + propertiesStatus: 404, + connectRequests: 0, + propertiesRequests: 0 + }; + + await page.setRequestInterception(true); + page.on('request', (req) => { + let url; + try { + url = new URL(req.url()); + } catch (e) { + return req.respond({ status: 404, contentType: 'text/plain', body: 'bad url' }); + } + + // Connect version lookup (external) - always succeeds + if (url.hostname === 'raw.githubusercontent.com') { + return req.respond({ + status: 200, + contentType: 'text/yaml', + body: "latest-connect-version: '9.9.9'\n" + }); + } + + if (url.origin === HOST) { + if (url.pathname === '/test.html') { + return req.respond({ status: 200, contentType: 'text/html', body: TEST_PAGE }); + } + if (url.pathname === '/js/16-bloblang-interactive.js') { + return req.respond({ status: 200, contentType: 'application/javascript', body: BLOBLANG_JS }); + } + if (url.pathname === '/js/19-property-tooltips.js') { + return req.respond({ status: 200, contentType: 'application/javascript', body: PROPERTY_JS }); + } + if (url.pathname === CONNECT_PATH) { + state.connectRequests++; + if (state.connectStatus === 200) { + return req.respond({ status: 200, contentType: 'application/json', body: CONNECT_JSON_BODY }); + } + return req.respond({ status: state.connectStatus, contentType: 'text/plain', body: 'error' }); + } + if (url.pathname === PROPERTIES_PATH) { + state.propertiesRequests++; + if (state.propertiesStatus === 200) { + return req.respond({ status: 200, contentType: 'application/json', body: PROPERTIES_JSON_BODY }); + } + return req.respond({ status: state.propertiesStatus, contentType: 'text/plain', body: 'error' }); + } + } + + // Anything else (bloblang-docs.json fallback, favicon, ...) is a 404 + return req.respond({ status: 404, contentType: 'text/plain', body: 'not found' }); + }); + + // Loads the test page and returns how many requests hit each JSON URL + // during that page view + async function loadPage() { + const before = { + connect: state.connectRequests, + properties: state.propertiesRequests + }; + await page.goto(`${HOST}/test.html`, { waitUntil: 'networkidle0', timeout: 30000 }); + // Property tooltips fetch via requestIdleCallback; give async work + // time to settle beyond networkidle0 + await page.waitForNetworkIdle({ idleTime: 600, timeout: 5000 }).catch(() => {}); + await sleep(500); + return { + connect: state.connectRequests - before.connect, + properties: state.propertiesRequests - before.properties + }; + } + + function readStorage(key) { + return page.evaluate((k) => localStorage.getItem(k), key); + } + + // --- Scenario 1: 404 responses are negative-cached --- + console.log('\nšŸ“‹ Scenario 1: 404 responses are negative-cached'); + state.connectStatus = 404; + state.propertiesStatus = 404; + const load1 = await loadPage(); + assert('404: Connect JSON requested once on first view', load1.connect === 1, `got ${load1.connect}`); + assert('404: properties JSON requested once on first view', load1.properties === 1, `got ${load1.properties}`); + const connectFailures = JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); + assert('404: Connect failure marker written', CONNECT_PATH in connectFailures); + assert('404: properties missing-marker written', !!(await readStorage('redpanda-properties-missing'))); + + const load2 = await loadPage(); + assert('404: Connect JSON NOT re-requested on next view', load2.connect === 0, `got ${load2.connect}`); + assert('404: properties JSON NOT re-requested on next view', load2.properties === 0, `got ${load2.properties}`); + + // --- Scenario 2: markers expire after their TTL --- + console.log('\nšŸ“‹ Scenario 2: markers expire after their TTL'); + await page.evaluate((connectPath) => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const failures = JSON.parse(localStorage.getItem('connect-json-fetch-failures') || '{}'); + if (failures[connectPath]) failures[connectPath] = twoHoursAgo; + localStorage.setItem('connect-json-fetch-failures', JSON.stringify(failures)); + const missing = JSON.parse(localStorage.getItem('redpanda-properties-missing') || 'null'); + if (missing) { + missing.timestamp = twoHoursAgo; + localStorage.setItem('redpanda-properties-missing', JSON.stringify(missing)); + } + }, CONNECT_PATH); + const load3 = await loadPage(); + assert('Expiry: Connect JSON re-requested after TTL', load3.connect === 1, `got ${load3.connect}`); + assert('Expiry: properties JSON re-requested after TTL', load3.properties === 1, `got ${load3.properties}`); + + // --- Scenario 3: transient failures (503, 429) are retried --- + console.log('\nšŸ“‹ Scenario 3: transient failures are retried'); + await page.evaluate(() => localStorage.clear()); + state.connectStatus = 503; + state.propertiesStatus = 503; + const load4 = await loadPage(); + assert('503: Connect JSON requested', load4.connect >= 1, `got ${load4.connect}`); + assert('503: properties JSON requested', load4.properties >= 1, `got ${load4.properties}`); + const failuresAfter503 = JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); + assert('503: no Connect failure marker written', !(CONNECT_PATH in failuresAfter503)); + assert('503: no properties missing-marker written', !(await readStorage('redpanda-properties-missing'))); + + state.connectStatus = 429; + state.propertiesStatus = 429; + const load5 = await loadPage(); + assert('429: Connect JSON retried on next view', load5.connect >= 1, `got ${load5.connect}`); + assert('429: properties JSON retried on next view', load5.properties >= 1, `got ${load5.properties}`); + const failuresAfter429 = JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); + assert('429: no Connect failure marker written', !(CONNECT_PATH in failuresAfter429)); + assert('429: no properties missing-marker written', !(await readStorage('redpanda-properties-missing'))); + + // --- Scenario 4: success clears markers and populates the cache --- + console.log('\nšŸ“‹ Scenario 4: success clears markers and populates the cache'); + await page.evaluate(() => localStorage.clear()); + state.connectStatus = 404; + state.propertiesStatus = 404; + await loadPage(); // writes fresh markers + state.connectStatus = 200; + state.propertiesStatus = 200; + const load6 = await loadPage(); + assert('Fresh markers still suppress fetches', load6.connect === 0 && load6.properties === 0, + `connect ${load6.connect}, properties ${load6.properties}`); + + // Expire the markers so the next view retries and succeeds + await page.evaluate((connectPath) => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const failures = JSON.parse(localStorage.getItem('connect-json-fetch-failures') || '{}'); + if (failures[connectPath]) failures[connectPath] = twoHoursAgo; + localStorage.setItem('connect-json-fetch-failures', JSON.stringify(failures)); + const missing = JSON.parse(localStorage.getItem('redpanda-properties-missing') || 'null'); + if (missing) { + missing.timestamp = twoHoursAgo; + localStorage.setItem('redpanda-properties-missing', JSON.stringify(missing)); + } + }, CONNECT_PATH); + const load7 = await loadPage(); + assert('Success: Connect JSON fetched', load7.connect === 1, `got ${load7.connect}`); + assert('Success: properties JSON fetched', load7.properties === 1, `got ${load7.properties}`); + assert('Success: properties missing-marker cleared', !(await readStorage('redpanda-properties-missing'))); + assert('Success: properties data cached', !!(await readStorage('redpanda-properties-cache'))); + + const load8 = await loadPage(); + assert('Success: properties served from cache on next view', load8.properties === 0, `got ${load8.properties}`); + + // --- Summary --- + const total = results.length; + const passed = total - failures; + console.log(`\nšŸ“Š Results: ${passed}/${total} passed`); + + fs.writeFileSync( + path.resolve(__dirname, '../../test-results-negative-cache.json'), + JSON.stringify({ total, passed, failed: failures, results }, null, 2) + ); + + if (failures > 0) { + process.exitCode = 1; + } + } catch (error) { + console.error('šŸ’„ Test runner error:', error); + process.exitCode = 1; + } finally { + if (browser) await browser.close(); + } +} + +runTests(); From 3082683496185b429cdce3d38c06a9ad6090ea83 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 12:29:42 +0100 Subject: [PATCH 04/12] Key properties missing-markers per URL and harden the negative cache Findings from a deeper review pass on top of the kbatuigas fix: - The properties missing-marker was a single slot keyed only by tag, so a user browsing multiple doc versions with missing JSON thrashed it and kept firing 404s. It is now a per-URL map (matching the Connect side), pruned of expired entries on read, and a successful fetch clears only its own URL's entry. - The dataset cache and missing-marker reads now use separate try/catch blocks, so a corrupt cache entry cannot disable the marker check. - When the connect-json-url meta tag resolves, loadBloblangDocs now tries that URL exactly once instead of also walking the 5-version fallback loop (which could only re-request the same URL - up to 6 identical requests per view during a transient 5xx/429). The version lookup is skipped entirely in that case. - Corrected the Connect failure-map header comment (only 404/410 are recorded, not any error response). Test suite expanded to 45 assertions: 410 responses, network errors, JSON parse errors, preview mode (localhost) never negative-caching, a DOM assertion that a tooltip actually attaches after a successful fetch, and tightened exact request counts. Workflow path triggers now include package.json/package-lock.json. Co-Authored-By: Claude Fable 5 --- .../workflows/test-bloblang-playground.yml | 4 + src/js/16-bloblang-interactive.js | 36 ++- src/js/19-property-tooltips.js | 116 ++++++--- tests/negative-cache/README.md | 24 +- tests/negative-cache/test-runner.js | 240 +++++++++++------- 5 files changed, 272 insertions(+), 148 deletions(-) diff --git a/.github/workflows/test-bloblang-playground.yml b/.github/workflows/test-bloblang-playground.yml index 8150ed67..603b58d4 100644 --- a/.github/workflows/test-bloblang-playground.yml +++ b/.github/workflows/test-bloblang-playground.yml @@ -14,6 +14,8 @@ on: - 'tests/bloblang-playground/**' - 'tests/bloblang-interactive/**' - 'tests/negative-cache/**' + - 'package.json' + - 'package-lock.json' - 'gulpfile.js' - '.github/workflows/test-bloblang-playground.yml' pull_request: @@ -30,6 +32,8 @@ on: - 'tests/bloblang-playground/**' - 'tests/bloblang-interactive/**' - 'tests/negative-cache/**' + - 'package.json' + - 'package-lock.json' - 'gulpfile.js' - '.github/workflows/test-bloblang-playground.yml' workflow_dispatch: diff --git a/src/js/16-bloblang-interactive.js b/src/js/16-bloblang-interactive.js index d5e462b2..4b3020cc 100644 --- a/src/js/16-bloblang-interactive.js +++ b/src/js/16-bloblang-interactive.js @@ -73,9 +73,10 @@ } /** - * Track Connect JSON URLs that recently returned an error response, so a - * URL that is known to 404 (for example, a version whose JSON was never - * generated) is not re-requested on every page view. + * Track Connect JSON URLs that recently returned HTTP 404/410, so a URL + * that is known not to exist (for example, a version whose JSON was never + * generated) is not re-requested on every page view. Transient failures + * (429, 5xx, network errors) are never recorded here. */ const FETCH_FAILURE_KEY = 'connect-json-fetch-failures'; const FETCH_FAILURE_TTL = 60 * 60 * 1000; // 1 hour @@ -404,18 +405,25 @@ // Skip remote fetches on docs-ui preview site - JSON files don't exist there if (!isDocsUiPreview) { - // Try to get latest version from cached antora.yml - var latestVersion = await getConnectVersion(); - if (latestVersion) { - data = await tryFetchConnectJSON(latestVersion); - } + if (getConnectJsonUrl()) { + // The meta-tag URL takes precedence over any version inside + // tryFetchConnectJSON, so one attempt is enough: the version + // lookup and fallback loop could only re-request the same URL. + data = await tryFetchConnectJSON(null); + } else { + // Try to get latest version from cached antora.yml + var latestVersion = await getConnectVersion(); + if (latestVersion) { + data = await tryFetchConnectJSON(latestVersion); + } - // Fallback: try known recent versions - if (!data) { - var fallbackVersions = ['4.79.0', '4.78.0', '4.77.0', '4.76.0', '4.75.0']; - for (var i = 0; i < fallbackVersions.length; i++) { - data = await tryFetchConnectJSON(fallbackVersions[i]); - if (data) break; + // Fallback: try known recent versions + if (!data) { + var fallbackVersions = ['4.79.0', '4.78.0', '4.77.0', '4.76.0', '4.75.0']; + for (var i = 0; i < fallbackVersions.length; i++) { + data = await tryFetchConnectJSON(fallbackVersions[i]); + if (data) break; + } } } } diff --git a/src/js/19-property-tooltips.js b/src/js/19-property-tooltips.js index c6bc70fa..df11df05 100644 --- a/src/js/19-property-tooltips.js +++ b/src/js/19-property-tooltips.js @@ -56,6 +56,65 @@ ) } + var CACHE_KEY = 'redpanda-properties-cache' + var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours + // Missing-resource markers live under their own key so they can never + // overwrite a valid cached dataset, and are only written for HTTP 404/410 + // (the resource does not exist). Transient failures (5xx, offline, parse + // errors) are not cached at all: the next page view simply retries. + // The value is a per-URL map ({url: {version, timestamp}}) because the + // properties JSON URL and tag vary per doc version: a user browsing + // several versions with missing JSON must not thrash a single marker. + var MISSING_CACHE_KEY = 'redpanda-properties-missing' + var MISSING_CACHE_TTL = 60 * 60 * 1000 // 1 hour: re-check missing resources so a fix deploy is picked up + + /** + * Read the missing-resource map, dropping expired or malformed entries + */ + function readMissingMarkers () { + try { + var parsed = JSON.parse(localStorage.getItem(MISSING_CACHE_KEY) || '{}') + if (!parsed || typeof parsed !== 'object') return {} + var now = Date.now() + var fresh = {} + Object.keys(parsed).forEach(function (markedUrl) { + var entry = parsed[markedUrl] + if (entry && typeof entry.timestamp === 'number' && now - entry.timestamp < MISSING_CACHE_TTL) { + fresh[markedUrl] = entry + } + }) + return fresh + } catch (e) { + return {} + } + } + + function hasMissingMarker (url, version) { + var entry = readMissingMarkers()[url] + return !!(entry && entry.version === version) + } + + function markMissing (url, version) { + try { + var markers = readMissingMarkers() + markers[url] = { version: version, timestamp: Date.now() } + localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(markers)) + } catch (e) { + // localStorage full or unavailable + } + } + + function clearMissingMarker (url) { + try { + var markers = readMissingMarkers() + delete markers[url] + // Always rewrite: this also prunes expired entries from storage + localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(markers)) + } catch (e) { + // localStorage full or unavailable + } + } + /** * Fetch properties JSON with caching */ @@ -90,19 +149,12 @@ } } - var CACHE_KEY = 'redpanda-properties-cache' - // Missing-resource marker lives under its own key so it can never - // overwrite a valid cached dataset, and it is only written for HTTP - // 404/410 (the resource does not exist). Transient failures (5xx, - // offline, parse errors) are not cached at all: the next page view - // simply retries. - var MISSING_CACHE_KEY = 'redpanda-properties-missing' - var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours - var MISSING_CACHE_TTL = 60 * 60 * 1000 // 1 hour: re-check missing resources so a fix deploy is picked up // Use latest-redpanda-tag meta tag for cache versioning var cacheVersion = getLatestRedpandaTag() || 'unknown' - // Check localStorage cache (skip in preview mode for easier testing) + // Check localStorage cache (skip in preview mode for easier testing). + // The dataset cache and the missing-marker each get their own try/catch + // so a corrupt entry in one cannot disable the other check. if (!isPreviewMode()) { try { var cached = localStorage.getItem(CACHE_KEY) @@ -118,24 +170,20 @@ return Promise.resolve(propertiesData) } } - var missing = localStorage.getItem(MISSING_CACHE_KEY) - if (missing) { - var missingParsed = JSON.parse(missing) - if (missingParsed.version === cacheVersion && Date.now() - missingParsed.timestamp < MISSING_CACHE_TTL) { - // The resource is known not to exist for this version: resolve to - // an empty lookup instead of re-requesting a 404 on every page view - propertiesData = {} - propertiesLoading = false - propertiesLoadQueue.forEach(function (resolve) { - resolve(propertiesData) - }) - propertiesLoadQueue = [] - return Promise.resolve(propertiesData) - } - } } catch (e) { // Ignore cache errors } + if (hasMissingMarker(url, cacheVersion)) { + // The resource is known not to exist for this version: resolve to + // an empty lookup instead of re-requesting a 404 on every page view + propertiesData = {} + propertiesLoading = false + propertiesLoadQueue.forEach(function (resolve) { + resolve(propertiesData) + }) + propertiesLoadQueue = [] + return Promise.resolve(propertiesData) + } } return fetch(url) @@ -152,10 +200,10 @@ // Cache the result (skip in preview mode) if (!isPreviewMode()) { + // Clear the missing-marker before the cache write: if setItem + // throws on quota, a stale marker must not outlive a successful fetch + clearMissingMarker(url) try { - // Clear the missing-marker before the cache write: if setItem - // throws on quota, a stale marker must not outlive a successful fetch - localStorage.removeItem(MISSING_CACHE_KEY) localStorage.setItem( CACHE_KEY, JSON.stringify({ @@ -210,17 +258,7 @@ console.warn('Property tooltips: Failed to load properties data:', error) if (!isPreviewMode() && (error.status === 404 || error.status === 410)) { - try { - localStorage.setItem( - MISSING_CACHE_KEY, - JSON.stringify({ - version: cacheVersion, - timestamp: Date.now(), - }) - ) - } catch (cacheError) { - // localStorage full or unavailable - } + markMissing(url, cacheVersion) } propertiesLoading = false propertiesData = {} diff --git a/tests/negative-cache/README.md b/tests/negative-cache/README.md index 6865fccb..0380970f 100644 --- a/tests/negative-cache/README.md +++ b/tests/negative-cache/README.md @@ -6,16 +6,24 @@ Verifies the localStorage negative caching for tooltip data fetches in - HTTP `404`/`410` responses are negative-cached for 1 hour, so a missing JSON file is not re-requested on every page view (the 404-storm fix). -- Transient failures (`429`, `5xx`, network errors) are **not** cached and - are retried on the next page view. + Both caches are keyed per URL, so browsing multiple doc versions with + missing JSON cannot thrash a shared marker. +- Transient failures (`429`, `5xx`, network errors, JSON parse errors) are + **not** cached and are retried on the next page view. - Markers expire after their TTL, and a successful fetch clears the - properties missing-marker. + matching properties missing-marker and populates the dataset cache. +- Preview mode never writes markers and always retries. -The negative cache is disabled in preview mode (`localhost`, -`docs-ui.netlify.app`), so the runner uses Puppeteer request interception to -serve a synthetic test page from a fake production hostname -(`docs.example.test`) and to control the HTTP status of each JSON response. -No real network requests are made. +Preview mode means the hostname is `localhost`, `127.0.0.1`, or contains +`docs-ui.netlify.app`. Content-repo deploy previews (other `*.netlify.app` +hosts) are treated as production, so a 404 seen there is negative-cached +for up to 1 hour on that origin; clear the browser's localStorage to retry +sooner. + +The runner uses Puppeteer request interception to serve a synthetic test +page from a fake production hostname (`docs.example.test`) — and from +`localhost` for the preview-mode scenario — controlling the HTTP status of +each JSON response. No real network requests are made. ## Run diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js index d58e8e5f..9d1ebce4 100644 --- a/tests/negative-cache/test-runner.js +++ b/tests/negative-cache/test-runner.js @@ -6,20 +6,27 @@ const path = require('path'); * Test runner for JSON-fetch negative caching * * Verifies that: - * - 404/410 responses for Connect JSON and properties JSON are negative-cached - * in localStorage, so they are not re-requested on every page view - * - Transient failures (429, 5xx) are NOT cached and are retried on the next + * - HTTP 404 and 410 responses for Connect JSON and properties JSON are + * negative-cached in localStorage, so they are not re-requested on every * page view + * - Transient failures (429, 5xx, network errors, JSON parse errors) are NOT + * cached and are retried on the next page view * - Markers expire after their TTL and successful fetches clear them + * - Preview mode (localhost) never writes markers and always retries * - * The negative cache is disabled in preview mode (localhost / - * docs-ui.netlify.app), so these tests use Puppeteer request interception to - * serve the page from a fake production hostname without touching the network. + * The negative cache is disabled in preview mode (localhost / 127.0.0.1 / + * docs-ui.netlify.app), so most tests use Puppeteer request interception to + * serve the page from a fake production hostname without touching the + * network; the preview-mode scenario serves the same page from localhost. */ -const HOST = 'http://docs.example.test'; +const PROD_HOST = 'http://docs.example.test'; +const PREVIEW_HOST = 'http://localhost'; const CONNECT_PATH = '/redpanda-connect/components/_attachments/connect-9.9.9.json'; const PROPERTIES_PATH = '/current/reference/properties/_attachments/redpanda-properties-v9.9.9.json'; +// In preview mode the Connect fetch ignores the meta tag and uses the static +// UI path instead +const CONNECT_PREVIEW_PATH = '/_/connect.json'; const BLOBLANG_JS = fs.readFileSync(path.resolve(__dirname, '../../src/js/16-bloblang-interactive.js'), 'utf8'); const PROPERTY_JS = fs.readFileSync(path.resolve(__dirname, '../../src/js/19-property-tooltips.js'), 'utf8'); @@ -89,14 +96,26 @@ async function runTests() { const page = await browser.newPage(); - // Mutable per-scenario response statuses and request counters + // Per-scenario response behavior: an HTTP status number, 'abort' + // (network error), or 'badjson' (200 with a non-JSON body) const state = { - connectStatus: 404, - propertiesStatus: 404, + connect: 404, + properties: 404, connectRequests: 0, propertiesRequests: 0 }; + function respondJson(req, behavior, successBody) { + if (behavior === 'abort') return req.abort('failed'); + if (behavior === 'badjson') { + return req.respond({ status: 200, contentType: 'application/json', body: 'this is not json' }); + } + if (behavior === 200) { + return req.respond({ status: 200, contentType: 'application/json', body: successBody }); + } + return req.respond({ status: behavior, contentType: 'text/plain', body: 'error' }); + } + await page.setRequestInterception(true); page.on('request', (req) => { let url; @@ -106,16 +125,18 @@ async function runTests() { return req.respond({ status: 404, contentType: 'text/plain', body: 'bad url' }); } - // Connect version lookup (external) - always succeeds + // Connect version lookup (external) - always succeeds. Only + // fetched when the connect-json-url meta tag is absent. if (url.hostname === 'raw.githubusercontent.com') { return req.respond({ status: 200, contentType: 'text/yaml', + headers: { 'Access-Control-Allow-Origin': '*' }, body: "latest-connect-version: '9.9.9'\n" }); } - if (url.origin === HOST) { + if (url.origin === PROD_HOST || url.origin === PREVIEW_HOST) { if (url.pathname === '/test.html') { return req.respond({ status: 200, contentType: 'text/html', body: TEST_PAGE }); } @@ -125,19 +146,13 @@ async function runTests() { if (url.pathname === '/js/19-property-tooltips.js') { return req.respond({ status: 200, contentType: 'application/javascript', body: PROPERTY_JS }); } - if (url.pathname === CONNECT_PATH) { + if (url.pathname === CONNECT_PATH || url.pathname === CONNECT_PREVIEW_PATH) { state.connectRequests++; - if (state.connectStatus === 200) { - return req.respond({ status: 200, contentType: 'application/json', body: CONNECT_JSON_BODY }); - } - return req.respond({ status: state.connectStatus, contentType: 'text/plain', body: 'error' }); + return respondJson(req, state.connect, CONNECT_JSON_BODY); } if (url.pathname === PROPERTIES_PATH) { state.propertiesRequests++; - if (state.propertiesStatus === 200) { - return req.respond({ status: 200, contentType: 'application/json', body: PROPERTIES_JSON_BODY }); - } - return req.respond({ status: state.propertiesStatus, contentType: 'text/plain', body: 'error' }); + return respondJson(req, state.properties, PROPERTIES_JSON_BODY); } } @@ -145,14 +160,14 @@ async function runTests() { return req.respond({ status: 404, contentType: 'text/plain', body: 'not found' }); }); - // Loads the test page and returns how many requests hit each JSON URL - // during that page view - async function loadPage() { + // Loads the test page from the given origin and returns how many + // requests hit each JSON URL during that page view + async function loadPage(origin = PROD_HOST) { const before = { connect: state.connectRequests, properties: state.propertiesRequests }; - await page.goto(`${HOST}/test.html`, { waitUntil: 'networkidle0', timeout: 30000 }); + await page.goto(`${origin}/test.html`, { waitUntil: 'networkidle0', timeout: 30000 }); // Property tooltips fetch via requestIdleCallback; give async work // time to settle beyond networkidle0 await page.waitForNetworkIdle({ idleTime: 600, timeout: 5000 }).catch(() => {}); @@ -167,16 +182,36 @@ async function runTests() { return page.evaluate((k) => localStorage.getItem(k), key); } + async function readMissingMarkers() { + return JSON.parse(await readStorage('redpanda-properties-missing') || '{}'); + } + + async function readConnectFailures() { + return JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); + } + + // Rewrites all stored marker timestamps to two hours ago + function expireMarkers() { + return page.evaluate(() => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const failures = JSON.parse(localStorage.getItem('connect-json-fetch-failures') || '{}'); + Object.keys(failures).forEach((url) => { failures[url] = twoHoursAgo; }); + localStorage.setItem('connect-json-fetch-failures', JSON.stringify(failures)); + const missing = JSON.parse(localStorage.getItem('redpanda-properties-missing') || '{}'); + Object.keys(missing).forEach((url) => { missing[url].timestamp = twoHoursAgo; }); + localStorage.setItem('redpanda-properties-missing', JSON.stringify(missing)); + }); + } + // --- Scenario 1: 404 responses are negative-cached --- console.log('\nšŸ“‹ Scenario 1: 404 responses are negative-cached'); - state.connectStatus = 404; - state.propertiesStatus = 404; + state.connect = 404; + state.properties = 404; const load1 = await loadPage(); assert('404: Connect JSON requested once on first view', load1.connect === 1, `got ${load1.connect}`); assert('404: properties JSON requested once on first view', load1.properties === 1, `got ${load1.properties}`); - const connectFailures = JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); - assert('404: Connect failure marker written', CONNECT_PATH in connectFailures); - assert('404: properties missing-marker written', !!(await readStorage('redpanda-properties-missing'))); + assert('404: Connect failure marker written', CONNECT_PATH in await readConnectFailures()); + assert('404: properties missing-marker written for URL', PROPERTIES_PATH in await readMissingMarkers()); const load2 = await loadPage(); assert('404: Connect JSON NOT re-requested on next view', load2.connect === 0, `got ${load2.connect}`); @@ -184,74 +219,105 @@ async function runTests() { // --- Scenario 2: markers expire after their TTL --- console.log('\nšŸ“‹ Scenario 2: markers expire after their TTL'); - await page.evaluate((connectPath) => { - const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; - const failures = JSON.parse(localStorage.getItem('connect-json-fetch-failures') || '{}'); - if (failures[connectPath]) failures[connectPath] = twoHoursAgo; - localStorage.setItem('connect-json-fetch-failures', JSON.stringify(failures)); - const missing = JSON.parse(localStorage.getItem('redpanda-properties-missing') || 'null'); - if (missing) { - missing.timestamp = twoHoursAgo; - localStorage.setItem('redpanda-properties-missing', JSON.stringify(missing)); - } - }, CONNECT_PATH); + await expireMarkers(); const load3 = await loadPage(); assert('Expiry: Connect JSON re-requested after TTL', load3.connect === 1, `got ${load3.connect}`); assert('Expiry: properties JSON re-requested after TTL', load3.properties === 1, `got ${load3.properties}`); - // --- Scenario 3: transient failures (503, 429) are retried --- - console.log('\nšŸ“‹ Scenario 3: transient failures are retried'); + // --- Scenario 3: 410 responses are negative-cached --- + console.log('\nšŸ“‹ Scenario 3: 410 responses are negative-cached'); + await page.evaluate(() => localStorage.clear()); + state.connect = 410; + state.properties = 410; + const load410a = await loadPage(); + assert('410: Connect JSON requested once on first view', load410a.connect === 1, `got ${load410a.connect}`); + assert('410: properties JSON requested once on first view', load410a.properties === 1, `got ${load410a.properties}`); + const load410b = await loadPage(); + assert('410: Connect JSON NOT re-requested on next view', load410b.connect === 0, `got ${load410b.connect}`); + assert('410: properties JSON NOT re-requested on next view', load410b.properties === 0, `got ${load410b.properties}`); + + // --- Scenario 4: transient failures (503, 429) are retried --- + console.log('\nšŸ“‹ Scenario 4: transient HTTP failures are retried'); await page.evaluate(() => localStorage.clear()); - state.connectStatus = 503; - state.propertiesStatus = 503; + state.connect = 503; + state.properties = 503; const load4 = await loadPage(); - assert('503: Connect JSON requested', load4.connect >= 1, `got ${load4.connect}`); - assert('503: properties JSON requested', load4.properties >= 1, `got ${load4.properties}`); - const failuresAfter503 = JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); - assert('503: no Connect failure marker written', !(CONNECT_PATH in failuresAfter503)); - assert('503: no properties missing-marker written', !(await readStorage('redpanda-properties-missing'))); - - state.connectStatus = 429; - state.propertiesStatus = 429; + assert('503: Connect JSON requested exactly once', load4.connect === 1, `got ${load4.connect}`); + assert('503: properties JSON requested exactly once', load4.properties === 1, `got ${load4.properties}`); + assert('503: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('503: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + + state.connect = 429; + state.properties = 429; const load5 = await loadPage(); - assert('429: Connect JSON retried on next view', load5.connect >= 1, `got ${load5.connect}`); - assert('429: properties JSON retried on next view', load5.properties >= 1, `got ${load5.properties}`); - const failuresAfter429 = JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); - assert('429: no Connect failure marker written', !(CONNECT_PATH in failuresAfter429)); - assert('429: no properties missing-marker written', !(await readStorage('redpanda-properties-missing'))); - - // --- Scenario 4: success clears markers and populates the cache --- - console.log('\nšŸ“‹ Scenario 4: success clears markers and populates the cache'); - await page.evaluate(() => localStorage.clear()); - state.connectStatus = 404; - state.propertiesStatus = 404; - await loadPage(); // writes fresh markers - state.connectStatus = 200; - state.propertiesStatus = 200; + assert('429: Connect JSON retried on next view', load5.connect === 1, `got ${load5.connect}`); + assert('429: properties JSON retried on next view', load5.properties === 1, `got ${load5.properties}`); + assert('429: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('429: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + + // --- Scenario 5: network errors are retried --- + console.log('\nšŸ“‹ Scenario 5: network errors are retried'); + state.connect = 'abort'; + state.properties = 'abort'; const load6 = await loadPage(); - assert('Fresh markers still suppress fetches', load6.connect === 0 && load6.properties === 0, - `connect ${load6.connect}, properties ${load6.properties}`); - - // Expire the markers so the next view retries and succeeds - await page.evaluate((connectPath) => { - const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; - const failures = JSON.parse(localStorage.getItem('connect-json-fetch-failures') || '{}'); - if (failures[connectPath]) failures[connectPath] = twoHoursAgo; - localStorage.setItem('connect-json-fetch-failures', JSON.stringify(failures)); - const missing = JSON.parse(localStorage.getItem('redpanda-properties-missing') || 'null'); - if (missing) { - missing.timestamp = twoHoursAgo; - localStorage.setItem('redpanda-properties-missing', JSON.stringify(missing)); - } - }, CONNECT_PATH); + assert('Network error: Connect JSON requested', load6.connect === 1, `got ${load6.connect}`); + assert('Network error: properties JSON requested', load6.properties === 1, `got ${load6.properties}`); const load7 = await loadPage(); - assert('Success: Connect JSON fetched', load7.connect === 1, `got ${load7.connect}`); - assert('Success: properties JSON fetched', load7.properties === 1, `got ${load7.properties}`); - assert('Success: properties missing-marker cleared', !(await readStorage('redpanda-properties-missing'))); - assert('Success: properties data cached', !!(await readStorage('redpanda-properties-cache'))); + assert('Network error: Connect JSON retried on next view', load7.connect === 1, `got ${load7.connect}`); + assert('Network error: properties JSON retried on next view', load7.properties === 1, `got ${load7.properties}`); + assert('Network error: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('Network error: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + // --- Scenario 6: JSON parse errors are retried --- + console.log('\nšŸ“‹ Scenario 6: JSON parse errors are retried'); + state.connect = 'badjson'; + state.properties = 'badjson'; const load8 = await loadPage(); - assert('Success: properties served from cache on next view', load8.properties === 0, `got ${load8.properties}`); + assert('Parse error: Connect JSON requested', load8.connect === 1, `got ${load8.connect}`); + assert('Parse error: properties JSON requested', load8.properties === 1, `got ${load8.properties}`); + const load9 = await loadPage(); + assert('Parse error: Connect JSON retried on next view', load9.connect === 1, `got ${load9.connect}`); + assert('Parse error: properties JSON retried on next view', load9.properties === 1, `got ${load9.properties}`); + assert('Parse error: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('Parse error: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + + // --- Scenario 7: success clears markers and populates the cache --- + console.log('\nšŸ“‹ Scenario 7: success clears markers and populates the cache'); + await page.evaluate(() => localStorage.clear()); + state.connect = 404; + state.properties = 404; + await loadPage(); // writes fresh markers + state.connect = 200; + state.properties = 200; + const load10 = await loadPage(); + assert('Fresh markers still suppress fetches', load10.connect === 0 && load10.properties === 0, + `connect ${load10.connect}, properties ${load10.properties}`); + + await expireMarkers(); + const load11 = await loadPage(); + assert('Success: Connect JSON fetched', load11.connect === 1, `got ${load11.connect}`); + assert('Success: properties JSON fetched', load11.properties === 1, `got ${load11.properties}`); + assert('Success: properties missing-marker cleared', !(PROPERTIES_PATH in await readMissingMarkers())); + assert('Success: properties data cached', !!(await readStorage('redpanda-properties-cache'))); + const tooltipAttached = await page.waitForSelector('code.has-property-tooltip', { timeout: 5000 }) + .then(() => true).catch(() => false); + assert('Success: property tooltip attached to matching code element', tooltipAttached); + + const load12 = await loadPage(); + assert('Success: properties served from cache on next view', load12.properties === 0, `got ${load12.properties}`); + + // --- Scenario 8: preview mode (localhost) never negative-caches --- + console.log('\nšŸ“‹ Scenario 8: preview mode (localhost) never negative-caches'); + state.connect = 404; + state.properties = 404; + const load13 = await loadPage(PREVIEW_HOST); + assert('Preview: Connect JSON requested', load13.connect === 1, `got ${load13.connect}`); + assert('Preview: properties JSON requested', load13.properties === 1, `got ${load13.properties}`); + const load14 = await loadPage(PREVIEW_HOST); + assert('Preview: Connect JSON re-requested on next view', load14.connect === 1, `got ${load14.connect}`); + assert('Preview: properties JSON re-requested on next view', load14.properties === 1, `got ${load14.properties}`); + assert('Preview: no Connect failure marker written', Object.keys(await readConnectFailures()).length === 0); + assert('Preview: no properties missing-marker written', Object.keys(await readMissingMarkers()).length === 0); // --- Summary --- const total = results.length; From 45bd2d7d15bb850b15ef15ee569228f7d3b09242 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 12:48:24 +0100 Subject: [PATCH 05/12] Trust the build-resolved meta tag; drop dead Connect version guessing The connect-json-url meta tag is now resolved at build time against the attachments that actually exist in the catalog (set-available-attachment-versions in docs-extensions-and-macros#224), and only the newest connect-.json is hosted. That makes the client-side version discovery pure 404 fuel: - getConnectVersion() fetched rp-connect-docs/antora.yml from raw.githubusercontent.com, but that repo is now private, so the fetch 404s for every visitor and always returned null. - The hardcoded fallback lists (4.79.0-4.75.0 in 16-bloblang-interactive, 4.78.0-4.75.0 in bloblang-playground.hbs) name versions whose JSON no longer exists, so every walk was a guaranteed 404 chain. 16-bloblang-interactive.js now fetches only the meta-tag URL (or the static preview path). The playground's completion fetch prefers the meta-tag URL, keeps the GitHub latest-release lookup as a secondary (that version's JSON is the one that is hosted), and no longer guesses older versions. Co-Authored-By: Claude Fable 5 --- src/js/16-bloblang-interactive.js | 87 +++------------------------- src/partials/bloblang-playground.hbs | 29 ++++++---- tests/negative-cache/test-runner.js | 11 ---- 3 files changed, 26 insertions(+), 101 deletions(-) diff --git a/src/js/16-bloblang-interactive.js b/src/js/16-bloblang-interactive.js index 4b3020cc..edb28624 100644 --- a/src/js/16-bloblang-interactive.js +++ b/src/js/16-bloblang-interactive.js @@ -215,9 +215,14 @@ } /** - * Try to fetch Connect JSON - uses meta tag URL or falls back to version-based path + * Try to fetch Connect JSON. The connect-json-url meta tag - resolved at + * build time against the attachments that actually exist in the catalog + * (set-available-attachment-versions extension in + * docs-extensions-and-macros) - is the only production source: only the + * newest connect-.json is hosted, so guessing other versions can + * only produce 404s. */ - async function tryFetchConnectJSON(version) { + async function tryFetchConnectJSON() { try { let url; @@ -226,15 +231,9 @@ const rootPath = typeof uiRootPath !== 'undefined' ? uiRootPath : '/_'; url = `${rootPath}/connect.json`; } else { - // Production: try meta tag URL first (resolved by Antora) url = getConnectJsonUrl(); - // Fallback if meta tag not set - if (!url) { - url = `/redpanda-connect/components/_attachments/connect-${version}.json`; - } - - if (hasRecentFetchFailure(url)) { + if (!url || hasRecentFetchFailure(url)) { return null; } } @@ -335,54 +334,6 @@ return docs; } - /** - * Get Connect version from cache or fetch from antora.yml - * Caches in localStorage for 1 hour to avoid repeated fetches - */ - async function getConnectVersion() { - var CACHE_KEY = 'bloblang-connect-version'; - var CACHE_TTL = 60 * 60 * 1000; // 1 hour - - // Check cache first - try { - var cached = localStorage.getItem(CACHE_KEY); - if (cached) { - var parsed = JSON.parse(cached); - if (Date.now() - parsed.timestamp < CACHE_TTL) { - return parsed.version; - } - } - } catch (e) { - // localStorage not available or parse error - } - - // Fetch from antora.yml (no rate limits, CDN-served) - try { - var resp = await fetch('https://raw.githubusercontent.com/redpanda-data/rp-connect-docs/main/antora.yml'); - if (resp.ok) { - var yaml = await resp.text(); - var match = yaml.match(/latest-connect-version:\s*['"]?(\d+\.\d+\.\d+)/); - if (match) { - var version = match[1]; - // Cache the result - try { - localStorage.setItem(CACHE_KEY, JSON.stringify({ - version: version, - timestamp: Date.now() - })); - } catch (e) { - // localStorage not available - } - return version; - } - } - } catch (e) { - // Silent fail - will use fallback versions - } - - return null; - } - /** * Load Bloblang documentation from Connect JSON */ @@ -405,27 +356,7 @@ // Skip remote fetches on docs-ui preview site - JSON files don't exist there if (!isDocsUiPreview) { - if (getConnectJsonUrl()) { - // The meta-tag URL takes precedence over any version inside - // tryFetchConnectJSON, so one attempt is enough: the version - // lookup and fallback loop could only re-request the same URL. - data = await tryFetchConnectJSON(null); - } else { - // Try to get latest version from cached antora.yml - var latestVersion = await getConnectVersion(); - if (latestVersion) { - data = await tryFetchConnectJSON(latestVersion); - } - - // Fallback: try known recent versions - if (!data) { - var fallbackVersions = ['4.79.0', '4.78.0', '4.77.0', '4.76.0', '4.75.0']; - for (var i = 0; i < fallbackVersions.length; i++) { - data = await tryFetchConnectJSON(fallbackVersions[i]); - if (data) break; - } - } - } + data = await tryFetchConnectJSON(); } // Transform data to our format diff --git a/src/partials/bloblang-playground.hbs b/src/partials/bloblang-playground.hbs index 4a122697..88ade6c4 100644 --- a/src/partials/bloblang-playground.hbs +++ b/src/partials/bloblang-playground.hbs @@ -497,34 +497,39 @@ async function fetchConnectCompletions() { return extractCompletionsFromConnectData(mockConnectData); } - // Try to get latest version from GitHub releases + // Prefer the connect-json-url meta tag: it is resolved at build time + // against the attachments that actually exist in the catalog + // (set-available-attachment-versions extension), so it never points at + // a version whose JSON was not generated. + const meta = document.querySelector('meta[name="connect-json-url"]'); + const metaUrl = meta && meta.content; + if (metaUrl && !metaUrl.startsWith('attachment$') && !metaUrl.startsWith('page$')) { + const result = await tryFetchConnectJSON(metaUrl); + if (result.length > 0) return result; + } + + // Secondary: derive the URL from the latest GitHub release. Only the + // newest connect-.json is hosted, so no other version is worth + // guessing when this fails. try { const releasesResp = await fetch('https://api.github.com/repos/redpanda-data/connect/releases/latest'); if (releasesResp.ok) { const release = await releasesResp.json(); const version = release.tag_name.replace(/^v/, ''); // Remove 'v' prefix if present - const result = await tryFetchConnectJSON(version); + const result = await tryFetchConnectJSON(`/connect/components/_attachments/connect-${version}.json`); if (result.length > 0) { return result; } } } catch (e) { - // Silently fall back to known versions - } - - // Fallback: try known recent versions - const fallbackVersions = ['4.78.0', '4.77.0', '4.76.0', '4.75.0']; - for (const version of fallbackVersions) { - const result = await tryFetchConnectJSON(version); - if (result.length > 0) return result; + // Silently fall back to static completions } return []; } -async function tryFetchConnectJSON(version) { +async function tryFetchConnectJSON(url) { try { - const url = `/connect/components/_attachments/connect-${version}.json`; const response = await fetch(url); if (!response.ok) { diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js index 9d1ebce4..c64c0378 100644 --- a/tests/negative-cache/test-runner.js +++ b/tests/negative-cache/test-runner.js @@ -125,17 +125,6 @@ async function runTests() { return req.respond({ status: 404, contentType: 'text/plain', body: 'bad url' }); } - // Connect version lookup (external) - always succeeds. Only - // fetched when the connect-json-url meta tag is absent. - if (url.hostname === 'raw.githubusercontent.com') { - return req.respond({ - status: 200, - contentType: 'text/yaml', - headers: { 'Access-Control-Allow-Origin': '*' }, - body: "latest-connect-version: '9.9.9'\n" - }); - } - if (url.origin === PROD_HOST || url.origin === PREVIEW_HOST) { if (url.pathname === '/test.html') { return req.respond({ status: 200, contentType: 'text/html', body: TEST_PAGE }); From a27066e6451419253ea75d41ad9ea57381798ac8 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 14:05:44 +0100 Subject: [PATCH 06/12] Use interval polling for the tooltip DOM assertion waitForSelector's default requestAnimationFrame polling can stall in headless Chrome on busy CI runners even though the element is present, which failed this assertion in CI while every other check in the scenario (fetch counted, cache written, marker cleared) passed. Co-Authored-By: Claude Fable 5 --- tests/negative-cache/test-runner.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js index c64c0378..ea462d49 100644 --- a/tests/negative-cache/test-runner.js +++ b/tests/negative-cache/test-runner.js @@ -288,8 +288,13 @@ async function runTests() { assert('Success: properties JSON fetched', load11.properties === 1, `got ${load11.properties}`); assert('Success: properties missing-marker cleared', !(PROPERTIES_PATH in await readMissingMarkers())); assert('Success: properties data cached', !!(await readStorage('redpanda-properties-cache'))); - const tooltipAttached = await page.waitForSelector('code.has-property-tooltip', { timeout: 5000 }) - .then(() => true).catch(() => false); + // Interval polling, not the default requestAnimationFrame polling: + // RAF can stall in headless Chrome on busy CI runners even though + // the element is present + const tooltipAttached = await page.waitForFunction( + () => !!document.querySelector('code.has-property-tooltip'), + { polling: 100, timeout: 10000 } + ).then(() => true).catch(() => false); assert('Success: property tooltip attached to matching code element', tooltipAttached); const load12 = await loadPage(); From 0795644c4c5c9e644d0ef6d11410ca764dc1eb04 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 14:43:57 +0100 Subject: [PATCH 07/12] Add failure diagnostics to the tooltip DOM assertion The assertion fails only in CI; on failure, dump the page's code elements, cached dataset, and article presence, and echo page errors and console warnings throughout the run. Co-Authored-By: Claude Fable 5 --- tests/negative-cache/test-runner.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js index ea462d49..c5a36658 100644 --- a/tests/negative-cache/test-runner.js +++ b/tests/negative-cache/test-runner.js @@ -96,6 +96,14 @@ async function runTests() { const page = await browser.newPage(); + // Surface page-side failures in the runner output for debugging + page.on('pageerror', (err) => console.log(` āš ļø pageerror: ${err.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + console.log(` āš ļø console.${msg.type()}: ${msg.text().slice(0, 200)}`); + } + }); + // Per-scenario response behavior: an HTTP status number, 'abort' // (network error), or 'badjson' (200 with a non-JSON body) const state = { @@ -295,6 +303,18 @@ async function runTests() { () => !!document.querySelector('code.has-property-tooltip'), { polling: 100, timeout: 10000 } ).then(() => true).catch(() => false); + if (!tooltipAttached) { + const diag = await page.evaluate(() => ({ + hasArticle: !!document.querySelector('article.doc'), + codeEls: Array.from(document.querySelectorAll('code')).map((el) => ({ + text: el.textContent.slice(0, 40), + cls: el.className + })), + cachedData: (localStorage.getItem('redpanda-properties-cache') || 'null').slice(0, 300), + tippyType: typeof window.tippy + })).catch((e) => ({ evalError: String(e) })); + console.log(' šŸ” DIAG:', JSON.stringify(diag)); + } assert('Success: property tooltip attached to matching code element', tooltipAttached); const load12 = await loadPage(); From b6339719114479883a3e22b190aeac4c8ecc0bd3 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 14:57:06 +0100 Subject: [PATCH 08/12] Bound the property-tooltips idle callback with a 500ms timeout requestIdleCallback with no timeout can defer processCodeElements for seconds when the main thread never goes idle, so tooltips attach arbitrarily late (or a pending fetch smears into the next navigation). This is what made the CI DOM assertion fail: on the loaded runner, one page's idle callback fired seconds late while the next page's had not run at all within the assertion window. A timeout guarantees the callback runs within 500ms. Co-Authored-By: Claude Fable 5 --- src/js/19-property-tooltips.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/js/19-property-tooltips.js b/src/js/19-property-tooltips.js index df11df05..0c4d1074 100644 --- a/src/js/19-property-tooltips.js +++ b/src/js/19-property-tooltips.js @@ -536,10 +536,12 @@ function tryInit (retriesLeft) { if (window.tippy) { - // Use requestIdleCallback for non-blocking processing - var schedule = window.requestIdleCallback || function (cb) { - setTimeout(cb, 100) - } + // Use requestIdleCallback for non-blocking processing, with a + // timeout so tooltips still attach promptly when the main thread + // never goes idle (busy pages, loaded CI runners) + var schedule = window.requestIdleCallback + ? function (cb) { window.requestIdleCallback(cb, { timeout: 500 }) } + : function (cb) { setTimeout(cb, 100) } schedule(function () { processCodeElements() }) From 96fdf2c7f746cc073f027b02a2a3e641fd5f565c Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 15:02:14 +0100 Subject: [PATCH 09/12] Instrument the test page with idle/rejection/resource probes Co-Authored-By: Claude Fable 5 --- tests/negative-cache/test-runner.js | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js index c5a36658..c21e340f 100644 --- a/tests/negative-cache/test-runner.js +++ b/tests/negative-cache/test-runner.js @@ -53,7 +53,26 @@ const TEST_PAGE = ` - +
@@ -311,7 +330,12 @@ async function runTests() { cls: el.className })), cachedData: (localStorage.getItem('redpanda-properties-cache') || 'null').slice(0, 300), - tippyType: typeof window.tippy + tippyType: typeof window.tippy, + ricScheduled: window.__ricScheduled, + ricFired: window.__ricFired, + rejections: window.__rejections, + // Which requests THIS page actually issued + resources: performance.getEntriesByType('resource').map((r) => r.name) })).catch((e) => ({ evalError: String(e) })); console.log(' šŸ” DIAG:', JSON.stringify(diag)); } From 24513a8b745b48b96fa94646a0e10f68c718c048 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 18 Aug 2026 15:11:30 +0100 Subject: [PATCH 10/12] Probe article lookup and tippy attach in test page Co-Authored-By: Claude Fable 5 --- tests/negative-cache/test-runner.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js index c21e340f..15a2c9c3 100644 --- a/tests/negative-cache/test-runner.js +++ b/tests/negative-cache/test-runner.js @@ -54,7 +54,19 @@ const TEST_PAGE = ` +
@@ -342,36 +295,14 @@ async function runTests() { assert('Success: Connect JSON fetched', load11.connect === 1, `got ${load11.connect}`); assert('Success: properties JSON fetched', load11.properties === 1, `got ${load11.properties}`); assert('Success: properties missing-marker cleared', !(PROPERTIES_PATH in await readMissingMarkers())); - assert('Success: properties data cached', !!(await readStorage('redpanda-properties-cache'))); - // Interval polling, not the default requestAnimationFrame polling: - // RAF can stall in headless Chrome on busy CI runners even though - // the element is present - const tooltipAttached = await page.waitForFunction( - () => !!document.querySelector('code.has-property-tooltip'), - { polling: 100, timeout: 10000 } - ).then(() => true).catch(() => false); - if (!tooltipAttached) { - const diag = await page.evaluate(() => ({ - hasArticle: !!document.querySelector('article.doc'), - codeEls: Array.from(document.querySelectorAll('code')).map((el) => ({ - text: el.textContent.slice(0, 40), - cls: el.className - })), - cachedData: (localStorage.getItem('redpanda-properties-cache') || 'null').slice(0, 300), - tippyType: typeof window.tippy, - ricScheduled: window.__ricScheduled, - ricFired: window.__ricFired, - rejections: window.__rejections, - qsArticle: window.__qsArticle, - tippyCalls: window.__tippyCalls, - setArgs: window.__setArgs, - hasCalls: window.__hasCalls.slice(0, 20), - // Which requests THIS page actually issued - resources: performance.getEntriesByType('resource').map((r) => r.name) - })).catch((e) => ({ evalError: String(e) })); - console.log(' šŸ” DIAG:', JSON.stringify(diag)); - } - assert('Success: property tooltip attached to matching code element', tooltipAttached); + // Assert the cached lookup content, not just the key's existence: + // an empty lookup (JSON shape drift, buildPropertyLookup regression) + // would still write the cache key and clear the marker + const cachedLookup = JSON.parse(await readStorage('redpanda-properties-cache') || '{}'); + const cachedProp = cachedLookup.data && cachedLookup.data.log_retention_ms; + assert('Success: cached lookup contains the property with its fields', + !!(cachedProp && cachedProp.type === 'integer' && cachedProp.description === 'Test property'), + (await readStorage('redpanda-properties-cache') || 'null').slice(0, 120)); const load12 = await loadPage(); assert('Success: properties served from cache on next view', load12.properties === 0, `got ${load12.properties}`);