migrate_history.js 8.3 KB

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