pdf-caching.js 30 KB

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