From 5d8e078b70a6b9f77662cede38f21702d902eabd Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 30 Sep 2026 08:27:15 +0200 Subject: [PATCH 1/4] Cache documentation responses before Worker execution --- docs/operations.md | 47 ++++++++++++++++++++++ workers/docs/README.md | 46 ++++++++++++++++++++- workers/docs/src/index.test.ts | 58 +++++++-------------------- workers/docs/src/index.ts | 73 +++++++++++++++------------------- workers/docs/wrangler.jsonc | 4 ++ 5 files changed, 141 insertions(+), 87 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index daebd05ff..67c854a0a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -59,6 +59,53 @@ every failure generated by the documentation Worker or R2. A Workers Observability notification also needs an actual alert rule; the notification destination alone does not monitor errors. +## CDN cache policy + +The Cloudflare zone's Browser Cache TTL is set to **Respect Existing Headers**. +Two active Cache Rules refine that default: + +- `Cache immutable Astro assets` matches `www.gecode.dev/_astro/*`, makes the + response cache-eligible, and sets both edge and browser TTLs to one year. +- `Cache website HTML and redirects` matches the apex host and non-documentation + website HTML paths on `www`. It makes those responses cache-eligible, respects + origin cache headers, and uses Cloudflare's status-code TTL when the origin + sends no cache header. + +Keep `/doc*` out of the website rule. The documentation Worker owns its cache +policy. The prepared Workers Cache change gives selected routes and aliases +thirty days of edge freshness plus seven days of stale-while-revalidate, while +browsers retain five-minute freshness. Explicit revisions retain a one-year +policy. See the [Worker runbook](../workers/docs/README.md#response-caching) for +the policy and staging checks. As of 30 September 2026, this change is prepared +locally, not verified or deployed in production. + +Ordinary zone caching runs after Worker routing. Workers Cache sits before +execution, but its hits still count as Worker requests. Neither response headers +nor a zone Cache Rule removes that request charge. Measure execution avoidance +separately from billable requests and zone cache hits. + +### Option: a CDN ahead of the Worker + +An external CDN can avoid Cloudflare requests on cache hits. A concrete option +is Fastly Full Site Delivery at `www.gecode.dev`, with documentation misses sent +to a dedicated Cloudflare Worker origin hostname and website misses sent to +GitHub Pages. This is a researched option, not a provisioned or tested service. + +Use Fastly's [Surrogate-Control header](https://www.fastly.com/documentation/reference/http/http-headers/Surrogate-Control/) +for the long edge TTL and SWR policy; Cloudflare's edge-only header does not +configure Fastly. Enable [origin shielding](https://www.fastly.com/documentation/guides/concepts/shielding/) +to share cache fills across locations. Tag mutable documentation responses and +[soft-purge those tags](https://www.fastly.com/documentation/guides/concepts/cache/purging/) +after release promotion or rollback. Deploying the Worker alone cannot +invalidate the external cache. + +Before switching public DNS, verify backend Host/TLS settings, public canonical +URLs and redirects, analytics injection and `/e/` forwarding, query handling, +PDF ranges, SWR, and release invalidation on a preview hostname. Keep private +R2 access through the Worker. Do not point the CDN backend at its own public +hostname or expose the existing bucket's staging and manifest keys. Select the +provider and billing arrangement before provisioning this separate rollout. + ## Email forwarding The email Worker waits for every configured forward to finish and reports any diff --git a/workers/docs/README.md b/workers/docs/README.md index 0d6fe6a73..f5ec742b3 100644 --- a/workers/docs/README.md +++ b/workers/docs/README.md @@ -23,7 +23,8 @@ IDs, for example `{"6.4.0":"20260905-rst2"}`. The Worker resolves The explicit `/doc/6.4.0/revisions/20260905-rst2/...` route always addresses that revision, independently of `DOC_REVISIONS`. Verify it before selecting a newly published revision. Only this explicit revision route has a one-year immutable -cache policy; selected version routes and aliases have a five-minute policy. +browser cache policy; selected version routes and aliases have a five-minute +browser policy and a thirty-day edge policy. Responses identify both choices with `X-Gecode-Documentation-Version` and `X-Gecode-Documentation-Revision` (the latter is `legacy` for an unselected historical prefix). @@ -41,6 +42,47 @@ origin without documentation indexing headers. Unknown staging paths return 404, avoiding a fetch back into the custom-domain Worker. The `/doc` landing redirect uses `/documentation.html`, which exists before and after Astro. +## Response caching + +The checked-in configuration enables [Workers Cache](https://developers.cloudflare.com/workers/cache/). +Cloudflare checks this cache before executing the Worker and stores the final +response, including indexing headers and analytics injection. The Worker no +longer uses `caches.default`. Cache hits avoid execution and R2 reads, but still +count as billable Worker requests and against the Free request allowance. + +`Cloudflare-CDN-Cache-Control` separates edge freshness from browser freshness: + +| Response | Browser freshness | Edge freshness | Stale while revalidating | +| --- | --- | --- | --- | +| Latest, compatibility alias, selected version, redirects | 5 minutes | 30 days | 7 days | +| Explicit revision | 1 year, immutable | 1 year | 7 days | +| Selected sitemap and robots.txt | 5 minutes | 1 day | 1 day | +| 404 | No storage | 5 minutes | None | + +Successful responses also allow stale delivery for thirty days on an origin +error. Other errors are not stored. Neighboring website paths bypass Workers +Cache and retain their origin's browser policy. Use `max-age`, not `s-maxage`, +in the edge header: [Workers Cache disables stale serving with `s-maxage`](https://developers.cloudflare.com/workers/cache/configuration/). + +`cross_version_cache: false` isolates each Worker deployment's cache. Promoting +or rolling back a revision requires deploying the changed configuration; the +new deployment does not reuse the previous deployment's cached aliases. Browser +copies can remain fresh for five minutes. Immutable R2 objects must never be +overwritten. Cache lifetime is not a retention guarantee: eviction and distinct +query strings can still cause misses. + +Before the first production rollout, validate this configuration on staging: + +1. Check repeated GETs for cache hits and confirm only misses execute the Worker + using Workers Cache metrics and execution logs. Zone cache statistics alone + do not establish the execution avoidance rate. +2. Check cold and warm HEAD, PDF ranges, redirects, canonical headers, sitemaps, + and errors. Local tests call the handler directly, not the pre-Worker cache. +3. Exercise SWR with a temporary short staging TTL, then restore the configured + policy. Verify a deployment changing the selected revision serves new content + and that rollback restores the previous selection. +4. Run the existing smoke checks before promoting through the protected workflow. + ## Local validation Run all Worker integration tests and compile the production configuration: @@ -172,7 +214,7 @@ node scripts/docs/smoke-worker.mjs https://www.gecode.dev 6.4.0 \ For a selected version that is not latest, add `--immutable-only` to skip latest aliases. Revision checks cover the modeling entry page, Pagefind index and runtime assets, reference HTML, sitemap headers, exact PDF ranges, and 404s. -Previously cached selected routes may remain visible for up to five minutes. +Previously browser-cached selected routes may remain visible for up to five minutes. Rollback restores the previous `DOC_REVISIONS` entry (or removes it to select historical objects), without changing stored documentation. diff --git a/workers/docs/src/index.test.ts b/workers/docs/src/index.test.ts index 8579645d3..0a3a51209 100644 --- a/workers/docs/src/index.test.ts +++ b/workers/docs/src/index.test.ts @@ -38,7 +38,10 @@ describe("documentation worker", () => { const page = await request("/doc/6.4.0/reference/PageChange.html"); expect(page.status).toBe(200); expect(await page.text()).toBe("0123456789"); - expect(page.headers.get("cache-control")).toBe("public, max-age=300, s-maxage=300"); + expect(page.headers.get("cache-control")).toBe("public, max-age=300"); + expect(page.headers.get("cloudflare-cdn-cache-control")).toBe( + "public, max-age=2592000, stale-while-revalidate=604800, stale-if-error=2592000", + ); expect(page.headers.get("x-gecode-documentation-revision")).toBe("legacy"); expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8"); expect(page.headers.get("link")).toBeNull(); @@ -71,15 +74,6 @@ describe("documentation worker", () => { expect(await response.text()).not.toContain("/e/init.js"); }); - it("serves repeat requests from the edge cache", async () => { - const url = "/doc/6.4.0/reference/PageChange.html?cache-test=1"; - expect((await request(url)).status).toBe(200); - await env.DOCS.delete("6.4.0/reference/PageChange.html"); - const cached = await request("/doc/6.4.0/reference/PageChange.html?different-query=1"); - expect(cached.status).toBe(200); - expect(await cached.text()).toBe("0123456789"); - }); - it("returns 503 when R2 fails", async () => { const originalGet = env.DOCS.get.bind(env.DOCS); env.DOCS.get = async () => { throw new Error("test outage"); }; @@ -87,6 +81,7 @@ describe("documentation worker", () => { env.DOCS.get = originalGet; expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBe("60"); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); }); it("uses one R2 read for an ordinary cache miss", async () => { @@ -161,31 +156,7 @@ describe("documentation worker", () => { } }); - it("applies the current indexing policy even to cached headers", async () => { - const match = vi.spyOn(caches.default, "match").mockImplementation(async () => new Response("cached content", { - headers: { - "Content-Type": "text/html; charset=utf-8", - Link: '; rel="canonical"', - "X-Robots-Tag": "index", - }, - })); - try { - const immutable = await request("/doc/6.4.0/reference/PageChange.html"); - expect(await immutable.text()).toBe("cached content"); - expect(immutable.headers.get("x-robots-tag")).toBe("noindex"); - expect(immutable.headers.get("link")).toBeNull(); - const latest = await request("/doc/latest/reference/PageChange.html"); - expect(await latest.text()).toBe("cached content"); - expect(latest.headers.get("x-robots-tag")).toBeNull(); - expect(latest.headers.get("link")).toBe( - '; rel="canonical"', - ); - } finally { - match.mockRestore(); - } - }); - - it("selects new latest content without reusing the preceding release's cache", async () => { + it("selects new latest content when the release configuration changes", async () => { await request("/doc/latest/reference/PageChange.html"); await env.DOCS.put("7.0.0/reference/PageChange.html", "new release", { httpMetadata: { contentType: "text/html; charset=utf-8" }, @@ -204,14 +175,11 @@ describe("documentation worker", () => { const shardXml = 'https://www.gecode.dev/doc/6.5.0/reference/PageChange.html'; await env.DOCS.put(`${version}/sitemap.xml`, indexXml, { httpMetadata: { contentType: "application/xml" } }); await env.DOCS.put(`${version}/sitemap-1.xml`, shardXml, { httpMetadata: { contentType: "application/xml" } }); - // Entries cached before the indexing-policy change must not leak old URLs. - await caches.default.put(new Request(`${base}/doc/sitemap.xml`), new Response(indexXml, { - headers: { "Cache-Control": "public, max-age=300", "Content-Type": "application/xml" }, - })); for (const path of ["/doc/sitemap.xml", "/doc/latest/sitemap.xml", "/doc-latest/sitemap.xml"]) { const response = await request(path, undefined, version); expect(response.status).toBe(200); expect(await response.text()).toBe(indexXml.replaceAll(`/doc/${version}/`, "/doc/latest/")); + expect(response.headers.get("cloudflare-cdn-cache-control")).toContain("max-age=86400,"); } const shard = await request("/doc/latest/sitemap-1.xml", undefined, version); expect(await shard.text()).toBe(shardXml.replaceAll(`/doc/${version}/`, "/doc/latest/")); @@ -306,6 +274,7 @@ describe("documentation worker", () => { const response = await request("/doc/6.4.0/reference/PageChange.html", { headers: { Range: "bytes=20-30" } }); expect(response.status).toBe(416); expect(response.headers.get("content-range")).toBe("bytes */10"); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); }); it("resumes PDFs only when If-Range matches the current representation", async () => { @@ -375,7 +344,7 @@ describe("documentation worker", () => { const response = await worker.fetch(incoming, env, context); await waitOnExecutionContext(context); expect(originFetch).toHaveBeenLastCalledWith(incoming); - expect(response).toBe(upstream); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); expect(response.status).toBe(status); expect(response.headers.get("x-robots-tag")).toBe("index, follow"); expect(response.headers.get("link")).toBe('; rel="canonical"'); @@ -391,7 +360,10 @@ describe("documentation worker", () => { }); it("returns explicit errors", async () => { - expect((await request("/doc/6.4.0/missing.html")).status).toBe(404); + const missing = await request("/doc/6.4.0/missing.html"); + expect(missing.status).toBe(404); + expect(missing.headers.get("cache-control")).toBe("no-store"); + expect(missing.headers.get("cloudflare-cdn-cache-control")).toBe("public, max-age=300"); const method = await request("/doc/6.4.0/index.html", { method: "POST" }); expect(method.status).toBe(405); expect(method.headers.get("allow")).toBe("GET, HEAD"); @@ -399,7 +371,7 @@ describe("documentation worker", () => { expect((await request("/doc/6.4.0/%252e%252e/secret")).status).toBe(400); expect((await request("/doc/6.4.0/reference%2fPageChange.html")).status).toBe(400); }); - it("promotes revisions without reusing the previous selection's cache", async () => { + it("promotes and rolls back the selected revision", async () => { const relative = "modeling/revision-test/index.html"; for (const [revision, body] of [["r1", "first"], ["r2", "second"]]) { await env.DOCS.put(`_revisions/6.4.0/${revision}/${relative}`, body, { @@ -413,7 +385,7 @@ describe("documentation worker", () => { expect(await promoted.text()).toBe("second"); expect(promoted.headers.get("x-gecode-documentation-version")).toBe("6.4.0"); expect(promoted.headers.get("x-gecode-documentation-revision")).toBe("r2"); - expect(promoted.headers.get("cache-control")).toBe("public, max-age=300, s-maxage=300"); + expect(promoted.headers.get("cache-control")).toBe("public, max-age=300"); const rollback = await request(prefix + relative, undefined, "6.4.0", '{"6.4.0":"r1"}'); expect(await rollback.text()).toBe("first"); } diff --git a/workers/docs/src/index.ts b/workers/docs/src/index.ts index 5b2de454c..b8787428f 100644 --- a/workers/docs/src/index.ts +++ b/workers/docs/src/index.ts @@ -38,6 +38,9 @@ const securityHeaders = { "X-Frame-Options": "SAMEORIGIN", }; +const documentationEdgeCache = "public, max-age=2592000, stale-while-revalidate=604800, stale-if-error=2592000"; +const sitemapEdgeCache = "public, max-age=86400, stale-while-revalidate=86400, stale-if-error=2592000"; + function errorResponse(status: number, message: string, extraHeaders: HeadersInit = {}): Response { return new Response( `${status} ${message}

${status}

${message}

`, @@ -45,6 +48,8 @@ function errorResponse(status: number, message: string, extraHeaders: HeadersIni status, headers: { "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Cloudflare-CDN-Cache-Control": status === 404 ? "public, max-age=300" : "no-store", ...securityHeaders, ...extraHeaders, }, @@ -137,8 +142,11 @@ function applyObjectHeaders(headers: Headers, object: R2Object, resolved: Resolv "Cache-Control", resolved.isRevision ? "public, max-age=31536000, immutable" - : "public, max-age=300, s-maxage=300", + : "public, max-age=300", ); + headers.set("Cloudflare-CDN-Cache-Control", resolved.isRevision + ? "public, max-age=31536000, stale-while-revalidate=604800, stale-if-error=2592000" + : /^sitemap(?:-\d+)?\.xml$/.test(resolved.relative) ? sitemapEdgeCache : documentationEdgeCache); for (const [name, value] of Object.entries(securityHeaders)) headers.set(name, value); } @@ -152,8 +160,7 @@ function applyIndexingPolicy(request: Request, response: Response, env: Env): Re && !resolved.isRevision && [200, 206, 304].includes(response.status); const headers = new Headers(response.headers); - // Apply this after cache reads too: a previous deployment may have cached - // version-specific canonical links or different indexing instructions. + // Workers Cache stores the final response, including these indexing headers. headers.delete("Link"); if (indexable) { headers.delete("X-Robots-Tag"); @@ -211,7 +218,7 @@ async function sitemapResponse(request: Request, object: R2ObjectBody, resolved: return new Response(request.method === "HEAD" ? null : bytes, { status: 200, headers }); } -async function serve(request: Request, env: Env, context: ExecutionContext): Promise { +async function serve(request: Request, env: Env): Promise { if (request.method !== "GET" && request.method !== "HEAD") { return errorResponse(405, "Method not allowed", { Allow: "GET, HEAD" }); } @@ -222,14 +229,19 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro headers: { "Content-Type": "text/plain; charset=utf-8", "Content-Length": String(new TextEncoder().encode(robots).byteLength), - "Cache-Control": "public, max-age=300, s-maxage=300", + "Cache-Control": "public, max-age=300", + "Cloudflare-CDN-Cache-Control": sitemapEdgeCache, }, }); } const redirect = (pathname: string) => { const destination = new URL(url); destination.pathname = pathname; - return Response.redirect(destination.href, 308); + return new Response(null, { status: 308, headers: { + Location: destination.href, + "Cache-Control": "public, max-age=300", + "Cloudflare-CDN-Cache-Control": documentationEdgeCache, + } }); }; if (url.pathname === "/doc" || url.pathname === "/doc/") return redirect("/documentation.html"); const resolved = resolvePath(url.pathname, env.LATEST_DOC_VERSION); @@ -251,35 +263,10 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro return errorResponse(404, "Documentation page not found"); }; - const cacheableRequest = request.method === "GET" - && !request.headers.has("Range") - && !request.headers.has("If-None-Match"); - const cacheUrl = new URL(request.url); - cacheUrl.search = ""; - // Promotion changes the physical key, including when the public version stays - // the same. The policy also avoids old immutable version-URL cache entries. - cacheUrl.searchParams.set("__gecode_docs_policy", "revisions-v1"); - cacheUrl.searchParams.set("__gecode_docs_object", resolved.key); - const cacheKey = new Request(cacheUrl, { method: "GET" }); - if (cacheableRequest) { - try { - const cached = await caches.default.match(cacheKey); - if (cached) return cached; - } catch (error) { - console.error(JSON.stringify({ event: "cache_read_failed", message: String(error) })); - } - } - if (resolved.isAlias && /^sitemap(?:-\d+)?\.xml$/.test(resolved.relative)) { const object = await env.DOCS.get(resolved.key); if (!object) return missingObject(); - const response = await sitemapResponse(request, object, resolved); - if (cacheableRequest) { - context.waitUntil(caches.default.put(cacheKey, response.clone()).catch((error) => { - console.error(JSON.stringify({ event: "cache_write_failed", message: String(error) })); - })); - } - return response; + return sitemapResponse(request, object, resolved); } // Range applies only to GET. HEAD describes the complete representation. @@ -301,6 +288,8 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro const range = rangeHeader && rangeMatches ? parseRange(rangeHeader, metadata.size) : null; if (range === "invalid") { headers.set("Content-Range", `bytes */${metadata.size}`); + headers.set("Cache-Control", "no-store"); + headers.set("Cloudflare-CDN-Cache-Control", "no-store"); return new Response(null, { status: 416, headers }); } if (range) { @@ -326,17 +315,11 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro return new Response(null, { status: 304, headers }); } headers.set("Content-Length", String(object.size)); - const response = new Response(object.body, { status: 200, headers }); - if (cacheableRequest) { - context.waitUntil(caches.default.put(cacheKey, response.clone()).catch((error) => { - console.error(JSON.stringify({ event: "cache_write_failed", message: String(error) })); - })); - } - return response; + return new Response(object.body, { status: 200, headers }); } export default { - async fetch(request: Request, env: Env, context: ExecutionContext): Promise { + async fetch(request: Request, env: Env, _context: ExecutionContext): Promise { const url = new URL(request.url); const pathname = url.pathname; const ownsPath = pathname === "/robots.txt" @@ -345,13 +328,19 @@ export default { // Wildcard routes also receive /documentation.html and similarly named // website paths. Leave their origin response and indexing headers intact. if (!ownsPath) { - if (url.hostname === "www.gecode.dev") return fetch(request); + if (url.hostname === "www.gecode.dev") { + const upstream = await fetch(request); + const response = new Response(upstream.body, upstream); + // Do not put neighboring website pages in the documentation cache. + response.headers.set("Cloudflare-CDN-Cache-Control", "no-store"); + return response; + } return applyIndexingPolicy(request, errorResponse(404, "Page not found"), env); } let response: Response; try { - response = await serve(request, env, context); + response = await serve(request, env); } catch (error) { console.error(JSON.stringify({ event: "documentation_storage_failure", diff --git a/workers/docs/wrangler.jsonc b/workers/docs/wrangler.jsonc index 58cefb010..b0fe4f912 100644 --- a/workers/docs/wrangler.jsonc +++ b/workers/docs/wrangler.jsonc @@ -4,6 +4,10 @@ "main": "src/index.ts", "compatibility_date": "2026-08-08", "workers_dev": true, + "cache": { + "enabled": true, + "cross_version_cache": false + }, "observability": { "enabled": true }, From c5377f049aaede14606156bb5782dafab82601ce Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 30 Sep 2026 08:35:41 +0200 Subject: [PATCH 2/4] Record staging cache verification and HEAD range limitation --- docs/operations.md | 11 +++++++++-- workers/docs/README.md | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 67c854a0a..46f59330b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -76,8 +76,15 @@ policy. The prepared Workers Cache change gives selected routes and aliases thirty days of edge freshness plus seven days of stale-while-revalidate, while browsers retain five-minute freshness. Explicit revisions retain a one-year policy. See the [Worker runbook](../workers/docs/README.md#response-caching) for -the policy and staging checks. As of 30 September 2026, this change is prepared -locally, not verified or deployed in production. +the policy and staging checks. On 30 September 2026, the change was deployed to +staging from [PR #13](https://github.com/Gecode/gecode.github.io/pull/13), but not +to production. A fresh URL returned MISS, HIT, HIT, with one execution visible +in Worker logs. The deployment smoke test failed because native Workers Cache +returns 206 for HEAD requests carrying Range; the handler previously ignored +Range on HEAD and returned 200. Plain HEAD and the remaining smoke assertions +passed in a separate diagnostic run. Cloudflare rejected a request-header rule +to remove Range because that header is protected; no transform rule was created. +Production promotion awaits a decision on this compatibility difference. Ordinary zone caching runs after Worker routing. Workers Cache sits before execution, but its hits still count as Worker requests. Neither response headers diff --git a/workers/docs/README.md b/workers/docs/README.md index f5ec742b3..0814509c1 100644 --- a/workers/docs/README.md +++ b/workers/docs/README.md @@ -73,6 +73,13 @@ query strings can still cause misses. Before the first production rollout, validate this configuration on staging: +Staging note (30 September 2026): native Workers Cache returns 206 for HEAD +requests carrying Range. The existing smoke check expects 200, matching the +handler's behavior, and currently blocks promotion. Plain HEAD, GET ranges, +and stale If-Range checks pass. Cloudflare does not allow a Request Header +Transform Rule to remove Range. Keep the smoke assertion until the deployment +contract explicitly accepts this edge behavior. + 1. Check repeated GETs for cache hits and confirm only misses execute the Worker using Workers Cache metrics and execution logs. Zone cache statistics alone do not establish the execution avoidance rate. From ba23c1699806743da298781a96cbba1f0f194a02 Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 30 Sep 2026 08:42:00 +0200 Subject: [PATCH 3/4] Extend documentation browser caching to one day --- docs/operations.md | 6 ++++-- scripts/docs/smoke-worker.mjs | 9 ++++++++- workers/docs/README.md | 18 ++++++++---------- workers/docs/src/index.test.ts | 6 +++--- workers/docs/src/index.ts | 7 ++++--- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 46f59330b..2dcf326b3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -74,7 +74,8 @@ Two active Cache Rules refine that default: Keep `/doc*` out of the website rule. The documentation Worker owns its cache policy. The prepared Workers Cache change gives selected routes and aliases thirty days of edge freshness plus seven days of stale-while-revalidate, while -browsers retain five-minute freshness. Explicit revisions retain a one-year +browsers retain one-day freshness. Sitemaps and robots.txt retain five-minute +browser freshness. Explicit revisions retain a one-year policy. See the [Worker runbook](../workers/docs/README.md#response-caching) for the policy and staging checks. On 30 September 2026, the change was deployed to staging from [PR #13](https://github.com/Gecode/gecode.github.io/pull/13), but not @@ -84,7 +85,8 @@ returns 206 for HEAD requests carrying Range; the handler previously ignored Range on HEAD and returned 200. Plain HEAD and the remaining smoke assertions passed in a separate diagnostic run. Cloudflare rejected a request-header rule to remove Range because that header is protected; no transform rule was created. -Production promotion awaits a decision on this compatibility difference. +The HEAD-with-Range behavior is now accepted, and the smoke check tests it +separately from plain HEAD. Production promotion awaits the updated staging run. Ordinary zone caching runs after Worker routing. Workers Cache sits before execution, but its hits still count as Worker requests. Neither response headers diff --git a/scripts/docs/smoke-worker.mjs b/scripts/docs/smoke-worker.mjs index 328ecb1c6..23274c19e 100644 --- a/scripts/docs/smoke-worker.mjs +++ b/scripts/docs/smoke-worker.mjs @@ -154,7 +154,7 @@ await check(`/doc/${version}/readiness-missing-page.html`, 404); for (const prefix of prefixes) { const pdf = `${prefix}/MPG.pdf`; let etag; - await check(pdf, 200, { method: "HEAD", headers: { Range: "bytes=0-15" } }, (response) => { + await check(pdf, 200, { method: "HEAD" }, (response) => { assert.match(response.headers.get("content-type"), /application\/pdf/); assert(Number(response.headers.get("content-length")) > 16); assert.equal(response.headers.get("content-range"), null); @@ -162,6 +162,13 @@ for (const prefix of prefixes) { etag = response.headers.get("etag"); assert(etag); }); + // Workers Cache applies Range to HEAD as well as GET. + await check(pdf, 206, { method: "HEAD", headers: { Range: "bytes=0-15" } }, async (response) => { + assert.match(response.headers.get("content-range"), /^bytes 0-15\/\d+$/); + assert.equal(response.headers.get("content-length"), "16"); + assertCanonical(response, prefix, "MPG.pdf"); + assert.equal((await response.arrayBuffer()).byteLength, 0); + }); await check(pdf, 206, { headers: { Range: "bytes=0-15", "If-Range": etag } }, async (response) => { assert.match(response.headers.get("content-range"), /^bytes 0-15\/\d+$/); assert.equal(response.headers.get("content-length"), "16"); diff --git a/workers/docs/README.md b/workers/docs/README.md index 0814509c1..f438a956b 100644 --- a/workers/docs/README.md +++ b/workers/docs/README.md @@ -23,7 +23,7 @@ IDs, for example `{"6.4.0":"20260905-rst2"}`. The Worker resolves The explicit `/doc/6.4.0/revisions/20260905-rst2/...` route always addresses that revision, independently of `DOC_REVISIONS`. Verify it before selecting a newly published revision. Only this explicit revision route has a one-year immutable -browser cache policy; selected version routes and aliases have a five-minute +browser cache policy; selected version routes and aliases have a one-day browser policy and a thirty-day edge policy. Responses identify both choices with `X-Gecode-Documentation-Version` and `X-Gecode-Documentation-Revision` (the latter is `legacy` for an unselected @@ -54,7 +54,7 @@ count as billable Worker requests and against the Free request allowance. | Response | Browser freshness | Edge freshness | Stale while revalidating | | --- | --- | --- | --- | -| Latest, compatibility alias, selected version, redirects | 5 minutes | 30 days | 7 days | +| Latest, compatibility alias, selected version, redirects | 1 day | 30 days | 7 days | | Explicit revision | 1 year, immutable | 1 year | 7 days | | Selected sitemap and robots.txt | 5 minutes | 1 day | 1 day | | 404 | No storage | 5 minutes | None | @@ -67,18 +67,16 @@ in the edge header: [Workers Cache disables stale serving with `s-maxage`](https `cross_version_cache: false` isolates each Worker deployment's cache. Promoting or rolling back a revision requires deploying the changed configuration; the new deployment does not reuse the previous deployment's cached aliases. Browser -copies can remain fresh for five minutes. Immutable R2 objects must never be +copies can remain fresh for one day after a release or rollback. Immutable R2 objects must never be overwritten. Cache lifetime is not a retention guarantee: eviction and distinct query strings can still cause misses. Before the first production rollout, validate this configuration on staging: -Staging note (30 September 2026): native Workers Cache returns 206 for HEAD -requests carrying Range. The existing smoke check expects 200, matching the -handler's behavior, and currently blocks promotion. Plain HEAD, GET ranges, -and stale If-Range checks pass. Cloudflare does not allow a Request Header -Transform Rule to remove Range. Keep the smoke assertion until the deployment -contract explicitly accepts this edge behavior. +Native Workers Cache returns 206 for HEAD requests carrying Range, with range +metadata and no body. This edge behavior is accepted; the deployment smoke +check covers it separately from plain HEAD, which returns full metadata with +status 200. Direct handler tests still expect HEAD to ignore Range. 1. Check repeated GETs for cache hits and confirm only misses execute the Worker using Workers Cache metrics and execution logs. Zone cache statistics alone @@ -221,7 +219,7 @@ node scripts/docs/smoke-worker.mjs https://www.gecode.dev 6.4.0 \ For a selected version that is not latest, add `--immutable-only` to skip latest aliases. Revision checks cover the modeling entry page, Pagefind index and runtime assets, reference HTML, sitemap headers, exact PDF ranges, and 404s. -Previously browser-cached selected routes may remain visible for up to five minutes. +Previously browser-cached selected routes may remain visible for up to one day. Rollback restores the previous `DOC_REVISIONS` entry (or removes it to select historical objects), without changing stored documentation. diff --git a/workers/docs/src/index.test.ts b/workers/docs/src/index.test.ts index 0a3a51209..9f9469ad2 100644 --- a/workers/docs/src/index.test.ts +++ b/workers/docs/src/index.test.ts @@ -38,7 +38,7 @@ describe("documentation worker", () => { const page = await request("/doc/6.4.0/reference/PageChange.html"); expect(page.status).toBe(200); expect(await page.text()).toBe("0123456789"); - expect(page.headers.get("cache-control")).toBe("public, max-age=300"); + expect(page.headers.get("cache-control")).toBe("public, max-age=86400"); expect(page.headers.get("cloudflare-cdn-cache-control")).toBe( "public, max-age=2592000, stale-while-revalidate=604800, stale-if-error=2592000", ); @@ -119,7 +119,7 @@ describe("documentation worker", () => { async (path) => { const response = await request(path); expect(await response.text()).toBe("0123456789"); - expect(response.headers.get("cache-control")).toContain("max-age=300"); + expect(response.headers.get("cache-control")).toContain("max-age=86400"); expect(response.headers.get("x-gecode-documentation-version")).toBe("6.4.0"); const canonical = path.startsWith("/doc/latest/"); expect(response.headers.get("x-robots-tag")).toBe(canonical ? null : "noindex"); @@ -385,7 +385,7 @@ describe("documentation worker", () => { expect(await promoted.text()).toBe("second"); expect(promoted.headers.get("x-gecode-documentation-version")).toBe("6.4.0"); expect(promoted.headers.get("x-gecode-documentation-revision")).toBe("r2"); - expect(promoted.headers.get("cache-control")).toBe("public, max-age=300"); + expect(promoted.headers.get("cache-control")).toBe("public, max-age=86400"); const rollback = await request(prefix + relative, undefined, "6.4.0", '{"6.4.0":"r1"}'); expect(await rollback.text()).toBe("first"); } diff --git a/workers/docs/src/index.ts b/workers/docs/src/index.ts index b8787428f..b19cca4e8 100644 --- a/workers/docs/src/index.ts +++ b/workers/docs/src/index.ts @@ -132,6 +132,7 @@ function parseRange(value: string, size: number): { offset: number; length: numb } function applyObjectHeaders(headers: Headers, object: R2Object, resolved: ResolvedPath): void { + const isSitemap = /^sitemap(?:-\d+)?\.xml$/.test(resolved.relative); object.writeHttpMetadata(headers); headers.set("ETag", object.httpEtag); headers.set("Last-Modified", object.uploaded.toUTCString()); @@ -142,11 +143,11 @@ function applyObjectHeaders(headers: Headers, object: R2Object, resolved: Resolv "Cache-Control", resolved.isRevision ? "public, max-age=31536000, immutable" - : "public, max-age=300", + : isSitemap ? "public, max-age=300" : "public, max-age=86400", ); headers.set("Cloudflare-CDN-Cache-Control", resolved.isRevision ? "public, max-age=31536000, stale-while-revalidate=604800, stale-if-error=2592000" - : /^sitemap(?:-\d+)?\.xml$/.test(resolved.relative) ? sitemapEdgeCache : documentationEdgeCache); + : isSitemap ? sitemapEdgeCache : documentationEdgeCache); for (const [name, value] of Object.entries(securityHeaders)) headers.set(name, value); } @@ -239,7 +240,7 @@ async function serve(request: Request, env: Env): Promise { destination.pathname = pathname; return new Response(null, { status: 308, headers: { Location: destination.href, - "Cache-Control": "public, max-age=300", + "Cache-Control": "public, max-age=86400", "Cloudflare-CDN-Cache-Control": documentationEdgeCache, } }); }; From 474662f6b9206ac0f02597a69563da041426f2ae Mon Sep 17 00:00:00 2001 From: Mikael Zayenz Lagerkvist Date: Wed, 30 Sep 2026 08:44:09 +0200 Subject: [PATCH 4/4] Record successful cache rollout checks on staging --- docs/operations.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 2dcf326b3..6ef698947 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -78,15 +78,14 @@ browsers retain one-day freshness. Sitemaps and robots.txt retain five-minute browser freshness. Explicit revisions retain a one-year policy. See the [Worker runbook](../workers/docs/README.md#response-caching) for the policy and staging checks. On 30 September 2026, the change was deployed to -staging from [PR #13](https://github.com/Gecode/gecode.github.io/pull/13), but not -to production. A fresh URL returned MISS, HIT, HIT, with one execution visible -in Worker logs. The deployment smoke test failed because native Workers Cache -returns 206 for HEAD requests carrying Range; the handler previously ignored -Range on HEAD and returned 200. Plain HEAD and the remaining smoke assertions -passed in a separate diagnostic run. Cloudflare rejected a request-header rule -to remove Range because that header is protected; no transform rule was created. -The HEAD-with-Range behavior is now accepted, and the smoke check tests it -separately from plain HEAD. Production promotion awaits the updated staging run. +staging from [PR #13](https://github.com/Gecode/gecode.github.io/pull/13). +The updated [staging deployment and smoke checks passed](https://github.com/Gecode/gecode.github.io/actions/runs/36679599037). +A fresh URL returned MISS, HIT, HIT with `Cache-Control: public, max-age=86400`. +An earlier probe confirmed one Worker execution for three requests. The smoke +check accepts native Workers Cache's 206 response to HEAD with Range and checks +plain HEAD separately. Production deployment remains subject to the protected +environment's approval. Short-TTL SWR and live revision rollback have not yet +been exercised against the native cache. Ordinary zone caching runs after Worker routing. Workers Cache sits before execution, but its hits still count as Worker requests. Neither response headers