@@ -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 */
2150const 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
2364const 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
2869const 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
43121function languagesFromString ( str : string ) : string [ ] {
0 commit comments