resync_projects.mjs 8.9 KB

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