UrlCachingTests.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. const express = require('express')
  2. const Path = require('node:path')
  3. const Client = require('./helpers/Client')
  4. const sinon = require('sinon')
  5. const ClsiApp = require('./helpers/ClsiApp')
  6. const { fetchString } = require('@overleaf/fetch-utils')
  7. const Settings = require('@overleaf/settings')
  8. const Server = {
  9. run() {
  10. const app = express()
  11. const staticServer = express.static(Path.join(__dirname, '../fixtures/'))
  12. const alreadyFailed = new Map()
  13. app.get('/fail/:times/:id', (req, res) => {
  14. this.getFile(req.url)
  15. const soFar = alreadyFailed.get(req.params.id) || 0
  16. const wanted = parseInt(req.params.times, 10)
  17. if (soFar < wanted) {
  18. alreadyFailed.set(req.params.id, soFar + 1)
  19. res.status(503).end()
  20. } else {
  21. res.send('THE CONTENT')
  22. }
  23. })
  24. app.get('/not-found', (req, res, next) => {
  25. this.getFile(req.url)
  26. res.status(404).end()
  27. })
  28. app.get('/project/:projectId/file/:fileId', (req, res, next) => {
  29. this.getFile(req.url)
  30. res.send(`${req.params.projectId}:${req.params.fileId}`)
  31. })
  32. app.get('/bucket/:bucket/key/*', (req, res, next) => {
  33. this.getFile(req.url)
  34. res.send(`${req.params.bucket}:${req.params[0]}`)
  35. })
  36. app.get('/:random_id/*', (req, res, next) => {
  37. this.getFile(req.url)
  38. req.url = `/${req.params[0]}`
  39. staticServer(req, res, next)
  40. })
  41. Client.startFakeFilestoreApp(app)
  42. },
  43. getFile() {},
  44. randomId() {
  45. return Math.random().toString(16).slice(2)
  46. },
  47. }
  48. describe('Url Caching', function () {
  49. Server.run()
  50. describe('Retries', function () {
  51. before(async function () {
  52. this.project_id = Client.randomId()
  53. this.happyFile = `${Server.randomId()}/lion.png`
  54. this.retryFileOnce = `fail/1/${Server.randomId()}`
  55. this.retryFileTwice = `fail/2/${Server.randomId()}`
  56. this.fatalFile = `fail/42/${Server.randomId()}`
  57. this.request = {
  58. resources: [
  59. {
  60. path: 'main.tex',
  61. content: `\
  62. \\documentclass{article}
  63. \\usepackage{graphicx}
  64. \\begin{document}
  65. \\includegraphics{lion.png}
  66. \\end{document}\
  67. `,
  68. },
  69. {
  70. path: 'lion.png',
  71. url: `http://filestore/${this.happyFile}`,
  72. },
  73. {
  74. path: 'foo.tex',
  75. url: `http://filestore/${this.retryFileOnce}`,
  76. },
  77. {
  78. path: 'foo.tex',
  79. url: `http://filestore/${this.retryFileTwice}`,
  80. },
  81. {
  82. path: 'foo.tex',
  83. url: `http://filestore/${this.fatalFile}`,
  84. },
  85. ],
  86. }
  87. sinon.spy(Server, 'getFile')
  88. await ClsiApp.ensureRunning()
  89. this.body = await Client.compile(this.project_id, this.request)
  90. })
  91. after(function () {
  92. Server.getFile.restore()
  93. })
  94. function expectNFilestoreRequests(file, count) {
  95. Server.getFile.args.filter(a => a[0] === file).should.have.length(count)
  96. }
  97. it('should download the happy file once', function () {
  98. expectNFilestoreRequests(`/${this.happyFile}`, 1)
  99. })
  100. it('should retry the download of the unhappy files', function () {
  101. expectNFilestoreRequests(`/${this.retryFileOnce}`, 2)
  102. expectNFilestoreRequests(`/${this.retryFileTwice}`, 3)
  103. expectNFilestoreRequests(`/${this.fatalFile}`, 3)
  104. })
  105. })
  106. describe('Downloading an image for the first time', function () {
  107. before(async function () {
  108. this.project_id = Client.randomId()
  109. this.file = `${Server.randomId()}/lion.png`
  110. this.request = {
  111. resources: [
  112. {
  113. path: 'main.tex',
  114. content: `\
  115. \\documentclass{article}
  116. \\usepackage{graphicx}
  117. \\begin{document}
  118. \\includegraphics{lion.png}
  119. \\end{document}\
  120. `,
  121. },
  122. {
  123. path: 'lion.png',
  124. url: `http://filestore/${this.file}`,
  125. },
  126. ],
  127. }
  128. sinon.spy(Server, 'getFile')
  129. await ClsiApp.ensureRunning()
  130. this.body = await Client.compile(this.project_id, this.request)
  131. })
  132. afterEach(function () {
  133. Server.getFile.restore()
  134. })
  135. it('should download the image', function () {
  136. Server.getFile.calledWith(`/${this.file}`).should.equal(true)
  137. })
  138. })
  139. describe('When an image is in the cache and the last modified date is unchanged', function () {
  140. before(async function () {
  141. this.project_id = Client.randomId()
  142. this.file = `${Server.randomId()}/lion.png`
  143. this.request = {
  144. resources: [
  145. {
  146. path: 'main.tex',
  147. content: `\
  148. \\documentclass{article}
  149. \\usepackage{graphicx}
  150. \\begin{document}
  151. \\includegraphics{lion.png}
  152. \\end{document}\
  153. `,
  154. },
  155. (this.image_resource = {
  156. path: 'lion.png',
  157. url: `http://filestore/${this.file}`,
  158. modified: Date.now(),
  159. }),
  160. ],
  161. }
  162. await Client.compile(this.project_id, this.request)
  163. sinon.spy(Server, 'getFile')
  164. await Client.compile(this.project_id, this.request)
  165. })
  166. after(function () {
  167. Server.getFile.restore()
  168. })
  169. it('should not download the image again', function () {
  170. Server.getFile.called.should.equal(false)
  171. })
  172. it('should gather metrics', async function () {
  173. const body = await fetchString(`${Settings.apis.clsi.url}/metrics`)
  174. body
  175. .split('\n')
  176. .some(line => {
  177. return (
  178. line.startsWith('url_source') && line.includes('path="unknown"')
  179. )
  180. })
  181. .should.equal(true)
  182. })
  183. })
  184. describe('When an image is in the cache and the last modified date is advanced', function () {
  185. before(async function () {
  186. this.project_id = Client.randomId()
  187. this.file = `${Server.randomId()}/lion.png`
  188. this.request = {
  189. resources: [
  190. {
  191. path: 'main.tex',
  192. content: `\
  193. \\documentclass{article}
  194. \\usepackage{graphicx}
  195. \\begin{document}
  196. \\includegraphics{lion.png}
  197. \\end{document}\
  198. `,
  199. },
  200. (this.image_resource = {
  201. path: 'lion.png',
  202. url: `http://filestore/${this.file}`,
  203. modified: (this.last_modified = Date.now()),
  204. }),
  205. ],
  206. }
  207. await Client.compile(this.project_id, this.request)
  208. sinon.spy(Server, 'getFile')
  209. this.image_resource.modified = new Date(this.last_modified + 3000)
  210. await Client.compile(this.project_id, this.request)
  211. })
  212. afterEach(function () {
  213. Server.getFile.restore()
  214. })
  215. it('should download the image again', function () {
  216. Server.getFile.called.should.equal(true)
  217. })
  218. })
  219. describe('When an image is in the cache and the last modified date is further in the past', function () {
  220. before(async function () {
  221. this.project_id = Client.randomId()
  222. this.file = `${Server.randomId()}/lion.png`
  223. this.request = {
  224. resources: [
  225. {
  226. path: 'main.tex',
  227. content: `\
  228. \\documentclass{article}
  229. \\usepackage{graphicx}
  230. \\begin{document}
  231. \\includegraphics{lion.png}
  232. \\end{document}\
  233. `,
  234. },
  235. (this.image_resource = {
  236. path: 'lion.png',
  237. url: `http://filestore/${this.file}`,
  238. modified: (this.last_modified = Date.now()),
  239. }),
  240. ],
  241. }
  242. await Client.compile(this.project_id, this.request)
  243. sinon.spy(Server, 'getFile')
  244. this.image_resource.modified = new Date(this.last_modified - 3000)
  245. await Client.compile(this.project_id, this.request)
  246. })
  247. afterEach(function () {
  248. Server.getFile.restore()
  249. })
  250. it('should download the other revision', function () {
  251. Server.getFile.called.should.equal(true)
  252. })
  253. })
  254. describe('When an image is in the cache and the last modified date is not specified', function () {
  255. before(async function () {
  256. this.project_id = Client.randomId()
  257. this.file = `${Server.randomId()}/lion.png`
  258. this.request = {
  259. resources: [
  260. {
  261. path: 'main.tex',
  262. content: `\
  263. \\documentclass{article}
  264. \\usepackage{graphicx}
  265. \\begin{document}
  266. \\includegraphics{lion.png}
  267. \\end{document}\
  268. `,
  269. },
  270. (this.image_resource = {
  271. path: 'lion.png',
  272. url: `http://filestore/${this.file}`,
  273. modified: (this.last_modified = Date.now()),
  274. }),
  275. ],
  276. }
  277. await Client.compile(this.project_id, this.request)
  278. sinon.spy(Server, 'getFile')
  279. delete this.image_resource.modified
  280. await Client.compile(this.project_id, this.request)
  281. })
  282. afterEach(function () {
  283. Server.getFile.restore()
  284. })
  285. it('should download the image again', function () {
  286. Server.getFile.called.should.equal(true)
  287. })
  288. })
  289. describe('After clearing the cache', function () {
  290. before(async function () {
  291. this.project_id = Client.randomId()
  292. this.file = `${Server.randomId()}/lion.png`
  293. this.request = {
  294. resources: [
  295. {
  296. path: 'main.tex',
  297. content: `\
  298. \\documentclass{article}
  299. \\usepackage{graphicx}
  300. \\begin{document}
  301. \\includegraphics{lion.png}
  302. \\end{document}\
  303. `,
  304. },
  305. (this.image_resource = {
  306. path: 'lion.png',
  307. url: `http://filestore/${this.file}`,
  308. modified: (this.last_modified = Date.now()),
  309. }),
  310. ],
  311. }
  312. await Client.compile(this.project_id, this.request)
  313. await Client.clearCache(this.project_id)
  314. sinon.spy(Server, 'getFile')
  315. await Client.compile(this.project_id, this.request)
  316. })
  317. afterEach(function () {
  318. Server.getFile.restore()
  319. })
  320. it('should download the image again', function () {
  321. Server.getFile.called.should.equal(true)
  322. })
  323. })
  324. describe('fallbackURL', function () {
  325. describe('when the primary resource is available', function () {
  326. before(async function () {
  327. this.project_id = Client.randomId()
  328. this.file = `/project/${Server.randomId()}/file/${Server.randomId()}`
  329. this.fallback = `/bucket/project-blobs/key/ab/cd/${Server.randomId()}`
  330. this.request = {
  331. resources: [
  332. {
  333. path: 'main.tex',
  334. content: `\
  335. \\documentclass{article}
  336. \\usepackage{graphicx}
  337. \\begin{document}
  338. \\includegraphics{lion.png}
  339. \\end{document}\
  340. `,
  341. },
  342. {
  343. path: 'lion.png',
  344. url: `http://filestore${this.file}`,
  345. fallbackURL: `http://filestore${this.fallback}`,
  346. },
  347. ],
  348. }
  349. sinon.spy(Server, 'getFile')
  350. await ClsiApp.ensureRunning()
  351. await Client.compile(this.project_id, this.request)
  352. })
  353. after(function () {
  354. Server.getFile.restore()
  355. })
  356. it('should download from the primary', function () {
  357. Server.getFile.calledWith(this.file).should.equal(true)
  358. })
  359. it('should not download from the fallback', function () {
  360. Server.getFile.calledWith(this.fallback).should.equal(false)
  361. })
  362. it('should gather metrics', async function () {
  363. const body = await fetchString(`${Settings.apis.clsi.url}/metrics`)
  364. body
  365. .split('\n')
  366. .some(line => {
  367. return (
  368. line.startsWith('url_source') &&
  369. line.includes('path="user-files"')
  370. )
  371. })
  372. .should.equal(true)
  373. })
  374. })
  375. describe('when the primary resource is not available', function () {
  376. before(async function () {
  377. this.project_id = Client.randomId()
  378. this.file = `/project/${Server.randomId()}/file/${Server.randomId()}`
  379. this.fallback = `/bucket/project-blobs/key/ab/cd/${Server.randomId()}`
  380. this.request = {
  381. resources: [
  382. {
  383. path: 'main.tex',
  384. content: `\
  385. \\documentclass{article}
  386. \\usepackage{graphicx}
  387. \\begin{document}
  388. \\includegraphics{lion.png}
  389. \\end{document}\
  390. `,
  391. },
  392. {
  393. path: 'lion.png',
  394. url: `http://filestore/not-found`,
  395. fallbackURL: `http://filestore${this.fallback}`,
  396. },
  397. ],
  398. }
  399. sinon.spy(Server, 'getFile')
  400. await ClsiApp.ensureRunning()
  401. await Client.compile(this.project_id, this.request)
  402. })
  403. after(function () {
  404. Server.getFile.restore()
  405. })
  406. it('should download from the fallback', function () {
  407. Server.getFile.calledWith(`/not-found`).should.equal(true)
  408. Server.getFile.calledWith(this.fallback).should.equal(true)
  409. })
  410. it('should gather metrics', async function () {
  411. const body = await fetchString(`${Settings.apis.clsi.url}/metrics`)
  412. body
  413. .split('\n')
  414. .some(line => {
  415. return (
  416. line.startsWith('url_source') &&
  417. line.includes('path="project-blobs"')
  418. )
  419. })
  420. .should.equal(true)
  421. })
  422. })
  423. })
  424. })