Skip to content

Commit deb8c6f

Browse files
heiskrCopilot
andauthored
Stagger Fastly second purge 20s while keeping 10s language cadence (#61545)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 78d7950 commit deb8c6f

1 file changed

Lines changed: 90 additions & 12 deletions

File tree

src/languages/scripts/purge-fastly-edge-cache-per-language.ts

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,58 @@ import purgeEdgeCache from '@/workflows/purge-edge-cache'
1313
* ...
1414
*
1515
* ...and so on for all languages.
16-
* But to avoid a stampeding herd after each purge, and to avoid unnecessary
17-
* load on the backend, put a slight delay between each language.
18-
* This gives the backend a chance to finish processing all the now stale
19-
* URLs, for that language, before tackling the next.
16+
*
17+
* Each surrogate key is purged twice because of Fastly shielding: the first
18+
* purge clears the edge nodes and the second clears the origin shield. See
19+
* `purge-edge-cache.ts` for the details.
20+
*
21+
* Two delays shape the schedule:
22+
*
23+
* - To avoid a stampeding herd after each purge, and to avoid unnecessary load
24+
* on the backend, there's a slight delay between each language's FIRST purge.
25+
* This gives the backend a chance to finish processing all the now stale URLs,
26+
* for that language, before tackling the next.
27+
* - The SECOND purge of a key happens a while after its first purge, long enough
28+
* for the now-stale content to be re-fetched and re-shielded before we clear
29+
* the shield again. Fastly's docs recommend ~2s for surrogate-key purges, but
30+
* in practice that's been too short and stale content survives the shielding
31+
* race, so we use a larger margin. See the "Race conditions" subsection of
32+
* https://www.fastly.com/documentation/guides/concepts/cache/purging#race-conditions
33+
* (the 30s figure there is for purge-all, which we don't use). The value must
34+
* stay a multiple of DELAY_BETWEEN_LANGUAGES to keep the slot alignment below.
35+
*
36+
* Rather than block on the second purge (which would serialize everything and
37+
* make the whole run take `DELAY_BEFORE_SECOND_PURGE` per key), we schedule all
38+
* purges against a wall-clock timeline up front. Because the second-purge delay
39+
* is a multiple of the between-languages delay, a key's second purge lands on
40+
* the same slot as a later key's first purge. For example, with a 10s cadence
41+
* and a 20s second-purge delay:
42+
*
43+
* t=0 no-language (1st)
44+
* t=10 en (1st)
45+
* t=20 es (1st) + no-language (2nd)
46+
* t=30 ja (1st) + en (2nd)
47+
* t=40 pt (1st) + es (2nd)
48+
* ...
2049
*/
2150
const DELAY_BETWEEN_LANGUAGES = 10 * 1000
51+
const DELAY_BEFORE_SECOND_PURGE = 20 * 1000
52+
53+
// The pipelining only lines up if the second-purge delay is a whole number of
54+
// language slots; otherwise second purges would drift off the cadence. Enforce
55+
// it so a future tweak to either constant can't silently break the schedule.
56+
if (DELAY_BEFORE_SECOND_PURGE % DELAY_BETWEEN_LANGUAGES !== 0) {
57+
throw new Error(
58+
`DELAY_BEFORE_SECOND_PURGE (${DELAY_BEFORE_SECOND_PURGE}ms) must be a multiple of ` +
59+
`DELAY_BETWEEN_LANGUAGES (${DELAY_BETWEEN_LANGUAGES}ms) to keep second purges ` +
60+
`aligned with later first-purge slots`,
61+
)
62+
}
2263

2364
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
2465

25-
// This covers things like `/api/webhooks` which isn't language specific.
26-
await purgeEdgeCache(makeLanguageSurrogateKey())
66+
type PurgePhase = 'first' | 'second'
67+
type PurgeOutcome = { key: string; phase: PurgePhase; error?: unknown }
2768

2869
const languages = process.env.LANGUAGES
2970
? languagesFromString(process.env.LANGUAGES)
@@ -32,12 +73,49 @@ const languages = process.env.LANGUAGES
3273
// in production as soon as possible.
3374
languageKeys.sort((a) => (a === 'en' ? -1 : 1))
3475

35-
for (const language of languages) {
36-
console.log(
37-
`Sleeping ${DELAY_BETWEEN_LANGUAGES / 1000} seconds before purging for '${language}'...`,
38-
)
39-
await sleep(DELAY_BETWEEN_LANGUAGES)
40-
await purgeEdgeCache(makeLanguageSurrogateKey(language))
76+
// This covers things like `/api/webhooks` which isn't language specific, hence
77+
// the no-language key (an empty `makeLanguageSurrogateKey()`) leading the list.
78+
const surrogateKeys = [
79+
makeLanguageSurrogateKey(),
80+
...languages.map((language) => makeLanguageSurrogateKey(language)),
81+
]
82+
83+
// Schedule every purge against a single wall-clock start time so the cadence
84+
// doesn't drift with per-purge network latency, and so each second purge aligns
85+
// with a later first purge as described above.
86+
const startTime = Date.now()
87+
const purges: Promise<PurgeOutcome>[] = []
88+
89+
// Each call returns a promise that resolves (never rejects) to an outcome: the
90+
// internal try/catch means a failed purge can't become an unhandled rejection
91+
// while we wait for the rest of the schedule, which can outlast an early second
92+
// purge. Failures are surfaced after all purges settle, below.
93+
async function runPurge(key: string, phase: PurgePhase, targetTime: number): Promise<PurgeOutcome> {
94+
await sleep(Math.max(0, targetTime - Date.now()))
95+
try {
96+
// `purgeEdgeCache` logs its own "first Fastly purge" line; word this as the
97+
// scheduled phase trigger so the two purges of a key are distinguishable.
98+
console.log(`Triggering ${phase}-phase purge for '${key}'...`)
99+
await purgeEdgeCache(key, { purgeTwice: false })
100+
return { key, phase }
101+
} catch (error) {
102+
return { key, phase, error }
103+
}
104+
}
105+
106+
for (const [index, key] of surrogateKeys.entries()) {
107+
const slotStart = startTime + index * DELAY_BETWEEN_LANGUAGES
108+
purges.push(runPurge(key, 'first', slotStart))
109+
purges.push(runPurge(key, 'second', slotStart + DELAY_BEFORE_SECOND_PURGE))
110+
}
111+
112+
const outcomes = await Promise.all(purges)
113+
const failures = outcomes.filter((outcome) => outcome.error)
114+
if (failures.length) {
115+
for (const failure of failures) {
116+
console.error(`Fastly ${failure.phase} purge failed for '${failure.key}':`, failure.error)
117+
}
118+
throw new Error(`${failures.length} Fastly purge(s) failed`)
41119
}
42120

43121
function languagesFromString(str: string): string[] {

0 commit comments

Comments
 (0)