resync_projects.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. // @ts-check
  2. import minimist from 'minimist'
  3. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  4. import logger from '@overleaf/logger'
  5. import ProjectGetter from '../../app/src/Features/Project/ProjectGetter.mjs'
  6. import {
  7. db,
  8. ObjectId,
  9. READ_PREFERENCE_SECONDARY,
  10. } from '../../app/src/infrastructure/mongodb.mjs'
  11. import HistoryManager from '../../app/src/Features/History/HistoryManager.mjs'
  12. import DocstoreManager from '../../app/src/Features/Docstore/DocstoreManager.mjs'
  13. import DocumentUpdaterHandler from '../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.mjs'
  14. function usage() {
  15. console.error(`Usage: resync_projects.mjs [OPTIONS]
  16. Options:
  17. --help Print this help
  18. --project-id Migrate this project
  19. --min-id Migrate projects from this id
  20. --max-id Migrate projects to this id
  21. --last-updated-after Migrate projects last updated after this date
  22. --last-updated-before Migrate projects last updated before this date
  23. --commit Actually perform the resync, instead of just checking which projects would be resynced
  24. --skip-metadata-checks Skip doing Mongo/Redis-level only checks to determine if the projects needs resyncing (has ranges or linked file data)
  25. --concurrency How many jobs to run in parallel
  26. `)
  27. }
  28. /**
  29. *
  30. * @returns {{ projectIds?: string[]; minId?: string; maxId?: string; concurrency: number; commit: boolean; skipMetadataChecks: boolean; lastUpdatedAfter?: string; lastUpdatedBefore?: string; }}
  31. */
  32. function parseArgs() {
  33. const args = minimist(process.argv.slice(2), {
  34. boolean: ['help', 'commit', 'skip-metadata-checks'],
  35. string: [
  36. 'project-id',
  37. 'min-id',
  38. 'max-id',
  39. 'last-updated-after',
  40. 'last-updated-before',
  41. ],
  42. })
  43. if (args.help) {
  44. usage()
  45. process.exit(0)
  46. }
  47. const projectIds = arrayOpt(args['project-id'])
  48. const minId = args['min-id']
  49. const maxId = args['max-id']
  50. const lastUpdatedAfter = args['last-updated-after']
  51. const lastUpdatedBefore = args['last-updated-before']
  52. const concurrency = parseInt(args.concurrency, 10) || 1
  53. const commit = args.commit
  54. const skipMetadataChecks = args['skip-metadata-checks']
  55. if (
  56. projectIds == null &&
  57. minId == null &&
  58. maxId == null &&
  59. lastUpdatedAfter == null &&
  60. lastUpdatedBefore == null
  61. ) {
  62. console.error('Please specify at least one filter\n')
  63. usage()
  64. process.exit(1)
  65. }
  66. return {
  67. projectIds,
  68. minId,
  69. maxId,
  70. concurrency,
  71. commit,
  72. skipMetadataChecks,
  73. lastUpdatedAfter,
  74. lastUpdatedBefore,
  75. }
  76. }
  77. async function main() {
  78. const {
  79. projectIds,
  80. minId,
  81. maxId,
  82. concurrency,
  83. commit,
  84. skipMetadataChecks,
  85. lastUpdatedAfter,
  86. lastUpdatedBefore,
  87. } = parseArgs()
  88. // skip projects that don't have full project history
  89. /** @type {any[]} */
  90. const clauses = [{ 'overleaf.history.id': { $exists: true } }]
  91. if (projectIds != null) {
  92. clauses.push({ _id: { $in: projectIds.map(id => new ObjectId(id)) } })
  93. }
  94. if (minId) {
  95. clauses.push({ _id: { $gte: new ObjectId(minId) } })
  96. }
  97. if (maxId) {
  98. clauses.push({ _id: { $lte: new ObjectId(maxId) } })
  99. }
  100. if (lastUpdatedAfter) {
  101. clauses.push({ lastUpdated: { $gt: new Date(lastUpdatedAfter) } })
  102. }
  103. if (lastUpdatedBefore) {
  104. clauses.push({ lastUpdated: { $lt: new Date(lastUpdatedBefore) } })
  105. }
  106. const filter = { $and: clauses }
  107. const projects = db.projects
  108. .find(filter, {
  109. readPreference: READ_PREFERENCE_SECONDARY,
  110. projection: { _id: 1, overleaf: 1 },
  111. })
  112. .sort({ _id: -1 })
  113. /** @type {{ skipped: number; resync: number; total: number;}} */
  114. const projectsProcessed = {
  115. skipped: 0,
  116. resync: 0,
  117. total: 0,
  118. }
  119. /** @type {Map<string, Promise<void>>} */
  120. const jobsByProjectId = new Map()
  121. let errors = 0
  122. let terminating = false
  123. /**
  124. * @param {any} signal
  125. */
  126. const handleSignal = signal => {
  127. logger.info({ signal }, 'History resync job received signal')
  128. terminating = true
  129. }
  130. process.on('SIGINT', handleSignal)
  131. process.on('SIGTERM', handleSignal)
  132. for await (const project of projects) {
  133. if (terminating) {
  134. break
  135. }
  136. const projectId = project._id.toString()
  137. if (jobsByProjectId.size >= concurrency) {
  138. // Wait until the next job finishes
  139. await Promise.race(jobsByProjectId.values())
  140. }
  141. const job = processProject(projectId, { commit, skipMetadataChecks })
  142. .then(
  143. /** @param {'skipped' | 'resync'} migrationType */ migrationType => {
  144. jobsByProjectId.delete(projectId)
  145. projectsProcessed[migrationType] += 1
  146. projectsProcessed.total += 1
  147. logger.debug(
  148. {
  149. projectId,
  150. projectsProcessed,
  151. errors,
  152. migrationType,
  153. },
  154. 'History resync'
  155. )
  156. if (projectsProcessed.total % 10000 === 0) {
  157. logger.info(
  158. { projectsProcessed, errors, lastProjectId: projectId },
  159. 'History resync progress'
  160. )
  161. }
  162. }
  163. )
  164. .catch(
  165. /** @param {any} err */ err => {
  166. jobsByProjectId.delete(projectId)
  167. errors += 1
  168. logger.error(
  169. { err, projectId, projectsProcessed, errors },
  170. 'Failed to resync project history'
  171. )
  172. }
  173. )
  174. jobsByProjectId.set(projectId, job)
  175. }
  176. // Clear the remaining backlog of jobs
  177. await Promise.all(jobsByProjectId.values())
  178. logger.info({ projectsProcessed, errors }, 'History resync completion')
  179. }
  180. /**
  181. *
  182. * @param {string} projectId
  183. * @param {{skipMetadataChecks: boolean, commit: boolean}} opts
  184. * @returns
  185. */
  186. async function processProject(projectId, opts) {
  187. const shouldProceed = opts.skipMetadataChecks
  188. ? true
  189. : await hasHistoryMetadata(projectId)
  190. if (!shouldProceed) {
  191. logger.debug(
  192. { projectId },
  193. 'Skipping project as it has no history relevant data in Mongo'
  194. )
  195. return 'skipped'
  196. }
  197. if (opts.commit) {
  198. logger.debug({ projectId }, 'Resyncing project')
  199. await HistoryManager.promises.flushProject(projectId)
  200. await HistoryManager.promises.resyncProject(projectId)
  201. } else {
  202. logger.debug({ projectId }, 'Project would be resynced')
  203. }
  204. return 'resync'
  205. }
  206. /**
  207. *
  208. * @param {string} projectId
  209. * @returns
  210. */
  211. async function hasHistoryMetadata(projectId) {
  212. try {
  213. const blockSuccess =
  214. await DocumentUpdaterHandler.promises.blockProject(projectId)
  215. if (!blockSuccess) {
  216. logger.debug(
  217. { projectId },
  218. 'Project is currently active, so we cannot skip'
  219. )
  220. return true
  221. }
  222. } catch (err) {
  223. logger.warn(
  224. { projectId, err },
  225. 'Error thrown while acquiring block for project'
  226. )
  227. return true
  228. }
  229. try {
  230. if (await hasLinkedFileData(projectId)) {
  231. return true
  232. }
  233. if (await DocstoreManager.promises.projectHasRanges(projectId)) {
  234. return true
  235. }
  236. return false
  237. } catch (err) {
  238. logger.warn(
  239. { projectId, err },
  240. 'Error checking for history data in Mongo, proceeding with resync just in case'
  241. )
  242. } finally {
  243. try {
  244. await DocumentUpdaterHandler.promises.unblockProject(projectId)
  245. } catch (err) {
  246. logger.warn(
  247. { projectId, err },
  248. 'Error unblocking project after checking for history data in Mongo'
  249. )
  250. }
  251. }
  252. return true
  253. }
  254. /**
  255. *
  256. * @param {string} projectId
  257. * @returns {Promise<boolean>}
  258. */
  259. async function hasLinkedFileData(projectId) {
  260. const project = await ProjectGetter.promises.getProjectWithoutLock(
  261. projectId,
  262. {
  263. rootFolder: 1,
  264. }
  265. )
  266. if (!project) {
  267. return false
  268. }
  269. return hasLinkedDataInFileTree(project.rootFolder?.[0])
  270. }
  271. /**
  272. *
  273. * @param {any} folder
  274. * @returns {boolean}
  275. */
  276. function hasLinkedDataInFileTree(folder) {
  277. if (!folder) {
  278. return false
  279. }
  280. if (Array.isArray(folder.fileRefs)) {
  281. for (const fileRef of folder.fileRefs) {
  282. if (fileRef.linkedFileData) {
  283. return true
  284. }
  285. }
  286. }
  287. if (Array.isArray(folder.folders)) {
  288. for (const subfolder of folder.folders) {
  289. if (hasLinkedDataInFileTree(subfolder)) {
  290. return true
  291. }
  292. }
  293. }
  294. return false
  295. }
  296. /**
  297. * @param {any} value
  298. * @returns {Array<any> | undefined}
  299. */
  300. function arrayOpt(value) {
  301. if (typeof value === 'string') {
  302. return [value]
  303. } else if (Array.isArray(value)) {
  304. return value
  305. } else {
  306. return undefined
  307. }
  308. }
  309. try {
  310. await scriptRunner(main)
  311. process.exit(0)
  312. } catch (error) {
  313. console.error(error)
  314. process.exit(1)
  315. }