stress_test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. const minimist = require('minimist')
  2. const settings = require('@overleaf/settings')
  3. const ProjectDetailsHandler = require('../app/src/Features/Project/ProjectDetailsHandler')
  4. const mongodb = require('../app/src/infrastructure/mongodb')
  5. const mongoose = require('../app/src/infrastructure/Mongoose')
  6. const crypto = require('crypto')
  7. const fetch = require('node-fetch')
  8. const { ObjectId } = mongodb
  9. const _ = require('lodash')
  10. // Examples:
  11. //
  12. // Simple usage:
  13. // node stress_test.js --project-id=ID -n 100 --download-zip # download 100 zips from history-v1
  14. // node stress_test.js --project-id=ID -n 100 --create-blob # create 100 blobs in history-v1
  15. // node stress_test.js --project-id=ID -n 100 --fetch-blob # create blob and fetch it 100 times from history-v1
  16. // node stress_test.js --project-id=ID -n 100 --upload-file # upload 100 files to filestore
  17. // node stress_test.js --project-id=ID -n 100 --download-file # create file in filestore and download it 100 times
  18. //
  19. // Delay between requests:
  20. // node stress_test.js --project-id=ID -n 100 --download-zip --sleep=0.1 # download 100 zips from history-v1 with 0.1s sleep
  21. //
  22. // Abort requests at random times:
  23. // node stress_test.js --project-id=ID -n 100 --download-zip --abort # download 100 zips from history-v1 with aborts
  24. //
  25. // Parallel workers:
  26. // node stress_test.js --project-id=ID -n 1000 -j 10 --upload-file # upload 1000 files in 10 parallel workers
  27. //
  28. // Fixed file size:
  29. // node stress_test.js --project-id=ID -n 1000 --size 1000000 --upload-file # upload 1000 files of 1MB in 10 parallel workers
  30. //
  31. // Random file size:
  32. // node stress_test.js --project-id=ID -n 1000 --size-min 1024 --size-max 10000000 --upload-file # upload 1000 files of 1KB to 10MB in 10 parallel workers
  33. const argv = minimist(process.argv.slice(2), {
  34. string: ['n', 'j', 'project-id', 'sleep', 'size', 'size-min', 'size-max'],
  35. boolean: [
  36. 'download-zip',
  37. 'create-blob',
  38. 'fetch-blob',
  39. 'upload-file',
  40. 'download-file',
  41. 'abort',
  42. ],
  43. default: {
  44. n: 1,
  45. j: 1,
  46. sleep: 1,
  47. size: 100 * 1024,
  48. },
  49. })
  50. const projectId = argv['project-id']
  51. if (!projectId) {
  52. console.error(
  53. 'Usage: node stress_test.js --project-id ID -n COUNT -j CONCURRENCY --sleep T --size BYTES --[create-blob|fetch-blob|download-zip|upload-file|download-file]'
  54. )
  55. process.exit(1)
  56. }
  57. process.on('exit', () => {
  58. log('Exiting')
  59. })
  60. async function sleep() {
  61. const ms = argv.sleep * 1000 * (0.5 + Math.random())
  62. return new Promise(resolve => setTimeout(resolve, ms))
  63. }
  64. function log(...args) {
  65. const date = new Date()
  66. console.log(date.toISOString(), ...args)
  67. }
  68. let abortTime = 1000
  69. function adjustAbortTime(aborted, dt) {
  70. if (!argv.abort) {
  71. return
  72. }
  73. // If the last task was aborted, increase the abort time gradually
  74. // Otherwise, reset the abort time to a random fraction of the response time.
  75. if (aborted) {
  76. abortTime = Math.min(abortTime * 1.5, 10000)
  77. } else {
  78. abortTime = Math.random() * dt
  79. }
  80. // Clamp to valid AbortSignal times
  81. abortTime = Math.max(1, Math.round(abortTime))
  82. }
  83. function abortSignal() {
  84. if (!argv.abort) {
  85. return
  86. }
  87. return AbortSignal.timeout(abortTime)
  88. }
  89. async function stressTest(testCase, numberOfRuns, concurrentJobs) {
  90. process.on('SIGINT', () => {
  91. log('Caught interrupt signal. Running cleanup...')
  92. numberOfRuns = 0
  93. })
  94. let startedTasks = 0
  95. let finishedTasks = 0
  96. let abortedTasks = 0
  97. const periodicLog = _.throttle(log, 1000, { leading: true })
  98. const errors = []
  99. const { action, cleanup } = testCase
  100. const executeTask = async () => {
  101. startedTasks++
  102. await sleep()
  103. const t0 = Date.now()
  104. try {
  105. await action(abortSignal())
  106. finishedTasks++
  107. adjustAbortTime(false, Date.now() - t0)
  108. } catch (err) {
  109. if (err.name === 'AbortError') {
  110. abortedTasks++
  111. adjustAbortTime(true, Date.now() - t0)
  112. } else {
  113. errors.push(err)
  114. log(startedTasks, err)
  115. }
  116. } finally {
  117. periodicLog(
  118. `Completed ${finishedTasks} / Aborted ${abortedTasks} / Errors ${errors.length}`
  119. )
  120. }
  121. if (startedTasks < numberOfRuns) {
  122. await executeTask()
  123. }
  124. }
  125. const workers = []
  126. for (let i = 0; i < concurrentJobs; i++) {
  127. workers.push(executeTask())
  128. }
  129. try {
  130. await Promise.all(workers)
  131. periodicLog.cancel()
  132. log(
  133. `Completed ${finishedTasks} / Aborted ${abortedTasks} / Errors ${errors.length}`
  134. )
  135. log(startedTasks, 'tasks completed')
  136. if (cleanup) {
  137. log('Cleaning up')
  138. try {
  139. await cleanup()
  140. } catch (err) {
  141. log('error cleaning up', err)
  142. }
  143. }
  144. } catch (err) {
  145. log('error running stress test', err)
  146. }
  147. if (errors.length > 0) {
  148. log('Errors:', errors.length)
  149. throw new Error('Errors')
  150. }
  151. }
  152. function generateRandomBuffer(size) {
  153. return Buffer.alloc(size, crypto.randomUUID())
  154. }
  155. function computeGitHash(buffer) {
  156. const byteLength = buffer.byteLength
  157. const hash = crypto.createHash('sha1')
  158. hash.setEncoding('hex')
  159. hash.update('blob ' + byteLength + '\x00')
  160. hash.update(buffer)
  161. hash.end()
  162. return { hashHex: hash.read(), byteLength }
  163. }
  164. function computeMD5Hash(buffer) {
  165. const hash = crypto.createHash('md5')
  166. hash.update(buffer)
  167. return hash.digest('hex')
  168. }
  169. function readableSize(size) {
  170. // convert a size in bytes to a human readable string
  171. const units = ['B', 'KB', 'MB', 'GB']
  172. let i = 0
  173. while (size > 1024 && i < units.length) {
  174. size /= 1024
  175. i++
  176. }
  177. return `${size.toFixed(2)} ${units[i]}`.trim()
  178. }
  179. class SizeGenerator {
  180. constructor() {
  181. if (argv['size-min'] && argv['size-max']) {
  182. this.size_min = parseInt(argv['size-min']) || 0
  183. this.size_max = parseInt(argv['size-max']) || argv.size
  184. log(
  185. `File size range [${readableSize(this.size_min)}, ${readableSize(
  186. this.size_max
  187. )}]`
  188. )
  189. } else {
  190. this.size = parseInt(argv.size)
  191. this.fixed = true
  192. log('File size', readableSize(this.size))
  193. }
  194. }
  195. get() {
  196. return this.fixed
  197. ? this.size
  198. : this.size_min + Math.random() * (this.size_max - this.size_min)
  199. }
  200. }
  201. async function createBlob(projectId) {
  202. log('Getting history id')
  203. const v1Id = await getHistoryId(projectId)
  204. // generate a random blob in a buffer and compute the git hash of the buffer
  205. log('Creating test blob')
  206. const userSize = new SizeGenerator()
  207. async function putBlob(abortSignal) {
  208. // create a random buffer and compute its hash
  209. const buffer = generateRandomBuffer(userSize.get())
  210. const { hashHex, byteLength } = computeGitHash(buffer)
  211. const putUrl = `${settings.apis.v1_history.url}/projects/${v1Id}/blobs/${hashHex}`
  212. const response = await historyFetch(putUrl, {
  213. method: 'PUT',
  214. body: buffer,
  215. signal: abortSignal,
  216. })
  217. if (!response.ok) {
  218. throw new Error(`failed to put blob ${putUrl} status=${response.status}`)
  219. }
  220. return { hashHex, byteLength }
  221. }
  222. return { action: putBlob, description: 'createBlob in history-v1' }
  223. }
  224. async function fetchBlob(projectId) {
  225. log('Getting history id and creating test blob')
  226. const v1Id = await getHistoryId(projectId)
  227. const { action: putBlob } = await createBlob(projectId)
  228. const { hashHex, byteLength } = await putBlob()
  229. async function getBlob(abortSignal) {
  230. const getUrl = `${settings.apis.v1_history.url}/projects/${v1Id}/blobs/${hashHex}`
  231. const response = await historyFetch(getUrl, { signal: abortSignal })
  232. if (!response.ok) {
  233. throw new Error(`failed to get blob ${getUrl} status=${response.status}`)
  234. }
  235. const buffer = await response.arrayBuffer()
  236. if (buffer.byteLength !== byteLength) {
  237. throw new Error(
  238. `unexpected fetch blob length ${buffer.byteLength} vs expected ${byteLength}`
  239. )
  240. }
  241. }
  242. return { action: getBlob, description: 'fetchBlob from history-v1' }
  243. }
  244. async function getHistoryId(projectId) {
  245. const project = await ProjectDetailsHandler.promises.getDetails(projectId)
  246. const v1Id = project?.overleaf?.history?.id
  247. return v1Id
  248. }
  249. async function downloadHistoryZip(projectId) {
  250. log('Getting history id and latest version')
  251. const v1Id = await getHistoryId(projectId)
  252. const latestUrl = `${settings.apis.v1_history.url}/projects/${v1Id}/latest/history`
  253. let response = await historyFetch(latestUrl)
  254. if (!response.ok) {
  255. throw new Error(
  256. `failed to get latest version ${latestUrl} status=${response.status}`
  257. )
  258. }
  259. const latestBody = await response.json()
  260. const version =
  261. latestBody.chunk.startVersion + latestBody.chunk.history.changes.length
  262. const zipUrl = `${settings.apis.v1_history.url}/projects/${v1Id}/version/${version}/zip`
  263. let expectedLength = null
  264. async function getZip(abortSignal) {
  265. response = await historyFetch(zipUrl, { signal: abortSignal })
  266. const responseBuffer = await response.arrayBuffer()
  267. if (expectedLength === null) {
  268. expectedLength = responseBuffer.byteLength
  269. } else if (responseBuffer.byteLength !== expectedLength) {
  270. throw new Error(
  271. `unexpected zip download length ${responseBuffer.byteLength} vs expected ${expectedLength}`
  272. )
  273. }
  274. }
  275. return { action: getZip, description: 'download zip from history-v1' }
  276. }
  277. async function historyFetch(url, options) {
  278. const authHeader = {
  279. Authorization: `Basic ${Buffer.from(
  280. `${settings.apis.v1_history.user}:${settings.apis.v1_history.pass}`
  281. ).toString('base64')}`,
  282. }
  283. const response = await fetch(url, { ...options, headers: authHeader })
  284. if (!response.ok) {
  285. throw new Error(`failed to download url ${url} status=${response.status}`)
  286. }
  287. return response
  288. }
  289. async function _deleteFile(url, log) {
  290. const response = await fetch(url, { method: 'DELETE' })
  291. if (!response.ok) {
  292. throw new Error(`failed to delete file status=${response.status}`)
  293. }
  294. }
  295. async function uploadFile(projectId) {
  296. // generate a random blob in a buffer and compute the md5 hash of the buffer
  297. const userSize = new SizeGenerator()
  298. async function upload(abortSignal, deleteFile = true) {
  299. const size = userSize.get()
  300. const buffer = Buffer.alloc(size, crypto.randomUUID())
  301. const fileId = ObjectId()
  302. const url = `${settings.apis.filestore.url}/project/${projectId}/file/${fileId}`
  303. const md5 = computeMD5Hash(buffer)
  304. const response = await fetch(url, {
  305. method: 'POST',
  306. body: buffer,
  307. signal: abortSignal,
  308. })
  309. if (!response.ok) {
  310. throw new Error(`failed to upload file ${url} status=${response.status}`)
  311. }
  312. if (deleteFile) {
  313. await _deleteFile(url)
  314. }
  315. return { url, md5 }
  316. }
  317. return { action: upload, description: 'upload file to filestore' }
  318. }
  319. async function downloadFile(projectId) {
  320. log('Creating test file')
  321. const { action: upload } = await uploadFile(projectId)
  322. const { url, md5: expectedMd5 } = await upload(null, false)
  323. async function download(abortSignal) {
  324. const response = await fetch(url, {
  325. method: 'GET',
  326. signal: abortSignal,
  327. })
  328. if (!response.ok) {
  329. throw new Error(`failed to get file ${url} status=${response.status}`)
  330. }
  331. const md5 = computeMD5Hash(Buffer.from(await response.arrayBuffer()))
  332. if (md5 !== expectedMd5) {
  333. throw new Error(`md5 mismatch`)
  334. }
  335. }
  336. async function cleanup() {
  337. log('Deleting test file')
  338. await _deleteFile(url)
  339. }
  340. return {
  341. action: download,
  342. cleanup,
  343. description: 'download file from filestore',
  344. }
  345. }
  346. async function run() {
  347. let testCase
  348. if (argv['download-zip']) {
  349. testCase = await downloadHistoryZip(projectId)
  350. } else if (argv['create-blob']) {
  351. testCase = await createBlob(projectId)
  352. } else if (argv['fetch-blob']) {
  353. testCase = await fetchBlob(projectId)
  354. } else if (argv['upload-file']) {
  355. testCase = await uploadFile(projectId)
  356. } else if (argv['download-file']) {
  357. testCase = await downloadFile(projectId)
  358. } else {
  359. throw new Error('unknown command')
  360. }
  361. log('Running stress test:', testCase.description)
  362. await stressTest(testCase, argv.n, argv.j)
  363. log('Stress test done')
  364. }
  365. Promise.all([mongodb.waitForDb(), mongoose.connectionPromise])
  366. .then(() => run())
  367. .then(() => {
  368. log('Completed')
  369. process.exit(0)
  370. })
  371. .catch(error => {
  372. console.error(error)
  373. process.exit(1)
  374. })