UrlCachingTests.js 13 KB

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