pdf-caching-transport.js 9.8 KB

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