DocArchiveManager.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. const { callbackify } = require('util')
  2. const MongoManager = require('./MongoManager').promises
  3. const Errors = require('./Errors')
  4. const logger = require('logger-sharelatex')
  5. const settings = require('@overleaf/settings')
  6. const crypto = require('crypto')
  7. const Streamifier = require('streamifier')
  8. const RangeManager = require('./RangeManager')
  9. const PersistorManager = require('./PersistorManager')
  10. const pMap = require('p-map')
  11. const PARALLEL_JOBS = settings.parallelArchiveJobs
  12. const ARCHIVE_BATCH_SIZE = settings.archiveBatchSize
  13. const UN_ARCHIVE_BATCH_SIZE = settings.unArchiveBatchSize
  14. const DESTROY_BATCH_SIZE = settings.destroyBatchSize
  15. const DESTROY_RETRY_COUNT = settings.destroyRetryCount
  16. module.exports = {
  17. archiveAllDocs: callbackify(archiveAllDocs),
  18. archiveDocById: callbackify(archiveDocById),
  19. archiveDoc: callbackify(archiveDoc),
  20. unArchiveAllDocs: callbackify(unArchiveAllDocs),
  21. unarchiveDoc: callbackify(unarchiveDoc),
  22. destroyAllDocs: callbackify(destroyAllDocs),
  23. destroyDoc: callbackify(destroyDoc),
  24. promises: {
  25. archiveAllDocs,
  26. archiveDocById,
  27. archiveDoc,
  28. unArchiveAllDocs,
  29. unarchiveDoc,
  30. destroyAllDocs,
  31. destroyDoc
  32. }
  33. }
  34. async function archiveAllDocs(projectId) {
  35. while (true) {
  36. const docs = await MongoManager.getNonArchivedProjectDocs(
  37. projectId,
  38. ARCHIVE_BATCH_SIZE
  39. )
  40. if (!docs || docs.length === 0) {
  41. break
  42. }
  43. await pMap(docs, (doc) => archiveDoc(projectId, doc), {
  44. concurrency: PARALLEL_JOBS
  45. })
  46. }
  47. }
  48. async function archiveDocById(projectId, docId) {
  49. const doc = await MongoManager.findDoc(projectId, docId, {
  50. lines: true,
  51. ranges: true,
  52. rev: true,
  53. inS3: true
  54. })
  55. if (!doc) {
  56. throw new Errors.NotFoundError(
  57. `Cannot find doc ${docId} in project ${projectId}`
  58. )
  59. }
  60. // TODO(das7pad): consider refactoring MongoManager.findDoc to take a query
  61. if (doc.inS3) return
  62. return archiveDoc(projectId, doc)
  63. }
  64. async function archiveDoc(projectId, doc) {
  65. logger.log(
  66. { project_id: projectId, doc_id: doc._id },
  67. 'sending doc to persistor'
  68. )
  69. const key = `${projectId}/${doc._id}`
  70. if (doc.lines == null) {
  71. throw new Error('doc has no lines')
  72. }
  73. const json = JSON.stringify({
  74. lines: doc.lines,
  75. ranges: doc.ranges,
  76. schema_v: 1
  77. })
  78. // this should never happen, but protects against memory-corruption errors that
  79. // have happened in the past
  80. if (json.indexOf('\u0000') > -1) {
  81. const error = new Error('null bytes detected')
  82. logger.err({ err: error, doc }, error.message)
  83. throw error
  84. }
  85. const md5 = crypto.createHash('md5').update(json).digest('hex')
  86. const stream = Streamifier.createReadStream(json)
  87. await PersistorManager.sendStream(settings.docstore.bucket, key, stream, {
  88. sourceMd5: md5
  89. })
  90. await MongoManager.markDocAsArchived(doc._id, doc.rev)
  91. }
  92. async function unArchiveAllDocs(projectId) {
  93. while (true) {
  94. let docs
  95. if (settings.docstore.keepSoftDeletedDocsArchived) {
  96. docs = await MongoManager.getNonDeletedArchivedProjectDocs(
  97. projectId,
  98. UN_ARCHIVE_BATCH_SIZE
  99. )
  100. } else {
  101. docs = await MongoManager.getArchivedProjectDocs(
  102. projectId,
  103. UN_ARCHIVE_BATCH_SIZE
  104. )
  105. }
  106. if (!docs || docs.length === 0) {
  107. break
  108. }
  109. await pMap(docs, (doc) => unarchiveDoc(projectId, doc._id), {
  110. concurrency: PARALLEL_JOBS
  111. })
  112. }
  113. }
  114. async function unarchiveDoc(projectId, docId) {
  115. logger.log(
  116. { project_id: projectId, doc_id: docId },
  117. 'getting doc from persistor'
  118. )
  119. const originalDoc = await MongoManager.findDoc(projectId, docId, { inS3: 1 })
  120. if (!originalDoc.inS3) {
  121. // return if it's not actually in S3 as there's nothing to do
  122. return
  123. }
  124. const key = `${projectId}/${docId}`
  125. let stream, sourceMd5
  126. try {
  127. sourceMd5 = await PersistorManager.getObjectMd5Hash(
  128. settings.docstore.bucket,
  129. key
  130. )
  131. stream = await PersistorManager.getObjectStream(
  132. settings.docstore.bucket,
  133. key
  134. )
  135. } catch (err) {
  136. // if we get a 404, we could be in a race and something else has unarchived the doc already
  137. if (err instanceof Errors.NotFoundError) {
  138. const doc = await MongoManager.findDoc(projectId, docId, { inS3: 1 })
  139. if (!doc.inS3) {
  140. // the doc has been archived while we were looking for it, so no error
  141. return
  142. }
  143. }
  144. throw err
  145. }
  146. stream.resume()
  147. const json = await _streamToString(stream)
  148. const md5 = crypto.createHash('md5').update(json).digest('hex')
  149. if (sourceMd5 !== md5) {
  150. throw new Errors.Md5MismatchError('md5 mismatch when downloading doc', {
  151. key,
  152. sourceMd5,
  153. md5
  154. })
  155. }
  156. const doc = JSON.parse(json)
  157. const mongoDoc = {}
  158. if (doc.schema_v === 1 && doc.lines != null) {
  159. mongoDoc.lines = doc.lines
  160. if (doc.ranges != null) {
  161. mongoDoc.ranges = RangeManager.jsonRangesToMongo(doc.ranges)
  162. }
  163. } else if (Array.isArray(doc)) {
  164. mongoDoc.lines = doc
  165. } else {
  166. throw new Error("I don't understand the doc format in s3")
  167. }
  168. await MongoManager.upsertIntoDocCollection(projectId, docId, mongoDoc)
  169. await PersistorManager.deleteObject(settings.docstore.bucket, key)
  170. }
  171. async function destroyAllDocs(projectId) {
  172. while (true) {
  173. const docs = await MongoManager.getProjectsDocs(
  174. projectId,
  175. { include_deleted: true, limit: DESTROY_BATCH_SIZE },
  176. { _id: 1 }
  177. )
  178. if (!docs || docs.length === 0) {
  179. break
  180. }
  181. await pMap(docs, (doc) => destroyDoc(projectId, doc._id), {
  182. concurrency: PARALLEL_JOBS
  183. })
  184. }
  185. }
  186. async function destroyDoc(projectId, docId) {
  187. logger.log(
  188. { project_id: projectId, doc_id: docId },
  189. 'removing doc from mongo and persistor'
  190. )
  191. const doc = await MongoManager.findDoc(projectId, docId, {
  192. inS3: 1
  193. })
  194. if (!doc) {
  195. throw new Errors.NotFoundError('Doc not found in Mongo')
  196. }
  197. if (doc.inS3) {
  198. await destroyArchiveWithRetry(projectId, docId)
  199. }
  200. await MongoManager.destroyDoc(docId)
  201. }
  202. async function destroyArchiveWithRetry(projectId, docId) {
  203. let attempt = 0
  204. let lastError
  205. while (attempt++ <= DESTROY_RETRY_COUNT) {
  206. try {
  207. await PersistorManager.deleteObject(
  208. settings.docstore.bucket,
  209. `${projectId}/${docId}`
  210. )
  211. return
  212. } catch (err) {
  213. lastError = err
  214. logger.warn(
  215. { projectId, docId, err, attempt },
  216. 'destroying archive failed'
  217. )
  218. }
  219. }
  220. throw lastError
  221. }
  222. async function _streamToString(stream) {
  223. const chunks = []
  224. return new Promise((resolve, reject) => {
  225. stream.on('data', (chunk) => chunks.push(chunk))
  226. stream.on('error', reject)
  227. stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
  228. })
  229. }