pdf-caching-transport.js 6.4 KB

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