pdf-caching.js 27 KB

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