pdf-caching.js 27 KB

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