ClsiCacheHandler.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. const _ = require('lodash')
  2. const {
  3. fetchNothing,
  4. fetchRedirectWithResponse,
  5. RequestFailedError,
  6. } = require('@overleaf/fetch-utils')
  7. const logger = require('@overleaf/logger')
  8. const Settings = require('@overleaf/settings')
  9. const OError = require('@overleaf/o-error')
  10. const { NotFoundError, InvalidNameError } = require('../Errors/Errors')
  11. function validateFilename(filename) {
  12. if (
  13. !(
  14. [
  15. 'output.blg',
  16. 'output.log',
  17. 'output.pdf',
  18. 'output.synctex.gz',
  19. 'output.overleaf.json',
  20. 'output.tar.gz',
  21. ].includes(filename) || filename.endsWith('.blg')
  22. )
  23. ) {
  24. throw new InvalidNameError('bad filename')
  25. }
  26. }
  27. /**
  28. * Clear the cache on all clsi-cache instances.
  29. *
  30. * @param projectId
  31. * @param userId
  32. * @return {Promise<void>}
  33. */
  34. async function clearCache(projectId, userId) {
  35. let path = `/project/${projectId}`
  36. if (userId) {
  37. path += `/user/${userId}`
  38. }
  39. path += '/output'
  40. await Promise.all(
  41. Settings.apis.clsiCache.instances.map(async ({ url, shard }) => {
  42. const u = new URL(url)
  43. u.pathname = path
  44. try {
  45. await fetchNothing(u, {
  46. method: 'DELETE',
  47. signal: AbortSignal.timeout(15_000),
  48. })
  49. } catch (err) {
  50. throw OError.tag(err, 'clear clsi-cache', { url, shard })
  51. }
  52. })
  53. )
  54. }
  55. /**
  56. * Get an output file from a specific build.
  57. *
  58. * @param projectId
  59. * @param userId
  60. * @param buildId
  61. * @param filename
  62. * @param signal
  63. * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>}
  64. */
  65. async function getOutputFile(
  66. projectId,
  67. userId,
  68. buildId,
  69. filename,
  70. signal = AbortSignal.timeout(15_000)
  71. ) {
  72. validateFilename(filename)
  73. if (!/^[a-f0-9-]+$/.test(buildId)) {
  74. throw new InvalidNameError('bad buildId')
  75. }
  76. let path = `/project/${projectId}`
  77. if (userId) {
  78. path += `/user/${userId}`
  79. }
  80. path += `/build/${buildId}/search/output/${filename}`
  81. return getRedirectWithFallback(projectId, userId, path, signal)
  82. }
  83. /**
  84. * Get an output file from the most recent build.
  85. *
  86. * @param projectId
  87. * @param userId
  88. * @param filename
  89. * @param signal
  90. * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>}
  91. */
  92. async function getLatestOutputFile(
  93. projectId,
  94. userId,
  95. filename,
  96. signal = AbortSignal.timeout(15_000)
  97. ) {
  98. validateFilename(filename)
  99. let path = `/project/${projectId}`
  100. if (userId) {
  101. path += `/user/${userId}`
  102. }
  103. path += `/latest/output/${filename}`
  104. return getRedirectWithFallback(projectId, userId, path, signal)
  105. }
  106. /**
  107. * Request the given path from any of the clsi-cache instances.
  108. *
  109. * Some of them might be down temporarily. Try the next one until we receive a redirect or 404.
  110. *
  111. * This function is similar to the Coordinator in the clsi-cache, notable differences:
  112. * - all the logic for sorting builds is in clsi-cache (re-used by clsi and web)
  113. * - fan-out (1 client performs lookup on many clsi-cache instances) is "central" in clsi-cache, resulting in better connection re-use
  114. * - we only cross the k8s cluster boundary via an internal GCLB once ($$$)
  115. *
  116. * @param projectId
  117. * @param userId
  118. * @param path
  119. * @param signal
  120. * @return {Promise<{size: number, zone: string, shard: string, location: string, lastModified: Date, allFiles: string[]}>}
  121. */
  122. async function getRedirectWithFallback(
  123. projectId,
  124. userId,
  125. path,
  126. signal = AbortSignal.timeout(15_000)
  127. ) {
  128. // Avoid hitting the same instance first all the time.
  129. const instances = _.shuffle(Settings.apis.clsiCache.instances)
  130. for (const { url, shard } of instances) {
  131. const u = new URL(url)
  132. u.pathname = path
  133. try {
  134. const {
  135. location,
  136. response: { headers },
  137. } = await fetchRedirectWithResponse(u, {
  138. signal,
  139. })
  140. // Success, return the cache entry.
  141. return {
  142. location,
  143. zone: headers.get('X-Zone'),
  144. shard: headers.get('X-Shard') || 'cache',
  145. lastModified: new Date(headers.get('X-Last-Modified')),
  146. size: parseInt(headers.get('X-Content-Length'), 10),
  147. allFiles: JSON.parse(headers.get('X-All-Files')),
  148. }
  149. } catch (err) {
  150. if (err instanceof RequestFailedError && err.response.status === 404) {
  151. break // No clsi-cache instance has cached something for this project/user.
  152. }
  153. logger.warn(
  154. { err, projectId, userId, url, shard },
  155. 'getLatestOutputFile from clsi-cache failed'
  156. )
  157. // This clsi-cache instance is down, try the next backend.
  158. }
  159. }
  160. throw new NotFoundError('nothing cached yet')
  161. }
  162. /**
  163. * Populate the clsi-cache for the given project/user with the provided source
  164. *
  165. * This is either another project, or a template (id+version).
  166. *
  167. * @param projectId
  168. * @param userId
  169. * @param sourceProjectId
  170. * @param templateId
  171. * @param templateVersionId
  172. * @param lastUpdated
  173. * @param shard
  174. * @param signal
  175. * @return {Promise<void>}
  176. */
  177. async function prepareCacheSource(
  178. projectId,
  179. userId,
  180. { sourceProjectId, templateId, templateVersionId, lastUpdated, shard, signal }
  181. ) {
  182. const url = new URL(
  183. `/project/${projectId}/user/${userId}/import-from`,
  184. Settings.apis.clsiCache.instances.find(i => i.shard === shard).url
  185. )
  186. try {
  187. await fetchNothing(url, {
  188. method: 'POST',
  189. json: {
  190. sourceProjectId,
  191. lastUpdated,
  192. templateId,
  193. templateVersionId,
  194. },
  195. signal,
  196. })
  197. } catch (err) {
  198. if (err instanceof RequestFailedError && err.response.status === 404) {
  199. throw new NotFoundError()
  200. }
  201. throw err
  202. }
  203. }
  204. module.exports = {
  205. clearCache,
  206. getOutputFile,
  207. getLatestOutputFile,
  208. prepareCacheSource,
  209. }