recover_docs_from_redis.mjs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. // recover docs from redis where there is no doc in mongo but the project exists
  2. import minimist from 'minimist'
  3. import { db, ObjectId } from '../app/src/infrastructure/mongodb.js'
  4. import ProjectEntityUpdateHandler from '../app/src/Features/Project/ProjectEntityUpdateHandler.js'
  5. import ProjectEntityRestoreHandler from '../app/src/Features/Project/ProjectEntityRestoreHandler.js'
  6. import RedisWrapper from '@overleaf/redis-wrapper'
  7. import Settings from '@overleaf/settings'
  8. import logger from '@overleaf/logger'
  9. const opts = parseArgs()
  10. const redis = RedisWrapper.createClient(Settings.redis.web)
  11. function parseArgs() {
  12. const args = minimist(process.argv.slice(2), {
  13. boolean: ['commit'],
  14. })
  15. const commit = args.commit
  16. const deleteOversized = args['delete-oversized']
  17. return {
  18. commit,
  19. deleteOversized,
  20. maxDocSize: 2 * 1024 * 1024,
  21. }
  22. }
  23. function extractObjectId(s) {
  24. const m = s.match(/:\{?([0-9a-f]{24})\}?/)
  25. return m[1]
  26. }
  27. async function main() {
  28. logger.info({ opts }, 'removing deleted docs')
  29. let cursor = 0
  30. do {
  31. const [newCursor, doclinesKeys] = await redis.scan(
  32. cursor,
  33. 'MATCH',
  34. 'doclines:{*}',
  35. 'COUNT',
  36. 1000
  37. )
  38. const docIds = doclinesKeys.map(extractObjectId)
  39. for (const docId of docIds) {
  40. await processDoc(docId)
  41. }
  42. cursor = newCursor
  43. } while (cursor !== '0')
  44. if (!opts.commit) {
  45. console.log('This was a dry run. Re-run with --commit to apply changes')
  46. }
  47. }
  48. async function processDoc(docId) {
  49. // check if the doc is in mongo.. if so ignore it
  50. const docCount = await db.docs.find({ _id: new ObjectId(docId) }).count()
  51. if (docCount > 0) {
  52. logger.debug({ docId }, 'doc is present in mongo - no recovery needed')
  53. return
  54. }
  55. // get the doc from redis and check if it has a project id
  56. const doc = await getDoc(docId)
  57. const projectId = doc.projectId
  58. if (!projectId) {
  59. logger.warn(
  60. { docId, doc },
  61. 'projectId not available in redis, cannot restore - skipping'
  62. )
  63. // we could delete the document in redis here since we have no way to recover it
  64. return
  65. }
  66. // check that the project is in mongo, if not delete the doc
  67. const project = await db.projects.findOne({ _id: new ObjectId(projectId) })
  68. if (!project) {
  69. logger.warn(
  70. { docId },
  71. 'project not present in mongo - could remove doc from redis'
  72. )
  73. return
  74. }
  75. // if the doc is too big we will need to convert it to a file, skip it for now
  76. // or delete the doc if the --delete-oversized option is used
  77. const size = doc.lines.reduce((sum, line) => sum + line.length + 1, 0)
  78. if (size > opts.maxDocSize) {
  79. logger.warn(
  80. { docId, projectId, size },
  81. 'doc that exceeds max size, cannot restore'
  82. )
  83. if (opts.deleteOversized) {
  84. logger.info(
  85. { docId, projectId, size, commit: opts.commit },
  86. 'forcing delete of large doc'
  87. )
  88. if (opts.commit) {
  89. try {
  90. await deleteDocFromRedis(projectId, docId)
  91. } catch (err) {
  92. logger.error(
  93. { docId, projectId, deleteErr: err },
  94. 'error deleting doc from redis'
  95. )
  96. }
  97. }
  98. }
  99. return
  100. }
  101. // now we have a doc content from redis, in a project where the doc has been deleted
  102. const restoredName = ProjectEntityRestoreHandler.generateRestoredName(
  103. doc.name || 'unknown',
  104. 'recovered'
  105. )
  106. logger.info(
  107. { docId, projectId, restoredName, commit: opts.commit },
  108. 'recovering doc from redis to mongo'
  109. )
  110. if (opts.commit) {
  111. const folderId = project.rootFolder[0]._id
  112. try {
  113. await ProjectEntityUpdateHandler.promises.addDocWithRanges(
  114. projectId,
  115. folderId,
  116. restoredName,
  117. doc.lines,
  118. doc.ranges,
  119. null,
  120. 'recovery-script'
  121. )
  122. await deleteDocFromRedis(projectId, docId)
  123. } catch (err) {
  124. logger.error(
  125. { docId, projectId, restoreErr: err },
  126. 'error restoring doc from redis to mongo'
  127. )
  128. }
  129. }
  130. }
  131. async function getDoc(docId) {
  132. const [projectId, lines, ranges, pathname] = await redis.mget(
  133. `ProjectId:{${docId}}`,
  134. `doclines:{${docId}}`,
  135. `Ranges:{${docId}}`,
  136. `Pathname:{${docId}}`
  137. )
  138. const name = pathname?.split('/').pop()
  139. return {
  140. projectId,
  141. id: docId,
  142. lines: JSON.parse(lines),
  143. ranges: ranges ? JSON.parse(ranges) : {},
  144. name: name || 'unnamed',
  145. }
  146. }
  147. async function deleteDocFromRedis(projectId, docId) {
  148. await redis.del(
  149. `Blocking:{${docId}}`,
  150. `doclines:{${docId}}`,
  151. `DocOps:{${docId}}`,
  152. `DocVersion:{${docId}}`,
  153. `DocHash:{${docId}}`,
  154. `ProjectId:{${docId}}`,
  155. `Ranges:{${docId}}`,
  156. `UnflushedTime:{${docId}}`,
  157. `Pathname:{${docId}}`,
  158. `ProjectHistoryId:{${docId}}`,
  159. `PendingUpdates:{${docId}}`,
  160. `lastUpdatedAt:{${docId}}`,
  161. `lastUpdatedBy:{${docId}}`
  162. )
  163. await redis.srem(`DocsIn:{${projectId}}`, projectId)
  164. }
  165. try {
  166. await main()
  167. process.exit(0)
  168. } catch (error) {
  169. console.error(error)
  170. process.exit(1)
  171. }