Skip to content

Commit 3d8d3e3

Browse files
heiskrCopilot
andauthored
Batch-purge changed content by per-page surrogate key (#62454)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80d768a6-3069-432e-aae6-4d9cbd1e38be Copilot-Session: 11731c34-4a31-4175-ba46-4970237ba75d
1 parent d611976 commit 3d8d3e3

9 files changed

Lines changed: 375 additions & 376 deletions

File tree

.github/workflows/purge-fastly.yml

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
name: Purge Fastly
22

3-
# **What it does**: Purges Fastly after a deploy and on demand. Soft purge by
4-
# default; can hard purge specific languages, or hard purge the ENTIRE cache.
5-
# **Why we have it**: So that, right after a production deploy, we start afresh,
6-
# and so docs engineering can clear a bad cache state without the Fastly UI.
7-
# **Who does it impact**: Writers and engineers. A full purge impacts all readers,
8-
# origin sees a traffic spike while the cache refills, so it's gated below.
3+
# **What it does**:
4+
# On production deploy, hard-purge the changed English content pages by key.
5+
# On demand, soft or hard purge language keys, a single key, or entire cache.
6+
# **Why we have it**: So a just-deployed change is visible right away, and so
7+
# docs engineering can clear a bad cache state without the Fastly UI.
8+
# **Who does it impact**: Writers and engineers. A full purge impacts all readers
9+
# and spikes origin traffic while the cache refills, so it's gated below.
910

1011
on:
1112
deployment_status:
12-
schedule:
13-
# Daily soft purge of all languages to catch reusables and AUTOTITLE links.
14-
# Intentionally off-peak, around 6-7pm PT.
15-
- cron: '20 2 * * *'
1613
workflow_dispatch:
1714
inputs:
1815
languages:
@@ -89,26 +86,22 @@ jobs:
8986
exit 1
9087
fi
9188
92-
- name: Purge Fastly
93-
# Auto post-deploy runs wait for the build, purge English only (temporary),
94-
# and stay soft. The daily scheduled run waits for nothing, purges all
95-
# languages (blank = all), and stays soft. A manual run uses the inputs:
96-
# blank languages = all, the `hard` toggle, or a confirmed full-cache purge.
97-
#
89+
- name: Wait for production to serve this build
90+
if: ${{ github.event_name == 'deployment_status' }}
91+
run: npm run wait-for-build
92+
93+
- name: Purge Fastly (manual)
9894
# Raw inputs are passed through the environment and quoted, never spliced
9995
# into the command string, so a value like `en' --everything` can't break
10096
# out of its argument and inject another flag.
97+
if: ${{ github.event_name == 'workflow_dispatch' }}
10198
env:
102-
EVENT_NAME: ${{ github.event_name }}
10399
LANGUAGES_INPUT: ${{ inputs.languages }}
104100
HARD_INPUT: ${{ inputs.hard }}
105101
EVERYTHING_INPUT: ${{ inputs.everything }}
106102
run: |
107103
args=()
108-
if [ "$EVENT_NAME" = "deployment_status" ]; then
109-
args+=(--wait-for-build)
110-
args+=(--languages en)
111-
elif [ -n "$LANGUAGES_INPUT" ]; then
104+
if [ -n "$LANGUAGES_INPUT" ]; then
112105
args+=(--languages "$LANGUAGES_INPUT")
113106
fi
114107
if [ "$HARD_INPUT" = "true" ]; then
@@ -119,14 +112,9 @@ jobs:
119112
fi
120113
npm run purge-fastly -- "${args[@]}"
121114
122-
- name: Hard-purge changed English content URLs
123-
# Prod deploys only. The soft purge above just marks `language:en` stale,
124-
# so stale-while-revalidate can keep serving the pre-deploy copy of a
125-
# just-changed page for a while. This evicts the specific English URLs
126-
# whose content/ files changed in this deploy, so the next request fetches
127-
# fresh. By the time the deploy succeeds the old pods are already gone, so
128-
# the refill is deterministically the new content. data/ changes stay
129-
# covered by the soft purge above (too many URLs to enumerate cheaply).
115+
- name: Hard-purge changed English content pages
116+
# On prod deploys, evict the surrogate keys of the English content pages
117+
# whose content/ files changed in this deploy.
130118
if: ${{ github.event_name == 'deployment_status' }}
131119
env:
132120
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"prevent-pushes-to-main": "tsx src/workflows/prevent-pushes-to-main.ts",
8383
"purge-fastly": "tsx src/workflows/purge-fastly.ts",
8484
"purge-fastly-changed-content": "tsx src/workflows/purge-fastly-changed-content.ts",
85+
"wait-for-build": "tsx src/workflows/wait-for-build.ts",
8586
"readability-report": "tsx src/workflows/experimental/readability-report.ts",
8687
"ready-for-docs-review": "tsx src/workflows/ready-for-docs-review.ts",
8788
"release-banner": "tsx src/ghes-releases/scripts/release-banner.ts",

src/frame/middleware/set-fastly-surrogate-key.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function setLanguageFastlySurrogateKey(
4646
langCode: req.language,
4747
productId: productSurrogateId(context?.page),
4848
versionKey: versionSurrogateKey(context?.currentVersionObj),
49+
relativePath: context?.page?.relativePath,
4950
})
5051
res.set(KEY, keys.join(' '))
5152
return next()
@@ -58,29 +59,26 @@ export function makeLanguageSurrogateKey(langCode?: string) {
5859
return `language:${langCode}`
5960
}
6061

61-
// Build the fine-grained surrogate keys for a content response (docs-engineering#6719).
62-
// A content page is exactly one of each axis, so ~4 keys per page, well under
62+
// Build the fine-grained surrogate keys for a content response.
63+
// A content page is exactly one of each axis, so ~5 keys per page, well under
6364
// Fastly's 16 KB Surrogate-Key header limit:
6465
//
6566
// language:<code> (also emitted for non-content responses)
6667
// product:<top-level dir> e.g. product:actions (~36)
6768
// version:<short release slug> e.g. version:ghes-3.14 (~7-8)
6869
// product:<x>,language:<y> compound, for targeted translation purges
70+
// language:<code>,path:<path> compound, one key per source page, all versions
6971
//
70-
// These keys are inert: nothing purges the new keys yet. docs-engineering#6720
71-
// will use them to target the tightest key that covers a deploy instead of
72-
// soft-purging a whole language. Space is the Fastly delimiter; colons and
73-
// commas are fine (we already ship `language:en` and `api-search:en`). The
74-
// language key stays first so anything that only reads the first token (e.g.
75-
// the caching-headers test helper) keeps working.
7672
export function makeContentSurrogateKeys({
7773
langCode,
7874
productId,
7975
versionKey,
76+
relativePath,
8077
}: {
8178
langCode?: string
8279
productId?: string
8380
versionKey?: string
81+
relativePath?: string
8482
}): string[] {
8583
const keys = [makeLanguageSurrogateKey(langCode)]
8684
if (productId) {
@@ -92,9 +90,23 @@ export function makeContentSurrogateKeys({
9290
if (versionKey) {
9391
keys.push(`version:${versionKey}`)
9492
}
93+
const pageKey = makePageSurrogateKey(langCode, relativePath)
94+
if (pageKey) {
95+
keys.push(pageKey)
96+
}
9597
return keys
9698
}
9799

100+
// One surrogate key per source page, e.g. `language:en,path:actions/foo.md`,
101+
// covering every version-URL of the page. A pure function of language and
102+
// relativePath so the purge job can rebuild the same key from a changed file's
103+
// path. Language-scoped so an English deploy doesn't evict translations. Returns
104+
// undefined for non-content responses.
105+
export function makePageSurrogateKey(langCode?: string, relativePath?: string): string | undefined {
106+
if (!langCode || !relativePath) return undefined
107+
return `language:${langCode},path:${relativePath}`
108+
}
109+
98110
// Derive the product id for the `product:` surrogate key from a content page's
99111
// path. The top-level content directory is the product id (mirrors
100112
// Page.parentProductId), e.g. `actions`. Returns undefined for non-content

src/frame/tests/server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { loadPages } from '@/frame/lib/page-data'
99
import {
1010
SURROGATE_ENUMS,
1111
makeLanguageSurrogateKey,
12+
makePageSurrogateKey,
1213
} from '@/frame/middleware/set-fastly-surrogate-key'
1314

1415
interface Category {
@@ -97,12 +98,14 @@ describe('server', () => {
9798
const res = await get('/en/get-started')
9899
expect(res.statusCode).toBe(200)
99100
const keys = res.headers['surrogate-key'].split(/\s/g)
100-
// Language key stays first for anything that only reads the first token.
101101
expect(keys[0]).toBe(makeLanguageSurrogateKey('en'))
102102
expect(keys).toContain('product:get-started')
103103
expect(keys).toContain('product:get-started,language:en')
104104
expect(keys.some((key: string) => /^version:.+/.test(key))).toBe(true)
105-
// Stays well under Fastly's limits: about 4 keys per page.
105+
// Exact key, not just a pattern, to lock the render side byte-for-byte to what
106+
// the purge job rebuilds from a changed file path (content/get-started/index.md).
107+
expect(keys).toContain('language:en,path:get-started/index.md')
108+
expect(keys).toContain(makePageSurrogateKey('en', 'get-started/index.md'))
106109
expect(keys.length).toBeLessThanOrEqual(6)
107110
})
108111

0 commit comments

Comments
 (0)