count_blob_references.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. #!/usr/bin/env node
  2. 'use strict'
  3. /**
  4. * This script fetches all history chunks from active projects (as listed in the
  5. * active_doc_ids table) and counts how many times each blob is referenced. The
  6. * reference count is stored in the blobs.estimated_reference_count column.
  7. */
  8. const Path = require('path')
  9. const BPromise = require('bluebird')
  10. const commandLineArgs = require('command-line-args')
  11. const config = require('config')
  12. const stringToStream = require('string-to-stream')
  13. const { History, EditFileOperation } = require('overleaf-editor-core')
  14. const { knex, historyStore, persistor } = require('..')
  15. const DEFAULT_BATCH_SIZE = 100
  16. const DEFAULT_TIMEOUT = 23 * 60 * 60 // 23 hours
  17. const MAX_POSTGRES_INTEGER = 2147483647
  18. const TEXT_OPERATION_COUNT_THRESHOLD = 500
  19. const BUCKET = config.get('analytics.bucket')
  20. const BLOB_REFERENCE_COUNTS_PREFIX = 'blob-reference-counts/batches/'
  21. const TEXT_OPERATION_COUNTS_PREFIX = 'text-operation-counts/'
  22. async function main() {
  23. const programName = Path.basename(process.argv[1])
  24. const options = commandLineArgs([
  25. { name: 'restart', type: Boolean },
  26. { name: 'continue', type: Boolean },
  27. { name: 'batch-size', type: Number, defaultValue: DEFAULT_BATCH_SIZE },
  28. { name: 'timeout', type: Number, defaultValue: DEFAULT_TIMEOUT },
  29. { name: 'concurrency', type: Number, defaultValue: 1 },
  30. { name: 'min-doc-id', type: Number, defaultValue: 1 },
  31. { name: 'max-doc-id', type: Number, defaultValue: MAX_POSTGRES_INTEGER },
  32. ])
  33. const minDocId = options['min-doc-id']
  34. const maxDocId = options['max-doc-id']
  35. const runOptions = {
  36. batchSize: options['batch-size'],
  37. timeout: options.timeout,
  38. concurrency: options.concurrency,
  39. }
  40. const inProgress = await isRunInProgress()
  41. if (inProgress && !options.restart && !options.continue) {
  42. console.log(`\
  43. A blob reference count is already under way.
  44. To resume this run, use: ${programName} --continue
  45. To start a new run, use: ${programName} --restart`)
  46. return
  47. }
  48. if (!inProgress || options.restart) {
  49. await initialize()
  50. }
  51. const nextDocId = await getNextDocId(minDocId, maxDocId)
  52. await run(nextDocId, maxDocId, runOptions)
  53. }
  54. async function isRunInProgress() {
  55. const record = await knex('blob_reference_count_batches').first()
  56. return record != null
  57. }
  58. async function getNextDocId(minDocId, maxDocId) {
  59. const { lastDocId } = await knex('blob_reference_count_batches')
  60. .where('end_doc_id', '<=', maxDocId)
  61. .max({ lastDocId: 'end_doc_id' })
  62. .first()
  63. if (lastDocId == null) {
  64. return minDocId
  65. } else {
  66. return Math.max(minDocId, lastDocId + 1)
  67. }
  68. }
  69. async function initialize() {
  70. await persistor.deleteDirectory(BUCKET, BLOB_REFERENCE_COUNTS_PREFIX)
  71. await persistor.deleteDirectory(BUCKET, TEXT_OPERATION_COUNTS_PREFIX)
  72. await knex('blob_reference_count_batches').truncate()
  73. }
  74. async function run(startDocId, maxDocId, options) {
  75. const { timeout, batchSize, concurrency } = options
  76. const maxRunningTime = Date.now() + timeout * 1000
  77. let batchStart = startDocId
  78. while (true) {
  79. if (Date.now() > maxRunningTime) {
  80. console.log('Timeout exceeded. Exiting early.')
  81. break
  82. }
  83. const docIds = await getDocIds(batchStart, maxDocId, batchSize)
  84. if (docIds.length === 0) {
  85. console.log('No more projects to process. Bye!')
  86. break
  87. }
  88. const batchEnd = docIds[docIds.length - 1]
  89. console.log(`Processing doc ids ${batchStart} to ${batchEnd}...`)
  90. const chunks = await getChunks(docIds)
  91. const blobReferenceCounter = new BlobReferenceCounter()
  92. const textOperationCounter = new TextOperationCounter()
  93. await BPromise.map(
  94. chunks,
  95. async chunk => {
  96. const history = await getHistory(chunk)
  97. blobReferenceCounter.processHistory(history, chunk.projectId)
  98. textOperationCounter.processHistory(history, chunk.projectId)
  99. },
  100. { concurrency }
  101. )
  102. await storeBlobReferenceCounts(batchStart, blobReferenceCounter.getCounts())
  103. await storeTextOperationCounts(batchStart, textOperationCounter.getCounts())
  104. await recordBatch(batchStart, batchEnd)
  105. batchStart = batchEnd + 1
  106. }
  107. }
  108. async function getDocIds(minDocId, maxDocId, batchSize) {
  109. const docIds = await knex('active_doc_ids')
  110. .select('doc_id')
  111. .where('doc_id', '>=', minDocId)
  112. .andWhere('doc_id', '<=', maxDocId)
  113. .orderBy('doc_id')
  114. .limit(batchSize)
  115. .pluck('doc_id')
  116. return docIds
  117. }
  118. async function getChunks(docIds) {
  119. const chunks = await knex('chunks')
  120. .select('id', { projectId: 'doc_id' })
  121. .where('doc_id', 'in', docIds)
  122. return chunks
  123. }
  124. async function recordBatch(batchStart, batchEnd) {
  125. await knex('blob_reference_count_batches').insert({
  126. start_doc_id: batchStart,
  127. end_doc_id: batchEnd,
  128. })
  129. }
  130. async function getHistory(chunk) {
  131. const rawHistory = await historyStore.loadRaw(chunk.projectId, chunk.id)
  132. const history = History.fromRaw(rawHistory)
  133. return history
  134. }
  135. async function storeBlobReferenceCounts(startDocId, counts) {
  136. const key = `${BLOB_REFERENCE_COUNTS_PREFIX}${startDocId}.csv`
  137. const csv = makeCsvFromMap(counts)
  138. const stream = stringToStream(csv)
  139. persistor.sendStream(BUCKET, key, stream)
  140. }
  141. async function storeTextOperationCounts(startDocId, counts) {
  142. const key = `${TEXT_OPERATION_COUNTS_PREFIX}${startDocId}.csv`
  143. const csv = makeCsvFromMap(counts)
  144. const stream = stringToStream(csv)
  145. await persistor.sendStream(BUCKET, key, stream)
  146. }
  147. function makeCsvFromMap(map) {
  148. const entries = Array.from(map.entries())
  149. entries.sort((a, b) => {
  150. if (a[0] < b[0]) {
  151. return -1
  152. }
  153. if (a[0] > b[0]) {
  154. return 1
  155. }
  156. return 0
  157. })
  158. return entries.map(entry => entry.join(',')).join('\n')
  159. }
  160. function incrementMapEntry(map, key) {
  161. const currentCount = map.get(key) || 0
  162. map.set(key, currentCount + 1)
  163. }
  164. class BlobReferenceCounter {
  165. constructor() {
  166. this.blobHashesByProjectId = new Map()
  167. }
  168. processHistory(history, projectId) {
  169. let blobHashes = this.blobHashesByProjectId.get(projectId)
  170. if (blobHashes == null) {
  171. blobHashes = new Set()
  172. this.blobHashesByProjectId.set(projectId, blobHashes)
  173. }
  174. history.findBlobHashes(blobHashes)
  175. }
  176. getCounts() {
  177. const countsByHash = new Map()
  178. for (const blobHashes of this.blobHashesByProjectId.values()) {
  179. for (const hash of blobHashes) {
  180. incrementMapEntry(countsByHash, hash)
  181. }
  182. }
  183. return countsByHash
  184. }
  185. }
  186. class TextOperationCounter {
  187. constructor() {
  188. this.countsByProjectId = new Map()
  189. }
  190. processHistory(history, projectId) {
  191. for (const change of history.getChanges()) {
  192. let textOperationCount = 0
  193. for (const operation of change.getOperations()) {
  194. if (operation instanceof EditFileOperation) {
  195. textOperationCount++
  196. }
  197. }
  198. if (textOperationCount >= TEXT_OPERATION_COUNT_THRESHOLD) {
  199. this.countsByProjectId.set(
  200. projectId,
  201. Math.max(
  202. this.countsByProjectId.get(projectId) || 0,
  203. textOperationCount
  204. )
  205. )
  206. }
  207. }
  208. }
  209. getCounts() {
  210. return this.countsByProjectId
  211. }
  212. }
  213. main()
  214. .then(() => {
  215. process.exit()
  216. })
  217. .catch(err => {
  218. console.error(err)
  219. process.exit(1)
  220. })