pdf-caching-transport.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import OError from '@overleaf/o-error'
  2. import { fallbackRequest, fetchRange } from './pdf-caching'
  3. import { captureException } from '@/infrastructure/error-reporter'
  4. import { EDITOR_SESSION_ID, getPdfCachingMetrics } from './metrics'
  5. import {
  6. cachedUrlLookupEnabled,
  7. enablePdfCaching,
  8. prefetchingEnabled,
  9. prefetchLargeEnabled,
  10. trackPdfDownloadEnabled,
  11. useClsiCache,
  12. } from './pdf-caching-flags'
  13. import { isNetworkError } from '@/utils/is-network-error'
  14. import { debugConsole } from '@/utils/debugging'
  15. import { PDFJS } from './pdf-js'
  16. // 30 seconds: The shutdown grace period of a clsi pre-emp instance.
  17. const STALE_OUTPUT_REQUEST_THRESHOLD_MS = 30 * 1000
  18. export function generatePdfCachingTransportFactory() {
  19. // NOTE: The custom transport can be used for tracking download volume.
  20. if (!enablePdfCaching && !trackPdfDownloadEnabled) {
  21. return () => undefined
  22. }
  23. const usageScore = new Map()
  24. const cachedUrls = new Map()
  25. const metrics = Object.assign(getPdfCachingMetrics(), {
  26. failedCount: 0,
  27. failedOnce: false,
  28. tooMuchBandwidthCount: 0,
  29. tooManyRequestsCount: 0,
  30. cachedCount: 0,
  31. cachedBytes: 0,
  32. fetchedCount: 0,
  33. fetchedBytes: 0,
  34. latencyComputeMax: 0,
  35. latencyComputeTotal: 0,
  36. requestedCount: 0,
  37. requestedBytes: 0,
  38. oldUrlHitCount: 0,
  39. oldUrlMissCount: 0,
  40. enablePdfCaching,
  41. prefetchingEnabled,
  42. prefetchLargeEnabled,
  43. cachedUrlLookupEnabled,
  44. })
  45. const verifyChunks =
  46. new URLSearchParams(window.location.search).get('verify_chunks') === 'true'
  47. class PDFDataRangeTransport extends PDFJS.PDFDataRangeTransport {
  48. constructor({ url, pdfFile, abortController, handleFetchError }) {
  49. super(pdfFile.size, new Uint8Array())
  50. this.url = url
  51. pdfFile.ranges = pdfFile.ranges || []
  52. pdfFile.editorId = pdfFile.editorId || EDITOR_SESSION_ID
  53. this.pdfFile = pdfFile
  54. // Clone the chunks as the objectId field is encoded to a Uint8Array.
  55. this.leanPdfRanges = pdfFile.ranges.map(r => Object.assign({}, r))
  56. this.handleFetchError = handleFetchError
  57. this.abortController = abortController
  58. this.startTime = performance.now()
  59. const params = new URL(url).searchParams
  60. // drop no needed params
  61. params.delete('enable_pdf_caching')
  62. params.delete('verify_chunks')
  63. this.queryForChunks = params.toString()
  64. }
  65. abort() {
  66. this.abortController.abort()
  67. }
  68. requestDataRange(start, end) {
  69. const abortSignal = this.abortController.signal
  70. const getDebugInfo = () => ({
  71. // Sentry does not serialize objects in twice nested objects.
  72. // Move the ranges to the root level to see them in Sentry.
  73. pdfRanges: this.leanPdfRanges,
  74. pdfFile: Object.assign({}, this.pdfFile, {
  75. ranges: '[extracted]',
  76. // Hide prefetched chunks as these include binary blobs.
  77. prefetched: this.pdfFile.prefetched?.length,
  78. }),
  79. pdfUrl: this.url,
  80. start,
  81. end,
  82. metrics,
  83. })
  84. const isStaleOutputRequest = () =>
  85. performance.now() - this.startTime > STALE_OUTPUT_REQUEST_THRESHOLD_MS
  86. const is404 = err => OError.getFullInfo(err).statusCode === 404
  87. const isFromOutputPDFRequest = err =>
  88. OError.getFullInfo(err).url?.includes?.('/output.pdf') === true
  89. // Do not consider "expected 404s" and network errors as pdf caching
  90. // failures.
  91. // "expected 404s" here include:
  92. // - any stale download request
  93. // Example: The user returns to a browser tab after 1h and scrolls.
  94. // - requests for the main output.pdf file
  95. // A fallback request would not be able to retrieve the PDF either.
  96. const isExpectedError = err =>
  97. (is404(err) || isNetworkError(err)) &&
  98. (isStaleOutputRequest() || isFromOutputPDFRequest(err))
  99. const usesCache = url => {
  100. if (!url) return false
  101. const u = new URL(url)
  102. return (
  103. u.pathname.endsWith(
  104. `build/${this.pdfFile.editorId}-${this.pdfFile.build}/output/output.pdf`
  105. ) && u.searchParams.get('clsiserverid') === 'cache'
  106. )
  107. }
  108. const canTryFromCache = err => {
  109. if (!useClsiCache) return false
  110. if (!is404(err)) return false
  111. return !usesCache(OError.getFullInfo(err).url)
  112. }
  113. const getOutputPDFURLFromCache = () => {
  114. if (usesCache(this.url)) return this.url
  115. const u = new URL(this.url)
  116. u.searchParams.set('clsiserverid', 'cache')
  117. u.pathname = u.pathname.replace(
  118. /build\/[a-f0-9-]+\//,
  119. `build/${this.pdfFile.editorId}-${this.pdfFile.build}/`
  120. )
  121. return u.href
  122. }
  123. const fetchFromCache = async () => {
  124. // Try fetching the chunk from clsi-cache
  125. const url = getOutputPDFURLFromCache()
  126. return fallbackRequest({
  127. file: this.pdfFile,
  128. url,
  129. start,
  130. end,
  131. abortSignal,
  132. })
  133. .then(blob => {
  134. // Send the next output.pdf request directly to the cache.
  135. this.url = url
  136. // Only try downloading chunks that were cached previously
  137. this.pdfFile.ranges = this.pdfFile.ranges.filter(r =>
  138. cachedUrls.has(r.hash)
  139. )
  140. return blob
  141. })
  142. .catch(err => {
  143. throw OError.tag(
  144. new PDFJS.MissingPDFException(),
  145. 'cache-fallback',
  146. {
  147. statusCode: OError.getFullInfo(err).statusCode,
  148. url: OError.getFullInfo(err).url,
  149. err,
  150. }
  151. )
  152. })
  153. }
  154. fetchRange({
  155. url: this.url,
  156. start,
  157. end,
  158. file: this.pdfFile,
  159. queryForChunks: this.queryForChunks,
  160. metrics,
  161. usageScore,
  162. cachedUrls,
  163. verifyChunks,
  164. prefetchingEnabled,
  165. prefetchLargeEnabled,
  166. cachedUrlLookupEnabled,
  167. abortSignal,
  168. fallbackToCacheURL: getOutputPDFURLFromCache(),
  169. })
  170. .catch(err => {
  171. if (abortSignal.aborted) return
  172. if (canTryFromCache(err)) return fetchFromCache()
  173. if (isExpectedError(err)) {
  174. if (is404(err)) {
  175. // A regular pdf-js request would have seen this 404 as well.
  176. } else {
  177. // Flaky network, switch back to regular pdf-js requests.
  178. metrics.failedCount++
  179. metrics.failedOnce = true
  180. }
  181. throw OError.tag(new PDFJS.MissingPDFException(), 'caching', {
  182. statusCode: OError.getFullInfo(err).statusCode,
  183. url: OError.getFullInfo(err).url,
  184. err,
  185. })
  186. }
  187. metrics.failedCount++
  188. metrics.failedOnce = true
  189. if (!enablePdfCaching) {
  190. throw err // This was a fallback request already. Do not retry.
  191. }
  192. err = OError.tag(err, 'optimized pdf download error', getDebugInfo())
  193. debugConsole.error(err)
  194. captureException(err, {
  195. tags: {
  196. fromPdfCaching: true,
  197. isFromOutputPDFRequest: isFromOutputPDFRequest(err),
  198. },
  199. })
  200. return fallbackRequest({
  201. file: this.pdfFile,
  202. url: this.url,
  203. start,
  204. end,
  205. abortSignal,
  206. }).catch(err => {
  207. if (canTryFromCache(err)) return fetchFromCache()
  208. if (isExpectedError(err)) {
  209. throw OError.tag(new PDFJS.MissingPDFException(), 'fallback', {
  210. statusCode: OError.getFullInfo(err).statusCode,
  211. url: OError.getFullInfo(err).url,
  212. err,
  213. })
  214. }
  215. throw err
  216. })
  217. })
  218. .then(blob => {
  219. if (abortSignal.aborted) return
  220. this.onDataRange(start, blob)
  221. })
  222. .catch(err => {
  223. if (abortSignal.aborted) return
  224. err = OError.tag(err, 'fatal pdf download error', getDebugInfo())
  225. debugConsole.error(err)
  226. if (!(err instanceof PDFJS.MissingPDFException)) {
  227. captureException(err, {
  228. tags: {
  229. fromPdfCaching: true,
  230. isFromOutputPDFRequest: isFromOutputPDFRequest(err),
  231. },
  232. })
  233. }
  234. // Signal error for (subsequent) page load.
  235. this.handleFetchError(err)
  236. })
  237. }
  238. }
  239. return function ({ url, pdfFile, abortController, handleFetchError }) {
  240. if (metrics.failedOnce) {
  241. // Disable pdf caching once any fetch request failed.
  242. // Be trigger-happy here until we reached a stable state of the feature.
  243. return undefined
  244. }
  245. // Latency is collected per preview cycle.
  246. metrics.latencyComputeMax = 0
  247. metrics.latencyComputeTotal = 0
  248. return new PDFDataRangeTransport({
  249. url,
  250. pdfFile,
  251. abortController,
  252. handleFetchError,
  253. })
  254. }
  255. }