backupBlob.mjs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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. try {
  72. if (blob.getStringLength()) {
  73. backupSource = filePathCompressed
  74. contentEncoding = 'gzip'
  75. size = 0
  76. await Stream.promises.pipeline(
  77. fs.createReadStream(path, { highWaterMark: HIGHWATER_MARK }),
  78. zLib.createGzip(),
  79. async function* (source) {
  80. for await (const chunk of source) {
  81. size += chunk.byteLength
  82. yield chunk
  83. }
  84. },
  85. fs.createWriteStream(filePathCompressed, {
  86. highWaterMark: HIGHWATER_MARK,
  87. })
  88. )
  89. } else {
  90. backupSource = path
  91. size = blob.getByteLength()
  92. }
  93. const key = makeProjectKey(historyId, blob.getHash())
  94. await persistor.sendStream(
  95. projectBlobsBucket,
  96. key,
  97. fs.createReadStream(backupSource, { highWaterMark: HIGHWATER_MARK }),
  98. {
  99. contentEncoding,
  100. contentType: 'application/octet-stream',
  101. contentLength: size,
  102. ifNoneMatch: '*',
  103. }
  104. )
  105. } finally {
  106. if (backupSource === filePathCompressed) {
  107. try {
  108. await fs.promises.rm(filePathCompressed, { force: true })
  109. } catch {}
  110. }
  111. }
  112. }
  113. /**
  114. * Converts a legacy (postgres) historyId to a mongo projectId
  115. *
  116. * @param {string} historyId
  117. * @return {Promise<string>}
  118. * @private
  119. */
  120. async function _convertLegacyHistoryIdToProjectId(historyId) {
  121. const project = await projects.findOne(
  122. { 'overleaf.history.id': parseInt(historyId) },
  123. { projection: { _id: 1 } }
  124. )
  125. if (!project?._id) {
  126. throw new Error('Did not find project for history id')
  127. }
  128. return project?._id?.toString()
  129. }
  130. /**
  131. * Records that a blob was backed up for a project.
  132. *
  133. * @param {string} projectId - projectId for a project (mongo format)
  134. * @param {string} hash
  135. * @return {Promise<void>}
  136. */
  137. export async function storeBlobBackup(projectId, hash) {
  138. await backedUpBlobs.updateOne(
  139. { _id: new ObjectId(projectId) },
  140. { $addToSet: { blobs: new Binary(Buffer.from(hash, 'hex')) } },
  141. { upsert: true }
  142. )
  143. }
  144. /**
  145. * Determine whether a specific blob has been backed up in this project.
  146. *
  147. * @param {string} projectId
  148. * @param {string} hash
  149. * @return {Promise<*>}
  150. * @private
  151. */
  152. export async function _blobIsBackedUp(projectId, hash) {
  153. const blobs = await backedUpBlobs.findOne(
  154. {
  155. _id: new ObjectId(projectId),
  156. blobs: new Binary(Buffer.from(hash, 'hex')),
  157. },
  158. { projection: { _id: 1 } }
  159. )
  160. return blobs?._id
  161. }
  162. /**
  163. * Back up a blob to the global storage and record that it was backed up.
  164. *
  165. * @param {string} historyId - history ID for a project (can be postgres format or mongo format)
  166. * @param {Blob} blob - The blob that is being backed up
  167. * @param {string} tmpPath - The path to a temporary file storing the contents of the blob.
  168. * @param {CachedPerProjectEncryptedS3Persistor} [persistor] - The persistor to use (optional)
  169. * @return {Promise<void>}
  170. */
  171. export async function backupBlob(historyId, blob, tmpPath, persistor) {
  172. const hash = blob.getHash()
  173. let projectId = historyId
  174. if (assert.POSTGRES_ID_REGEXP.test(historyId)) {
  175. projectId = await _convertLegacyHistoryIdToProjectId(historyId)
  176. }
  177. const globalBlob = GLOBAL_BLOBS.get(hash)
  178. if (globalBlob && !globalBlob.demoted) {
  179. recordBackupConclusion('skipped', 'global')
  180. logger.debug({ projectId, hash }, 'Blob is global - skipping backup')
  181. return
  182. }
  183. try {
  184. if (await _blobIsBackedUp(projectId, hash)) {
  185. recordBackupConclusion('skipped', 'already_backed_up')
  186. logger.debug(
  187. { projectId, hash },
  188. 'Blob already backed up - skipping backup'
  189. )
  190. return
  191. }
  192. } catch (error) {
  193. logger.warn({ error }, 'Failed to check if blob is backed up')
  194. // We'll try anyway - we'll catch the error if it was backed up
  195. }
  196. // If we weren't passed a persistor for this project, create one.
  197. // This will fetch the key from AWS, so it's prefereable to use
  198. // the same persistor for all blobs in a project where possible.
  199. if (!persistor) {
  200. logger.debug(
  201. { historyId, hash },
  202. 'warning: persistor not passed to backupBlob'
  203. )
  204. }
  205. persistor ??= await backupPersistor.forProject(
  206. projectBlobsBucket,
  207. makeProjectKey(historyId, '')
  208. )
  209. try {
  210. logger.debug({ projectId, hash }, 'Starting blob backup')
  211. await uploadBlobToBackup(historyId, blob, tmpPath, persistor)
  212. await storeBlobBackup(projectId, hash)
  213. recordBackupConclusion('success')
  214. } catch (error) {
  215. if (error instanceof AlreadyWrittenError) {
  216. logger.debug({ error, projectId, hash }, 'Blob already backed up')
  217. // record that we backed it up already
  218. await storeBlobBackup(projectId, hash)
  219. recordBackupConclusion('failure', 'already_backed_up')
  220. return
  221. }
  222. // eventually queue this for retry - for now this will be fixed by running the script
  223. recordBackupConclusion('failure')
  224. logger.warn({ error, projectId, hash }, 'Failed to upload blob to backup')
  225. } finally {
  226. logger.debug({ projectId, hash }, 'Ended blob backup')
  227. }
  228. }