copy_project_blobs.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. #!/usr/bin/env node
  2. const { promisify } = require('util')
  3. const BPromise = require('bluebird')
  4. const commandLineArgs = require('command-line-args')
  5. const config = require('config')
  6. const fs = require('fs')
  7. const readline = require('readline')
  8. const { History } = require('overleaf-editor-core')
  9. const { knex, historyStore, persistor } = require('..')
  10. const projectKey = require('../lib/project_key')
  11. const MAX_POSTGRES_INTEGER = 2147483647
  12. const DEFAULT_BATCH_SIZE = 1000
  13. const MAX_RETRIES = 10
  14. const RETRY_DELAY_MS = 5000
  15. // Obtain a preconfigured GCS client through a non-documented property of
  16. // object-persistor. Sorry about that. We need the GCS client because we use
  17. // operations that are not implemented in object-persistor.
  18. const gcsClient = persistor.storage
  19. const globalBucket = gcsClient.bucket(config.get('blobStore.globalBucket'))
  20. const projectBucket = gcsClient.bucket(config.get('blobStore.projectBucket'))
  21. const delay = promisify(setTimeout)
  22. async function main() {
  23. const options = commandLineArgs([
  24. { name: 'global-blobs', type: String },
  25. { name: 'min-project-id', type: Number, defaultValue: 1 },
  26. {
  27. name: 'max-project-id',
  28. type: Number,
  29. defaultValue: MAX_POSTGRES_INTEGER,
  30. },
  31. { name: 'batch-size', type: Number, defaultValue: DEFAULT_BATCH_SIZE },
  32. { name: 'concurrency', type: Number, defaultValue: 1 },
  33. ])
  34. if (!options['global-blobs']) {
  35. console.error(
  36. 'You must specify a global blobs file with the --global-blobs option'
  37. )
  38. process.exit(1)
  39. }
  40. const globalBlobs = await readGlobalBlobs(options['global-blobs'])
  41. const minProjectId = options['min-project-id']
  42. const maxProjectId = options['max-project-id']
  43. const batchSize = options['batch-size']
  44. const concurrency = options.concurrency
  45. console.log(`Keeping ${globalBlobs.size} global blobs`)
  46. await run({ globalBlobs, minProjectId, maxProjectId, batchSize, concurrency })
  47. console.log('Done.')
  48. }
  49. async function readGlobalBlobs(filename) {
  50. const stream = fs.createReadStream(filename)
  51. const reader = readline.createInterface({
  52. input: stream,
  53. crlfDelay: Infinity,
  54. })
  55. const blobs = new Set()
  56. for await (const line of reader) {
  57. blobs.add(line.trim())
  58. }
  59. return blobs
  60. }
  61. async function run(options) {
  62. const { globalBlobs, minProjectId, maxProjectId, batchSize, concurrency } =
  63. options
  64. let batchStart = minProjectId
  65. while (batchStart <= maxProjectId) {
  66. let projectIds = await getProjectIds(batchStart, maxProjectId, batchSize)
  67. if (projectIds.length === 0) {
  68. break
  69. }
  70. const batchEnd = projectIds[projectIds.length - 1]
  71. console.log(`Processing projects ${batchStart} to ${batchEnd}`)
  72. const chunkIdsByProject = await getChunkIdsByProject(projectIds)
  73. let retries = 0
  74. while (true) {
  75. const results = await BPromise.map(
  76. projectIds,
  77. async projectId =>
  78. await processProject(
  79. projectId,
  80. chunkIdsByProject.get(projectId),
  81. globalBlobs
  82. ),
  83. { concurrency }
  84. )
  85. const failedProjectIds = results
  86. .filter(result => !result.success)
  87. .map(result => result.projectId)
  88. if (failedProjectIds.length === 0) {
  89. // All projects were copied successfully. Carry on.
  90. break
  91. }
  92. // Some projects failed. Retry.
  93. retries += 1
  94. if (retries > MAX_RETRIES) {
  95. console.log(
  96. `Too many retries processing projects ${batchStart} to ${batchEnd}. Giving up.`
  97. )
  98. process.exit(1)
  99. }
  100. console.log(`Retrying projects: ${failedProjectIds.join(', ')}`)
  101. await delay(RETRY_DELAY_MS)
  102. projectIds = failedProjectIds
  103. }
  104. // Set up next batch
  105. batchStart = batchEnd + 1
  106. }
  107. }
  108. async function getProjectIds(minProjectId, maxProjectId, batchSize) {
  109. const projectIds = await knex('chunks')
  110. .distinct('doc_id')
  111. .where('doc_id', '>=', minProjectId)
  112. .andWhere('doc_id', '<=', maxProjectId)
  113. .orderBy('doc_id')
  114. .limit(batchSize)
  115. .pluck('doc_id')
  116. return projectIds
  117. }
  118. async function getChunkIdsByProject(projectIds) {
  119. const chunks = await knex('chunks')
  120. .select('id', { projectId: 'doc_id' })
  121. .where('doc_id', 'in', projectIds)
  122. const chunkIdsByProject = new Map()
  123. for (const projectId of projectIds) {
  124. chunkIdsByProject.set(projectId, [])
  125. }
  126. for (const chunk of chunks) {
  127. chunkIdsByProject.get(chunk.projectId).push(chunk.id)
  128. }
  129. return chunkIdsByProject
  130. }
  131. async function processProject(projectId, chunkIds, globalBlobs) {
  132. try {
  133. const blobHashes = await getBlobHashes(projectId, chunkIds)
  134. const projectBlobHashes = blobHashes.filter(hash => !globalBlobs.has(hash))
  135. const gcsSizesByHash = new Map()
  136. for (const blobHash of projectBlobHashes) {
  137. const blobSize = await copyBlobInGcs(projectId, blobHash)
  138. if (blobSize != null) {
  139. gcsSizesByHash.set(blobHash, blobSize)
  140. }
  141. }
  142. const dbSizesByHash = await copyBlobsInDatabase(
  143. projectId,
  144. projectBlobHashes
  145. )
  146. compareBlobSizes(gcsSizesByHash, dbSizesByHash)
  147. return { projectId, success: true }
  148. } catch (err) {
  149. console.error(`Failed to process project ${projectId}:`, err.stack)
  150. return { projectId, success: false }
  151. }
  152. }
  153. function compareBlobSizes(gcsSizesByHash, dbSizesByHash) {
  154. // Throw an error if the database doesn't report as many blobs as GCS
  155. if (dbSizesByHash.size !== gcsSizesByHash.size) {
  156. throw new Error(
  157. `the database reported ${dbSizesByHash.size} blobs copied, but GCS reported ${gcsSizesByHash.size} blobs copied`
  158. )
  159. }
  160. const mismatches = []
  161. for (const [hash, dbSize] of dbSizesByHash.entries()) {
  162. if (gcsSizesByHash.get(hash) !== dbSize) {
  163. mismatches.push(hash)
  164. }
  165. }
  166. if (mismatches.length > 0) {
  167. throw new Error(`blob size mismatch for hashes: ${mismatches.join(', ')}`)
  168. }
  169. }
  170. async function getHistory(projectId, chunkId) {
  171. const rawHistory = await historyStore.loadRaw(projectId, chunkId)
  172. const history = History.fromRaw(rawHistory)
  173. return history
  174. }
  175. async function getBlobHashes(projectId, chunkIds) {
  176. const blobHashes = new Set()
  177. for (const chunkId of chunkIds) {
  178. const history = await getHistory(projectId, chunkId)
  179. history.findBlobHashes(blobHashes)
  180. }
  181. return Array.from(blobHashes)
  182. }
  183. async function copyBlobInGcs(projectId, blobHash) {
  184. const globalBlobKey = [
  185. blobHash.slice(0, 2),
  186. blobHash.slice(2, 4),
  187. blobHash.slice(4),
  188. ].join('/')
  189. const projectBlobKey = [
  190. projectKey.format(projectId),
  191. blobHash.slice(0, 2),
  192. blobHash.slice(2),
  193. ].join('/')
  194. const globalBlobObject = globalBucket.file(globalBlobKey)
  195. const projectBlobObject = projectBucket.file(projectBlobKey)
  196. // Check if the project blob exists
  197. let projectBlobMetadata = null
  198. try {
  199. ;[projectBlobMetadata] = await projectBlobObject.getMetadata()
  200. } catch (err) {
  201. if (err.code !== 404) {
  202. throw err
  203. }
  204. }
  205. // Check that the blob exists
  206. let globalBlobMetadata = null
  207. try {
  208. ;[globalBlobMetadata] = await globalBlobObject.getMetadata()
  209. } catch (err) {
  210. if (err.code !== 404) {
  211. throw err
  212. }
  213. }
  214. if (projectBlobMetadata) {
  215. // Project blob already exists. Compare the metadata if the global blob
  216. // also exists and return early.
  217. if (
  218. globalBlobMetadata != null &&
  219. (globalBlobMetadata.size !== projectBlobMetadata.size ||
  220. globalBlobMetadata.md5Hash !== projectBlobMetadata.md5Hash)
  221. ) {
  222. throw new Error(
  223. `Project blob ${blobHash} in project ${projectId} doesn't match global blob`
  224. )
  225. }
  226. return null
  227. }
  228. await globalBlobObject.copy(projectBlobObject)
  229. // Paranoid check that the copy went well. The getMetadata() method returns
  230. // an array, with the metadata in first position.
  231. ;[projectBlobMetadata] = await projectBlobObject.getMetadata()
  232. if (
  233. globalBlobMetadata.size !== projectBlobMetadata.size ||
  234. globalBlobMetadata.md5Hash !== projectBlobMetadata.md5Hash
  235. ) {
  236. throw new Error(`Failed to copy blob ${blobHash} to project ${projectId})`)
  237. }
  238. return parseInt(projectBlobMetadata.size, 10)
  239. }
  240. async function copyBlobsInDatabase(projectId, blobHashes) {
  241. const blobSizesByHash = new Map()
  242. if (blobHashes.length === 0) {
  243. return blobSizesByHash
  244. }
  245. const binaryBlobHashes = blobHashes.map(hash => Buffer.from(hash, 'hex'))
  246. const result = await knex.raw(
  247. `INSERT INTO project_blobs (
  248. project_id, hash_bytes, byte_length, string_length
  249. )
  250. SELECT ?, hash_bytes, byte_length, string_length
  251. FROM blobs
  252. WHERE hash_bytes IN (${binaryBlobHashes.map(_ => '?').join(',')})
  253. ON CONFLICT (project_id, hash_bytes) DO NOTHING
  254. RETURNING hash_bytes, byte_length`,
  255. [projectId, ...binaryBlobHashes]
  256. )
  257. for (const row of result.rows) {
  258. blobSizesByHash.set(row.hash_bytes.toString('hex'), row.byte_length)
  259. }
  260. return blobSizesByHash
  261. }
  262. main()
  263. .then(() => {
  264. process.exit()
  265. })
  266. .catch(err => {
  267. console.error(err)
  268. process.exit(1)
  269. })