pdf-caching-transport.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import OError from '@overleaf/o-error'
  2. import { fallbackRequest, fetchRange } from './pdf-caching'
  3. import { captureException } from '../../../infrastructure/error-reporter'
  4. import { getPdfCachingMetrics } from './metrics'
  5. import {
  6. cachedUrlLookupEnabled,
  7. enablePdfCaching,
  8. prefetchingEnabled,
  9. prefetchLargeEnabled,
  10. trackPdfDownloadEnabled,
  11. } from './pdf-caching-flags'
  12. import { isNetworkError } from '../../../utils/isNetworkError'
  13. // 30 seconds: The shutdown grace period of a clsi pre-emp instance.
  14. const STALE_OUTPUT_REQUEST_THRESHOLD_MS = 30 * 1000
  15. export function generatePdfCachingTransportFactory(PDFJS) {
  16. // NOTE: The custom transport can be used for tracking download volume.
  17. if (!enablePdfCaching && !trackPdfDownloadEnabled) {
  18. return () => null
  19. }
  20. const usageScore = new Map()
  21. const cachedUrls = new Map()
  22. const metrics = Object.assign(getPdfCachingMetrics(), {
  23. failedCount: 0,
  24. failedOnce: false,
  25. tooMuchBandwidthCount: 0,
  26. tooManyRequestsCount: 0,
  27. cachedCount: 0,
  28. cachedBytes: 0,
  29. fetchedCount: 0,
  30. fetchedBytes: 0,
  31. latencyComputeMax: 0,
  32. latencyComputeTotal: 0,
  33. requestedCount: 0,
  34. requestedBytes: 0,
  35. oldUrlHitCount: 0,
  36. oldUrlMissCount: 0,
  37. enablePdfCaching,
  38. prefetchingEnabled,
  39. prefetchLargeEnabled,
  40. cachedUrlLookupEnabled,
  41. })
  42. const verifyChunks =
  43. new URLSearchParams(window.location.search).get('verify_chunks') === 'true'
  44. class PDFDataRangeTransport extends PDFJS.PDFDataRangeTransport {
  45. constructor({ url, pdfFile, abortController, handleFetchError }) {
  46. super(pdfFile.size, new Uint8Array())
  47. this.url = url
  48. this.pdfFile = pdfFile
  49. // Clone the chunks as the objectId field is encoded to a Uint8Array.
  50. this.pdfRanges = pdfFile.ranges.map(r => Object.assign({}, r))
  51. this.handleFetchError = handleFetchError
  52. this.abortController = abortController
  53. this.startTime = performance.now()
  54. }
  55. abort() {
  56. this.abortController.abort()
  57. }
  58. requestDataRange(start, end) {
  59. const abortSignal = this.abortController.signal
  60. const getDebugInfo = () => ({
  61. // Sentry does not serialize objects in twice nested objects.
  62. // Move the ranges to the root level to see them in Sentry.
  63. pdfRanges: this.pdfRanges,
  64. pdfFile: Object.assign({}, this.pdfFile, {
  65. ranges: '[extracted]',
  66. // Hide prefetched chunks as these include binary blobs.
  67. prefetched: this.pdfFile.prefetched?.length,
  68. }),
  69. pdfUrl: this.url,
  70. start,
  71. end,
  72. metrics,
  73. })
  74. const isStaleOutputRequest = () =>
  75. performance.now() - this.startTime > STALE_OUTPUT_REQUEST_THRESHOLD_MS
  76. const is404 = err => OError.getFullInfo(err).statusCode === 404
  77. const isFromOutputPDFRequest = err =>
  78. OError.getFullInfo(err).url === this.url
  79. // Do not consider "expected 404s" and network errors as pdf caching
  80. // failures.
  81. // "expected 404s" here include:
  82. // - any stale download request
  83. // Example: The user returns to a browser tab after 1h and scrolls.
  84. // - requests for the main output.pdf file
  85. // A fallback request would not be able to retrieve the PDF either.
  86. const isExpectedError = err =>
  87. (is404(err) || isNetworkError(err)) &&
  88. (isStaleOutputRequest() || isFromOutputPDFRequest(err))
  89. fetchRange({
  90. url: this.url,
  91. start,
  92. end,
  93. file: this.pdfFile,
  94. metrics,
  95. usageScore,
  96. cachedUrls,
  97. verifyChunks,
  98. prefetchingEnabled,
  99. prefetchLargeEnabled,
  100. cachedUrlLookupEnabled,
  101. abortSignal,
  102. })
  103. .catch(err => {
  104. if (abortSignal.aborted) return
  105. if (isExpectedError(err)) {
  106. if (is404(err)) {
  107. // A regular pdf-js request would have seen this 404 as well.
  108. } else {
  109. // Flaky network, switch back to regular pdf-js requests.
  110. metrics.failedCount++
  111. metrics.failedOnce = true
  112. }
  113. throw OError.tag(new PDFJS.MissingPDFException(), 'caching', {
  114. statusCode: OError.getFullInfo(err).statusCode,
  115. url: OError.getFullInfo(err).url,
  116. err,
  117. })
  118. }
  119. metrics.failedCount++
  120. metrics.failedOnce = true
  121. if (!enablePdfCaching) {
  122. throw err // This was a fallback request already. Do not retry.
  123. }
  124. err = OError.tag(err, 'optimized pdf download error', getDebugInfo())
  125. console.error(err)
  126. captureException(err, {
  127. tags: {
  128. fromPdfCaching: true,
  129. isFromOutputPDFRequest: isFromOutputPDFRequest(err),
  130. },
  131. })
  132. return fallbackRequest({
  133. url: this.url,
  134. start,
  135. end,
  136. abortSignal,
  137. }).catch(err => {
  138. if (isExpectedError(err)) {
  139. throw OError.tag(new PDFJS.MissingPDFException(), 'fallback', {
  140. statusCode: OError.getFullInfo(err).statusCode,
  141. url: OError.getFullInfo(err).url,
  142. err,
  143. })
  144. }
  145. throw err
  146. })
  147. })
  148. .then(blob => {
  149. if (abortSignal.aborted) return
  150. this.onDataRange(start, blob)
  151. })
  152. .catch(err => {
  153. if (abortSignal.aborted) return
  154. err = OError.tag(err, 'fatal pdf download error', getDebugInfo())
  155. console.error(err)
  156. if (!(err instanceof PDFJS.MissingPDFException)) {
  157. captureException(err, {
  158. tags: {
  159. fromPdfCaching: true,
  160. isFromOutputPDFRequest: isFromOutputPDFRequest(err),
  161. },
  162. })
  163. }
  164. // Signal error for (subsequent) page load.
  165. this.handleFetchError(err)
  166. })
  167. }
  168. }
  169. return function ({ url, pdfFile, abortController, handleFetchError }) {
  170. if (metrics.failedOnce) {
  171. // Disable pdf caching once any fetch request failed.
  172. // Be trigger-happy here until we reached a stable state of the feature.
  173. return null
  174. }
  175. // Latency is collected per preview cycle.
  176. metrics.latencyComputeMax = 0
  177. metrics.latencyComputeTotal = 0
  178. return new PDFDataRangeTransport({
  179. url,
  180. pdfFile,
  181. abortController,
  182. handleFetchError,
  183. })
  184. }
  185. }