pdf-caching.ts 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153
  1. import {
  2. Chunk,
  3. PartiallyProcessedPDFFile,
  4. PDFRange,
  5. PrefetchedChunk,
  6. ProcessedPDFFile,
  7. } from '@ol-types/compile'
  8. import OError from '@overleaf/o-error'
  9. import { normalizeStringError } from './normalize-string-error'
  10. import { PdfCachingMetricsFull } from './types'
  11. const PDF_JS_CHUNK_SIZE = 128 * 1024
  12. const MAX_SUB_REQUEST_COUNT = 4
  13. const MAX_SUB_REQUEST_BYTES = 4 * PDF_JS_CHUNK_SIZE
  14. const SAMPLE_NGINX_BOUNDARY = '00000000000000000001'
  15. export const HEADER_OVERHEAD_PER_MULTI_PART_CHUNK = composeMultipartHeader({
  16. boundary: SAMPLE_NGINX_BOUNDARY,
  17. // Assume an upper bound of O(9GB) for the pdf size.
  18. start: 9 * 1024 * 1024 * 1024,
  19. end: 9 * 1024 * 1024 * 1024,
  20. size: 9 * 1024 * 1024 * 1024,
  21. }).length
  22. const MULTI_PART_THRESHOLD = 4
  23. const INCREMENTAL_CACHE_SIZE = 1000
  24. // Download large chunks once the shard bandwidth exceeds 50% of their size.
  25. const CHUNK_USAGE_THRESHOLD_PREFETCH_LARGE = 0.5
  26. // Preferred caching once we downloaded a chunk (in multiple shards) in full.
  27. const CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED = 1
  28. const CHUNK_USAGE_THRESHOLD_CACHED = 42
  29. // 42 * 0.7^11 < 1, aka we keep stale entries around for 11 compiles.
  30. const CHUNK_USAGE_STALE_DECAY_RATE = 0.7
  31. let cacheFlag: RequestCache = 'default'
  32. // Work around a Chrome bug: https://issues.chromium.org/issues/40542704
  33. // Multiple simultaneous requests to same URL with Range header cause failure (block backend returns ERR_CACHE_OPERATION_NOT_SUPPORTED)
  34. const CACHE_NO_STORE = 'no-store'
  35. async function fetchWithBrowserCacheFallback(url: string, init: RequestInit) {
  36. try {
  37. return await fetch(url, init)
  38. } catch (err) {
  39. if (
  40. (init.headers as Headers | undefined)?.has('Range') &&
  41. init.cache !== CACHE_NO_STORE
  42. ) {
  43. cacheFlag = CACHE_NO_STORE
  44. init.cache = CACHE_NO_STORE
  45. return await fetch(url, init)
  46. }
  47. throw err
  48. }
  49. }
  50. function backfillEdgeBounds(file: PartiallyProcessedPDFFile) {
  51. const encoder = new TextEncoder()
  52. for (const chunk of file.ranges) {
  53. if (chunk.objectId) {
  54. chunk.objectId = encoder.encode(chunk.objectId as string)
  55. chunk.start -= chunk.objectId.byteLength
  56. chunk.size = chunk.end - chunk.start
  57. }
  58. }
  59. }
  60. function trimState({
  61. usageScore,
  62. cachedUrls,
  63. }: {
  64. usageScore: Map<string, number>
  65. cachedUrls: Map<string, { url: string; init: RequestInit }>
  66. }) {
  67. for (const [hash, score] of usageScore) {
  68. if (usageScore.size < INCREMENTAL_CACHE_SIZE) {
  69. break
  70. }
  71. if (score >= CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED) {
  72. // Keep entries that are worth caching around for longer.
  73. usageScore.set(hash, score * CHUNK_USAGE_STALE_DECAY_RATE)
  74. continue
  75. }
  76. cachedUrls.delete(hash)
  77. usageScore.delete(hash)
  78. }
  79. }
  80. export function preprocessFileOnce({
  81. file,
  82. usageScore,
  83. cachedUrls,
  84. }: {
  85. file: PartiallyProcessedPDFFile
  86. usageScore: Map<string, number>
  87. cachedUrls: Map<string, { url: string; init: RequestInit }>
  88. }) {
  89. if ('preprocessed' in file && file.preprocessed) return file
  90. file.preprocessed = true
  91. file.createdAt = new Date(file.createdAt || '')
  92. file.prefetched = file.prefetched || []
  93. trimState({ usageScore, cachedUrls })
  94. backfillEdgeBounds(file)
  95. }
  96. function estimateSizeOfMultipartResponse(chunks: Chunk[]) {
  97. /*
  98. --boundary
  99. HEADER
  100. BLOB
  101. --boundary
  102. HEADER
  103. BLOB
  104. --boundary--
  105. */
  106. return (
  107. chunks.reduce(
  108. (totalBytes, chunk) =>
  109. totalBytes +
  110. HEADER_OVERHEAD_PER_MULTI_PART_CHUNK +
  111. (chunk.end - chunk.start),
  112. 0
  113. ) + ('\r\n' + SAMPLE_NGINX_BOUNDARY + '--').length
  114. )
  115. }
  116. function trackDownloadStats(
  117. metrics: PdfCachingMetricsFull,
  118. {
  119. size,
  120. cachedCount,
  121. cachedBytes,
  122. fetchedCount,
  123. fetchedBytes,
  124. }: {
  125. size: number
  126. cachedCount: number
  127. cachedBytes: number
  128. fetchedCount: number
  129. fetchedBytes: number
  130. }
  131. ) {
  132. metrics.cachedCount += cachedCount
  133. metrics.cachedBytes += cachedBytes
  134. metrics.fetchedCount += fetchedCount
  135. metrics.fetchedBytes += fetchedBytes
  136. metrics.requestedCount++
  137. metrics.requestedBytes += size
  138. }
  139. function trackChunkVerify(
  140. metrics: PdfCachingMetricsFull,
  141. {
  142. sizeDiffers,
  143. mismatch,
  144. success,
  145. }: {
  146. sizeDiffers: boolean
  147. mismatch: boolean
  148. success: boolean
  149. }
  150. ) {
  151. if (sizeDiffers) {
  152. incrementMetric(metrics, 'chunkVerifySizeDiffers')
  153. }
  154. if (mismatch) {
  155. incrementMetric(metrics, 'chunkVerifyMismatch')
  156. }
  157. if (success) {
  158. incrementMetric(metrics, 'chunkVerifySuccess')
  159. }
  160. }
  161. function backFillObjectContext(
  162. chunk: Chunk | Chunk[] | PDFRange<Uint8Array>,
  163. arrayBuffer: ArrayBuffer
  164. ) {
  165. if (!('objectId' in chunk)) {
  166. // This is a dynamic chunk
  167. return new Uint8Array(arrayBuffer)
  168. }
  169. const { size, objectId } = chunk
  170. const fullBuffer = new Uint8Array(size)
  171. const sourceBuffer = new Uint8Array(arrayBuffer)
  172. try {
  173. fullBuffer.set(objectId, 0)
  174. fullBuffer.set(sourceBuffer, objectId.byteLength)
  175. } catch (err) {
  176. throw OError.tag(err, 'broken back-filling of object-id', {
  177. objectIdByteLength: objectId.byteLength,
  178. fullBufferByteLength: fullBuffer.byteLength,
  179. arrayBufferByteLength: arrayBuffer.byteLength,
  180. sourceBufferByteLength: sourceBuffer.byteLength,
  181. })
  182. }
  183. return fullBuffer
  184. }
  185. function getMatchingChunks<ChunkType extends Chunk>(
  186. chunks: ChunkType[],
  187. start: number,
  188. end: number
  189. ) {
  190. const matchingChunks: ChunkType[] = []
  191. for (const chunk of chunks) {
  192. if (chunk.end <= start) {
  193. // no overlap:
  194. // | REQUESTED_RANGE |
  195. // | CHUNK |
  196. continue
  197. }
  198. if (chunk.start >= end) {
  199. // no overlap:
  200. // | REQUESTED_RANGE |
  201. // | CHUNK |
  202. break
  203. }
  204. matchingChunks.push(chunk)
  205. }
  206. return matchingChunks
  207. }
  208. function sortBySizeDESC(a: { size: number }, b: { size: number }) {
  209. return a.size > b.size ? -1 : 1
  210. }
  211. function sortByStartASC(a: { start: number }, b: { start: number }) {
  212. return a.start > b.start ? 1 : -1
  213. }
  214. function usageAboveThreshold(chunk: PDFRange) {
  215. // We fetched enough shards of this chunk. Cache it in full now.
  216. return chunk.totalUsage > CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED
  217. }
  218. function cutRequestAmplification({
  219. potentialChunks,
  220. usageScore,
  221. metrics,
  222. start,
  223. end,
  224. prefetchLargeEnabled,
  225. }: {
  226. potentialChunks: PDFRange[]
  227. usageScore: Map<string, number>
  228. metrics: PdfCachingMetricsFull
  229. start: number
  230. end: number
  231. prefetchLargeEnabled: boolean
  232. }) {
  233. // NOTE: Map keys are stored in insertion order.
  234. // We re-insert keys on cache hit and turn 'usageScore' into a cheap LRU.
  235. const chunks: PDFRange[] = []
  236. const skipAlreadyAdded = (chunk: PDFRange) => !chunks.includes(chunk)
  237. let tooManyRequests = false
  238. let tooMuchBandwidth = false
  239. let newChunks = 0
  240. let newCacheBandwidth = 0
  241. for (const chunk of potentialChunks) {
  242. const newUsage =
  243. (Math.min(end, chunk.end) - Math.max(start, chunk.start)) / chunk.size
  244. const totalUsage = (usageScore.get(chunk.hash) || 0) + newUsage
  245. usageScore.delete(chunk.hash)
  246. usageScore.set(chunk.hash, totalUsage)
  247. chunk.totalUsage = totalUsage
  248. }
  249. // Always download already cached entries
  250. for (const chunk of potentialChunks) {
  251. if (chunk.totalUsage >= CHUNK_USAGE_THRESHOLD_CACHED) {
  252. chunks.push(chunk)
  253. }
  254. }
  255. // Prefer large blobs over small ones.
  256. potentialChunks.sort(sortBySizeDESC)
  257. // Prefer chunks with high (previous) usage over brand-new chunks.
  258. const firstComeFirstCache = () => true
  259. for (const trigger of [usageAboveThreshold, firstComeFirstCache]) {
  260. for (const chunk of potentialChunks.filter(skipAlreadyAdded)) {
  261. if (newCacheBandwidth + chunk.size > MAX_SUB_REQUEST_BYTES) {
  262. // We would breach the bandwidth amplification limit.
  263. tooMuchBandwidth = true
  264. continue
  265. }
  266. if (newChunks + 1 > MAX_SUB_REQUEST_COUNT) {
  267. // We would breach the request rate amplification limit.
  268. tooManyRequests = true
  269. continue
  270. }
  271. if (trigger(chunk)) {
  272. newCacheBandwidth += chunk.size
  273. newChunks += 1
  274. chunks.push(chunk)
  275. }
  276. }
  277. }
  278. const largeChunk = potentialChunks.filter(skipAlreadyAdded)[0]
  279. if (largeChunk?.size >= PDF_JS_CHUNK_SIZE) {
  280. // This is a large chunk that exceeds the bandwidth amplification limit.
  281. if (largeChunk.start <= start && largeChunk.end >= end) {
  282. // This is a large chunk spanning the entire range. pdf.js will only
  283. // request these in case it needs the underlying stream, so it is OK to
  284. // download as much data as the stream is large in one go.
  285. chunks.push(largeChunk)
  286. } else if (
  287. prefetchLargeEnabled &&
  288. largeChunk.totalUsage > CHUNK_USAGE_THRESHOLD_PREFETCH_LARGE
  289. ) {
  290. // pdf.js actually wants the smaller (dynamic) chunk in the range that
  291. // happens to sit right next to this large chunk.
  292. // pdf.js has requested a lot of the large chunk via shards by now, and it
  293. // is time to download it in full to stop "wasting" more bandwidth and
  294. // more importantly cut down latency as we can prefetch the small chunk.
  295. chunks.push(largeChunk)
  296. }
  297. }
  298. if (tooManyRequests) {
  299. metrics.tooManyRequestsCount++
  300. }
  301. if (tooMuchBandwidth) {
  302. metrics.tooMuchBandwidthCount++
  303. }
  304. chunks.sort(sortByStartASC)
  305. return chunks
  306. }
  307. function getInterleavingDynamicChunks(
  308. chunks: Chunk[],
  309. start: number,
  310. end: number
  311. ) {
  312. const dynamicChunks: Chunk[] = []
  313. for (const chunk of chunks) {
  314. if (start < chunk.start) {
  315. dynamicChunks.push({ start, end: chunk.start })
  316. }
  317. start = chunk.end
  318. }
  319. if (start < end) {
  320. dynamicChunks.push({ start, end })
  321. }
  322. return dynamicChunks
  323. }
  324. function getServerTime(response: Response) {
  325. const raw = response.headers.get('Date')
  326. if (!raw) return new Date()
  327. return new Date(raw)
  328. }
  329. function getResponseSize(response: Response) {
  330. const raw = response.headers.get('Content-Length')
  331. if (!raw) return 0
  332. return parseInt(raw, 10)
  333. }
  334. function getMultipartBoundary(response: Response, chunk: Chunk | Chunk[]) {
  335. if (!Array.isArray(chunk)) return ''
  336. const raw = response.headers.get('Content-Type')
  337. if (raw?.includes('multipart/byteranges')) {
  338. const idx = raw.indexOf('boundary=')
  339. if (idx !== -1) return raw.slice(idx + 'boundary='.length)
  340. }
  341. throw new OError('missing boundary on multipart request', {
  342. headers: Object.fromEntries(response.headers.entries()),
  343. chunk,
  344. })
  345. }
  346. function composeMultipartHeader({
  347. boundary,
  348. start,
  349. end,
  350. size,
  351. }: {
  352. boundary: string
  353. start: number
  354. end: number
  355. size: number
  356. }) {
  357. return `\r\n--${boundary}\r\nContent-Type: application/pdf\r\nContent-Range: bytes ${start}-${
  358. end - 1
  359. }/${size}\r\n\r\n`
  360. }
  361. function incrementMetric(
  362. metrics: PdfCachingMetricsFull,
  363. key: keyof PdfCachingMetricsFull
  364. ) {
  365. if (key in metrics) {
  366. metrics[key]++
  367. } else {
  368. ;(metrics[key] as number) = 1
  369. }
  370. }
  371. function resolveMultiPartResponses({
  372. file,
  373. chunks,
  374. data,
  375. boundary,
  376. metrics,
  377. }: {
  378. file: ProcessedPDFFile
  379. chunks: Chunk[]
  380. data: Uint8Array
  381. boundary: string
  382. metrics: PdfCachingMetricsFull
  383. }) {
  384. const responses = []
  385. let offsetStart = 0
  386. const encoder = new TextEncoder()
  387. for (const chunk of chunks) {
  388. const header = composeMultipartHeader({
  389. boundary,
  390. start: chunk.start,
  391. end: chunk.end,
  392. size: file.size,
  393. })
  394. const headerSize = header.length
  395. // Verify header content. A proxy might have tampered with it.
  396. const headerRaw = encoder.encode(header)
  397. if (
  398. !data
  399. .subarray(offsetStart, offsetStart + headerSize)
  400. .every((v, idx) => v === headerRaw[idx])
  401. ) {
  402. incrementMetric(metrics, 'headerVerifyFailure')
  403. throw new OError('multipart response header does not match', {
  404. actual: new TextDecoder().decode(
  405. data.subarray(offsetStart, offsetStart + headerSize)
  406. ),
  407. expected: header,
  408. })
  409. }
  410. offsetStart += headerSize
  411. const chunkSize = chunk.end - chunk.start
  412. responses.push({
  413. chunk,
  414. data: data.subarray(offsetStart, offsetStart + chunkSize),
  415. })
  416. offsetStart += chunkSize
  417. }
  418. return responses
  419. }
  420. function checkChunkResponse(
  421. response: Response,
  422. estimatedSize: number,
  423. init: RequestInit
  424. ) {
  425. if (!(response.status === 206 || response.status === 200)) {
  426. throw new OError('non successful response status: ' + response.status, {
  427. statusCode: response.status,
  428. responseHeaders: Object.fromEntries(response.headers.entries()),
  429. requestHeader: init.headers,
  430. })
  431. }
  432. const responseSize = getResponseSize(response)
  433. if (!responseSize) {
  434. throw new OError('content-length response header missing', {
  435. responseHeaders: Object.fromEntries(response.headers.entries()),
  436. requestHeader: init.headers,
  437. })
  438. }
  439. if (responseSize > estimatedSize) {
  440. throw new OError('response size exceeds estimate', {
  441. estimatedSize,
  442. responseSize,
  443. responseHeaders: Object.fromEntries(response.headers.entries()),
  444. requestHeader: init.headers,
  445. })
  446. }
  447. }
  448. function getDynamicChunkInit({
  449. file,
  450. start,
  451. end,
  452. signal,
  453. }: {
  454. file: ProcessedPDFFile
  455. start: number
  456. end: number
  457. signal?: AbortSignal | null
  458. }): RequestInit {
  459. // Avoid making range request when downloading the PDF file in full.
  460. const isFullFile = start === 0 && end === file.size
  461. return {
  462. cache: cacheFlag,
  463. headers: new Headers(
  464. isFullFile ? {} : { Range: `bytes=${start}-${end - 1}` }
  465. ),
  466. signal,
  467. }
  468. }
  469. export async function fallbackRequest({
  470. file,
  471. url,
  472. start,
  473. end,
  474. abortSignal,
  475. }: {
  476. file: ProcessedPDFFile
  477. url: string
  478. start: number
  479. end: number
  480. abortSignal: AbortSignal
  481. }) {
  482. try {
  483. const init = getDynamicChunkInit({ file, start, end, signal: abortSignal })
  484. const response = await fetchWithBrowserCacheFallback(url, init)
  485. checkChunkResponse(response, end - start, init)
  486. return await response.arrayBuffer()
  487. } catch (e) {
  488. throw OError.tag(normalizeStringError(e), 'fallback request failed', {
  489. url,
  490. start,
  491. end,
  492. })
  493. }
  494. }
  495. async function verifyRange({
  496. file,
  497. url,
  498. start,
  499. end,
  500. metrics,
  501. actual,
  502. abortSignal,
  503. }: {
  504. file: ProcessedPDFFile
  505. url: string
  506. start: number
  507. end: number
  508. metrics: PdfCachingMetricsFull
  509. actual: Uint8Array
  510. abortSignal: AbortSignal
  511. }) {
  512. let expectedRaw
  513. try {
  514. expectedRaw = await fallbackRequest({
  515. file,
  516. url,
  517. start,
  518. end,
  519. abortSignal,
  520. })
  521. } catch (error) {
  522. throw OError.tag(error, 'cannot verify range', { url, start, end })
  523. }
  524. const expected = new Uint8Array(expectedRaw)
  525. const stats = {
  526. sizeDiffers: false,
  527. mismatch: false,
  528. success: false,
  529. }
  530. if (actual.byteLength !== expected.byteLength) {
  531. stats.sizeDiffers = true
  532. } else if (!expected.every((v, idx) => v === actual[idx])) {
  533. stats.mismatch = true
  534. } else {
  535. stats.success = true
  536. }
  537. trackChunkVerify(metrics, stats)
  538. return expected
  539. }
  540. function skipPrefetched<ChunkType extends Chunk>(
  541. chunks: ChunkType[],
  542. prefetched: PDFRange[],
  543. start: number,
  544. end: number
  545. ): ChunkType[] {
  546. return chunks.filter(chunk => {
  547. return !prefetched.find(
  548. c =>
  549. c.start <= Math.max(chunk.start, start) &&
  550. c.end >= Math.min(chunk.end, end)
  551. )
  552. })
  553. }
  554. async function fetchChunk({
  555. chunk,
  556. url,
  557. init,
  558. cachedUrls,
  559. metrics,
  560. cachedUrlLookupEnabled,
  561. canTryFromCache,
  562. fallbackToCacheURL,
  563. file,
  564. }: {
  565. chunk: Chunk | PDFRange<Uint8Array> | Chunk[]
  566. url: string
  567. init: RequestInit
  568. cachedUrls: Map<string, { url: string; init: RequestInit }>
  569. metrics: PdfCachingMetricsFull
  570. cachedUrlLookupEnabled: boolean
  571. canTryFromCache: (error: any) => boolean
  572. fallbackToCacheURL: string
  573. file: ProcessedPDFFile
  574. }) {
  575. const estimatedSize = Array.isArray(chunk)
  576. ? estimateSizeOfMultipartResponse(chunk)
  577. : chunk.end - chunk.start
  578. if ('hash' in chunk) {
  579. const oldUrl = cachedUrls.get(chunk.hash)
  580. if (cachedUrlLookupEnabled && chunk.hash && oldUrl && oldUrl.url !== url) {
  581. // When the clsi server id changes, the content id changes too and as a
  582. // result all the browser cache keys (aka urls) get invalidated.
  583. // We memorize the previous browser cache keys in `cachedUrls`.
  584. try {
  585. oldUrl.init.signal = init.signal
  586. const response = await fetchWithBrowserCacheFallback(
  587. oldUrl.url,
  588. oldUrl.init
  589. )
  590. if (response.status === 200) {
  591. checkChunkResponse(response, estimatedSize, init)
  592. metrics.oldUrlHitCount += 1
  593. return response
  594. }
  595. if (response.status === 404) {
  596. // The old browser cache entry is gone and the old file is gone too.
  597. metrics.oldUrlMissCount += 1
  598. }
  599. // Fallback to the latest url.
  600. } catch (e) {
  601. // Fallback to the latest url.
  602. }
  603. cachedUrls.delete(chunk.hash) // clear cached state
  604. }
  605. }
  606. let response
  607. try {
  608. response = await fetchWithBrowserCacheFallback(url, init)
  609. checkChunkResponse(response, estimatedSize, init)
  610. if ('hash' in chunk && chunk.hash) {
  611. delete init.signal // omit the signal from the cache
  612. cachedUrls.set(chunk.hash, { url, init })
  613. }
  614. } catch (rawErr1) {
  615. const err1 = normalizeStringError(rawErr1)
  616. if ('hash' in chunk && chunk.hash) {
  617. cachedUrls.delete(chunk.hash)
  618. }
  619. const hasOthersCached = cachedUrls.size > 0
  620. const info = { url, init, statusCode: response?.status }
  621. const errTagged = OError.tag(err1, 'add info for canTryFromCache', info)
  622. if (hasOthersCached && canTryFromCache(errTagged) && fallbackToCacheURL) {
  623. // Only try downloading chunks that were cached previously
  624. file.ranges = file.ranges.filter(r => cachedUrls.has(r.hash))
  625. // Try harder at fetching the chunk, fallback to cache
  626. url = fallbackToCacheURL
  627. if ('hash' in chunk && chunk.hash) {
  628. init = getDynamicChunkInit({
  629. file,
  630. // skip object id prefix
  631. start: chunk.start + chunk.objectId.byteLength,
  632. end: chunk.end,
  633. signal: init.signal,
  634. })
  635. }
  636. try {
  637. response = await fetchWithBrowserCacheFallback(url, init)
  638. checkChunkResponse(response, estimatedSize, init)
  639. } catch (err2) {
  640. throw err1
  641. }
  642. } else {
  643. throw err1
  644. }
  645. }
  646. return response
  647. }
  648. function addPrefetchingChunks({
  649. file,
  650. start,
  651. end,
  652. dynamicChunks,
  653. prefetchXRefTable,
  654. startXRefTableRange,
  655. }: {
  656. file: ProcessedPDFFile
  657. start: number
  658. end: number
  659. dynamicChunks: Chunk[]
  660. prefetchXRefTable: boolean
  661. startXRefTableRange: number
  662. }) {
  663. // Prefetch in case this is the first range, or we are fetching dynamic
  664. // chunks anyway (so we can ride-share the round trip).
  665. // Rendering cannot start without downloading the xref table, so it's OK to
  666. // "delay" the first range.
  667. if (!(start === 0 || dynamicChunks.length > 0)) {
  668. return
  669. }
  670. let extraChunks: Chunk[] = []
  671. if (prefetchXRefTable) {
  672. // Prefetch the dynamic chunks around the xref table.
  673. extraChunks = skipPrefetched(
  674. getInterleavingDynamicChunks(
  675. getMatchingChunks(file.ranges, startXRefTableRange, file.size),
  676. startXRefTableRange,
  677. file.size
  678. ),
  679. file.prefetched,
  680. startXRefTableRange,
  681. file.size
  682. )
  683. }
  684. // Stop at the xref table range if present -- we may prefetch it early ^^^.
  685. const prefetchEnd = startXRefTableRange || file.size
  686. extraChunks = extraChunks.concat(
  687. skipPrefetched(
  688. getInterleavingDynamicChunks(
  689. getMatchingChunks(file.ranges, end, prefetchEnd),
  690. end,
  691. prefetchEnd
  692. ),
  693. file.prefetched,
  694. end,
  695. prefetchEnd
  696. )
  697. )
  698. let sum = estimateSizeOfMultipartResponse(dynamicChunks)
  699. for (const chunk of extraChunks) {
  700. const downloadSize =
  701. chunk.end - chunk.start + HEADER_OVERHEAD_PER_MULTI_PART_CHUNK
  702. if (sum + downloadSize > PDF_JS_CHUNK_SIZE) {
  703. // In prefetching this chunk we would exceed the bandwidth limit.
  704. // Try to prefetch another (smaller) chunk.
  705. continue
  706. }
  707. const sibling = dynamicChunks.find(
  708. sibling => sibling.end === chunk.start || sibling.start === chunk.end
  709. )
  710. if (sibling) {
  711. sum += downloadSize
  712. // Just expand the existing dynamic chunk.
  713. sibling.start = Math.min(sibling.start, chunk.start)
  714. sibling.end = Math.max(sibling.end, chunk.end)
  715. continue
  716. }
  717. if (dynamicChunks.length > MULTI_PART_THRESHOLD) {
  718. // We are already performing a multipart request. Add another part.
  719. } else if (dynamicChunks.length < MULTI_PART_THRESHOLD) {
  720. // We are not yet performing a multipart request. Add another request.
  721. } else {
  722. // In prefetching this chunk we would switch to a multipart request.
  723. // Try to prefetch another (smaller) chunk.
  724. continue
  725. }
  726. sum += downloadSize
  727. dynamicChunks.push(chunk)
  728. }
  729. dynamicChunks.sort(sortByStartASC)
  730. // Ensure that no chunks are overlapping.
  731. let lastEnd = 0
  732. for (const [idx, chunk] of dynamicChunks.entries()) {
  733. if (chunk.start < lastEnd) {
  734. throw new OError('detected overlapping dynamic chunks', {
  735. chunk,
  736. lastChunk: dynamicChunks[idx - 1],
  737. })
  738. }
  739. lastEnd = chunk.end
  740. }
  741. }
  742. class Timer {
  743. max: number
  744. total: number
  745. lastStart: number
  746. constructor() {
  747. this.max = 0
  748. this.total = 0
  749. this.lastStart = 0
  750. }
  751. startBlockingCompute() {
  752. this.lastStart = performance.now()
  753. }
  754. finishBlockingCompute() {
  755. if (this.lastStart === 0) return
  756. const last = performance.now() - this.lastStart
  757. if (last > this.max) {
  758. this.max = last
  759. }
  760. this.total += last
  761. this.lastStart = 0
  762. }
  763. reportInto(metrics: PdfCachingMetricsFull) {
  764. const max = Math.ceil(this.max)
  765. const total = Math.ceil(this.total)
  766. if (max > metrics.latencyComputeMax) {
  767. metrics.latencyComputeMax = max
  768. }
  769. metrics.latencyComputeTotal += total
  770. }
  771. }
  772. export async function fetchRange({
  773. url,
  774. start,
  775. end,
  776. file,
  777. queryForChunks,
  778. metrics,
  779. usageScore,
  780. cachedUrls,
  781. verifyChunks,
  782. prefetchingEnabled,
  783. prefetchLargeEnabled,
  784. cachedUrlLookupEnabled,
  785. abortSignal,
  786. canTryFromCache,
  787. fallbackToCacheURL,
  788. }: {
  789. url: string
  790. start: number
  791. end: number
  792. file: ProcessedPDFFile
  793. queryForChunks: string
  794. metrics: PdfCachingMetricsFull
  795. usageScore: Map<string, number>
  796. cachedUrls: Map<string, { url: string; init: RequestInit }>
  797. verifyChunks: boolean
  798. prefetchingEnabled: boolean
  799. prefetchLargeEnabled: boolean
  800. cachedUrlLookupEnabled: boolean
  801. abortSignal: AbortSignal
  802. canTryFromCache: (error: any) => boolean
  803. fallbackToCacheURL: string
  804. }) {
  805. const timer = new Timer()
  806. timer.startBlockingCompute()
  807. const startXRefTableRange =
  808. file.startXRefTable !== undefined
  809. ? Math.floor(file.startXRefTable / PDF_JS_CHUNK_SIZE) * PDF_JS_CHUNK_SIZE
  810. : 0
  811. const prefetchXRefTable =
  812. prefetchingEnabled && startXRefTableRange > 0 && start === 0
  813. const prefetched = getMatchingChunks(file.prefetched, start, end)
  814. // Check that handling the range request won't trigger excessive sub-requests,
  815. // (to avoid unwanted latency compared to the original request).
  816. const chunks: PDFRange[] = cutRequestAmplification({
  817. potentialChunks: skipPrefetched(
  818. getMatchingChunks(file.ranges, start, end),
  819. prefetched,
  820. start,
  821. end
  822. ),
  823. usageScore,
  824. metrics,
  825. start,
  826. end,
  827. prefetchLargeEnabled,
  828. })
  829. const dynamicChunks = skipPrefetched(
  830. getInterleavingDynamicChunks(chunks, start, end),
  831. prefetched,
  832. start,
  833. end
  834. )
  835. const size = end - start
  836. if (
  837. chunks.length === 0 &&
  838. prefetched.length === 0 &&
  839. dynamicChunks.length === 1 &&
  840. !prefetchXRefTable
  841. ) {
  842. // fall back to the original range request when no chunks are cached.
  843. // Exception: The first range should fetch the xref table as well.
  844. timer.finishBlockingCompute()
  845. timer.reportInto(metrics)
  846. trackDownloadStats(metrics, {
  847. size,
  848. cachedCount: 0,
  849. cachedBytes: 0,
  850. fetchedCount: 1,
  851. fetchedBytes: size,
  852. })
  853. return fallbackRequest({ file, url, start, end, abortSignal })
  854. }
  855. if (prefetchingEnabled) {
  856. addPrefetchingChunks({
  857. file,
  858. start,
  859. end,
  860. dynamicChunks,
  861. prefetchXRefTable,
  862. startXRefTableRange,
  863. })
  864. }
  865. const byteRanges = dynamicChunks
  866. .map(chunk => `${chunk.start}-${chunk.end - 1}`)
  867. .join(',')
  868. const coalescedDynamicChunks: {
  869. chunk: Chunk | Chunk[]
  870. url: string
  871. init: RequestInit
  872. }[] = []
  873. switch (true) {
  874. case dynamicChunks.length === 0:
  875. break
  876. case dynamicChunks.length === 1:
  877. coalescedDynamicChunks.push({
  878. chunk: dynamicChunks[0],
  879. url,
  880. init: {
  881. cache: cacheFlag,
  882. headers: new Headers({ Range: `bytes=${byteRanges}` }),
  883. },
  884. })
  885. break
  886. case dynamicChunks.length <= MULTI_PART_THRESHOLD:
  887. // There will always be an OPTIONS request for multi-ranges requests.
  888. // It is faster to request few ranges in parallel instead of waiting for
  889. // the OPTIONS request to round trip.
  890. dynamicChunks.forEach(chunk => {
  891. coalescedDynamicChunks.push({
  892. chunk,
  893. url,
  894. init: {
  895. cache: cacheFlag,
  896. headers: new Headers({
  897. Range: `bytes=${chunk.start}-${chunk.end - 1}`,
  898. }),
  899. },
  900. })
  901. })
  902. break
  903. default:
  904. coalescedDynamicChunks.push({
  905. chunk: dynamicChunks,
  906. url,
  907. init: {
  908. cache: cacheFlag,
  909. headers: new Headers({ Range: `bytes=${byteRanges}` }),
  910. },
  911. })
  912. }
  913. // The schema of `url` is https://domain/project/:id/user/:id/build/... for
  914. // authenticated and https://domain/project/:id/build/... for
  915. // unauthenticated users. Cut it before /build/.
  916. // The path may have an optional /zone/b prefix too.
  917. const perUserPrefix = url.slice(0, url.indexOf('/build/'))
  918. const requests = chunks
  919. .map(chunk => ({
  920. chunk: chunk as Chunk | Chunk[],
  921. url: `${perUserPrefix}/content/${file.contentId}/${chunk.hash}?${queryForChunks}`,
  922. init: {},
  923. }))
  924. .concat(coalescedDynamicChunks) as {
  925. chunk: PDFRange | Chunk | Chunk[]
  926. url: string
  927. init: RequestInit
  928. }[]
  929. let cachedCount = 0
  930. let cachedBytes = 0
  931. let fetchedCount = 0
  932. let fetchedBytes = 0
  933. const reassembledBlob = new Uint8Array(size)
  934. // Pause while performing network IO
  935. timer.finishBlockingCompute()
  936. const rawResponses = await Promise.all(
  937. requests.map(async ({ chunk, url, init }) => {
  938. try {
  939. const response = await fetchChunk({
  940. chunk,
  941. url,
  942. init: { ...init, signal: abortSignal },
  943. cachedUrls,
  944. metrics,
  945. cachedUrlLookupEnabled,
  946. canTryFromCache,
  947. fallbackToCacheURL,
  948. file,
  949. })
  950. timer.startBlockingCompute()
  951. const boundary = getMultipartBoundary(response, chunk)
  952. const blobFetchDate = getServerTime(response)
  953. const blobSize = getResponseSize(response)
  954. if (blobFetchDate && blobSize) {
  955. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  956. // | pdf.js chunk |
  957. // | A BIG IMAGE BLOB |
  958. // | THE FULL PDF |
  959. if ('hash' in chunk && chunk.hash && blobFetchDate < file.createdAt) {
  960. const usedChunkSection =
  961. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  962. cachedCount++
  963. cachedBytes += usedChunkSection
  964. // Roll the position of the hash in the Map.
  965. usageScore.delete(chunk.hash)
  966. usageScore.set(chunk.hash, CHUNK_USAGE_THRESHOLD_CACHED)
  967. } else {
  968. // Blobs are fetched in bulk, record the full size.
  969. fetchedCount++
  970. fetchedBytes += blobSize
  971. }
  972. }
  973. timer.finishBlockingCompute()
  974. const buf = await response.arrayBuffer()
  975. timer.startBlockingCompute()
  976. const data = backFillObjectContext(chunk, buf)
  977. if (!Array.isArray(chunk)) {
  978. return [{ chunk, data }]
  979. }
  980. return resolveMultiPartResponses({
  981. file,
  982. chunks: chunk,
  983. data,
  984. boundary,
  985. metrics,
  986. })
  987. } catch (err) {
  988. throw OError.tag(normalizeStringError(err), 'cannot fetch chunk', {
  989. chunk,
  990. url,
  991. init,
  992. })
  993. } finally {
  994. timer.finishBlockingCompute()
  995. }
  996. })
  997. )
  998. timer.startBlockingCompute()
  999. rawResponses
  1000. .flat() // flatten after splitting multipart responses
  1001. .concat(prefetched.map(chunk => ({ chunk, data: chunk.buffer })))
  1002. .forEach(
  1003. ({
  1004. chunk,
  1005. data,
  1006. }: {
  1007. chunk: Chunk | PrefetchedChunk<Chunk | PDFRange<Uint8Array>>
  1008. data: Uint8Array
  1009. }) => {
  1010. if ('hash' in chunk && chunk.end > end) {
  1011. // This is a (partially) prefetched chunk.
  1012. chunk.buffer = data
  1013. file.prefetched.push(chunk)
  1014. if (chunk.start > end) return // This is a fully prefetched chunk.
  1015. }
  1016. // overlap:
  1017. // | REQUESTED_RANGE |
  1018. // | CHUNK |
  1019. const offsetStart = Math.max(start - chunk.start, 0)
  1020. // overlap:
  1021. // | REQUESTED_RANGE |
  1022. // | CHUNK |
  1023. const offsetEnd = Math.max(chunk.end - end, 0)
  1024. const oldDataLength = data.length
  1025. if (offsetStart > 0 || offsetEnd > 0) {
  1026. // compute index positions for slice to handle case where offsetEnd=0
  1027. const chunkSize = chunk.end - chunk.start
  1028. data = data.subarray(offsetStart, chunkSize - offsetEnd)
  1029. }
  1030. const newDataLength = data.length
  1031. const insertPosition = Math.max(chunk.start - start, 0)
  1032. try {
  1033. reassembledBlob.set(data, insertPosition)
  1034. } catch (err) {
  1035. const reassembledBlobLength = reassembledBlob.length
  1036. const trimmedChunk = {
  1037. start: chunk.start,
  1038. end: chunk.end,
  1039. hash: 'hash' in chunk ? chunk.hash : undefined,
  1040. objectId:
  1041. 'objectId' in chunk
  1042. ? new TextDecoder().decode(chunk.objectId)
  1043. : undefined,
  1044. }
  1045. throw OError.tag(err, 'broken reassembly', {
  1046. start,
  1047. end,
  1048. chunk: trimmedChunk,
  1049. oldDataLength,
  1050. newDataLength,
  1051. offsetStart,
  1052. offsetEnd,
  1053. insertPosition,
  1054. reassembledBlobLength,
  1055. })
  1056. }
  1057. }
  1058. )
  1059. timer.finishBlockingCompute()
  1060. timer.reportInto(metrics)
  1061. trackDownloadStats(metrics, {
  1062. size,
  1063. cachedCount,
  1064. cachedBytes,
  1065. fetchedCount,
  1066. fetchedBytes,
  1067. })
  1068. if (verifyChunks) {
  1069. return await verifyRange({
  1070. file,
  1071. url,
  1072. start,
  1073. end,
  1074. metrics,
  1075. actual: reassembledBlob,
  1076. abortSignal,
  1077. })
  1078. }
  1079. return reassembledBlob
  1080. }