pdf-caching.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008
  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. /**
  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. export 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. export 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. export function resolveMultiPartResponses({
  368. file,
  369. chunks,
  370. data,
  371. boundary,
  372. metrics,
  373. }) {
  374. const responses = []
  375. let offsetStart = 0
  376. const encoder = new TextEncoder()
  377. for (const chunk of chunks) {
  378. const header = composeMultipartHeader({
  379. boundary,
  380. start: chunk.start,
  381. end: chunk.end,
  382. size: file.size,
  383. })
  384. const headerSize = header.length
  385. // Verify header content. A proxy might have tampered with it.
  386. const headerRaw = encoder.encode(header)
  387. if (
  388. !data
  389. .subarray(offsetStart, offsetStart + headerSize)
  390. .every((v, idx) => v === headerRaw[idx])
  391. ) {
  392. metrics.headerVerifyFailure |= 0
  393. metrics.headerVerifyFailure++
  394. throw new OError('multipart response header does not match', {
  395. actual: new TextDecoder().decode(
  396. data.subarray(offsetStart, offsetStart + headerSize)
  397. ),
  398. expected: header,
  399. })
  400. }
  401. offsetStart += headerSize
  402. const chunkSize = chunk.end - chunk.start
  403. responses.push({
  404. chunk,
  405. data: data.subarray(offsetStart, offsetStart + chunkSize),
  406. })
  407. offsetStart += chunkSize
  408. }
  409. return responses
  410. }
  411. /**
  412. *
  413. * @param {Response} response
  414. * @param {number} estimatedSize
  415. * @param {RequestInit} init
  416. */
  417. export function checkChunkResponse(response, estimatedSize, init) {
  418. if (!(response.status === 206 || response.status === 200)) {
  419. throw new OError('non successful response status: ' + response.status, {
  420. statusCode: response.status,
  421. responseHeaders: Object.fromEntries(response.headers.entries()),
  422. requestHeader: init.headers,
  423. })
  424. }
  425. const responseSize = getResponseSize(response)
  426. if (!responseSize) {
  427. throw new OError('content-length response header missing', {
  428. responseHeaders: Object.fromEntries(response.headers.entries()),
  429. requestHeader: init.headers,
  430. })
  431. }
  432. if (responseSize > estimatedSize) {
  433. throw new OError('response size exceeds estimate', {
  434. estimatedSize,
  435. responseSize,
  436. responseHeaders: Object.fromEntries(response.headers.entries()),
  437. requestHeader: init.headers,
  438. })
  439. }
  440. }
  441. /**
  442. *
  443. * @param {string} url
  444. * @param {number} start
  445. * @param {number} end
  446. * @param {AbortSignal} abortSignal
  447. */
  448. export async function fallbackRequest({ url, start, end, abortSignal }) {
  449. try {
  450. const init = {
  451. cache: 'no-store',
  452. headers: { Range: `bytes=${start}-${end - 1}` },
  453. signal: abortSignal,
  454. }
  455. const response = await fetch(url, init)
  456. checkChunkResponse(response, end - start, init)
  457. return await response.arrayBuffer()
  458. } catch (e) {
  459. throw OError.tag(e, 'fallback request failed', { url, start, end })
  460. }
  461. }
  462. /**
  463. *
  464. * @param {string} url
  465. * @param {number} start
  466. * @param {number} end
  467. * @param {Object} metrics
  468. * @param {Uint8Array} actual
  469. * @param {AbortSignal} abortSignal
  470. */
  471. async function verifyRange({ url, start, end, metrics, actual, abortSignal }) {
  472. let expectedRaw
  473. try {
  474. expectedRaw = await fallbackRequest({ url, start, end, abortSignal })
  475. } catch (error) {
  476. throw OError.tag(error, 'cannot verify range', { url, start, end })
  477. }
  478. const expected = new Uint8Array(expectedRaw)
  479. const stats = {}
  480. if (actual.byteLength !== expected.byteLength) {
  481. stats.sizeDiffers = true
  482. } else if (!expected.every((v, idx) => v === actual[idx])) {
  483. stats.mismatch = true
  484. } else {
  485. stats.success = true
  486. }
  487. trackChunkVerify(metrics, stats)
  488. return expected
  489. }
  490. /**
  491. * @param {Array} chunks
  492. * @param {Array} prefetched
  493. * @param {number} start
  494. * @param {number} end
  495. */
  496. function skipPrefetched(chunks, prefetched, start, end) {
  497. return chunks.filter(chunk => {
  498. return !prefetched.find(
  499. c =>
  500. c.start <= Math.max(chunk.start, start) &&
  501. c.end >= Math.min(chunk.end, end)
  502. )
  503. })
  504. }
  505. /**
  506. * @param {Object|Object[]} chunk
  507. * @param {string} url
  508. * @param {RequestInit} init
  509. * @param {Map<string, string>} cachedUrls
  510. * @param {Object} metrics
  511. * @param {boolean} cachedUrlLookupEnabled
  512. */
  513. async function fetchChunk({
  514. chunk,
  515. url,
  516. init,
  517. cachedUrls,
  518. metrics,
  519. cachedUrlLookupEnabled,
  520. }) {
  521. const estimatedSize = Array.isArray(chunk)
  522. ? estimateSizeOfMultipartResponse(chunk)
  523. : chunk.end - chunk.start
  524. const oldUrl = cachedUrls.get(chunk.hash)
  525. if (cachedUrlLookupEnabled && chunk.hash && oldUrl && oldUrl !== url) {
  526. // When the clsi server id changes, the content id changes too and as a
  527. // result all the browser cache keys (aka urls) get invalidated.
  528. // We memorize the previous browser cache keys in `cachedUrls`.
  529. try {
  530. const response = await fetch(oldUrl, init)
  531. if (response.status === 200) {
  532. checkChunkResponse(response, estimatedSize, init)
  533. metrics.oldUrlHitCount += 1
  534. return response
  535. }
  536. if (response.status === 404) {
  537. // The old browser cache entry is gone and the old file is gone too.
  538. metrics.oldUrlMissCount += 1
  539. }
  540. // Fallback to the latest url.
  541. } catch (e) {
  542. // Fallback to the latest url.
  543. }
  544. }
  545. const response = await fetch(url, init)
  546. checkChunkResponse(response, estimatedSize, init)
  547. if (chunk.hash) cachedUrls.set(chunk.hash, url)
  548. return response
  549. }
  550. /**
  551. * @param {Object} file
  552. * @param {number} start
  553. * @param {number} end
  554. * @param {Array} dynamicChunks
  555. * @param {boolean} prefetchXRefTable
  556. * @param {number} startXRefTableRange
  557. */
  558. function addPrefetchingChunks({
  559. file,
  560. start,
  561. end,
  562. dynamicChunks,
  563. prefetchXRefTable,
  564. startXRefTableRange,
  565. }) {
  566. // Prefetch in case this is the first range, or we are fetching dynamic
  567. // chunks anyway (so we can ride-share the round trip).
  568. // Rendering cannot start without downloading the xref table, so it's OK to
  569. // "delay" the first range.
  570. if (!(start === 0 || dynamicChunks.length > 0)) {
  571. return
  572. }
  573. let extraChunks = []
  574. if (prefetchXRefTable) {
  575. // Prefetch the dynamic chunks around the xref table.
  576. extraChunks = skipPrefetched(
  577. getInterleavingDynamicChunks(
  578. getMatchingChunks(file.ranges, startXRefTableRange, file.size),
  579. startXRefTableRange,
  580. file.size
  581. ),
  582. file.prefetched,
  583. startXRefTableRange,
  584. file.size
  585. )
  586. }
  587. // Stop at the xref table range if present -- we may prefetch it early ^^^.
  588. const prefetchEnd = startXRefTableRange || file.size
  589. extraChunks = extraChunks.concat(
  590. skipPrefetched(
  591. getInterleavingDynamicChunks(
  592. getMatchingChunks(file.ranges, end, prefetchEnd),
  593. end,
  594. prefetchEnd
  595. ),
  596. file.prefetched,
  597. end,
  598. prefetchEnd
  599. )
  600. )
  601. let sum = estimateSizeOfMultipartResponse(dynamicChunks)
  602. for (const chunk of extraChunks) {
  603. const downloadSize =
  604. chunk.end - chunk.start + HEADER_OVERHEAD_PER_MULTI_PART_CHUNK
  605. if (sum + downloadSize > PDF_JS_CHUNK_SIZE) {
  606. // In prefetching this chunk we would exceed the bandwidth limit.
  607. // Try to prefetch another (smaller) chunk.
  608. continue
  609. }
  610. const sibling = dynamicChunks.find(
  611. sibling => sibling.end === chunk.start || sibling.start === chunk.end
  612. )
  613. if (sibling) {
  614. sum += downloadSize
  615. // Just expand the existing dynamic chunk.
  616. sibling.start = Math.min(sibling.start, chunk.start)
  617. sibling.end = Math.max(sibling.end, chunk.end)
  618. continue
  619. }
  620. if (dynamicChunks.length > MULTI_PART_THRESHOLD) {
  621. // We are already performing a multipart request. Add another part.
  622. } else if (dynamicChunks.length < MULTI_PART_THRESHOLD) {
  623. // We are not yet performing a multipart request. Add another request.
  624. } else {
  625. // In prefetching this chunk we would switch to a multipart request.
  626. // Try to prefetch another (smaller) chunk.
  627. continue
  628. }
  629. sum += downloadSize
  630. dynamicChunks.push(chunk)
  631. }
  632. dynamicChunks.sort(sortByStartASC)
  633. // Ensure that no chunks are overlapping.
  634. let lastEnd = 0
  635. for (const [idx, chunk] of dynamicChunks.entries()) {
  636. if (chunk.start < lastEnd) {
  637. throw new OError('detected overlapping dynamic chunks', {
  638. chunk,
  639. lastChunk: dynamicChunks[idx - 1],
  640. })
  641. }
  642. lastEnd = chunk.end
  643. }
  644. }
  645. class Timer {
  646. constructor() {
  647. this.max = 0
  648. this.total = 0
  649. this.lastStart = 0
  650. }
  651. startBlockingCompute() {
  652. this.lastStart = performance.now()
  653. }
  654. finishBlockingCompute() {
  655. if (this.lastStart === 0) return
  656. const last = performance.now() - this.lastStart
  657. if (last > this.max) {
  658. this.max = last
  659. }
  660. this.total += last
  661. this.lastStart = 0
  662. }
  663. reportInto(metrics) {
  664. const max = Math.ceil(this.max)
  665. const total = Math.ceil(this.total)
  666. if (max > metrics.latencyComputeMax) {
  667. metrics.latencyComputeMax = max
  668. }
  669. metrics.latencyComputeTotal += total
  670. }
  671. }
  672. /**
  673. *
  674. * @param {string} url
  675. * @param {number} start
  676. * @param {number} end
  677. * @param {Object} file
  678. * @param {Object} metrics
  679. * @param {Map} usageScore
  680. * @param {Map} cachedUrls
  681. * @param {boolean} verifyChunks
  682. * @param {boolean} prefetchingEnabled
  683. * @param {boolean} prefetchLargeEnabled
  684. * @param {boolean} tryOldCachedUrlEnabled
  685. * @param {AbortSignal} abortSignal
  686. */
  687. export async function fetchRange({
  688. url,
  689. start,
  690. end,
  691. file,
  692. metrics,
  693. usageScore,
  694. cachedUrls,
  695. verifyChunks,
  696. prefetchingEnabled,
  697. prefetchLargeEnabled,
  698. cachedUrlLookupEnabled,
  699. abortSignal,
  700. }) {
  701. const timer = new Timer()
  702. timer.startBlockingCompute()
  703. preprocessFileOnce({ file, usageScore, cachedUrls })
  704. const startXRefTableRange =
  705. Math.floor(file.startXRefTable / PDF_JS_CHUNK_SIZE) * PDF_JS_CHUNK_SIZE
  706. const prefetchXRefTable =
  707. prefetchingEnabled && startXRefTableRange > 0 && start === 0
  708. const prefetched = getMatchingChunks(file.prefetched, start, end)
  709. // Check that handling the range request won't trigger excessive sub-requests,
  710. // (to avoid unwanted latency compared to the original request).
  711. const chunks = cutRequestAmplification({
  712. potentialChunks: skipPrefetched(
  713. getMatchingChunks(file.ranges, start, end),
  714. prefetched,
  715. start,
  716. end
  717. ),
  718. usageScore,
  719. cachedUrls,
  720. metrics,
  721. start,
  722. end,
  723. prefetchLargeEnabled,
  724. })
  725. const dynamicChunks = skipPrefetched(
  726. getInterleavingDynamicChunks(chunks, start, end),
  727. prefetched,
  728. start,
  729. end
  730. )
  731. const size = end - start
  732. if (
  733. chunks.length === 0 &&
  734. prefetched.length === 0 &&
  735. dynamicChunks.length === 1 &&
  736. !prefetchXRefTable
  737. ) {
  738. // fall back to the original range request when no chunks are cached.
  739. // Exception: The first range should fetch the xref table as well.
  740. timer.finishBlockingCompute()
  741. timer.reportInto(metrics)
  742. trackDownloadStats(metrics, {
  743. size,
  744. cachedCount: 0,
  745. cachedBytes: 0,
  746. fetchedCount: 1,
  747. fetchedBytes: size,
  748. })
  749. return fallbackRequest({ url, start, end, abortSignal })
  750. }
  751. if (prefetchingEnabled) {
  752. addPrefetchingChunks({
  753. file,
  754. start,
  755. end,
  756. dynamicChunks,
  757. prefetchXRefTable,
  758. startXRefTableRange,
  759. })
  760. }
  761. const byteRanges = dynamicChunks
  762. .map(chunk => `${chunk.start}-${chunk.end - 1}`)
  763. .join(',')
  764. const coalescedDynamicChunks = []
  765. switch (true) {
  766. case dynamicChunks.length === 0:
  767. break
  768. case dynamicChunks.length === 1:
  769. coalescedDynamicChunks.push({
  770. chunk: dynamicChunks[0],
  771. url,
  772. init: {
  773. cache: 'no-store',
  774. headers: { Range: `bytes=${byteRanges}` },
  775. },
  776. })
  777. break
  778. case dynamicChunks.length <= MULTI_PART_THRESHOLD:
  779. // There will always be an OPTIONS request for multi-ranges requests.
  780. // It is faster to request few ranges in parallel instead of waiting for
  781. // the OPTIONS request to round trip.
  782. dynamicChunks.forEach(chunk => {
  783. coalescedDynamicChunks.push({
  784. chunk,
  785. url,
  786. init: {
  787. cache: 'no-store',
  788. headers: { Range: `bytes=${chunk.start}-${chunk.end - 1}` },
  789. },
  790. })
  791. })
  792. break
  793. default:
  794. coalescedDynamicChunks.push({
  795. chunk: dynamicChunks,
  796. url,
  797. init: {
  798. cache: 'no-store',
  799. headers: { Range: `bytes=${byteRanges}` },
  800. },
  801. })
  802. }
  803. const params = new URL(url).searchParams
  804. // drop no needed params
  805. params.delete('enable_pdf_caching')
  806. params.delete('verify_chunks')
  807. const query = params.toString()
  808. // The schema of `url` is https://domain/project/:id/user/:id/build/... for
  809. // authenticated and https://domain/project/:id/build/... for
  810. // unauthenticated users. Cut it before /build/.
  811. // The path may have an optional /zone/b prefix too.
  812. const perUserPrefix = url.slice(0, url.indexOf('/build/'))
  813. const requests = chunks
  814. .map(chunk => ({
  815. chunk,
  816. url: `${perUserPrefix}/content/${file.contentId}/${chunk.hash}?${query}`,
  817. init: {},
  818. }))
  819. .concat(coalescedDynamicChunks)
  820. let cachedCount = 0
  821. let cachedBytes = 0
  822. let fetchedCount = 0
  823. let fetchedBytes = 0
  824. const reassembledBlob = new Uint8Array(size)
  825. // Pause while performing network IO
  826. timer.finishBlockingCompute()
  827. const rawResponses = await Promise.all(
  828. requests.map(async ({ chunk, url, init }) => {
  829. try {
  830. const response = await fetchChunk({
  831. chunk,
  832. url,
  833. init: { ...init, signal: abortSignal },
  834. cachedUrls,
  835. metrics,
  836. cachedUrlLookupEnabled,
  837. })
  838. timer.startBlockingCompute()
  839. const boundary = getMultipartBoundary(response, chunk)
  840. const blobFetchDate = getServerTime(response)
  841. const blobSize = getResponseSize(response)
  842. if (blobFetchDate && blobSize) {
  843. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  844. // | pdf.js chunk |
  845. // | A BIG IMAGE BLOB |
  846. // | THE FULL PDF |
  847. if (chunk.hash && blobFetchDate < file.createdAt) {
  848. const usedChunkSection =
  849. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  850. cachedCount++
  851. cachedBytes += usedChunkSection
  852. // Roll the position of the hash in the Map.
  853. usageScore.delete(chunk.hash)
  854. usageScore.set(chunk.hash, CHUNK_USAGE_THRESHOLD_CACHED)
  855. } else {
  856. // Blobs are fetched in bulk, record the full size.
  857. fetchedCount++
  858. fetchedBytes += blobSize
  859. }
  860. }
  861. timer.finishBlockingCompute()
  862. const buf = await response.arrayBuffer()
  863. timer.startBlockingCompute()
  864. const data = backFillObjectContext(chunk, buf)
  865. if (!Array.isArray(chunk)) {
  866. return [{ chunk, data }]
  867. }
  868. return resolveMultiPartResponses({
  869. file,
  870. chunks: chunk,
  871. data,
  872. boundary,
  873. metrics,
  874. })
  875. } catch (err) {
  876. throw OError.tag(err, 'cannot fetch chunk', { chunk, url, init })
  877. } finally {
  878. timer.finishBlockingCompute()
  879. }
  880. })
  881. )
  882. timer.startBlockingCompute()
  883. rawResponses
  884. .flat() // flatten after splitting multipart responses
  885. .concat(prefetched.map(chunk => ({ chunk, data: chunk.buffer })))
  886. .forEach(({ chunk, data }) => {
  887. if (!chunk.hash && chunk.end > end) {
  888. // This is a (partially) prefetched chunk.
  889. chunk.buffer = data
  890. file.prefetched.push(chunk)
  891. if (chunk.start > end) return // This is a fully prefetched chunk.
  892. }
  893. // overlap:
  894. // | REQUESTED_RANGE |
  895. // | CHUNK |
  896. const offsetStart = Math.max(start - chunk.start, 0)
  897. // overlap:
  898. // | REQUESTED_RANGE |
  899. // | CHUNK |
  900. const offsetEnd = Math.max(chunk.end - end, 0)
  901. const oldDataLength = data.length
  902. if (offsetStart > 0 || offsetEnd > 0) {
  903. // compute index positions for slice to handle case where offsetEnd=0
  904. const chunkSize = chunk.end - chunk.start
  905. data = data.subarray(offsetStart, chunkSize - offsetEnd)
  906. }
  907. const newDataLength = data.length
  908. const insertPosition = Math.max(chunk.start - start, 0)
  909. try {
  910. reassembledBlob.set(data, insertPosition)
  911. } catch (err) {
  912. const reassembledBlobLength = reassembledBlob.length
  913. const trimmedChunk = {
  914. start: chunk.start,
  915. end: chunk.end,
  916. hash: chunk.hash,
  917. objectId: new TextDecoder().decode(chunk.objectId),
  918. }
  919. throw OError.tag(err, 'broken reassembly', {
  920. start,
  921. end,
  922. chunk: trimmedChunk,
  923. oldDataLength,
  924. newDataLength,
  925. offsetStart,
  926. offsetEnd,
  927. insertPosition,
  928. reassembledBlobLength,
  929. })
  930. }
  931. })
  932. timer.finishBlockingCompute()
  933. timer.reportInto(metrics)
  934. trackDownloadStats(metrics, {
  935. size,
  936. cachedCount,
  937. cachedBytes,
  938. fetchedCount,
  939. fetchedBytes,
  940. })
  941. if (verifyChunks) {
  942. return await verifyRange({
  943. url,
  944. start,
  945. end,
  946. metrics,
  947. actual: reassembledBlob,
  948. abortSignal,
  949. })
  950. }
  951. return reassembledBlob
  952. }