DocArchiveManager.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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 Bson = require('bson')
  12. const BSON = new Bson()
  13. const PARALLEL_JOBS = Settings.parallelArchiveJobs
  14. const UN_ARCHIVE_BATCH_SIZE = Settings.unArchiveBatchSize
  15. module.exports = {
  16. archiveAllDocs: callbackify(archiveAllDocs),
  17. archiveDoc: callbackify(archiveDoc),
  18. unArchiveAllDocs: callbackify(unArchiveAllDocs),
  19. unarchiveDoc: callbackify(unarchiveDoc),
  20. destroyProject: callbackify(destroyProject),
  21. getDoc: callbackify(getDoc),
  22. promises: {
  23. archiveAllDocs,
  24. archiveDoc,
  25. unArchiveAllDocs,
  26. unarchiveDoc,
  27. destroyProject,
  28. getDoc,
  29. },
  30. }
  31. async function archiveAllDocs(projectId) {
  32. const docIds = await MongoManager.getNonArchivedProjectDocIds(projectId)
  33. await pMap(docIds, docId => archiveDoc(projectId, docId), {
  34. concurrency: PARALLEL_JOBS,
  35. })
  36. }
  37. async function archiveDoc(projectId, docId) {
  38. const doc = await MongoManager.getDocForArchiving(projectId, docId)
  39. if (!doc) {
  40. // The doc wasn't found, it was already archived, or the lock couldn't be
  41. // acquired. Since we don't know which it is, silently return.
  42. return
  43. }
  44. logger.debug({ projectId, docId: doc._id }, 'sending doc to persistor')
  45. const key = `${projectId}/${doc._id}`
  46. if (doc.lines == null) {
  47. throw new Error('doc has no lines')
  48. }
  49. // warn about any oversized docs already in mongo
  50. const linesSize = BSON.calculateObjectSize(doc.lines || {})
  51. const rangesSize = BSON.calculateObjectSize(doc.ranges || {})
  52. if (
  53. linesSize > Settings.max_doc_length ||
  54. rangesSize > Settings.max_doc_length
  55. ) {
  56. logger.warn(
  57. { projectId, docId: doc._id, linesSize, rangesSize },
  58. 'large doc found when archiving project'
  59. )
  60. }
  61. const json = JSON.stringify({
  62. lines: doc.lines,
  63. ranges: doc.ranges,
  64. rev: doc.rev,
  65. schema_v: 1,
  66. })
  67. // this should never happen, but protects against memory-corruption errors that
  68. // have happened in the past
  69. if (json.indexOf('\u0000') > -1) {
  70. const error = new Error('null bytes detected')
  71. logger.err({ err: error, doc }, error.message)
  72. throw error
  73. }
  74. const md5 = crypto.createHash('md5').update(json).digest('hex')
  75. const stream = Streamifier.createReadStream(json)
  76. await PersistorManager.sendStream(Settings.docstore.bucket, key, stream, {
  77. sourceMd5: md5,
  78. })
  79. await MongoManager.markDocAsArchived(projectId, docId, doc.rev)
  80. }
  81. async function unArchiveAllDocs(projectId) {
  82. while (true) {
  83. let docs
  84. if (Settings.docstore.keepSoftDeletedDocsArchived) {
  85. docs = await MongoManager.getNonDeletedArchivedProjectDocs(
  86. projectId,
  87. UN_ARCHIVE_BATCH_SIZE
  88. )
  89. } else {
  90. docs = await MongoManager.getArchivedProjectDocs(
  91. projectId,
  92. UN_ARCHIVE_BATCH_SIZE
  93. )
  94. }
  95. if (!docs || docs.length === 0) {
  96. break
  97. }
  98. await pMap(docs, doc => unarchiveDoc(projectId, doc._id), {
  99. concurrency: PARALLEL_JOBS,
  100. })
  101. }
  102. }
  103. // get the doc from the PersistorManager without storing it in mongo
  104. async function getDoc(projectId, docId) {
  105. const key = `${projectId}/${docId}`
  106. const sourceMd5 = await PersistorManager.getObjectMd5Hash(
  107. Settings.docstore.bucket,
  108. key
  109. )
  110. const stream = await PersistorManager.getObjectStream(
  111. Settings.docstore.bucket,
  112. key
  113. )
  114. stream.resume()
  115. const buffer = await _streamToBuffer(projectId, docId, stream)
  116. const md5 = crypto.createHash('md5').update(buffer).digest('hex')
  117. if (sourceMd5 !== md5) {
  118. throw new Errors.Md5MismatchError('md5 mismatch when downloading doc', {
  119. key,
  120. sourceMd5,
  121. md5,
  122. })
  123. }
  124. return _deserializeArchivedDoc(buffer)
  125. }
  126. // get the doc and unarchive it to mongo
  127. async function unarchiveDoc(projectId, docId) {
  128. logger.debug({ projectId, docId }, 'getting doc from persistor')
  129. const mongoDoc = await MongoManager.findDoc(projectId, docId, {
  130. inS3: 1,
  131. rev: 1,
  132. })
  133. if (!mongoDoc.inS3) {
  134. // The doc is already unarchived
  135. return
  136. }
  137. const archivedDoc = await getDoc(projectId, docId)
  138. if (archivedDoc.rev == null) {
  139. // Older archived docs didn't have a rev. Assume that the rev of the
  140. // archived doc is the rev that was stored in Mongo when we retrieved it
  141. // earlier.
  142. archivedDoc.rev = mongoDoc.rev
  143. }
  144. await MongoManager.restoreArchivedDoc(projectId, docId, archivedDoc)
  145. }
  146. async function destroyProject(projectId) {
  147. const tasks = [MongoManager.destroyProject(projectId)]
  148. if (_isArchivingEnabled()) {
  149. tasks.push(
  150. PersistorManager.deleteDirectory(Settings.docstore.bucket, projectId)
  151. )
  152. }
  153. await Promise.all(tasks)
  154. }
  155. async function _streamToBuffer(projectId, docId, stream) {
  156. const chunks = []
  157. let size = 0
  158. let logged = false
  159. const logIfTooLarge = finishedReading => {
  160. if (size <= Settings.max_doc_length) return
  161. // Log progress once and then again at the end.
  162. if (logged && !finishedReading) return
  163. logger.warn(
  164. { projectId, docId, size, finishedReading },
  165. 'potentially large doc pulled down from gcs'
  166. )
  167. logged = true
  168. }
  169. return new Promise((resolve, reject) => {
  170. stream.on('data', chunk => {
  171. size += chunk.byteLength
  172. logIfTooLarge(false)
  173. chunks.push(chunk)
  174. })
  175. stream.on('error', reject)
  176. stream.on('end', () => {
  177. logIfTooLarge(true)
  178. resolve(Buffer.concat(chunks))
  179. })
  180. })
  181. }
  182. function _deserializeArchivedDoc(buffer) {
  183. const doc = JSON.parse(buffer)
  184. const result = {}
  185. if (doc.schema_v === 1 && doc.lines != null) {
  186. result.lines = doc.lines
  187. if (doc.ranges != null) {
  188. result.ranges = RangeManager.jsonRangesToMongo(doc.ranges)
  189. }
  190. } else if (Array.isArray(doc)) {
  191. result.lines = doc
  192. } else {
  193. throw new Error("I don't understand the doc format in s3")
  194. }
  195. if (doc.rev != null) {
  196. result.rev = doc.rev
  197. }
  198. return result
  199. }
  200. function _isArchivingEnabled() {
  201. const backend = Settings.docstore.backend
  202. if (!backend) {
  203. return false
  204. }
  205. // The default backend is S3. If another backend is configured or the S3
  206. // backend itself is correctly configured, then archiving is enabled.
  207. if (backend === 's3' && Settings.docstore.s3 == null) {
  208. return false
  209. }
  210. return true
  211. }