flush_old.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. #!/usr/bin/env node
  2. import Settings from '@overleaf/settings'
  3. import minimist from 'minimist'
  4. import logger from '@overleaf/logger'
  5. import PQueue from 'p-queue'
  6. import * as RedisManager from '../app/js/RedisManager.js'
  7. import * as ErrorRecorder from '../app/js/ErrorRecorder.js'
  8. logger.logger.level('fatal')
  9. function usage() {
  10. console.log(`
  11. Usage: flush_old.js [options]
  12. Options:
  13. -b, --batch-size <size> Number of projects to process in each batch (default: 100)
  14. -a, --max-age <seconds> Maximum age of projects to keep (default: 3600)
  15. -i, --interval <seconds> Interval to spread the processing over (default: 300)
  16. -c, --concurrency <number> Number of concurrent jobs (default: 10)
  17. -u, --buffer <seconds> Buffer time in seconds to reserve at end (default: 15)
  18. -n, --dry-run Show what would be done without making changes
  19. -h, --help Show this help message
  20. Examples:
  21. # Flush projects older than 24 hours with 5 concurrent jobs
  22. flush_old.js --batch-size 100 --max-age 86400 -c 5
  23. # Dry run to see what would be flushed
  24. flush_old.js --max-age 3600 --dry-run
  25. `)
  26. process.exit(0)
  27. }
  28. const argv = minimist(process.argv.slice(2), {
  29. boolean: ['dry-run', 'help'],
  30. alias: {
  31. b: 'batch-size',
  32. a: 'max-age',
  33. i: 'interval',
  34. c: 'concurrency',
  35. n: 'dry-run',
  36. u: 'buffer',
  37. h: 'help',
  38. },
  39. default: {
  40. 'batch-size': 100,
  41. 'max-age': 3600,
  42. interval: 300,
  43. concurrency: 10,
  44. 'dry-run': false,
  45. buffer: 15,
  46. help: false,
  47. },
  48. })
  49. if (argv.help || process.argv.length === 2) {
  50. usage()
  51. }
  52. const batchSize = parseInt(argv['batch-size'], 10)
  53. const maxAge = argv['max-age'] ? parseInt(argv['max-age'], 10) : null
  54. const interval = parseInt(argv.interval, 10) || 300
  55. const concurrency = parseInt(argv.concurrency, 10) || 10
  56. const bufferTime = parseInt(argv.buffer, 10) || 15
  57. const dryRun = argv['dry-run']
  58. /**
  59. * Generator function that yields batches of items from an array
  60. * @param {Array} array - The array to batch
  61. * @param {number} size - The size of each batch
  62. * @yields {Array} A batch of items
  63. */
  64. function* getBatches(array, size) {
  65. for (let i = 0; i < array.length; i += size) {
  66. yield array.slice(i, i + size)
  67. }
  68. }
  69. let flushCount = 0
  70. async function flushProject({ projectId, timestamp }) {
  71. const url = `${Settings.apis.project_history.url}/project/${projectId}/flush`
  72. if (dryRun) {
  73. console.log(`[DRY RUN] would flush project ${projectId}`)
  74. return
  75. }
  76. const response = await fetch(url, {
  77. method: 'POST',
  78. })
  79. flushCount++
  80. if (flushCount % 100 === 0) {
  81. console.log('flushed', flushCount, 'projects, up to', timestamp)
  82. }
  83. if (!response.ok) {
  84. throw new Error(`failed to flush project ${projectId}`)
  85. }
  86. }
  87. const SCRIPT_START_TIME = Date.now() // current time in milliseconds from start of script
  88. function olderThan(maxAge, timestamp) {
  89. const age = (SCRIPT_START_TIME - timestamp) / 1000
  90. return age > maxAge
  91. }
  92. async function main() {
  93. const projectIds = await RedisManager.promises.getProjectIdsWithHistoryOps()
  94. const failedProjects = await ErrorRecorder.promises.getFailedProjects()
  95. const failedProjectIds = new Set(failedProjects.map(p => p.project_id))
  96. const projectIdsToProcess = projectIds.filter(p => !failedProjectIds.has(p))
  97. console.log('number of projects with history ops', projectIds.length)
  98. console.log(
  99. 'number of failed projects to exclude',
  100. projectIds.length - projectIdsToProcess.length
  101. )
  102. const collectedProjects = []
  103. let nullCount = 0
  104. // iterate over the project ids in batches of doing a redis MGET to retrieve the first op timestamps
  105. for (const batch of getBatches(projectIdsToProcess, batchSize)) {
  106. const timestamps = await RedisManager.promises.getFirstOpTimestamps(batch)
  107. const newProjects = batch
  108. .map((projectId, idx) => {
  109. return { projectId, timestamp: timestamps[idx] }
  110. })
  111. .filter(({ projectId, timestamp }) => {
  112. if (!timestamp) {
  113. nullCount++
  114. return true // Unknown age
  115. }
  116. if (olderThan(maxAge, timestamp)) return true // Older than threshold
  117. if (Settings.shortHistoryQueues.includes(projectId)) return true // Short queue
  118. return false // Do not flush
  119. })
  120. collectedProjects.push(...newProjects)
  121. }
  122. // sort the collected projects by ascending timestamp
  123. collectedProjects.sort((a, b) => a.timestamp - b.timestamp)
  124. console.log('number of projects to flush', collectedProjects.length)
  125. console.log('number with null timestamps', nullCount)
  126. const elapsedTime = Math.floor((Date.now() - SCRIPT_START_TIME) / 1000)
  127. console.log('elapsed time', elapsedTime, 'seconds, buffer time', bufferTime)
  128. const remainingTime = Math.max(interval - elapsedTime - bufferTime, 0)
  129. console.log('remaining time', remainingTime, 'seconds')
  130. const jobsPerSecond = Math.max(
  131. Math.ceil(collectedProjects.length / Math.max(remainingTime, 60)),
  132. 1
  133. )
  134. console.log('interval', interval, 'seconds')
  135. console.log('jobs per second', jobsPerSecond)
  136. console.log('concurrency', concurrency)
  137. const queue = new PQueue({
  138. concurrency,
  139. interval: 1000,
  140. intervalCap: jobsPerSecond,
  141. })
  142. const taskFns = collectedProjects.map(project => {
  143. return async () => {
  144. try {
  145. await flushProject(project)
  146. return { status: 'fulfilled', value: project }
  147. } catch (error) {
  148. return { status: 'rejected', reason: error, project }
  149. }
  150. }
  151. })
  152. const results = await queue.addAll(taskFns)
  153. console.log(
  154. 'finished after',
  155. Math.floor((Date.now() - SCRIPT_START_TIME) / 1000),
  156. 'seconds'
  157. )
  158. // count the number of successful and failed flushes
  159. const success = results.filter(r => r.status === 'fulfilled').length
  160. const failed = results.filter(r => r.status === 'rejected').length
  161. console.log('completed', { success, failed })
  162. }
  163. main()
  164. .then(() => {
  165. process.exit(0)
  166. })
  167. .catch(err => {
  168. console.error(err)
  169. process.exit(1)
  170. })