pdf-caching.js 24 KB

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