backup_worker.mjs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import Queue from 'bull'
  2. import logger from '@overleaf/logger'
  3. import config from 'config'
  4. import metrics from '@overleaf/metrics'
  5. import {
  6. backupProject,
  7. initializeProjects,
  8. configureBackup,
  9. closeConnections,
  10. } from './backup.mjs'
  11. const JOB_CONCURRENCY = parseInt(process.env.JOB_CONCURRENCY, 10) || 15
  12. const UPLOAD_CONCURRENCY = parseInt(process.env.UPLOAD_CONCURRENCY, 10) || 50
  13. const WARN_THRESHOLD = 2 * 60 * 60 * 1000 // warn if projects are older than this
  14. const redisOptions = config.get('redis.queue')
  15. const JOB_TIME_BUCKETS = [10, 100, 500, 1000, 5000, 10000, 30000, 60000] // milliseconds
  16. const LAG_TIME_BUCKETS_HRS = [
  17. 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.75, 2, 3, 4, 5, 6,
  18. ] // hours
  19. // Configure backup settings to match worker concurrency
  20. configureBackup({ concurrency: UPLOAD_CONCURRENCY, useSecondary: true })
  21. let gracefulShutdownInitiated = false
  22. process.on('SIGINT', handleSignal)
  23. process.on('SIGTERM', handleSignal)
  24. async function handleSignal() {
  25. if (!gracefulShutdownInitiated) {
  26. gracefulShutdownInitiated = true
  27. logger.info({}, 'graceful shutdown: stopping backup worker')
  28. await drainQueue()
  29. }
  30. }
  31. // Create a Bull queue named 'backup'
  32. const backupQueue = new Queue('backup', {
  33. redis: redisOptions,
  34. settings: {
  35. lockDuration: 15 * 60 * 1000, // 15 minutes
  36. lockRenewTime: 60 * 1000, // 1 minute
  37. maxStalledCount: 0, // mark stalled jobs as failed
  38. },
  39. })
  40. // Log queue events
  41. backupQueue.on('active', job => {
  42. logger.debug({ job }, 'job is now active')
  43. })
  44. backupQueue.on('completed', (job, result) => {
  45. metrics.inc('backup_worker_job', 1, { status: 'completed' })
  46. logger.debug({ job, result }, 'job completed')
  47. })
  48. backupQueue.on('failed', (job, err) => {
  49. metrics.inc('backup_worker_job', 1, { status: 'failed' })
  50. logger.error({ job, err }, 'job failed')
  51. })
  52. backupQueue.on('waiting', jobId => {
  53. logger.debug({ jobId }, 'job is waiting')
  54. })
  55. backupQueue.on('error', error => {
  56. logger.error({ error }, 'queue error')
  57. })
  58. backupQueue.on('stalled', job => {
  59. logger.error({ job }, 'job has stalled')
  60. })
  61. backupQueue.on('lock-extension-failed', (job, err) => {
  62. logger.error({ job, err }, 'lock extension failed')
  63. })
  64. backupQueue.on('paused', () => {
  65. logger.info({}, 'queue paused')
  66. })
  67. backupQueue.on('resumed', () => {
  68. logger.info({}, 'queue resumed')
  69. })
  70. // Process jobs
  71. backupQueue.process(JOB_CONCURRENCY, async job => {
  72. const { projectId, startDate, endDate } = job.data
  73. if (projectId) {
  74. return await runBackup(projectId, job.data, job)
  75. } else if (startDate && endDate) {
  76. return await runInit(startDate, endDate)
  77. } else {
  78. throw new Error('invalid job data')
  79. }
  80. })
  81. async function runBackup(projectId, data, job) {
  82. const { pendingChangeAt } = data
  83. // record the time it takes to run the backup job
  84. const timer = new metrics.Timer(
  85. 'backup_worker_job_duration',
  86. 1,
  87. {},
  88. JOB_TIME_BUCKETS
  89. )
  90. const pendingAge = Date.now() - pendingChangeAt
  91. if (pendingAge > WARN_THRESHOLD) {
  92. logger.warn(
  93. { projectId, pendingAge, job },
  94. 'project has been pending for a long time'
  95. )
  96. }
  97. try {
  98. logger.debug({ projectId }, 'processing backup for project')
  99. await backupProject(projectId, {})
  100. metrics.inc('backup_worker_project', 1, {
  101. status: 'success',
  102. })
  103. timer.done()
  104. // record the replication lag (time from change to backup)
  105. if (pendingChangeAt) {
  106. metrics.histogram(
  107. 'backup_worker_replication_lag_in_hours',
  108. (Date.now() - pendingChangeAt) / (3600 * 1000),
  109. LAG_TIME_BUCKETS_HRS
  110. )
  111. }
  112. return `backup completed ${projectId}`
  113. } catch (err) {
  114. if (err.message === 'Project deleted') {
  115. metrics.inc('backup_worker_project', 1, { status: 'deleted' })
  116. logger.warn({ projectId, err }, 'skipping backup of deleted project')
  117. } else {
  118. metrics.inc('backup_worker_project', 1, { status: 'failed' })
  119. logger.error({ projectId, err }, 'backup failed')
  120. throw err // Re-throw to mark job as failed
  121. }
  122. }
  123. }
  124. async function runInit(startDate, endDate) {
  125. try {
  126. logger.info({ startDate, endDate }, 'initializing projects')
  127. await initializeProjects({ 'start-date': startDate, 'end-date': endDate })
  128. return `initialization completed ${startDate} - ${endDate}`
  129. } catch (err) {
  130. logger.error({ startDate, endDate, err }, 'initialization failed')
  131. throw err
  132. }
  133. }
  134. export async function drainQueue() {
  135. logger.info({ queue: backupQueue.name }, 'closing queue')
  136. await backupQueue.close()
  137. logger.info({ queue: backupQueue.name }, 'closing database connections')
  138. await closeConnections()
  139. }
  140. export async function healthCheck() {
  141. const count = await backupQueue.count()
  142. metrics.gauge('backup_worker_queue_length', count)
  143. }