#!/usr/bin/env node // [start-readme] // // Run this script to manually purge the Redis rendered page cache. // This will typically only be run by Heroku during the deployment process, // as triggered via our Procfile's "release" phase configuration. // // [end-readme] require('dotenv').config() const { promisify } = require('util') const createClient = require('../lib/redis/create-client') const { REDIS_URL, HEROKU_RELEASE_VERSION, HEROKU_PRODUCTION_APP } = process.env const isHerokuProd = HEROKU_PRODUCTION_APP === 'true' const pageCacheDatabaseNumber = 1 const keyScanningPattern = HEROKU_RELEASE_VERSION ? '*:rp:*' : 'rp:*' const scanSetSize = 250 const startTime = Date.now() const expirationDuration = 30 * 60 * 1000 // 30 minutes const expirationTimestamp = startTime + expirationDuration // 30 minutes from now // print keys to be purged without actually purging const dryRun = ['-d', '--dry-run'].includes(process.argv[2]) // verify environment variables if (!REDIS_URL) { if (isHerokuProd) { console.error('Error: you must specify the REDIS_URL environment variable.\n') process.exit(1) } else { console.warn('Warning: you did not specify a REDIS_URL environment variable. Exiting...\n') process.exit(0) } } console.log({ HEROKU_RELEASE_VERSION, HEROKU_PRODUCTION_APP }) purgeRenderedPageCache() function purgeRenderedPageCache () { const redisClient = createClient({ url: REDIS_URL, db: pageCacheDatabaseNumber }) let iteration = 0 let potentialKeyCount = 0 let totalKeyCount = 0 // Promise wrappers const scanAsync = promisify(redisClient.scan).bind(redisClient) const quitAsync = promisify(redisClient.quit).bind(redisClient) // Run it! return scan() // // Define other subroutines // async function scan (cursor = '0') { try { // [0]: Update the cursor position for the next scan // [1]: Get the SCAN result for this iteration const [nextCursor, keys] = await scanAsync( cursor, 'MATCH', keyScanningPattern, 'COUNT', scanSetSize.toString() ) console.log(`\n[Iteration ${iteration++}] Received ${keys.length} keys...`) if (dryRun) { console.log(`DRY RUN! This iteration might have set TTL for up to ${keys.length} keys:\n - ${keys.join('\n - ')}`) } // NOTE: It is possible for a SCAN cursor iteration to return 0 keys when // using a MATCH because it is applied after the elements are retrieved // // Remember: more or less than COUNT or no keys may be returned // See http://redis.io/commands/scan#the-count-option // Also, SCAN may return the same key multiple times // See http://redis.io/commands/scan#scan-guarantees // Additionally, you should always have the code that uses the keys // before the code checking the cursor. if (keys.length > 0) { if (dryRun) { potentialKeyCount += keys.length } else { totalKeyCount += await updateTtls(keys) } } // From : // 'An iteration starts when the cursor is set to 0, // and terminates when the cursor returned by the server is 0.' if (nextCursor === '0') { const dryRunTrailer = dryRun ? ` (potentially up to ${potentialKeyCount})` : '' console.log(`\nDone purging keys; affected total: ${totalKeyCount}${dryRunTrailer}`) console.log(`Time elapsed: ${Date.now() - startTime} ms`) // Close the connection await quitAsync() return } // Tail recursion return scan(nextCursor) } catch (error) { console.error('An unexpected error occurred!\n' + error.stack) console.error('\nAborting...') process.exit(1) } } // Find existing TTLs to ensure we aren't extending the TTL if it's already set async function getTtls (keys) { const pttlPipeline = redisClient.batch() keys.forEach(key => pttlPipeline.pttl(key)) const pttlPipelineExecAsync = promisify(pttlPipeline.exec).bind(pttlPipeline) const pttlResults = await pttlPipelineExecAsync() if (pttlResults == null || pttlResults.length === 0) { throw new Error('PTTL results were empty') } return pttlResults } async function updateTtls (keys) { const pttlResults = await getTtls(keys) // Find pertinent keys to have TTLs set let updatingKeyCount = 0 const pexpireAtPipeline = redisClient.batch() keys.forEach((key, i) => { // Only operate on -1 result values or those greater than ONE_HOUR_FROM_NOW const pttl = pttlResults[i] // A TTL of -1 means the entry was not configured with any TTL (expiration) // currently and will remain as a permanent entry unless a TTL is added const needsShortenedTtl = pttl === -1 || pttl > expirationDuration const isOldKey = !HEROKU_RELEASE_VERSION || !key.startsWith(`${HEROKU_RELEASE_VERSION}:`) if (needsShortenedTtl && isOldKey) { pexpireAtPipeline.pexpireat(key, expirationTimestamp) updatingKeyCount += 1 } }) console.log(`Purging ${updatingKeyCount} keys...`) // Only update TTLs if there are records worth updating if (updatingKeyCount === 0) return // Set all the TTLs const pexpireAtPipelineExecAsync = promisify(pexpireAtPipeline.exec).bind(pexpireAtPipeline) const pexpireAtResults = await pexpireAtPipelineExecAsync() if (pttlResults == null || pttlResults.length === 0) { throw new Error('PEXPIREAT results were empty') } // Count only the entries whose TTLs were successfully updated const updatedResults = pexpireAtResults.filter((result) => result === 1) return updatedResults.length } }