pdf-caching.js 30 KB

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