recover_doc_versions.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. const fsPromises = require('fs/promises')
  2. const { ObjectId } = require('mongodb')
  3. const BPromise = require('bluebird')
  4. const logger = require('@overleaf/logger')
  5. const mongodb = require('../lib/mongodb')
  6. const { chunkStore } = require('..')
  7. const Events = require('events')
  8. // Silence warning.
  9. Events.setMaxListeners(20)
  10. const BATCH_SIZE = 1000
  11. const OPTIONS = {
  12. concurrency: parseInt(process.env.DOC_VERSION_RECOVERY_CONCURRENCY, 10) || 20,
  13. force: process.env.DOC_VERSION_RECOVERY_FORCE === 'true',
  14. 'skip-history-failures':
  15. process.env.DOC_VERSION_RECOVERY_SKIP_HISTORY_FAILURES === 'true',
  16. 'resyncs-needed-file': process.env.DOC_VERSION_RECOVERY_RESYNCS_NEEDED_FILE,
  17. }
  18. const db = {
  19. deletedProjects: mongodb.db.collection('deletedProjects'),
  20. docs: mongodb.db.collection('docs'),
  21. migrations: mongodb.db.collection('migrations'),
  22. projects: mongodb.db.collection('projects'),
  23. }
  24. const BAD_MIGRATION_NAME =
  25. '20231219081700_move_doc_versions_from_docops_to_docs'
  26. let loggingChain = Promise.resolve()
  27. const projectIdsThatNeedResyncing = []
  28. async function flushLogQueue() {
  29. const logPath = OPTIONS['resyncs-needed-file']
  30. loggingChain = loggingChain.then(async () => {
  31. const batch = projectIdsThatNeedResyncing.splice(0)
  32. if (batch.length === 0) return
  33. try {
  34. await fsPromises.appendFile(logPath, batch.join('\n') + '\n')
  35. } catch (err) {
  36. projectIdsThatNeedResyncing.push(...batch)
  37. logger.err({ err, logPath, batch }, 'Failed to write to log file')
  38. }
  39. })
  40. await loggingChain
  41. }
  42. async function recordProjectNeedsResync(projectId) {
  43. if (OPTIONS['resyncs-needed-file']) {
  44. projectIdsThatNeedResyncing.push(projectId)
  45. await flushLogQueue()
  46. } else {
  47. console.log(`Project ${projectId} needs a hard resync.`)
  48. }
  49. }
  50. async function main() {
  51. const badMigration = await db.migrations.findOne({ name: BAD_MIGRATION_NAME })
  52. if (OPTIONS.force || badMigration != null) {
  53. console.warn('Need to recover doc versions. This will take a while.')
  54. await runRecovery()
  55. }
  56. await db.migrations.deleteOne({ name: BAD_MIGRATION_NAME })
  57. console.log('Done.')
  58. }
  59. async function runRecovery() {
  60. let batch = []
  61. const summary = {
  62. updated: 0,
  63. ignored: 0,
  64. skipped: 0,
  65. deletedUpdated: 0,
  66. deletedIgnored: 0,
  67. }
  68. const processBatchAndLogProgress = async () => {
  69. try {
  70. await BPromise.map(batch, project => processProject(project, summary), {
  71. concurrency: OPTIONS.concurrency,
  72. })
  73. } finally {
  74. console.log(`${summary.updated} projects updated`)
  75. console.log(`${summary.ignored} projects had good versions`)
  76. console.log(`${summary.deletedUpdated} deleted projects updated`)
  77. console.log(
  78. `${summary.deletedIgnored} deleted projects had good versions`
  79. )
  80. console.log(`${summary.skipped} projects skipped`)
  81. }
  82. batch = []
  83. }
  84. await printDBStats()
  85. await touchResyncsNeededFile()
  86. for await (const project of getProjects()) {
  87. batch.push(project)
  88. if (batch.length >= BATCH_SIZE) {
  89. await processBatchAndLogProgress()
  90. }
  91. }
  92. for await (const deletedProject of getDeletedProjects()) {
  93. const project = deletedProject.project
  94. project.isDeleted = true
  95. batch.push(project)
  96. if (batch.length >= BATCH_SIZE) {
  97. await processBatchAndLogProgress()
  98. }
  99. }
  100. if (batch.length > 0) {
  101. await processBatchAndLogProgress()
  102. }
  103. await backfillMissingVersions()
  104. }
  105. async function printDBStats() {
  106. const projects = await db.projects.estimatedDocumentCount()
  107. const docs = await db.docs.estimatedDocumentCount()
  108. console.log(
  109. `Need to check ${projects} projects with a total of ${docs} docs.`
  110. )
  111. }
  112. async function touchResyncsNeededFile() {
  113. if (OPTIONS['resyncs-needed-file']) {
  114. await fsPromises.appendFile(OPTIONS['resyncs-needed-file'], '')
  115. }
  116. }
  117. function getProjects() {
  118. return db.projects.find({}, { projection: { _id: 1, overleaf: 1 } })
  119. }
  120. function getDeletedProjects() {
  121. return db.deletedProjects.find(
  122. { project: { $ne: null } },
  123. { projection: { 'project._id': 1, 'project.overleaf': 1 } }
  124. )
  125. }
  126. async function processProject(project, summary) {
  127. const projectId = project._id.toString()
  128. let updated = false
  129. try {
  130. const historyDocVersions = await getHistoryDocVersions(project)
  131. for (const { docId, version } of historyDocVersions) {
  132. const update = await fixMongoDocVersion(docId, version)
  133. if (update != null) {
  134. updated = true
  135. }
  136. }
  137. if (project.isDeleted) {
  138. if (updated) {
  139. summary.deletedUpdated += 1
  140. } else {
  141. summary.deletedIgnored += 1
  142. }
  143. } else {
  144. await recordProjectNeedsResync(projectId)
  145. if (updated) {
  146. summary.updated += 1
  147. } else {
  148. summary.ignored += 1
  149. }
  150. }
  151. } catch (err) {
  152. logger.error({ err, projectId }, 'Failed to process project')
  153. if (OPTIONS['skip-history-failures']) {
  154. summary.skipped += 1
  155. } else {
  156. throw err
  157. }
  158. }
  159. }
  160. async function getHistoryDocVersions(project) {
  161. const historyId = project.overleaf.history.id
  162. const chunk = await chunkStore.loadLatest(historyId)
  163. if (chunk == null) {
  164. return []
  165. }
  166. const snapshot = chunk.getSnapshot()
  167. const changes = chunk.getChanges()
  168. snapshot.applyAll(changes)
  169. const v2DocVersions = snapshot.getV2DocVersions()
  170. if (v2DocVersions == null) {
  171. return []
  172. }
  173. return Object.entries(v2DocVersions.data).map(([docId, versionInfo]) => ({
  174. docId,
  175. version: versionInfo.v,
  176. }))
  177. }
  178. async function fixMongoDocVersion(docId, historyVersion) {
  179. const docBeforeUpdate = await db.docs.findOneAndUpdate(
  180. {
  181. _id: new ObjectId(docId),
  182. $or: [
  183. { version: { $lte: historyVersion } },
  184. { version: { $exists: false } },
  185. ],
  186. },
  187. { $set: { version: historyVersion + 1 } }
  188. )
  189. if (docBeforeUpdate != null) {
  190. return {
  191. previousVersion: docBeforeUpdate.version,
  192. newVersion: historyVersion + 1,
  193. }
  194. } else {
  195. return null
  196. }
  197. }
  198. /**
  199. * Set all remaining versions to 0
  200. */
  201. async function backfillMissingVersions() {
  202. console.log('Defaulting version to 0 for remaining docs.')
  203. await db.docs.updateMany(
  204. { version: { $exists: false } },
  205. { $set: { version: 0 } }
  206. )
  207. }
  208. main()
  209. .finally(async () => {
  210. console.log('Flushing log queue.')
  211. await flushLogQueue()
  212. })
  213. .then(() => {
  214. process.exit(0)
  215. })
  216. .catch(err => {
  217. console.error(err)
  218. process.exit(1)
  219. })