ClsiCacheHandler.js 5.5 KB

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