pdf-caching.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008
  1. import OError from '@overleaf/o-error'
  2. import { fetchFromCompileDomain } from './fetchFromCompileDomain'
  3. const PDF_JS_CHUNK_SIZE = 128 * 1024
  4. const MAX_SUB_REQUEST_COUNT = 4
  5. const MAX_SUB_REQUEST_BYTES = 4 * PDF_JS_CHUNK_SIZE
  6. const SAMPLE_NGINX_BOUNDARY = '00000000000000000001'
  7. export const HEADER_OVERHEAD_PER_MULTI_PART_CHUNK = composeMultipartHeader({
  8. boundary: SAMPLE_NGINX_BOUNDARY,
  9. // Assume an upper bound of O(9GB) for the pdf size.
  10. start: 9 * 1024 * 1024 * 1024,
  11. end: 9 * 1024 * 1024 * 1024,
  12. size: 9 * 1024 * 1024 * 1024,
  13. }).length
  14. const MULTI_PART_THRESHOLD = 4
  15. const INCREMENTAL_CACHE_SIZE = 1000
  16. // Download large chunks once the shard bandwidth exceeds 50% of their size.
  17. const CHUNK_USAGE_THRESHOLD_PREFETCH_LARGE = 0.5
  18. // Preferred caching once we downloaded a chunk (in multiple shards) in full.
  19. const CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED = 1
  20. const CHUNK_USAGE_THRESHOLD_CACHED = 42
  21. // 42 * 0.7^11 < 1, aka we keep stale entries around for 11 compiles.
  22. const CHUNK_USAGE_STALE_DECAY_RATE = 0.7
  23. /**
  24. * @param {Object} file
  25. */
  26. function backfillEdgeBounds(file) {
  27. const encoder = new TextEncoder()
  28. for (const chunk of file.ranges) {
  29. if (chunk.objectId) {
  30. chunk.objectId = encoder.encode(chunk.objectId)
  31. chunk.start -= chunk.objectId.byteLength
  32. chunk.size = chunk.end - chunk.start
  33. }
  34. }
  35. }
  36. /**
  37. * @param {Map} usageScore
  38. * @param {Map} cachedUrls
  39. */
  40. function trimState({ usageScore, cachedUrls }) {
  41. for (const hash of usageScore) {
  42. if (usageScore.size < INCREMENTAL_CACHE_SIZE) {
  43. break
  44. }
  45. const score = usageScore.get(hash)
  46. if (score >= CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED) {
  47. // Keep entries that are worth caching around for longer.
  48. usageScore.set(hash, score * CHUNK_USAGE_STALE_DECAY_RATE)
  49. continue
  50. }
  51. cachedUrls.delete(hash)
  52. usageScore.delete(hash)
  53. }
  54. }
  55. /**
  56. * @param {Object} file
  57. * @param {Map} usageScore
  58. * @param {Map} cachedUrls
  59. */
  60. function preprocessFileOnce({ file, usageScore, cachedUrls }) {
  61. if (file.preprocessed) return
  62. file.preprocessed = true
  63. file.createdAt = new Date(file.createdAt)
  64. file.prefetched = file.prefetched || []
  65. trimState({ usageScore, cachedUrls })
  66. backfillEdgeBounds(file)
  67. }
  68. /**
  69. * @param {Array} chunks
  70. */
  71. export function estimateSizeOfMultipartResponse(chunks) {
  72. /*
  73. --boundary
  74. HEADER
  75. BLOB
  76. --boundary
  77. HEADER
  78. BLOB
  79. --boundary--
  80. */
  81. return (
  82. chunks.reduce(
  83. (totalBytes, chunk) =>
  84. totalBytes +
  85. HEADER_OVERHEAD_PER_MULTI_PART_CHUNK +
  86. (chunk.end - chunk.start),
  87. 0
  88. ) + ('\r\n' + SAMPLE_NGINX_BOUNDARY + '--').length
  89. )
  90. }
  91. /**
  92. *
  93. * @param {Object} metrics
  94. * @param {number} size
  95. * @param {number} cachedCount
  96. * @param {number} cachedBytes
  97. * @param {number} fetchedCount
  98. * @param {number} fetchedBytes
  99. */
  100. function trackDownloadStats(
  101. metrics,
  102. { size, cachedCount, cachedBytes, fetchedCount, fetchedBytes }
  103. ) {
  104. metrics.cachedCount += cachedCount
  105. metrics.cachedBytes += cachedBytes
  106. metrics.fetchedCount += fetchedCount
  107. metrics.fetchedBytes += fetchedBytes
  108. metrics.requestedCount++
  109. metrics.requestedBytes += size
  110. }
  111. /**
  112. * @param {Object} metrics
  113. * @param {boolean} sizeDiffers
  114. * @param {boolean} mismatch
  115. * @param {boolean} success
  116. */
  117. function trackChunkVerify(metrics, { sizeDiffers, mismatch, success }) {
  118. if (sizeDiffers) {
  119. metrics.chunkVerifySizeDiffers |= 0
  120. metrics.chunkVerifySizeDiffers += 1
  121. }
  122. if (mismatch) {
  123. metrics.chunkVerifyMismatch |= 0
  124. metrics.chunkVerifyMismatch += 1
  125. }
  126. if (success) {
  127. metrics.chunkVerifySuccess |= 0
  128. metrics.chunkVerifySuccess += 1
  129. }
  130. }
  131. /**
  132. * @param chunk
  133. * @param {ArrayBuffer} arrayBuffer
  134. * @return {Uint8Array}
  135. */
  136. function backFillObjectContext(chunk, arrayBuffer) {
  137. if (!chunk.objectId) {
  138. // This is a dynamic chunk
  139. return new Uint8Array(arrayBuffer)
  140. }
  141. const { size, objectId } = chunk
  142. const fullBuffer = new Uint8Array(size)
  143. const sourceBuffer = new Uint8Array(arrayBuffer)
  144. try {
  145. fullBuffer.set(objectId, 0)
  146. fullBuffer.set(sourceBuffer, objectId.byteLength)
  147. } catch (err) {
  148. throw OError.tag(err, 'broken back-filling of object-id', {
  149. objectIdByteLength: objectId.byteLength,
  150. fullBufferByteLength: fullBuffer.byteLength,
  151. arrayBufferByteLength: arrayBuffer.byteLength,
  152. sourceBufferByteLength: sourceBuffer.byteLength,
  153. })
  154. }
  155. return fullBuffer
  156. }
  157. /**
  158. * @param {Array} chunks
  159. * @param {number} start
  160. * @param {number} end
  161. * @returns {Array}
  162. */
  163. function getMatchingChunks(chunks, start, end) {
  164. const matchingChunks = []
  165. for (const chunk of chunks) {
  166. if (chunk.end <= start) {
  167. // no overlap:
  168. // | REQUESTED_RANGE |
  169. // | CHUNK |
  170. continue
  171. }
  172. if (chunk.start >= end) {
  173. // no overlap:
  174. // | REQUESTED_RANGE |
  175. // | CHUNK |
  176. break
  177. }
  178. matchingChunks.push(chunk)
  179. }
  180. return matchingChunks
  181. }
  182. /**
  183. * @param {Object} a
  184. * @param {Object} b
  185. */
  186. function sortBySizeDESC(a, b) {
  187. return a.size > b.size ? -1 : 1
  188. }
  189. /**
  190. * @param {Object} a
  191. * @param {Object} b
  192. */
  193. function sortByStartASC(a, b) {
  194. return a.start > b.start ? 1 : -1
  195. }
  196. /**
  197. * @param {Object} chunk
  198. */
  199. function usageAboveThreshold(chunk) {
  200. // We fetched enough shards of this chunk. Cache it in full now.
  201. return chunk.totalUsage > CHUNK_USAGE_THRESHOLD_TRIGGER_PREFERRED
  202. }
  203. /**
  204. * @param {Array} potentialChunks
  205. * @param {Map} usageScore
  206. * @param {Map} cachedUrls
  207. * @param {Object} metrics
  208. * @param {number} start
  209. * @param {number} end
  210. * @param {boolean} prefetchLargeEnabled
  211. */
  212. function cutRequestAmplification({
  213. potentialChunks,
  214. usageScore,
  215. cachedUrls,
  216. metrics,
  217. start,
  218. end,
  219. prefetchLargeEnabled,
  220. }) {
  221. // NOTE: Map keys are stored in insertion order.
  222. // We re-insert keys on cache hit and turn 'usageScore' into a cheap LRU.
  223. const chunks = []
  224. const skipAlreadyAdded = chunk => !chunks.includes(chunk)
  225. let tooManyRequests = false
  226. let tooMuchBandwidth = false
  227. let newChunks = 0
  228. let newCacheBandwidth = 0
  229. for (const chunk of potentialChunks) {
  230. const newUsage =
  231. (Math.min(end, chunk.end) - Math.max(start, chunk.start)) / chunk.size
  232. const totalUsage = (usageScore.get(chunk.hash) || 0) + newUsage
  233. usageScore.delete(chunk.hash)
  234. usageScore.set(chunk.hash, totalUsage)
  235. chunk.totalUsage = totalUsage
  236. }
  237. // Always download already cached entries
  238. for (const chunk of potentialChunks) {
  239. if (chunk.totalUsage >= CHUNK_USAGE_THRESHOLD_CACHED) {
  240. chunks.push(chunk)
  241. }
  242. }
  243. // Prefer large blobs over small ones.
  244. potentialChunks.sort(sortBySizeDESC)
  245. // Prefer chunks with high (previous) usage over brand-new chunks.
  246. const firstComeFirstCache = () => true
  247. for (const trigger of [usageAboveThreshold, firstComeFirstCache]) {
  248. for (const chunk of potentialChunks.filter(skipAlreadyAdded)) {
  249. if (newCacheBandwidth + chunk.size > MAX_SUB_REQUEST_BYTES) {
  250. // We would breach the bandwidth amplification limit.
  251. tooMuchBandwidth = true
  252. continue
  253. }
  254. if (newChunks + 1 > MAX_SUB_REQUEST_COUNT) {
  255. // We would breach the request rate amplification limit.
  256. tooManyRequests = true
  257. continue
  258. }
  259. if (trigger(chunk)) {
  260. newCacheBandwidth += chunk.size
  261. newChunks += 1
  262. chunks.push(chunk)
  263. }
  264. }
  265. }
  266. const largeChunk = potentialChunks.filter(skipAlreadyAdded)[0]
  267. if (largeChunk?.size >= PDF_JS_CHUNK_SIZE) {
  268. // This is a large chunk that exceeds the bandwidth amplification limit.
  269. if (largeChunk.start <= start && largeChunk.end >= end) {
  270. // This is a large chunk spanning the entire range. pdf.js will only
  271. // request these in case it needs the underlying stream, so it is OK to
  272. // download as much data as the stream is large in one go.
  273. chunks.push(largeChunk)
  274. } else if (
  275. prefetchLargeEnabled &&
  276. largeChunk.totalUsage > CHUNK_USAGE_THRESHOLD_PREFETCH_LARGE
  277. ) {
  278. // pdf.js actually wants the smaller (dynamic) chunk in the range that
  279. // happens to sit right next to this large chunk.
  280. // pdf.js has requested a lot of the large chunk via shards by now, and it
  281. // is time to download it in full to stop "wasting" more bandwidth and
  282. // more importantly cut down latency as we can prefetch the small chunk.
  283. chunks.push(largeChunk)
  284. }
  285. }
  286. if (tooManyRequests) {
  287. metrics.tooManyRequestsCount++
  288. }
  289. if (tooMuchBandwidth) {
  290. metrics.tooMuchBandwidthCount++
  291. }
  292. chunks.sort(sortByStartASC)
  293. return chunks
  294. }
  295. /**
  296. * @param {Array} chunks
  297. * @param {number} start
  298. * @param {number} end
  299. * @returns {Array}
  300. */
  301. function getInterleavingDynamicChunks(chunks, start, end) {
  302. const dynamicChunks = []
  303. for (const chunk of chunks) {
  304. if (start < chunk.start) {
  305. dynamicChunks.push({ start, end: chunk.start })
  306. }
  307. start = chunk.end
  308. }
  309. if (start < end) {
  310. dynamicChunks.push({ start, end })
  311. }
  312. return dynamicChunks
  313. }
  314. /**
  315. *
  316. * @param {Response} response
  317. */
  318. function getServerTime(response) {
  319. const raw = response.headers.get('Date')
  320. if (!raw) return new Date()
  321. return new Date(raw)
  322. }
  323. /**
  324. *
  325. * @param {Response} response
  326. */
  327. function getResponseSize(response) {
  328. const raw = response.headers.get('Content-Length')
  329. if (!raw) return 0
  330. return parseInt(raw, 10)
  331. }
  332. /**
  333. *
  334. * @param {Response} response
  335. * @param chunk
  336. */
  337. export function getMultipartBoundary(response, chunk) {
  338. if (!Array.isArray(chunk)) return ''
  339. const raw = response.headers.get('Content-Type')
  340. if (raw.includes('multipart/byteranges')) {
  341. const idx = raw.indexOf('boundary=')
  342. if (idx !== -1) return raw.slice(idx + 'boundary='.length)
  343. }
  344. throw new OError('missing boundary on multipart request', {
  345. headers: Object.fromEntries(response.headers.entries()),
  346. chunk,
  347. })
  348. }
  349. /**
  350. * @param {string} boundary
  351. * @param {number} start
  352. * @param {number} end
  353. * @param {number} size
  354. * @return {string}
  355. */
  356. function composeMultipartHeader({ boundary, start, end, size }) {
  357. return `\r\n--${boundary}\r\nContent-Type: application/pdf\r\nContent-Range: bytes ${start}-${
  358. end - 1
  359. }/${size}\r\n\r\n`
  360. }
  361. /**
  362. * @param {Object} file
  363. * @param {Array} chunks
  364. * @param {Uint8Array} data
  365. * @param {string} boundary
  366. * @param {Object} metrics
  367. */
  368. export function resolveMultiPartResponses({
  369. file,
  370. chunks,
  371. data,
  372. boundary,
  373. metrics,
  374. }) {
  375. const responses = []
  376. let offsetStart = 0
  377. const encoder = new TextEncoder()
  378. for (const chunk of chunks) {
  379. const header = composeMultipartHeader({
  380. boundary,
  381. start: chunk.start,
  382. end: chunk.end,
  383. size: file.size,
  384. })
  385. const headerSize = header.length
  386. // Verify header content. A proxy might have tampered with it.
  387. const headerRaw = encoder.encode(header)
  388. if (
  389. !data
  390. .subarray(offsetStart, offsetStart + headerSize)
  391. .every((v, idx) => v === headerRaw[idx])
  392. ) {
  393. metrics.headerVerifyFailure |= 0
  394. metrics.headerVerifyFailure++
  395. throw new OError('multipart response header does not match', {
  396. actual: new TextDecoder().decode(
  397. data.subarray(offsetStart, offsetStart + headerSize)
  398. ),
  399. expected: header,
  400. })
  401. }
  402. offsetStart += headerSize
  403. const chunkSize = chunk.end - chunk.start
  404. responses.push({
  405. chunk,
  406. data: data.subarray(offsetStart, offsetStart + chunkSize),
  407. })
  408. offsetStart += chunkSize
  409. }
  410. return responses
  411. }
  412. /**
  413. *
  414. * @param {Response} response
  415. * @param {number} estimatedSize
  416. * @param {RequestInit} init
  417. */
  418. export function checkChunkResponse(response, estimatedSize, init) {
  419. if (!(response.status === 206 || response.status === 200)) {
  420. throw new OError('non successful response status: ' + 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 fetchFromCompileDomain(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 fetchFromCompileDomain(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 fetchFromCompileDomain(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. }