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