ClsiCacheHandler.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import _ from 'lodash'
  2. import {
  3. fetchNothing,
  4. fetchRedirectWithResponse,
  5. RequestFailedError,
  6. } from '@overleaf/fetch-utils'
  7. import logger from '@overleaf/logger'
  8. import Settings from '@overleaf/settings'
  9. import OError from '@overleaf/o-error'
  10. import { NotFoundError, InvalidNameError } from '../Errors/Errors.js'
  11. import Features from '../../infrastructure/Features.mjs'
  12. import Path from 'node:path'
  13. const TIMEOUT = 4_000
  14. /**
  15. * @type {Map<string, number>}
  16. */
  17. const lastFailures = new Map()
  18. /**
  19. * Keep in sync with validateFilename in services/clsi-cache/app/js/utils.js
  20. *
  21. * @param {string} filename
  22. */
  23. function validateFilename(filename) {
  24. if (filename.split('/').includes('..')) {
  25. throw new InvalidNameError('path traversal')
  26. }
  27. if (
  28. !(
  29. [
  30. 'output.blg',
  31. 'output.log',
  32. 'output.pdf',
  33. 'output.synctex.gz',
  34. 'output.overleaf.json',
  35. 'output.tar.gz',
  36. ].includes(filename) || filename.endsWith('.blg')
  37. )
  38. ) {
  39. throw new InvalidNameError('bad filename')
  40. }
  41. }
  42. /**
  43. * Keep in sync with getIngressLabel in services/clsi-cache/app/js/utils.js
  44. *
  45. * @param {string} fsPath
  46. * @return {string}
  47. */
  48. function getEgressLabel(fsPath) {
  49. if (fsPath.endsWith('.blg')) {
  50. // .blg files may have custom names and can be in nested folders.
  51. return 'output.blg'
  52. }
  53. // The rest is limited to 5 file names via validateFilename: output.pdf, etc.
  54. return fsPath
  55. }
  56. /**
  57. * Clear the cache on all clsi-cache instances.
  58. *
  59. * @param projectId
  60. * @param userId
  61. * @return {Promise<void>}
  62. */
  63. async function clearCache(projectId, userId) {
  64. if (!Features.hasFeature('saas')) return
  65. let path = `/project/${projectId}`
  66. if (userId) {
  67. path += `/user/${userId}`
  68. }
  69. path += '/output'
  70. await Promise.all(
  71. Settings.apis.clsiCache.instances.map(async ({ url, shard }) => {
  72. const u = new URL(url)
  73. u.pathname = path
  74. try {
  75. await fetchNothing(u, {
  76. method: 'DELETE',
  77. signal: AbortSignal.timeout(TIMEOUT),
  78. })
  79. } catch (err) {
  80. throw OError.tag(err, 'clear clsi-cache', { url, shard })
  81. }
  82. })
  83. )
  84. }
  85. /**
  86. * Get an output file from a specific build.
  87. *
  88. * @param projectId
  89. * @param userId
  90. * @param buildId
  91. * @param filename
  92. * @param signal
  93. * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>}
  94. */
  95. async function getOutputFile(
  96. projectId,
  97. userId,
  98. buildId,
  99. filename,
  100. signal = AbortSignal.timeout(TIMEOUT)
  101. ) {
  102. validateFilename(filename)
  103. if (!/^[a-f0-9-]+$/.test(buildId)) {
  104. throw new InvalidNameError('bad buildId')
  105. }
  106. let path = `/project/${projectId}`
  107. if (userId) {
  108. path += `/user/${userId}`
  109. }
  110. path += `/build/${buildId}/search/output/${filename}`
  111. return getRedirectWithFallback(projectId, userId, path, signal)
  112. }
  113. /**
  114. * Get an output file from the most recent build.
  115. *
  116. * @param projectId
  117. * @param userId
  118. * @param filename
  119. * @param signal
  120. * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>}
  121. */
  122. async function getLatestOutputFile(
  123. projectId,
  124. userId,
  125. filename,
  126. signal = AbortSignal.timeout(TIMEOUT)
  127. ) {
  128. validateFilename(filename)
  129. let path = `/project/${projectId}`
  130. if (userId) {
  131. path += `/user/${userId}`
  132. }
  133. path += `/latest/output/${filename}`
  134. return getRedirectWithFallback(projectId, userId, path, signal)
  135. }
  136. /**
  137. * Request the given path from any of the clsi-cache instances.
  138. *
  139. * Some of them might be down temporarily. Try the next one until we receive a redirect or 404.
  140. *
  141. * This function is similar to the Coordinator in the clsi-cache, notable differences:
  142. * - all the logic for sorting builds is in clsi-cache (re-used by clsi and web)
  143. * - fan-out (1 client performs lookup on many clsi-cache instances) is "central" in clsi-cache, resulting in better connection re-use
  144. * - we only cross the k8s cluster boundary via an internal GCLB once ($$$)
  145. *
  146. * @param projectId
  147. * @param userId
  148. * @param path
  149. * @param signal
  150. * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>}
  151. */
  152. async function getRedirectWithFallback(
  153. projectId,
  154. userId,
  155. path,
  156. signal = AbortSignal.timeout(TIMEOUT)
  157. ) {
  158. // Avoid hitting the same instance first all the time.
  159. const instances = _.shuffle(Settings.apis.clsiCache.instances)
  160. for (const { url, shard } of instances) {
  161. if (signal.aborted) {
  162. break // Stop trying the next backend when the signal has expired.
  163. }
  164. const lastFailure = lastFailures.get(url) ?? 0
  165. if (lastFailure) {
  166. // Circuit breaker that avoids retries for 4-16s.
  167. const retryDelay = TIMEOUT * (1 + 3 * Math.random())
  168. if (performance.now() - lastFailure < retryDelay) {
  169. continue
  170. }
  171. }
  172. const u = new URL(url)
  173. u.pathname = path
  174. try {
  175. const {
  176. location,
  177. response: { headers },
  178. } = await fetchRedirectWithResponse(u, {
  179. signal,
  180. })
  181. lastFailures.delete(url) // The shard is back up.
  182. let allFilesRaw = headers.get('X-All-Files')
  183. if (!allFilesRaw.startsWith('[')) {
  184. allFilesRaw = Buffer.from(allFilesRaw, 'base64url').toString()
  185. }
  186. // Success, return the cache entry.
  187. return {
  188. location,
  189. zone: headers.get('X-Zone'),
  190. shard: headers.get('X-Shard') || 'cache',
  191. lastModified: new Date(headers.get('X-Last-Modified')),
  192. size: parseInt(headers.get('X-Content-Length'), 10),
  193. allFiles: JSON.parse(allFilesRaw),
  194. }
  195. } catch (err) {
  196. if (err instanceof RequestFailedError && err.response.status === 404) {
  197. lastFailures.delete(url) // The shard is back up.
  198. break // No clsi-cache instance has cached something for this project/user.
  199. }
  200. lastFailures.set(url, performance.now()) // The shard is unhealthy. Refresh timestamp of last failure.
  201. logger.warn(
  202. { err, projectId, userId, url, shard },
  203. 'getLatestOutputFile from clsi-cache failed'
  204. )
  205. // This clsi-cache instance is down, try the next backend.
  206. }
  207. }
  208. throw new NotFoundError('nothing cached yet')
  209. }
  210. /**
  211. * Populate the clsi-cache for the given project/user with the provided source
  212. *
  213. * This is either another project, or a template (id+version).
  214. *
  215. * @param projectId
  216. * @param userId
  217. * @param sourceProjectId
  218. * @param templateVersionId
  219. * @param imageName
  220. * @param lastUpdated
  221. * @param shard
  222. * @param signal
  223. * @return {Promise<void>}
  224. */
  225. async function prepareCacheSource(
  226. projectId,
  227. userId,
  228. { sourceProjectId, templateVersionId, imageName, lastUpdated, shard, signal }
  229. ) {
  230. imageName = Path.basename(imageName)
  231. const url = new URL(
  232. `/project/${projectId}/user/${userId}/import-from`,
  233. Settings.apis.clsiCache.instances.find(i => i.shard === shard).url
  234. )
  235. try {
  236. await fetchNothing(url, {
  237. method: 'POST',
  238. json: {
  239. sourceProjectId,
  240. lastUpdated,
  241. templateVersionId,
  242. imageName,
  243. },
  244. signal,
  245. })
  246. } catch (err) {
  247. if (err instanceof RequestFailedError && err.response.status === 404) {
  248. throw new NotFoundError()
  249. }
  250. throw err
  251. }
  252. }
  253. /**
  254. * Populate the clsi-cache for a template using a submission build
  255. *
  256. * @param clsiCacheShard
  257. * @param submissionId
  258. * @param buildId
  259. * @param templateVersionId
  260. * @param imageName
  261. * @return {Promise<void>}
  262. */
  263. async function exportSubmissionAsTemplate(
  264. clsiCacheShard,
  265. submissionId,
  266. buildId,
  267. templateVersionId,
  268. imageName
  269. ) {
  270. imageName = Path.basename(imageName)
  271. const url = new URL(
  272. `/submission/${submissionId}/build/${buildId}/export-as-template`,
  273. Settings.apis.clsiCache.instances.find(i => i.shard === clsiCacheShard).url
  274. )
  275. try {
  276. await fetchNothing(url, {
  277. method: 'POST',
  278. json: {
  279. templateVersionId,
  280. imageName,
  281. },
  282. // clsi-cache will poll up-to 15s for the output to be copied from clsi.
  283. signal: AbortSignal.timeout(30_000),
  284. })
  285. } catch (err) {
  286. if (err instanceof RequestFailedError && err.response.status === 404) {
  287. throw new NotFoundError()
  288. }
  289. throw err
  290. }
  291. }
  292. export default {
  293. TIMEOUT,
  294. getEgressLabel,
  295. clearCache,
  296. getOutputFile,
  297. getLatestOutputFile,
  298. prepareCacheSource,
  299. exportSubmissionAsTemplate,
  300. }