persist_redis_chunks.mjs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import config from 'config'
  2. import PQueue from 'p-queue'
  3. import { fetchNothing } from '@overleaf/fetch-utils'
  4. import logger from '@overleaf/logger'
  5. import commandLineArgs from 'command-line-args'
  6. import * as redis from '../lib/redis.js'
  7. import knex from '../lib/knex.js'
  8. import knexReadOnly from '../lib/knex_read_only.js'
  9. import { client } from '../lib/mongodb.js'
  10. import { scanAndProcessDueItems } from '../lib/scan.js'
  11. import persistBuffer from '../lib/persist_buffer.js'
  12. import { claimPersistJob } from '../lib/chunk_store/redis.js'
  13. import { loadGlobalBlobs } from '../lib/blob_store/index.js'
  14. import { EventEmitter } from 'node:events'
  15. import { fileURLToPath } from 'node:url'
  16. // Something is registering 11 listeners, over the limit of 10, which generates
  17. // a lot of warning noise.
  18. EventEmitter.defaultMaxListeners = 11
  19. const rclient = redis.rclientHistory
  20. const optionDefinitions = [
  21. { name: 'dry-run', alias: 'd', type: Boolean },
  22. { name: 'queue', type: Boolean },
  23. { name: 'max-time', type: Number },
  24. { name: 'min-rate', type: Number, defaultValue: 1 },
  25. ]
  26. const options = commandLineArgs(optionDefinitions)
  27. const DRY_RUN = options['dry-run'] || false
  28. const USE_QUEUE = options.queue || false
  29. const MAX_TIME = options['max-time'] || null
  30. const MIN_RATE = options['min-rate']
  31. const HISTORY_V1_URL = `http://${process.env.HISTORY_V1_HOST || 'localhost'}:${process.env.PORT || 3100}`
  32. let isShuttingDown = false
  33. logger.initialize('persist-redis-chunks')
  34. async function persistProjectAction(projectId) {
  35. const job = await claimPersistJob(projectId)
  36. // Set limits to force us to persist all of the changes.
  37. const farFuture = new Date()
  38. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  39. const limits = {
  40. maxChanges: 0,
  41. minChangeTimestamp: farFuture,
  42. maxChangeTimestamp: farFuture,
  43. autoResync: true,
  44. }
  45. await persistBuffer(projectId, limits)
  46. if (job && job.close) {
  47. await job.close()
  48. }
  49. }
  50. async function requestProjectFlush(projectId) {
  51. const job = await claimPersistJob(projectId)
  52. logger.debug({ projectId }, 'sending project flush request')
  53. const url = `${HISTORY_V1_URL}/api/projects/${projectId}/flush`
  54. const credentials = Buffer.from(
  55. `staging:${config.get('basicHttpAuth.password')}`
  56. ).toString('base64')
  57. await fetchNothing(url, {
  58. method: 'POST',
  59. headers: {
  60. Authorization: `Basic ${credentials}`,
  61. },
  62. })
  63. if (job && job.close) {
  64. await job.close()
  65. }
  66. }
  67. async function persistQueuedProjects(queuedProjects) {
  68. const totalCount = queuedProjects.size
  69. // Compute the rate at which we need to dispatch requests
  70. const targetRate = MAX_TIME > 0 ? Math.ceil(totalCount / MAX_TIME) : 0
  71. // Rate limit to spread the requests over the interval.
  72. const queue = new PQueue({
  73. intervalCap: Math.max(MIN_RATE, targetRate),
  74. interval: 1000, // use a 1 second interval
  75. })
  76. logger.info(
  77. { totalCount, targetRate, minRate: MIN_RATE, maxTime: MAX_TIME },
  78. 'dispatching project flush requests'
  79. )
  80. const startTime = Date.now()
  81. let dispatchedCount = 0
  82. for (const projectId of queuedProjects) {
  83. if (isShuttingDown) {
  84. logger.info('Shutting down, stopping project flush requests')
  85. queue.clear()
  86. break
  87. }
  88. queue.add(async () => {
  89. try {
  90. await requestProjectFlush(projectId)
  91. } catch (err) {
  92. logger.error({ err, projectId }, 'error while flushing project')
  93. }
  94. })
  95. dispatchedCount++
  96. if (dispatchedCount % 1000 === 0) {
  97. logger.info(
  98. { count: dispatchedCount },
  99. 'dispatched project flush requests'
  100. )
  101. }
  102. await queue.onEmpty()
  103. }
  104. const elapsedTime = Math.floor((Date.now() - startTime) / 1000)
  105. logger.info(
  106. { count: totalCount, elapsedTime },
  107. 'dispatched project flush requests'
  108. )
  109. await queue.onIdle()
  110. }
  111. async function runPersistChunks() {
  112. const queuedProjects = new Set()
  113. async function queueProjectAction(projectId) {
  114. queuedProjects.add(projectId)
  115. }
  116. await loadGlobalBlobs()
  117. await scanAndProcessDueItems(
  118. rclient,
  119. 'persistChunks',
  120. 'persist-time',
  121. USE_QUEUE ? queueProjectAction : persistProjectAction,
  122. DRY_RUN
  123. )
  124. if (USE_QUEUE) {
  125. if (isShuttingDown) {
  126. logger.info('Shutting down, skipping queued project persistence')
  127. return
  128. }
  129. logger.info(
  130. { count: queuedProjects.size },
  131. 'queued projects for persistence'
  132. )
  133. await persistQueuedProjects(queuedProjects)
  134. }
  135. }
  136. async function main() {
  137. try {
  138. await runPersistChunks()
  139. } catch (err) {
  140. logger.fatal(
  141. { err, taskName: 'persistChunks' },
  142. 'Unhandled error in runPersistChunks'
  143. )
  144. process.exit(1)
  145. } finally {
  146. await redis.disconnect()
  147. await client.close()
  148. await knex.destroy()
  149. await knexReadOnly.destroy()
  150. }
  151. }
  152. function gracefulShutdown() {
  153. if (isShuttingDown) {
  154. return
  155. }
  156. isShuttingDown = true
  157. logger.info({ isShuttingDown }, 'received shutdown signal, cleaning up...')
  158. }
  159. // Check if the module is being run directly
  160. const currentScriptPath = fileURLToPath(import.meta.url)
  161. if (process.argv[1] === currentScriptPath) {
  162. process.on('SIGINT', gracefulShutdown)
  163. process.on('SIGTERM', gracefulShutdown)
  164. main()
  165. }
  166. export { runPersistChunks }