HistoryRangesSupportMigration.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. // @ts-check
  2. const { callbackify } = require('util')
  3. const { ObjectId } = require('mongodb')
  4. const OError = require('@overleaf/o-error')
  5. const logger = require('@overleaf/logger')
  6. const HistoryManager = require('../History/HistoryManager')
  7. const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
  8. const DocstoreManager = require('../Docstore/DocstoreManager')
  9. const ProjectOptionsHandler = require('../Project/ProjectOptionsHandler')
  10. const {
  11. db,
  12. READ_PREFERENCE_SECONDARY,
  13. } = require('../../infrastructure/mongodb')
  14. /**
  15. * Migrate projects based on a query.
  16. *
  17. * @param {object} opts
  18. * @param {string[]} [opts.projectIds]
  19. * @param {string[]} [opts.ownerIds]
  20. * @param {string} [opts.minId]
  21. * @param {string} [opts.maxId]
  22. * @param {number} [opts.maxCount]
  23. * @param {"forwards" | "backwards"} [opts.direction]
  24. * @param {boolean} [opts.force]
  25. * @param {boolean} [opts.stopOnError]
  26. * @param {boolean} [opts.quickOnly]
  27. */
  28. async function migrateProjects(opts = {}) {
  29. const {
  30. ownerIds,
  31. projectIds,
  32. minId,
  33. maxId,
  34. maxCount = Infinity,
  35. direction = 'forwards',
  36. force = false,
  37. stopOnError = false,
  38. quickOnly = false,
  39. } = opts
  40. const clauses = []
  41. // skip projects that don't have full project history
  42. clauses.push({ 'overleaf.history.id': { $exists: true } })
  43. if (projectIds != null) {
  44. clauses.push({ _id: { $in: projectIds.map(id => new ObjectId(id)) } })
  45. }
  46. if (ownerIds != null) {
  47. clauses.push({ owner_ref: { $in: ownerIds.map(id => new ObjectId(id)) } })
  48. }
  49. if (minId) {
  50. clauses.push({ _id: { $gte: new ObjectId(minId) } })
  51. }
  52. if (maxId) {
  53. clauses.push({ _id: { $lte: new ObjectId(maxId) } })
  54. }
  55. const filter = {}
  56. if (clauses.length > 0) {
  57. filter.$and = clauses
  58. }
  59. const projects = db.projects
  60. .find(filter, {
  61. readPreference: READ_PREFERENCE_SECONDARY,
  62. projection: { _id: 1, overleaf: 1 },
  63. })
  64. .sort({ _id: -1 })
  65. let projectsProcessed = 0
  66. for await (const project of projects) {
  67. if (projectsProcessed >= maxCount) {
  68. break
  69. }
  70. const projectId = project._id.toString()
  71. if (!force) {
  72. // Skip projects that are already migrated
  73. if (
  74. (direction === 'forwards' &&
  75. project.overleaf.history.rangesSupportEnabled) ||
  76. (direction === 'backwards' &&
  77. !project.overleaf.history.rangesSupportEnabled)
  78. ) {
  79. continue
  80. }
  81. }
  82. const startTimeMs = Date.now()
  83. let quickMigrationSuccess
  84. try {
  85. quickMigrationSuccess = await quickMigration(projectId, direction)
  86. if (!quickMigrationSuccess) {
  87. if (quickOnly) {
  88. logger.info(
  89. { projectId, direction },
  90. 'Quick migration failed, skipping project'
  91. )
  92. } else {
  93. await migrateProject(projectId, direction)
  94. }
  95. }
  96. } catch (err) {
  97. logger.error(
  98. { err, projectId, direction, projectsProcessed },
  99. 'Failed to migrate history ranges support'
  100. )
  101. projectsProcessed += 1
  102. if (stopOnError) {
  103. break
  104. } else {
  105. continue
  106. }
  107. }
  108. const elapsedMs = Date.now() - startTimeMs
  109. projectsProcessed += 1
  110. logger.info(
  111. {
  112. projectId,
  113. direction,
  114. projectsProcessed,
  115. elapsedMs,
  116. quick: quickMigrationSuccess,
  117. },
  118. 'Migrated history ranges support'
  119. )
  120. }
  121. }
  122. /**
  123. * Attempt a quick migration (without resync)
  124. *
  125. * @param {string} projectId
  126. * @param {"forwards" | "backwards"} direction
  127. * @return {Promise<boolean>} whether or not the quick migration was a success
  128. */
  129. async function quickMigration(projectId, direction = 'forwards') {
  130. const blockSuccess =
  131. await DocumentUpdaterHandler.promises.blockProject(projectId)
  132. if (!blockSuccess) {
  133. return false
  134. }
  135. let projectHasRanges
  136. try {
  137. projectHasRanges =
  138. await DocstoreManager.promises.projectHasRanges(projectId)
  139. } catch (err) {
  140. // Docstore request probably timed out. Assume the project has ranges
  141. logger.warn(
  142. { err, projectId },
  143. 'Failed to check if project has ranges; proceeding with a resync migration'
  144. )
  145. projectHasRanges = true
  146. }
  147. if (projectHasRanges) {
  148. await DocumentUpdaterHandler.promises.unblockProject(projectId)
  149. return false
  150. }
  151. try {
  152. await ProjectOptionsHandler.promises.setHistoryRangesSupport(
  153. projectId,
  154. direction === 'forwards'
  155. )
  156. } catch (err) {
  157. await DocumentUpdaterHandler.promises.unblockProject(projectId)
  158. await hardResyncProject(projectId)
  159. throw err
  160. }
  161. let wasBlocked
  162. try {
  163. wasBlocked = await DocumentUpdaterHandler.promises.unblockProject(projectId)
  164. } catch (err) {
  165. await hardResyncProject(projectId)
  166. throw err
  167. }
  168. if (!wasBlocked) {
  169. await hardResyncProject(projectId)
  170. throw new OError('Tried to unblock project but it was not blocked', {
  171. projectId,
  172. })
  173. }
  174. return true
  175. }
  176. /**
  177. * Migrate a single project
  178. *
  179. * @param {string} projectId
  180. * @param {"forwards" | "backwards"} direction
  181. */
  182. async function migrateProject(projectId, direction = 'forwards') {
  183. await HistoryManager.promises.flushProject(projectId)
  184. await HistoryManager.promises.resyncProject(projectId, {
  185. historyRangesMigration: direction,
  186. })
  187. }
  188. /**
  189. * Hard resync a project
  190. *
  191. * This is used when something goes wrong with the quick migration after we've
  192. * changed the history ranges support flag on a project.
  193. *
  194. * @param {string} projectId
  195. */
  196. async function hardResyncProject(projectId) {
  197. await HistoryManager.promises.flushProject(projectId)
  198. await HistoryManager.promises.resyncProject(projectId, { force: true })
  199. }
  200. module.exports = {
  201. migrateProjects: callbackify(migrateProjects),
  202. migrateProject: callbackify(migrateProject),
  203. promises: { migrateProjects, migrateProject },
  204. }