backup_scheduler.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. import Queue from 'bull'
  2. import config from 'config'
  3. import commandLineArgs from 'command-line-args'
  4. import logger from '@overleaf/logger'
  5. import {
  6. listPendingBackups,
  7. listUninitializedBackups,
  8. getBackupStatus,
  9. } from '../lib/backup_store/index.js'
  10. logger.initialize('backup-queue')
  11. // Use the same redis config as backup_worker
  12. const redisOptions = config.get('redis.queue')
  13. // Create a Bull queue named 'backup'
  14. const backupQueue = new Queue('backup', {
  15. redis: redisOptions,
  16. defaultJobOptions: {
  17. removeOnComplete: { age: 60 }, // keep completed jobs for 60 seconds
  18. removeOnFail: { age: 7 * 24 * 3600, count: 1000 }, // keep failed jobs for 7 days, max 1000
  19. },
  20. })
  21. // Define command-line options
  22. const optionDefinitions = [
  23. { name: 'clean', type: Boolean },
  24. { name: 'status', type: Boolean },
  25. {
  26. name: 'add',
  27. type: String,
  28. multiple: true,
  29. description: 'Project IDs or date range in YYYY-MM-DD:YYYY-MM-DD format',
  30. },
  31. { name: 'monitor', type: Boolean },
  32. {
  33. name: 'queue-pending',
  34. type: Number,
  35. description:
  36. 'Find projects with pending changes older than N seconds and add them to the queue',
  37. },
  38. {
  39. name: 'show-pending',
  40. type: Number,
  41. description:
  42. 'Show count of pending projects older than N seconds without adding to queue',
  43. },
  44. {
  45. name: 'limit',
  46. type: Number,
  47. description: 'Limit the number of jobs to be added',
  48. },
  49. {
  50. name: 'interval',
  51. type: Number,
  52. description: 'Time in seconds to spread jobs over (default: 300)',
  53. defaultValue: 300,
  54. },
  55. {
  56. name: 'backoff-delay',
  57. type: Number,
  58. description:
  59. 'Backoff delay in milliseconds for failed jobs (default: 1000)',
  60. defaultValue: 1000,
  61. },
  62. {
  63. name: 'attempts',
  64. type: Number,
  65. description: 'Number of retry attempts for failed jobs (default: 3)',
  66. defaultValue: 3,
  67. },
  68. {
  69. name: 'warn-threshold',
  70. type: Number,
  71. description: 'Warn about any project exceeding this pending age',
  72. defaultValue: 2 * 3600, // 2 hours
  73. },
  74. {
  75. name: 'verbose',
  76. alias: 'v',
  77. type: Boolean,
  78. description: 'Show detailed information when used with --show-pending',
  79. },
  80. ]
  81. // Parse command line arguments
  82. const options = commandLineArgs(optionDefinitions)
  83. const WARN_THRESHOLD = options['warn-threshold']
  84. // Helper to validate date format
  85. function isValidDateFormat(dateStr) {
  86. return /^\d{4}-\d{2}-\d{2}$/.test(dateStr)
  87. }
  88. // Helper to validate the pending time parameter
  89. function validatePendingTime(option, value) {
  90. if (typeof value !== 'number' || value <= 0) {
  91. console.error(
  92. `Error: --${option} requires a positive numeric TIME argument in seconds`
  93. )
  94. console.error(`Example: --${option} 3600`)
  95. process.exit(1)
  96. }
  97. return value
  98. }
  99. // Helper to format the pending time display
  100. function formatPendingTime(timestamp) {
  101. const now = new Date()
  102. const diffMs = now - timestamp
  103. const seconds = Math.floor(diffMs / 1000)
  104. return `${timestamp.toISOString()} (${seconds} seconds ago)`
  105. }
  106. // Helper to add a job to the queue, checking for duplicates
  107. async function addJobWithCheck(queue, data, options) {
  108. const jobId = options.jobId
  109. // Check if the job already exists
  110. const existingJob = await queue.getJob(jobId)
  111. if (existingJob) {
  112. return { job: existingJob, added: false }
  113. } else {
  114. const job = await queue.add(data, options)
  115. return { job, added: true }
  116. }
  117. }
  118. // Setup queue event listeners
  119. function setupMonitoring() {
  120. console.log('Starting queue monitoring. Press Ctrl+C to exit.')
  121. backupQueue.on('global:error', error => {
  122. logger.info({ error }, 'Queue error')
  123. })
  124. backupQueue.on('global:waiting', jobId => {
  125. logger.info({ jobId }, 'job is waiting')
  126. })
  127. backupQueue.on('global:active', jobId => {
  128. logger.info({ jobId }, 'job is now active')
  129. })
  130. backupQueue.on('global:stalled', jobId => {
  131. logger.info({ jobId }, 'job has stalled')
  132. })
  133. backupQueue.on('global:progress', (jobId, progress) => {
  134. logger.info({ jobId, progress }, 'job progress')
  135. })
  136. backupQueue.on('global:completed', (jobId, result) => {
  137. logger.info({ jobId, result }, 'job completed')
  138. })
  139. backupQueue.on('global:failed', (jobId, err) => {
  140. logger.info({ jobId, err }, 'job failed')
  141. })
  142. backupQueue.on('global:paused', () => {
  143. logger.info({}, 'Queue paused')
  144. })
  145. backupQueue.on('global:resumed', () => {
  146. logger.info({}, 'Queue resumed')
  147. })
  148. backupQueue.on('global:cleaned', (jobs, type) => {
  149. logger.info({ jobsCount: jobs.length, type }, 'Jobs cleaned')
  150. })
  151. backupQueue.on('global:drained', () => {
  152. logger.info({}, 'Queue drained')
  153. })
  154. backupQueue.on('global:removed', jobId => {
  155. logger.info({ jobId }, 'Job removed')
  156. })
  157. }
  158. async function addDateRangeJob(input) {
  159. const [startDate, endDate] = input.split(':')
  160. if (!isValidDateFormat(startDate) || !isValidDateFormat(endDate)) {
  161. console.error(
  162. `Invalid date format for "${input}". Use YYYY-MM-DD:YYYY-MM-DD`
  163. )
  164. return
  165. }
  166. const jobId = `backup-${startDate}-to-${endDate}`
  167. const { job, added } = await addJobWithCheck(
  168. backupQueue,
  169. { startDate, endDate },
  170. { jobId }
  171. )
  172. console.log(
  173. `${added ? 'Added' : 'Already exists'}: date range backup job: ${startDate} to ${endDate}, job ID: ${job.id}`
  174. )
  175. }
  176. // Helper to list pending and uninitialized backups
  177. // This function combines the two cursors into a single generator
  178. // to yield projects from both lists
  179. async function* pendingCursor(timeIntervalMs, limit) {
  180. for await (const project of listPendingBackups(timeIntervalMs, limit)) {
  181. yield project
  182. }
  183. for await (const project of listUninitializedBackups(timeIntervalMs, limit)) {
  184. yield project
  185. }
  186. }
  187. // Process pending projects with changes older than the specified seconds
  188. async function processPendingProjects(
  189. age,
  190. showOnly,
  191. limit,
  192. verbose,
  193. jobInterval,
  194. jobOpts = {}
  195. ) {
  196. const timeIntervalMs = age * 1000
  197. console.log(
  198. `Finding projects with pending changes older than ${age} seconds${showOnly ? ' (count only)' : ''}`
  199. )
  200. let count = 0
  201. let addedCount = 0
  202. let existingCount = 0
  203. // Pass the limit directly to MongoDB query for better performance
  204. const changeTimes = []
  205. for await (const project of pendingCursor(timeIntervalMs, limit)) {
  206. const projectId = project._id.toHexString()
  207. const pendingAt =
  208. project.overleaf?.backup?.pendingChangeAt || project._id.getTimestamp()
  209. if (pendingAt) {
  210. changeTimes.push(pendingAt)
  211. const pendingAge = Math.floor((Date.now() - pendingAt.getTime()) / 1000)
  212. if (pendingAge > WARN_THRESHOLD) {
  213. try {
  214. const backupStatus = await getBackupStatus(projectId)
  215. logger.warn(
  216. {
  217. projectId,
  218. pendingAt,
  219. pendingAge,
  220. backupStatus,
  221. warnThreshold: WARN_THRESHOLD,
  222. },
  223. `pending change exceeds rpo warning threshold`
  224. )
  225. } catch (err) {
  226. logger.error(
  227. { projectId, pendingAt, pendingAge },
  228. 'Error getting backup status'
  229. )
  230. throw err
  231. }
  232. }
  233. }
  234. if (showOnly && verbose) {
  235. console.log(
  236. `Project: ${projectId} (pending since: ${formatPendingTime(pendingAt)})`
  237. )
  238. } else if (!showOnly) {
  239. const delay = Math.floor(Math.random() * jobInterval * 1000) // add random delay to avoid all jobs running simultaneously
  240. const { job, added } = await addJobWithCheck(
  241. backupQueue,
  242. { projectId, pendingChangeAt: pendingAt.getTime() },
  243. { ...jobOpts, delay, jobId: projectId }
  244. )
  245. if (added) {
  246. if (verbose) {
  247. console.log(
  248. `Added job for project: ${projectId}, job ID: ${job.id} (pending since: ${formatPendingTime(pendingAt)})`
  249. )
  250. }
  251. addedCount++
  252. } else {
  253. if (verbose) {
  254. console.log(
  255. `Job already exists for project: ${projectId}, job ID: ${job.id} (pending since: ${formatPendingTime(pendingAt)})`
  256. )
  257. }
  258. existingCount++
  259. }
  260. }
  261. count++
  262. if (count % 1000 === 0) {
  263. console.log(
  264. `Processed ${count} projects`,
  265. showOnly ? '' : `(${addedCount} added, ${existingCount} existing)`
  266. )
  267. }
  268. }
  269. // Set oldestChange to undefined if there are no changes
  270. const oldestChange =
  271. changeTimes.length > 0
  272. ? changeTimes.reduce((min, time) => (time < min ? time : min))
  273. : undefined
  274. if (showOnly) {
  275. console.log(
  276. `Found ${count} projects with pending changes (not added to queue)`
  277. )
  278. } else {
  279. console.log(`Found ${count} projects with pending changes:`)
  280. console.log(` ${addedCount} jobs added to queue`)
  281. console.log(` ${existingCount} jobs already existed in queue`)
  282. if (oldestChange) {
  283. console.log(` Oldest pending change: ${formatPendingTime(oldestChange)}`)
  284. }
  285. }
  286. }
  287. // Main execution block
  288. async function run() {
  289. const optionCount = [
  290. options.clean,
  291. options.status,
  292. options.add,
  293. options.monitor,
  294. options['queue-pending'] !== undefined,
  295. options['show-pending'] !== undefined,
  296. ].filter(Boolean).length
  297. if (optionCount > 1) {
  298. console.error('Only one option can be specified')
  299. process.exit(1)
  300. }
  301. if (options.clean) {
  302. const beforeCounts = await backupQueue.getJobCounts()
  303. console.log('Current queue state:', JSON.stringify(beforeCounts))
  304. console.log('Cleaning completed and failed jobs...')
  305. await backupQueue.clean(1, 'completed')
  306. await backupQueue.clean(1, 'failed')
  307. const afterCounts = await backupQueue.getJobCounts()
  308. console.log('Current queue state:', JSON.stringify(afterCounts))
  309. console.log('Queue cleaned successfully')
  310. } else if (options.status) {
  311. const counts = await backupQueue.getJobCounts()
  312. console.log('Current queue state:', JSON.stringify(counts))
  313. } else if (options.add) {
  314. const inputs = Array.isArray(options.add) ? options.add : [options.add]
  315. for (const input of inputs) {
  316. if (input.includes(':')) {
  317. // Handle date range format
  318. await addDateRangeJob(input)
  319. } else {
  320. // Handle project ID format
  321. const { job, added } = await addJobWithCheck(
  322. backupQueue,
  323. { projectId: input },
  324. { jobId: input }
  325. )
  326. console.log(
  327. `${added ? 'Added' : 'Already exists'}: job for project: ${input}, job ID: ${job.id}`
  328. )
  329. }
  330. }
  331. } else if (options.monitor) {
  332. setupMonitoring()
  333. } else if (options['queue-pending'] !== undefined) {
  334. const age = validatePendingTime('queue-pending', options['queue-pending'])
  335. await processPendingProjects(
  336. age,
  337. false,
  338. options.limit,
  339. options.verbose,
  340. options.interval,
  341. {
  342. attempts: options.attempts,
  343. backoff: {
  344. type: 'exponential',
  345. delay: options['backoff-delay'],
  346. },
  347. }
  348. )
  349. } else if (options['show-pending'] !== undefined) {
  350. const age = validatePendingTime('show-pending', options['show-pending'])
  351. await processPendingProjects(age, true, options.limit, options.verbose)
  352. } else {
  353. console.log('Usage:')
  354. console.log(' --clean Clean up completed and failed jobs')
  355. console.log(' --status Show current job counts')
  356. console.log(' --add [projectId] Add a job for the specified projectId')
  357. console.log(
  358. ' --add [YYYY-MM-DD:YYYY-MM-DD] Add a job for the specified date range'
  359. )
  360. console.log(' --monitor Monitor queue events')
  361. console.log(
  362. ' --queue-pending TIME Find projects with changes older than TIME seconds and add them to the queue'
  363. )
  364. console.log(
  365. ' --show-pending TIME Show count of pending projects older than TIME seconds'
  366. )
  367. console.log(' --limit N Limit the number of jobs to be added')
  368. console.log(
  369. ' --interval TIME Time interval in seconds to spread jobs over'
  370. )
  371. console.log(
  372. ' --backoff-delay TIME Backoff delay in milliseconds for failed jobs (default: 1000)'
  373. )
  374. console.log(
  375. ' --attempts N Number of retry attempts for failed jobs (default: 3)'
  376. )
  377. console.log(
  378. ' --verbose, -v Show detailed information when used with --show-pending'
  379. )
  380. }
  381. }
  382. // Run and handle errors
  383. run()
  384. .catch(err => {
  385. console.error('Error:', err)
  386. process.exit(1)
  387. })
  388. .then(result => {
  389. // Only exit if not in monitor mode
  390. if (!options.monitor) {
  391. process.exit(0)
  392. }
  393. })