pdf-caching.ts 30 KB

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