Register
Login
Resources
Docs Blog Datasets Glossary Case Studies Tutorials & Webinars
Product
Data Engine LLMs Platform Enterprise
Pricing Explore
Connect to our Discord channel

purge-redis-pages.js 5.8 KB

You have to be logged in to leave a comment. Sign In
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
  1. #!/usr/bin/env node
  2. // [start-readme]
  3. //
  4. // Run this script to manually "soft purge" the Redis rendered page cache
  5. // by shortening the expiration window of entries.
  6. // This will typically only be run by Heroku during the deployment process,
  7. // as triggered via our Procfile's "release" phase configuration.
  8. //
  9. // [end-readme]
  10. require('dotenv').config()
  11. const { promisify } = require('util')
  12. const createRedisClient = require('../lib/redis/create-client')
  13. const { REDIS_URL, HEROKU_RELEASE_VERSION, HEROKU_PRODUCTION_APP } = process.env
  14. const isHerokuProd = HEROKU_PRODUCTION_APP === 'true'
  15. const pageCacheDatabaseNumber = 1
  16. const keyScanningPattern = HEROKU_RELEASE_VERSION ? '*:rp:*' : 'rp:*'
  17. const scanSetSize = 250
  18. const startTime = Date.now()
  19. const expirationDuration = 30 * 60 * 1000 // 30 minutes
  20. const expirationTimestamp = startTime + expirationDuration // 30 minutes from now
  21. // print keys to be purged without actually purging
  22. const dryRun = ['-d', '--dry-run'].includes(process.argv[2])
  23. // verify environment variables
  24. if (!REDIS_URL) {
  25. if (isHerokuProd) {
  26. console.error('Error: you must specify the REDIS_URL environment variable.\n')
  27. process.exit(1)
  28. } else {
  29. console.warn('Warning: you did not specify a REDIS_URL environment variable. Exiting...\n')
  30. process.exit(0)
  31. }
  32. }
  33. console.log({
  34. HEROKU_RELEASE_VERSION,
  35. HEROKU_PRODUCTION_APP
  36. })
  37. purgeRenderedPageCache()
  38. function purgeRenderedPageCache () {
  39. const redisClient = createRedisClient({
  40. url: REDIS_URL,
  41. db: pageCacheDatabaseNumber,
  42. // These commands ARE important, so let's make sure they are all accounted for
  43. enable_offline_queue: true
  44. })
  45. let iteration = 0
  46. let potentialKeyCount = 0
  47. let totalKeyCount = 0
  48. // Promise wrappers
  49. const scanAsync = promisify(redisClient.scan).bind(redisClient)
  50. const quitAsync = promisify(redisClient.quit).bind(redisClient)
  51. // Run it!
  52. return scan()
  53. //
  54. // Define other subroutines
  55. //
  56. async function scan (cursor = '0') {
  57. try {
  58. // [0]: Update the cursor position for the next scan
  59. // [1]: Get the SCAN result for this iteration
  60. const [nextCursor, keys] = await scanAsync(
  61. cursor,
  62. 'MATCH', keyScanningPattern,
  63. 'COUNT', scanSetSize.toString()
  64. )
  65. console.log(`\n[Iteration ${iteration++}] Received ${keys.length} keys...`)
  66. if (dryRun) {
  67. console.log(`DRY RUN! This iteration might have set TTL for up to ${keys.length} keys:\n - ${keys.join('\n - ')}`)
  68. }
  69. // NOTE: It is possible for a SCAN cursor iteration to return 0 keys when
  70. // using a MATCH because it is applied after the elements are retrieved
  71. //
  72. // Remember: more or less than COUNT or no keys may be returned
  73. // See http://redis.io/commands/scan#the-count-option
  74. // Also, SCAN may return the same key multiple times
  75. // See http://redis.io/commands/scan#scan-guarantees
  76. // Additionally, you should always have the code that uses the keys
  77. // before the code checking the cursor.
  78. if (keys.length > 0) {
  79. if (dryRun) {
  80. potentialKeyCount += keys.length
  81. } else {
  82. totalKeyCount += await updateTtls(keys)
  83. }
  84. }
  85. // From <http://redis.io/commands/scan>:
  86. // 'An iteration starts when the cursor is set to 0,
  87. // and terminates when the cursor returned by the server is 0.'
  88. if (nextCursor === '0') {
  89. const dryRunTrailer = dryRun ? ` (potentially up to ${potentialKeyCount})` : ''
  90. console.log(`\nDone purging keys; affected total: ${totalKeyCount}${dryRunTrailer}`)
  91. console.log(`Time elapsed: ${Date.now() - startTime} ms`)
  92. // Close the connection
  93. await quitAsync()
  94. return
  95. }
  96. // Tail recursion
  97. return scan(nextCursor)
  98. } catch (error) {
  99. console.error('An unexpected error occurred!\n' + error.stack)
  100. console.error('\nAborting...')
  101. process.exit(1)
  102. }
  103. }
  104. // Find existing TTLs to ensure we aren't extending the TTL if it's already set
  105. async function getTtls (keys) {
  106. const pttlPipeline = redisClient.batch()
  107. keys.forEach(key => pttlPipeline.pttl(key))
  108. const pttlPipelineExecAsync = promisify(pttlPipeline.exec).bind(pttlPipeline)
  109. const pttlResults = await pttlPipelineExecAsync()
  110. if (pttlResults == null || pttlResults.length === 0) {
  111. throw new Error('PTTL results were empty')
  112. }
  113. return pttlResults
  114. }
  115. async function updateTtls (keys) {
  116. const pttlResults = await getTtls(keys)
  117. // Find pertinent keys to have TTLs set
  118. let updatingKeyCount = 0
  119. const pexpireAtPipeline = redisClient.batch()
  120. keys.forEach((key, i) => {
  121. // Only operate on -1 values or those later than our desired expiration timestamp
  122. const pttl = pttlResults[i]
  123. // A TTL of -1 means the entry was not configured with any TTL (expiration)
  124. // currently and will remain as a permanent entry unless a TTL is added
  125. const needsShortenedTtl = pttl === -1 || pttl > expirationDuration
  126. const isOldKey = !HEROKU_RELEASE_VERSION || !key.startsWith(`${HEROKU_RELEASE_VERSION}:`)
  127. if (needsShortenedTtl && isOldKey) {
  128. pexpireAtPipeline.pexpireat(key, expirationTimestamp)
  129. updatingKeyCount += 1
  130. }
  131. })
  132. console.log(`Purging ${updatingKeyCount} keys...`)
  133. // Only update TTLs if there are records worth updating
  134. if (updatingKeyCount === 0) return
  135. // Set all the TTLs
  136. const pexpireAtPipelineExecAsync = promisify(pexpireAtPipeline.exec).bind(pexpireAtPipeline)
  137. const pexpireAtResults = await pexpireAtPipelineExecAsync()
  138. if (pttlResults == null || pttlResults.length === 0) {
  139. throw new Error('PEXPIREAT results were empty')
  140. }
  141. // Count only the entries whose TTLs were successfully updated
  142. const updatedResults = pexpireAtResults.filter((result) => result === 1)
  143. return updatedResults.length
  144. }
  145. }
Tip!

Press p or to see the previous file or, n or to see the next file

Comments

Loading...