backup_blob.mjs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. // @ts-check
  2. import commandLineArgs from 'command-line-args'
  3. import {
  4. backupBlob,
  5. downloadBlobToDir,
  6. blobIsBackedUp,
  7. } from '../lib/backupBlob.mjs'
  8. import { backupPersistor, projectBlobsBucket } from '../lib/backupPersistor.mjs'
  9. import withTmpDir from '../../api/controllers/with_tmp_dir.js'
  10. import {
  11. BlobStore,
  12. GLOBAL_BLOBS,
  13. loadGlobalBlobs,
  14. makeProjectKey,
  15. } from '../lib/blob_store/index.js'
  16. import {
  17. getBackupStatus,
  18. unsetBackedUpBlobHashes,
  19. } from '../lib/backup_store/index.js'
  20. import chunkStore from '../lib/chunk_store/index.js'
  21. import assert from '../lib/assert.js'
  22. import knex from '../lib/knex.js'
  23. import { client } from '../lib/mongodb.js'
  24. import redis from '../lib/redis.js'
  25. import { setTimeout } from 'node:timers/promises'
  26. import fs from 'node:fs'
  27. import pLimit from 'p-limit'
  28. import Events from 'node:events'
  29. // Silence warning.
  30. Events.setMaxListeners(20)
  31. await loadGlobalBlobs()
  32. /**
  33. * Gracefully shutdown the process
  34. * @return {Promise<void>}
  35. */
  36. async function gracefulShutdown() {
  37. console.log('Gracefully shutting down')
  38. await knex.destroy()
  39. await client.close()
  40. await redis.disconnect()
  41. await setTimeout(100)
  42. process.exit()
  43. }
  44. /**
  45. *
  46. * @param {string} row
  47. * @return {BackupBlobJob}
  48. */
  49. function parseCSVRow(row) {
  50. const [historyId, hash] = row.split(',')
  51. validateBackedUpBlobJob({ historyId, hash })
  52. return { historyId, hash }
  53. }
  54. /**
  55. *
  56. * @param {BackupBlobJob} job
  57. */
  58. function validateBackedUpBlobJob(job) {
  59. assert.projectId(job.historyId)
  60. assert.blobHash(job.hash)
  61. }
  62. /**
  63. *
  64. * @param {string} path
  65. * @return {Promise<Array<BackupBlobJob>>}
  66. */
  67. async function readCSV(path) {
  68. let fh
  69. /** @type {Array<BackupBlobJob>} */
  70. const rows = []
  71. try {
  72. fh = await fs.promises.open(path, 'r')
  73. } catch (error) {
  74. console.error(`Could not open file: ${error}`)
  75. throw error
  76. }
  77. for await (const line of fh.readLines()) {
  78. try {
  79. const row = parseCSVRow(line)
  80. if (GLOBAL_BLOBS.has(row.hash)) {
  81. console.log(`Skipping global blob: ${line}`)
  82. continue
  83. }
  84. rows.push(row)
  85. } catch (error) {
  86. console.error(error instanceof Error ? error.message : error)
  87. console.log(`Skipping invalid row: ${line}`)
  88. }
  89. }
  90. return rows
  91. }
  92. /**
  93. * @typedef {Object} BackupBlobJob
  94. * @property {string} hash
  95. * @property {string} historyId
  96. */
  97. /**
  98. * @param {Object} options
  99. * @property {string} [options.historyId]
  100. * @property {string} [options.hash]
  101. * @property {string} [options.input]
  102. * @return {Promise<Array<BackupBlobJob>>}
  103. */
  104. async function initialiseJobs({ historyId, hash, input }) {
  105. if (input) {
  106. return await readCSV(input)
  107. }
  108. if (!historyId) {
  109. console.error('historyId is required')
  110. process.exitCode = 1
  111. await gracefulShutdown()
  112. }
  113. if (!hash) {
  114. console.error('hash is required')
  115. process.exitCode = 1
  116. await gracefulShutdown()
  117. }
  118. validateBackedUpBlobJob({ historyId, hash })
  119. if (GLOBAL_BLOBS.has(hash)) {
  120. console.error(`Blob ${hash} is a global blob; not backing up`)
  121. process.exitCode = 1
  122. await gracefulShutdown()
  123. }
  124. return [{ hash, historyId }]
  125. }
  126. /**
  127. * @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
  128. */
  129. /** @type {Map<string, Promise<CachedPerProjectEncryptedS3Persistor>>} */
  130. const persistorCache = new Map()
  131. /**
  132. * @param {string} historyId
  133. * @returns {Promise<CachedPerProjectEncryptedS3Persistor>}
  134. */
  135. function getPersistor(historyId) {
  136. let persistorPromise = persistorCache.get(historyId)
  137. if (!persistorPromise) {
  138. persistorPromise = backupPersistor.forProject(
  139. projectBlobsBucket,
  140. makeProjectKey(historyId, '')
  141. )
  142. persistorCache.set(historyId, persistorPromise)
  143. }
  144. return persistorPromise
  145. }
  146. // Track processed objects to handle input csv files with duplicate entries
  147. const processedObjects = new Set()
  148. /**
  149. *
  150. * @param {string} historyId
  151. * @param {string} hash
  152. * @return {Promise<void>}
  153. */
  154. export async function downloadAndBackupBlob(historyId, hash) {
  155. const key = `${historyId}:${hash}`
  156. if (processedObjects.has(key)) {
  157. console.log(`${historyId} ${hash} skipping previously processed blob`)
  158. return
  159. } else {
  160. processedObjects.add(key)
  161. }
  162. const backend = chunkStore.getBackend(historyId)
  163. const projectId = await backend.resolveHistoryIdToMongoProjectId(historyId)
  164. // Check whether the project still exists
  165. try {
  166. await getBackupStatus(projectId)
  167. } catch (err) {
  168. if (err instanceof Error && err.message === 'Project not found') {
  169. console.log(`${historyId} ${hash} project not found (expired)`)
  170. return
  171. } else if (err instanceof Error && err.message === 'Project deleted') {
  172. console.log(`${historyId} ${hash} project deleted but not expired`)
  173. // continue and allow backing up blob for a deleted project in case it is undeleted in future
  174. } else {
  175. throw err
  176. }
  177. }
  178. // Force clearning of any backed up blob record
  179. if (options.clear) {
  180. await unsetBackedUpBlobHashes(projectId, [hash])
  181. } else if (await blobIsBackedUp(projectId, hash)) {
  182. // Check if the blob is already backed up
  183. console.log(`${historyId} ${hash} already backed up`)
  184. return
  185. }
  186. const persistor = await getPersistor(historyId)
  187. const blobStore = new BlobStore(historyId)
  188. const blob = await blobStore.getBlob(hash)
  189. if (!blob) {
  190. throw new Error(`Blob ${hash} could not be loaded for history ${historyId}`)
  191. }
  192. await withTmpDir(`blob-${historyId}-${hash}`, async tmpDir => {
  193. const filePath = await downloadBlobToDir(historyId, blob, tmpDir)
  194. console.log(`${historyId} ${hash} Downloaded blob ${filePath}`)
  195. const status = await backupBlob(historyId, blob, filePath, persistor)
  196. console.log(`${historyId} ${hash} Blob`, status ?? 'backed up')
  197. })
  198. }
  199. let jobs
  200. const options = commandLineArgs([
  201. { name: 'historyId', type: String },
  202. { name: 'hash', type: String },
  203. { name: 'input', type: String },
  204. { name: 'concurrency', alias: 'c', type: Number, defaultValue: 1 },
  205. { name: 'clear', type: Boolean },
  206. ])
  207. try {
  208. jobs = await initialiseJobs(options)
  209. } catch (error) {
  210. console.error(error)
  211. await gracefulShutdown()
  212. }
  213. if (!Array.isArray(jobs)) {
  214. // This is mostly to satisfy typescript
  215. process.exitCode = 1
  216. await gracefulShutdown()
  217. process.exit(1)
  218. }
  219. const limit = pLimit(options.concurrency)
  220. let successCount = 0
  221. let failedCount = 0
  222. const totalJobs = jobs.length
  223. /**
  224. * @param {string} historyId
  225. * @param {string} hash
  226. */
  227. async function runJob(historyId, hash) {
  228. try {
  229. await downloadAndBackupBlob(historyId, hash)
  230. successCount++
  231. } catch (error) {
  232. console.error(`${historyId} ${hash} Error:`, error)
  233. process.exitCode = 1
  234. failedCount++
  235. }
  236. }
  237. const promises = jobs.map(({ historyId, hash }) =>
  238. limit(runJob, historyId, hash)
  239. )
  240. await Promise.all(promises)
  241. console.log(
  242. `Backup complete: ${successCount} succeeded, ${failedCount} failed, ${totalJobs} total`
  243. )
  244. await gracefulShutdown()