stress_test.mjs 13 KB

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