migrate_history.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. // raise mongo timeout to 1hr if otherwise unspecified
  2. process.env.MONGO_SOCKET_TIMEOUT =
  3. parseInt(process.env.MONGO_SOCKET_TIMEOUT, 10) || 3600000
  4. const VERSION = '0.9.0-cli'
  5. const {
  6. countProjects,
  7. countDocHistory,
  8. upgradeProject,
  9. findProjects,
  10. } = require('../../modules/history-migration/app/src/HistoryUpgradeHelper')
  11. const { waitForDb } = require('../../app/src/infrastructure/mongodb')
  12. const minimist = require('minimist')
  13. const fs = require('fs')
  14. const util = require('util')
  15. const pLimit = require('p-limit')
  16. const logger = require('@overleaf/logger')
  17. logger.initialize('history-migration')
  18. // disable logging to stdout from internal modules
  19. logger.logger.streams = []
  20. const DEFAULT_OUTPUT_FILE = `history-migration-${new Date()
  21. .toISOString()
  22. .replace(/[:.]/g, '_')}.log`
  23. const argv = minimist(process.argv.slice(2), {
  24. boolean: [
  25. 'verbose',
  26. 'fix-invalid-characters',
  27. 'convert-large-docs-to-file',
  28. 'import-broken-history-as-zip',
  29. 'force-upgrade-on-failure',
  30. 'dry-run',
  31. 'use-query-hint',
  32. 'retry-failed',
  33. 'archive-on-failure',
  34. ],
  35. string: ['output', 'user-id'],
  36. alias: {
  37. verbose: 'v',
  38. output: 'o',
  39. 'dry-run': 'd',
  40. concurrency: 'j',
  41. 'use-query-hint': 'q',
  42. 'retry-failed': 'r',
  43. 'archive-on-failure': 'a',
  44. },
  45. default: {
  46. output: DEFAULT_OUTPUT_FILE,
  47. concurrency: 1,
  48. 'batch-size': 100,
  49. 'max-upgrades-to-attempt': false,
  50. 'max-failures': 50,
  51. },
  52. })
  53. let INTERRUPT = false
  54. async function findProjectsToMigrate() {
  55. console.log('History Migration Statistics')
  56. // Show statistics about the number of projects to migrate
  57. const migratedProjects = await countProjects({
  58. 'overleaf.history.display': true,
  59. })
  60. const totalProjects = await countProjects()
  61. console.log('Migrated Projects : ', migratedProjects)
  62. console.log('Total Projects : ', totalProjects)
  63. console.log('Remaining Projects : ', totalProjects - migratedProjects)
  64. if (migratedProjects === totalProjects) {
  65. console.log('All projects have been migrated')
  66. process.exit(0)
  67. }
  68. // Get a list of projects to migrate
  69. const projectsToMigrate = await findProjects(
  70. { 'overleaf.history.display': { $ne: true } },
  71. { _id: 1, overleaf: 1 }
  72. )
  73. // Show statistics for docHistory collection
  74. const docHistoryWithoutProjectId = await countDocHistory({
  75. project_id: { $exists: false },
  76. })
  77. if (docHistoryWithoutProjectId > 0) {
  78. console.log(
  79. `WARNING: docHistory collection contains ${docHistoryWithoutProjectId} records without project_id`
  80. )
  81. process.exit(1)
  82. }
  83. // Find the total number of history records for the projects we need to migrate
  84. let docHistoryCount = 0
  85. for await (const project of projectsToMigrate) {
  86. const count = await countDocHistory({ project_id: project._id })
  87. docHistoryCount += count
  88. }
  89. console.log('Total history records to migrate:', docHistoryCount)
  90. return projectsToMigrate
  91. }
  92. function createProgressBar() {
  93. const startTime = new Date()
  94. return function progressBar(current, total, msg) {
  95. const barLength = 20
  96. const percentage = Math.floor((current / total) * 100)
  97. const bar = '='.repeat(percentage / (100 / barLength))
  98. const empty = ' '.repeat(barLength - bar.length)
  99. const elapsed = new Date() - startTime
  100. // convert elapsed time to hours, minutes, seconds
  101. const ss = Math.floor((elapsed / 1000) % 60)
  102. .toString()
  103. .padStart(2, '0')
  104. const mm = Math.floor((elapsed / (1000 * 60)) % 60)
  105. .toString()
  106. .padStart(2, '0')
  107. const hh = Math.floor(elapsed / (1000 * 60 * 60))
  108. .toString()
  109. .padStart(2, '0')
  110. process.stdout.write(
  111. `\r${hh}:${mm}:${ss} |${bar}${empty}| ${percentage}% (${current}/${total}) ${msg}`
  112. )
  113. }
  114. }
  115. async function migrateProjects(projectsToMigrate) {
  116. let projectsMigrated = 0
  117. let projectsFailed = 0
  118. console.log('Starting migration...')
  119. if (argv.concurrency > 1) {
  120. console.log(`Using ${argv.concurrency} concurrent migrations`)
  121. }
  122. // send log output for each migration to a file
  123. const output = fs.createWriteStream(argv.output, { flags: 'a' })
  124. console.log(`Writing log output to ${argv.output}`)
  125. const logger = new console.Console({ stdout: output })
  126. function logJson(obj) {
  127. logger.log(JSON.stringify(obj))
  128. }
  129. // limit the number of concurrent migrations
  130. const limit = pLimit(argv.concurrency)
  131. const jobs = []
  132. // throttle progress reporting to 2x per second
  133. const progressBar = createProgressBar()
  134. let i = 0
  135. const N = projectsToMigrate.length
  136. const progressBarTimer = setInterval(() => {
  137. if (INTERRUPT) {
  138. return // don't update the progress bar if we're shutting down
  139. }
  140. progressBar(
  141. i,
  142. N,
  143. `Migrated: ${projectsMigrated}, Failed: ${projectsFailed}`
  144. )
  145. }, 500)
  146. const options = {
  147. migrationOptions: {
  148. archiveOnFailure: argv['import-broken-history-as-zip'],
  149. fixInvalidCharacters: argv['fix-invalid-characters'],
  150. forceNewHistoryOnFailure: argv['force-upgrade-on-failure'],
  151. },
  152. convertLargeDocsToFile: argv['convert-large-docs-to-file'],
  153. userId: argv['user-id'],
  154. reason: VERSION,
  155. }
  156. async function _migrateProject(project) {
  157. if (INTERRUPT) {
  158. return // don't start any new jobs if we're shutting down
  159. }
  160. const startTime = new Date()
  161. try {
  162. const result = await upgradeProject(project._id, options)
  163. i++
  164. if (INTERRUPT && limit.activeCount > 1) {
  165. // an interrupt was requested while this job was running
  166. // report that we're waiting for the remaining jobs to finish
  167. console.log(
  168. `Waiting for remaining ${
  169. limit.activeCount - 1
  170. } active jobs to finish\r`
  171. )
  172. }
  173. if (result.error) {
  174. // failed to migrate this project
  175. logJson({
  176. project_id: project._id,
  177. result,
  178. stack: result.error.stack,
  179. startTime,
  180. endTime: new Date(),
  181. })
  182. projectsFailed++
  183. } else {
  184. // successfully migrated this project
  185. logJson({
  186. project_id: project._id,
  187. result,
  188. startTime,
  189. endTime: new Date(),
  190. })
  191. projectsMigrated++
  192. }
  193. } catch (err) {
  194. // unexpected error from the migration
  195. projectsFailed++
  196. logJson({
  197. project_id: project._id,
  198. exception: util.inspect(err),
  199. startTime,
  200. endTime: new Date(),
  201. })
  202. }
  203. }
  204. for (const project of projectsToMigrate) {
  205. jobs.push(limit(_migrateProject, project))
  206. }
  207. // wait for all the queued jobs to complete
  208. await Promise.all(jobs)
  209. clearInterval(progressBarTimer)
  210. progressBar(i, N, `Migrated: ${projectsMigrated}, Failed: ${projectsFailed}`)
  211. process.stdout.write('\n')
  212. return { projectsMigrated, projectsFailed }
  213. }
  214. async function main() {
  215. const projectsToMigrate = await findProjectsToMigrate()
  216. if (argv['dry-run']) {
  217. console.log('Dry run, exiting')
  218. process.exit(0)
  219. }
  220. const { projectsMigrated, projectsFailed } = await migrateProjects(
  221. projectsToMigrate
  222. )
  223. console.log('Projects migrated: ', projectsMigrated)
  224. console.log('Projects failed: ', projectsFailed)
  225. if (projectsFailed > 0) {
  226. console.log(`Log output written to ${argv.output}`)
  227. console.log('Please check the log for errors.')
  228. }
  229. if (INTERRUPT) {
  230. console.log('Migration interrupted, please run again to continue.')
  231. } else if (projectsFailed === 0) {
  232. console.log(`All projects migrated successfully.`)
  233. }
  234. console.log('Done.')
  235. process.exit(projectsFailed > 0 ? 1 : 0)
  236. }
  237. // Upgrading history is not atomic, if we quit out mid-initialisation
  238. // then history could get into a broken state
  239. // Instead, skip any unprocessed projects and exit() at end of the batch.
  240. process.on('SIGINT', function () {
  241. console.log(
  242. '\nCaught SIGINT, waiting for all in-progess upgrades to complete'
  243. )
  244. INTERRUPT = true
  245. })
  246. waitForDb()
  247. .then(main)
  248. .catch(err => {
  249. console.error(err)
  250. process.exit(1)
  251. })