deactivate_projects.mjs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. #!/usr/bin/env node
  2. import minimist from 'minimist'
  3. import PQueue from 'p-queue'
  4. import InactiveProjectManager from '../app/src/Features/InactiveData/InactiveProjectManager.mjs'
  5. import { gracefulShutdown } from '../app/src/infrastructure/GracefulShutdown.mjs'
  6. import logger from '@overleaf/logger'
  7. import { setTimeout } from 'node:timers/promises'
  8. // Global variables for tracking job and error counts
  9. let jobCount = 0
  10. let succeededCount = 0
  11. let skippedCount = 0
  12. let failedCount = 0
  13. let currentAgeInDays = null
  14. let currentLastOpened = null
  15. let DRY_RUN = false
  16. let gracefulShutdownInitiated = false
  17. const SCRIPT_START_TIME = Date.now()
  18. const MAX_RUNTIME_DEFAULT = null
  19. let MAX_RUNTIME = MAX_RUNTIME_DEFAULT // in milliseconds
  20. const MAX_PROJECT_ESTIMATE = 30_000
  21. // Configure signal handling
  22. process.on('SIGINT', handleSignal)
  23. process.on('SIGTERM', handleSignal)
  24. function handleSignal() {
  25. if (gracefulShutdownInitiated) return
  26. gracefulShutdownInitiated = true
  27. logger.warn(
  28. { gracefulShutdownInitiated },
  29. 'graceful shutdown initiated, draining queue'
  30. )
  31. }
  32. // Check if max runtime has been exceeded
  33. function hasMaxRuntimeExceeded() {
  34. if (MAX_RUNTIME === null) return false
  35. const elapsedTime = Date.now() - SCRIPT_START_TIME
  36. const hasExceeded = elapsedTime >= MAX_RUNTIME
  37. if (hasExceeded && !gracefulShutdownInitiated) {
  38. // Exit with code 1 eventually. The cron heartbeat script will alert us.
  39. process.exitCode = 1
  40. gracefulShutdownInitiated = true
  41. logger.warn(
  42. { elapsedTimeMs: elapsedTime, maxRuntimeMs: MAX_RUNTIME },
  43. 'maximum runtime exceeded, initiating graceful shutdown'
  44. )
  45. }
  46. return hasExceeded
  47. }
  48. // Calculates the age in days since the provided lastOpened date.
  49. function getAgeFromLastOpened(lastOpened) {
  50. const lastOpenedDate = new Date(lastOpened)
  51. const now = new Date()
  52. return Number(((now - lastOpenedDate) / (1000 * 60 * 60 * 24)).toFixed(2))
  53. }
  54. // Deactivates a single project and handles errors
  55. async function deactivateSingleProject(project) {
  56. const { _id: projectId, lastOpened } = project
  57. jobCount++
  58. if (lastOpened) {
  59. currentLastOpened = lastOpened
  60. currentAgeInDays = getAgeFromLastOpened(lastOpened)
  61. }
  62. // Periodic progress logging
  63. if (jobCount % 1000 === 0) {
  64. logger.info(
  65. { jobCount, failedCount, currentAgeInDays },
  66. 'project deactivation in progress'
  67. )
  68. }
  69. // Debug level detail logging
  70. logger.debug(
  71. { projectId, jobCount, failedCount, dryRun: DRY_RUN },
  72. 'attempting to deactivate project'
  73. )
  74. // Dry run handling
  75. if (DRY_RUN) {
  76. logger.info({ projectId }, '[DRY RUN] would deactivate project')
  77. succeededCount++
  78. }
  79. // Actual deactivation with error handling
  80. try {
  81. await InactiveProjectManager.promises.deactivateProject(projectId)
  82. logger.debug({ projectId }, 'successfully deactivated project')
  83. succeededCount++
  84. } catch (error) {
  85. failedCount++
  86. logger.error({ projectId, err: error }, 'failed to deactivate project')
  87. }
  88. }
  89. // Centralized project processing function
  90. async function processProjects(projectCursor, concurrency) {
  91. const queue = new PQueue({ concurrency })
  92. const projects = []
  93. for await (const project of projectCursor) {
  94. if (gracefulShutdownInitiated || hasMaxRuntimeExceeded()) {
  95. skippedCount++
  96. break
  97. }
  98. projects.push(project)
  99. }
  100. const start = Date.now()
  101. const isSteadyStateProcessing = projects.length < 10_000
  102. for (const [idx, project] of projects.entries()) {
  103. if (MAX_RUNTIME > 0) {
  104. // If the job has to run in a finite time (e.g. when running as the cron job)
  105. // then spread the work evenly over the runtime duration. Otherwise, process
  106. // all the outstanding projects without any delay, subject to the concurrency.
  107. const remainingTime = MAX_RUNTIME - (Date.now() - start)
  108. if (isSteadyStateProcessing && remainingTime > MAX_PROJECT_ESTIMATE) {
  109. const remainingProjects = projects.length - idx
  110. // Handle small number of projects better (don't wait for all of remainingTime to pass).
  111. await setTimeout(remainingTime / (remainingProjects + 1))
  112. }
  113. }
  114. await queue.onEmpty()
  115. if (gracefulShutdownInitiated || hasMaxRuntimeExceeded()) {
  116. skippedCount++
  117. break
  118. }
  119. logger.debug(
  120. { queueSize: queue.size, queuePending: queue.pending },
  121. 'queue size before adding new job'
  122. )
  123. queue.add(async () => {
  124. await deactivateSingleProject(project)
  125. })
  126. }
  127. await queue.onIdle()
  128. }
  129. const usage = `
  130. Usage: scripts/deactivate_projects.mjs [options]
  131. Options:
  132. --limit <number> Max number of projects to process (default: 10)
  133. --daysOld <number> Min age in days for a project to be considered inactive (default: 7)
  134. --concurrency <number> Number of deactivations to run in parallel (default: 1)
  135. --max-time <number> Maximum runtime in seconds before graceful shutdown (default: no limit)
  136. --dry-run, -n Simulate deactivation without making changes (default: false)
  137. --help Display this usage message
  138. `
  139. async function main() {
  140. const argv = minimist(process.argv.slice(2), {
  141. string: ['limit', 'daysOld', 'concurrency', 'maxTime'],
  142. boolean: ['dryRun', 'help'],
  143. alias: {
  144. dryRun: ['dry-run', 'n'],
  145. maxTime: 'max-time',
  146. help: 'h',
  147. },
  148. default: {
  149. limit: '10',
  150. daysOld: '7',
  151. concurrency: '1',
  152. maxTime: '',
  153. dryRun: false,
  154. },
  155. })
  156. if (argv.help || process.argv.length <= 2) {
  157. console.log(usage)
  158. process.exit(0)
  159. }
  160. const limit = parseInt(argv.limit, 10)
  161. const daysOld = parseInt(argv.daysOld, 10)
  162. const concurrency = parseInt(argv.concurrency, 10)
  163. const maxRuntimeInSeconds = parseInt(argv.maxTime, 10)
  164. DRY_RUN = argv.dryRun
  165. MAX_RUNTIME = maxRuntimeInSeconds * 1000 // Convert seconds to milliseconds
  166. if (DRY_RUN) {
  167. logger.info(
  168. {},
  169. 'DRY RUN MODE ENABLED: No actual deactivations will be performed'
  170. )
  171. }
  172. logger.info(
  173. {
  174. limit,
  175. daysOld,
  176. concurrency,
  177. dryRun: DRY_RUN,
  178. maxRuntimeSeconds: maxRuntimeInSeconds || 'unlimited',
  179. },
  180. 'finding inactive projects'
  181. )
  182. try {
  183. // Find projects to deactivate
  184. const projectCursor = await InactiveProjectManager.findInactiveProjects(
  185. limit,
  186. daysOld
  187. )
  188. // Process the projects
  189. await processProjects(projectCursor, concurrency)
  190. } catch (error) {
  191. logger.error({ err: error }, 'critical error during script execution')
  192. process.exitCode = 1
  193. } finally {
  194. logger.info(
  195. {
  196. jobCount,
  197. succeededCount,
  198. failedCount,
  199. skippedCount,
  200. currentAgeInDays,
  201. currentLastOpened,
  202. elapsedTimeInSeconds: Math.floor(
  203. (Date.now() - SCRIPT_START_TIME) / 1000
  204. ),
  205. maxRuntimeInSeconds: maxRuntimeInSeconds || 'unlimited',
  206. },
  207. 'project deactivation process completed'
  208. )
  209. }
  210. }
  211. main()
  212. .then(async () => {
  213. await gracefulShutdown()
  214. })
  215. .catch(err => {
  216. logger.fatal({ err }, 'unhandled error in main execution')
  217. process.exit(1)
  218. })