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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,61 @@ 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 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).
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
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
Expand Down
9 changes: 8 additions & 1 deletion scripts/docs/smoke-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,21 @@ 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);
assertCanonical(response, prefix, "MPG.pdf");
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");
Expand Down
51 changes: 49 additions & 2 deletions workers/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
historical prefix).
Expand All @@ -41,6 +42,52 @@ 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 | 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 |

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 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:

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
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:
Expand Down Expand Up @@ -172,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 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.

Expand Down
60 changes: 16 additions & 44 deletions workers/docs/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=86400");
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();
Expand Down Expand Up @@ -71,22 +74,14 @@ 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"); };
const response = await request("/doc/6.4.0/outage-test.html");
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 () => {
Expand Down Expand Up @@ -124,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");
Expand Down Expand Up @@ -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: '<https://www.gecode.dev/doc/6.4.0/reference/PageChange.html>; 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(
'<https://www.gecode.dev/doc/latest/reference/PageChange.html>; 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" },
Expand All @@ -204,14 +175,11 @@ describe("documentation worker", () => {
const shardXml = '<urlset><url><loc>https://www.gecode.dev/doc/6.5.0/reference/PageChange.html</loc></url></urlset>';
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/"));
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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('<https://www.gecode.dev/documentation/>; rel="canonical"');
Expand All @@ -391,15 +360,18 @@ 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");
expect((await request("/doc/6.4.0/%")).status).toBe(400);
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, {
Expand All @@ -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=86400");
const rollback = await request(prefix + relative, undefined, "6.4.0", '{"6.4.0":"r1"}');
expect(await rollback.text()).toBe("first");
}
Expand Down
Loading
Loading