8
8
//
9
9
// [end-readme]
10
10
11
- const Redis = require ( 'ioredis' )
11
+ require ( 'dotenv' ) . config ( )
12
+
13
+ const { promisify } = require ( 'util' )
14
+ const createClient = require ( '../lib/redis/create-client' )
12
15
13
16
const { REDIS_URL , HEROKU_RELEASE_VERSION , HEROKU_PRODUCTION_APP } = process . env
14
17
const isHerokuProd = HEROKU_PRODUCTION_APP === 'true'
@@ -42,68 +45,109 @@ console.log({
42
45
purgeRenderedPageCache ( )
43
46
44
47
function purgeRenderedPageCache ( ) {
45
- const redisClient = new Redis ( REDIS_URL , {
46
- db : pageCacheDatabaseNumber ,
47
-
48
- // Only add this configuration for TLS-enabled REDIS_URL values.
49
- // Otherwise, it breaks for local Redis instances without TLS enabled.
50
- ...REDIS_URL . startsWith ( 'rediss://' ) && {
51
- tls : {
52
- // Required for production Heroku Redis
53
- rejectUnauthorized : false
54
- }
55
- }
48
+ const redisClient = createClient ( {
49
+ url : REDIS_URL ,
50
+ db : pageCacheDatabaseNumber
56
51
} )
57
- let totalKeyCount = 0
52
+
58
53
let iteration = 0
54
+ let potentialKeyCount = 0
55
+ let totalKeyCount = 0
59
56
60
- // Create a readable stream (object mode) for the SCAN cursor
61
- const scanStream = redisClient . scanStream ( {
62
- match : keyScanningPattern ,
63
- count : scanSetSize
64
- } )
57
+ // Promise wrappers
58
+ const scanAsync = promisify ( redisClient . scan ) . bind ( redisClient )
59
+ const quitAsync = promisify ( redisClient . quit ) . bind ( redisClient )
65
60
66
- scanStream . on ( 'end' , function ( ) {
67
- console . log ( `Done purging keys; affected total: ${ totalKeyCount } ` )
68
- console . log ( `Time elapsed: ${ Date . now ( ) - startTime } ms` )
61
+ // Run it!
62
+ return scan ( )
69
63
70
- // This seems to be unexpectedly necessary
71
- process . exit ( 0 )
72
- } )
64
+ //
65
+ // Define other subroutines
66
+ //
73
67
74
- scanStream . on ( 'error' , function ( error ) {
75
- console . error ( 'An unexpected error occurred!\n' + error . stack )
76
- console . error ( '\nAborting...' )
77
- process . exit ( 1 )
78
- } )
68
+ async function scan ( cursor = '0' ) {
69
+ try {
70
+ // [0]: Update the cursor position for the next scan
71
+ // [1]: Get the SCAN result for this iteration
72
+ const [ nextCursor , keys ] = await scanAsync (
73
+ cursor ,
74
+ 'MATCH' , keyScanningPattern ,
75
+ 'COUNT' , scanSetSize . toString ( )
76
+ )
79
77
80
- scanStream . on ( 'data' , async function ( keys ) {
81
- console . log ( `[Iteration ${ iteration ++ } ] Received ${ keys . length } keys...` )
78
+ console . log ( `\n[Iteration ${ iteration ++ } ] Received ${ keys . length } keys...` )
82
79
83
- // NOTE: It is possible for a SCAN cursor iteration to return 0 keys when
84
- // using a MATCH because it is applied after the elements are retrieved
85
- if ( keys . length === 0 ) return
80
+ if ( dryRun ) {
81
+ console . log ( `DRY RUN! This iteration might have set TTL for up to ${ keys . length } keys:\n - ${ keys . join ( '\n - ' ) } ` )
82
+ }
86
83
87
- if ( dryRun ) {
88
- console . log ( `DRY RUN! This iteration might have set TTL for up to ${ keys . length } keys:\n - ${ keys . join ( '\n - ' ) } ` )
89
- return
90
- }
84
+ // NOTE: It is possible for a SCAN cursor iteration to return 0 keys when
85
+ // using a MATCH because it is applied after the elements are retrieved
86
+ //
87
+ // Remember: more or less than COUNT or no keys may be returned
88
+ // See http://redis.io/commands/scan#the-count-option
89
+ // Also, SCAN may return the same key multiple times
90
+ // See http://redis.io/commands/scan#scan-guarantees
91
+ // Additionally, you should always have the code that uses the keys
92
+ // before the code checking the cursor.
93
+ if ( keys . length > 0 ) {
94
+ if ( dryRun ) {
95
+ potentialKeyCount += keys . length
96
+ } else {
97
+ totalKeyCount += await updateTtls ( keys )
98
+ }
99
+ }
91
100
92
- // Pause the SCAN stream while we set a TTL on these keys
93
- scanStream . pause ( )
101
+ // From <http://redis.io/commands/scan>:
102
+ // 'An iteration starts when the cursor is set to 0,
103
+ // and terminates when the cursor returned by the server is 0.'
104
+ if ( nextCursor === '0' ) {
105
+ const dryRunTrailer = dryRun ? ` (potentially up to ${ potentialKeyCount } )` : ''
106
+ console . log ( `\nDone purging keys; affected total: ${ totalKeyCount } ${ dryRunTrailer } ` )
107
+ console . log ( `Time elapsed: ${ Date . now ( ) - startTime } ms` )
108
+
109
+ // Close the connection
110
+ await quitAsync ( )
111
+ return
112
+ }
94
113
95
- // Find existing TTLs to ensure we aren't extending the TTL if it's already set
96
- // PTTL mykey // only operate on -1 result values or those greater than ONE_HOUR_FROM_NOW
97
- const pttlPipeline = redisClient . pipeline ( )
114
+ // Tail recursion
115
+ return scan ( nextCursor )
116
+ } catch ( error ) {
117
+ console . error ( 'An unexpected error occurred!\n' + error . stack )
118
+ console . error ( '\nAborting...' )
119
+ process . exit ( 1 )
120
+ }
121
+ }
122
+
123
+ // Find existing TTLs to ensure we aren't extending the TTL if it's already set
124
+ async function getTtls ( keys ) {
125
+ const pttlPipeline = redisClient . batch ( )
98
126
keys . forEach ( key => pttlPipeline . pttl ( key ) )
99
- const pttlResults = await pttlPipeline . exec ( )
100
127
101
- // Update pertinent keys to have TTLs set
128
+ const pttlPipelineExecAsync = promisify ( pttlPipeline . exec ) . bind ( pttlPipeline )
129
+ const pttlResults = await pttlPipelineExecAsync ( )
130
+
131
+ if ( pttlResults == null || pttlResults . length === 0 ) {
132
+ throw new Error ( 'PTTL results were empty' )
133
+ }
134
+
135
+ return pttlResults
136
+ }
137
+
138
+ async function updateTtls ( keys ) {
139
+ const pttlResults = await getTtls ( keys )
140
+
141
+ // Find pertinent keys to have TTLs set
102
142
let updatingKeyCount = 0
103
- const pexpireAtPipeline = redisClient . pipeline ( )
143
+ const pexpireAtPipeline = redisClient . batch ( )
144
+
104
145
keys . forEach ( ( key , i ) => {
105
- const [ error , pttl ] = pttlResults [ i ]
106
- const needsShortenedTtl = error == null && ( pttl === - 1 || pttl > expirationDuration )
146
+ // Only operate on -1 result values or those greater than ONE_HOUR_FROM_NOW
147
+ const pttl = pttlResults [ i ]
148
+ // A TTL of -1 means the entry was not configured with any TTL (expiration)
149
+ // currently and will remain as a permanent entry unless a TTL is added
150
+ const needsShortenedTtl = pttl === - 1 || pttl > expirationDuration
107
151
const isOldKey = ! HEROKU_RELEASE_VERSION || ! key . startsWith ( `${ HEROKU_RELEASE_VERSION } :` )
108
152
109
153
if ( needsShortenedTtl && isOldKey ) {
@@ -112,17 +156,21 @@ function purgeRenderedPageCache () {
112
156
}
113
157
} )
114
158
159
+ console . log ( `Purging ${ updatingKeyCount } keys...` )
160
+
115
161
// Only update TTLs if there are records worth updating
116
- if ( updatingKeyCount > 0 ) {
117
- // Set all the TTLs
118
- const pexpireAtResults = await pexpireAtPipeline . exec ( )
119
- const updatedResults = pexpireAtResults . filter ( ( [ error , result ] ) => error == null && result === 1 )
162
+ if ( updatingKeyCount === 0 ) return
120
163
121
- // Count only the entries whose TTLs were successfully updated
122
- totalKeyCount += updatedResults . length
164
+ // Set all the TTLs
165
+ const pexpireAtPipelineExecAsync = promisify ( pexpireAtPipeline . exec ) . bind ( pexpireAtPipeline )
166
+ const pexpireAtResults = await pexpireAtPipelineExecAsync ( )
167
+
168
+ if ( pttlResults == null || pttlResults . length === 0 ) {
169
+ throw new Error ( 'PEXPIREAT results were empty' )
123
170
}
124
171
125
- // Resume the SCAN stream
126
- scanStream . resume ( )
127
- } )
172
+ // Count only the entries whose TTLs were successfully updated
173
+ const updatedResults = pexpireAtResults . filter ( ( result ) => result === 1 )
174
+ return updatedResults . length
175
+ }
128
176
}
0 commit comments