backupBlob.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. // @ts-check
  2. import { backupPersistor, projectBlobsBucket } from './backupPersistor.mjs'
  3. import { GLOBAL_BLOBS, makeProjectKey, BlobStore } from './blob_store/index.js'
  4. import Stream from 'node:stream'
  5. import fs from 'node:fs'
  6. import Crypto from 'node:crypto'
  7. import assert from './assert.js'
  8. import { backedUpBlobs, projects } from './mongodb.js'
  9. import { Binary, ObjectId } from 'mongodb'
  10. import logger from '@overleaf/logger/logging-manager.js'
  11. import { AlreadyWrittenError } from '@overleaf/object-persistor/src/Errors.js'
  12. import metrics from '@overleaf/metrics'
  13. import zLib from 'node:zlib'
  14. import Path from 'node:path'
  15. const HIGHWATER_MARK = 1024 * 1024
  16. /**
  17. * @typedef {import("overleaf-editor-core").Blob} Blob
  18. */
  19. /**
  20. * @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
  21. */
  22. /**
  23. * Increment a metric to record the outcome of a backup operation.
  24. *
  25. * @param {"success"|"failure"|"skipped"} status
  26. * @param {"global"|"already_backed_up"|"none"} reason
  27. */
  28. function recordBackupConclusion(status, reason = 'none') {
  29. metrics.inc('blob_backed_up', 1, { status, reason })
  30. }
  31. /**
  32. * Downloads a blob to a specified directory
  33. *
  34. * @param {string} historyId - The history ID of the project the blob belongs to
  35. * @param {Blob} blob - The blob to download
  36. * @param {string} tmpDir - The directory path where the blob will be downloaded
  37. * @returns {Promise<string>} The full path where the blob was downloaded
  38. */
  39. export async function downloadBlobToDir(historyId, blob, tmpDir) {
  40. const blobStore = new BlobStore(historyId)
  41. const blobHash = blob.getHash()
  42. const src = await blobStore.getStream(blobHash)
  43. const filePath = Path.join(tmpDir, `${historyId}-${blobHash}`)
  44. try {
  45. const dst = fs.createWriteStream(filePath, {
  46. highWaterMark: HIGHWATER_MARK,
  47. flags: 'wx',
  48. })
  49. await Stream.promises.pipeline(src, dst)
  50. return filePath
  51. } catch (error) {
  52. try {
  53. await fs.promises.unlink(filePath)
  54. } catch {}
  55. throw error
  56. }
  57. }
  58. /**
  59. * Performs the actual upload of the blob to the backup storage.
  60. *
  61. * @param {string} historyId - The history ID of the project the blob belongs to
  62. * @param {Blob} blob - The blob being uploaded
  63. * @param {string} path - The path to the file to upload (should have been stored on disk already)
  64. * @return {Promise<void>}
  65. */
  66. export async function uploadBlobToBackup(historyId, blob, path, persistor) {
  67. const filePathCompressed = path + '.gz'
  68. let backupSource
  69. let contentEncoding
  70. let size
  71. const timer = setTimeout(function () {
  72. logger.warn(
  73. { historyId, blob, path, size },
  74. 'blob upload still active after 1 minute'
  75. )
  76. }, 60 * 1000)
  77. try {
  78. if (blob.getStringLength()) {
  79. backupSource = filePathCompressed
  80. contentEncoding = 'gzip'
  81. size = 0
  82. await Stream.promises.pipeline(
  83. fs.createReadStream(path, { highWaterMark: HIGHWATER_MARK }),
  84. zLib.createGzip(),
  85. async function* (source) {
  86. for await (const chunk of source) {
  87. size += chunk.byteLength
  88. yield chunk
  89. }
  90. },
  91. fs.createWriteStream(filePathCompressed, {
  92. highWaterMark: HIGHWATER_MARK,
  93. })
  94. )
  95. } else {
  96. backupSource = path
  97. size = blob.getByteLength()
  98. }
  99. const key = makeProjectKey(historyId, blob.getHash())
  100. await persistor.sendStream(
  101. projectBlobsBucket,
  102. key,
  103. fs.createReadStream(backupSource, { highWaterMark: HIGHWATER_MARK }),
  104. {
  105. contentEncoding,
  106. contentType: 'application/octet-stream',
  107. contentLength: size,
  108. ifNoneMatch: '*',
  109. }
  110. )
  111. } finally {
  112. clearTimeout(timer)
  113. if (backupSource === filePathCompressed) {
  114. try {
  115. await fs.promises.rm(filePathCompressed, { force: true })
  116. } catch {}
  117. }
  118. }
  119. }
  120. /**
  121. * Converts a legacy (postgres) historyId to a mongo projectId
  122. *
  123. * @param {string} historyId
  124. * @return {Promise<string>}
  125. * @private
  126. */
  127. async function _convertLegacyHistoryIdToProjectId(historyId) {
  128. const project = await projects.findOne(
  129. { 'overleaf.history.id': parseInt(historyId) },
  130. { projection: { _id: 1 } }
  131. )
  132. if (!project?._id) {
  133. throw new Error('Did not find project for history id')
  134. }
  135. return project?._id?.toString()
  136. }
  137. /**
  138. * Records that a blob was backed up for a project.
  139. *
  140. * @param {string} projectId - projectId for a project (mongo format)
  141. * @param {string} hash
  142. * @return {Promise<void>}
  143. */
  144. export async function storeBlobBackup(projectId, hash) {
  145. await backedUpBlobs.updateOne(
  146. { _id: new ObjectId(projectId) },
  147. { $addToSet: { blobs: new Binary(Buffer.from(hash, 'hex')) } },
  148. { upsert: true }
  149. )
  150. }
  151. /**
  152. * Determine whether a specific blob has been backed up in this project.
  153. *
  154. * @param {string} projectId
  155. * @param {string} hash
  156. * @return {Promise<*>}
  157. * @private
  158. */
  159. export async function blobIsBackedUp(projectId, hash) {
  160. const blobs = await backedUpBlobs.findOne(
  161. {
  162. _id: new ObjectId(projectId),
  163. blobs: new Binary(Buffer.from(hash, 'hex')),
  164. },
  165. { projection: { _id: 1 } }
  166. )
  167. return blobs?._id
  168. }
  169. /**
  170. * Back up a blob to the global storage and record that it was backed up.
  171. *
  172. * @param {string} historyId - history ID for a project (can be postgres format or mongo format)
  173. * @param {Blob} blob - The blob that is being backed up
  174. * @param {string} tmpPath - The path to a temporary file storing the contents of the blob.
  175. * @param {CachedPerProjectEncryptedS3Persistor} [persistor] - The persistor to use (optional)
  176. * @return {Promise<string|void>}
  177. */
  178. export async function backupBlob(historyId, blob, tmpPath, persistor) {
  179. const hash = blob.getHash()
  180. let projectId = historyId
  181. if (assert.POSTGRES_ID_REGEXP.test(historyId)) {
  182. projectId = await _convertLegacyHistoryIdToProjectId(historyId)
  183. }
  184. const globalBlob = GLOBAL_BLOBS.get(hash)
  185. if (globalBlob && !globalBlob.demoted) {
  186. recordBackupConclusion('skipped', 'global')
  187. logger.debug({ projectId, hash }, 'Blob is global - skipping backup')
  188. return 'global'
  189. }
  190. try {
  191. if (await blobIsBackedUp(projectId, hash)) {
  192. recordBackupConclusion('skipped', 'already_backed_up')
  193. logger.debug(
  194. { projectId, hash },
  195. 'Blob already backed up - skipping backup'
  196. )
  197. return 'already-recorded'
  198. }
  199. } catch (error) {
  200. logger.warn({ error }, 'Failed to check if blob is backed up')
  201. // We'll try anyway - we'll catch the error if it was backed up
  202. }
  203. // If we weren't passed a persistor for this project, create one.
  204. // This will fetch the key from AWS, so it's prefereable to use
  205. // the same persistor for all blobs in a project where possible.
  206. if (!persistor) {
  207. logger.debug(
  208. { historyId, hash },
  209. 'warning: persistor not passed to backupBlob'
  210. )
  211. }
  212. persistor ??= await backupPersistor.forProject(
  213. projectBlobsBucket,
  214. makeProjectKey(historyId, '')
  215. )
  216. try {
  217. logger.debug({ projectId, hash }, 'Starting blob backup')
  218. await uploadBlobToBackup(historyId, blob, tmpPath, persistor)
  219. await storeBlobBackup(projectId, hash)
  220. recordBackupConclusion('success')
  221. } catch (error) {
  222. if (error instanceof AlreadyWrittenError) {
  223. logger.debug({ error, projectId, hash }, 'Blob already backed up')
  224. // record that we backed it up already
  225. await storeBlobBackup(projectId, hash)
  226. recordBackupConclusion('failure', 'already_backed_up')
  227. // Blob already backed up so report success
  228. return 'already-written'
  229. }
  230. recordBackupConclusion('failure')
  231. logger.warn({ error, projectId, hash }, 'Failed to upload blob to backup')
  232. // Always throw an exception if the blob is not backed up
  233. throw error
  234. } finally {
  235. logger.debug({ projectId, hash }, 'Ended blob backup')
  236. }
  237. }