pdf-caching.js 29 KB

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