DocArchiveManager.js 6.9 KB

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