pdf-caching.js 27 KB

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