pdf-caching.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149
  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. recordFallbackToClsiCache,
  560. }: {
  561. chunk: Chunk | PDFRange<Uint8Array> | Chunk[]
  562. url: string
  563. init: RequestInit
  564. cachedUrls: Map<string, { url: string; init: RequestInit }>
  565. metrics: PdfCachingMetricsFull
  566. cachedUrlLookupEnabled: boolean
  567. canTryFromCache: (error: any) => boolean
  568. fallbackToCacheURL: string
  569. file: ProcessedPDFFile
  570. recordFallbackToClsiCache: () => void
  571. }) {
  572. const estimatedSize = Array.isArray(chunk)
  573. ? estimateSizeOfMultipartResponse(chunk)
  574. : chunk.end - chunk.start
  575. if ('hash' in chunk) {
  576. const oldUrl = cachedUrls.get(chunk.hash)
  577. if (cachedUrlLookupEnabled && chunk.hash && oldUrl && oldUrl.url !== url) {
  578. // When the clsi server id changes, the content id changes too and as a
  579. // result all the browser cache keys (aka urls) get invalidated.
  580. // We memorize the previous browser cache keys in `cachedUrls`.
  581. try {
  582. oldUrl.init.signal = init.signal
  583. const response = await fetchWithBrowserCacheFallback(
  584. oldUrl.url,
  585. oldUrl.init
  586. )
  587. if (response.status === 200) {
  588. checkChunkResponse(response, estimatedSize, init)
  589. metrics.oldUrlHitCount += 1
  590. return response
  591. }
  592. if (response.status === 404) {
  593. // The old browser cache entry is gone and the old file is gone too.
  594. metrics.oldUrlMissCount += 1
  595. }
  596. // Fallback to the latest url.
  597. } catch (e) {
  598. // Fallback to the latest url.
  599. }
  600. cachedUrls.delete(chunk.hash) // clear cached state
  601. }
  602. }
  603. let response
  604. try {
  605. response = await fetchWithBrowserCacheFallback(url, init)
  606. checkChunkResponse(response, estimatedSize, init)
  607. if ('hash' in chunk && chunk.hash) {
  608. delete init.signal // omit the signal from the cache
  609. cachedUrls.set(chunk.hash, { url, init })
  610. }
  611. } catch (err1) {
  612. if ('hash' in chunk && chunk.hash) {
  613. cachedUrls.delete(chunk.hash)
  614. }
  615. const hasOthersCached = cachedUrls.size > 0
  616. const info = { url, init, statusCode: response?.status }
  617. const errTagged = OError.tag(err1, 'add info for canTryFromCache', info)
  618. if (hasOthersCached && canTryFromCache(errTagged) && fallbackToCacheURL) {
  619. // Only try downloading chunks that were cached previously
  620. file.ranges = file.ranges.filter(r => cachedUrls.has(r.hash))
  621. // Try harder at fetching the chunk, fallback to cache
  622. url = fallbackToCacheURL
  623. if ('hash' in chunk && chunk.hash) {
  624. init = getDynamicChunkInit({
  625. file,
  626. // skip object id prefix
  627. start: chunk.start + chunk.objectId.byteLength,
  628. end: chunk.end,
  629. signal: init.signal,
  630. })
  631. }
  632. try {
  633. response = await fetchWithBrowserCacheFallback(url, init)
  634. checkChunkResponse(response, estimatedSize, init)
  635. recordFallbackToClsiCache()
  636. } catch (err2) {
  637. throw err1
  638. }
  639. } else {
  640. throw err1
  641. }
  642. }
  643. return response
  644. }
  645. function addPrefetchingChunks({
  646. file,
  647. start,
  648. end,
  649. dynamicChunks,
  650. prefetchXRefTable,
  651. startXRefTableRange,
  652. }: {
  653. file: ProcessedPDFFile
  654. start: number
  655. end: number
  656. dynamicChunks: Chunk[]
  657. prefetchXRefTable: boolean
  658. startXRefTableRange: number
  659. }) {
  660. // Prefetch in case this is the first range, or we are fetching dynamic
  661. // chunks anyway (so we can ride-share the round trip).
  662. // Rendering cannot start without downloading the xref table, so it's OK to
  663. // "delay" the first range.
  664. if (!(start === 0 || dynamicChunks.length > 0)) {
  665. return
  666. }
  667. let extraChunks: Chunk[] = []
  668. if (prefetchXRefTable) {
  669. // Prefetch the dynamic chunks around the xref table.
  670. extraChunks = skipPrefetched(
  671. getInterleavingDynamicChunks(
  672. getMatchingChunks(file.ranges, startXRefTableRange, file.size),
  673. startXRefTableRange,
  674. file.size
  675. ),
  676. file.prefetched,
  677. startXRefTableRange,
  678. file.size
  679. )
  680. }
  681. // Stop at the xref table range if present -- we may prefetch it early ^^^.
  682. const prefetchEnd = startXRefTableRange || file.size
  683. extraChunks = extraChunks.concat(
  684. skipPrefetched(
  685. getInterleavingDynamicChunks(
  686. getMatchingChunks(file.ranges, end, prefetchEnd),
  687. end,
  688. prefetchEnd
  689. ),
  690. file.prefetched,
  691. end,
  692. prefetchEnd
  693. )
  694. )
  695. let sum = estimateSizeOfMultipartResponse(dynamicChunks)
  696. for (const chunk of extraChunks) {
  697. const downloadSize =
  698. chunk.end - chunk.start + HEADER_OVERHEAD_PER_MULTI_PART_CHUNK
  699. if (sum + downloadSize > PDF_JS_CHUNK_SIZE) {
  700. // In prefetching this chunk we would exceed the bandwidth limit.
  701. // Try to prefetch another (smaller) chunk.
  702. continue
  703. }
  704. const sibling = dynamicChunks.find(
  705. sibling => sibling.end === chunk.start || sibling.start === chunk.end
  706. )
  707. if (sibling) {
  708. sum += downloadSize
  709. // Just expand the existing dynamic chunk.
  710. sibling.start = Math.min(sibling.start, chunk.start)
  711. sibling.end = Math.max(sibling.end, chunk.end)
  712. continue
  713. }
  714. if (dynamicChunks.length > MULTI_PART_THRESHOLD) {
  715. // We are already performing a multipart request. Add another part.
  716. } else if (dynamicChunks.length < MULTI_PART_THRESHOLD) {
  717. // We are not yet performing a multipart request. Add another request.
  718. } else {
  719. // In prefetching this chunk we would switch to a multipart request.
  720. // Try to prefetch another (smaller) chunk.
  721. continue
  722. }
  723. sum += downloadSize
  724. dynamicChunks.push(chunk)
  725. }
  726. dynamicChunks.sort(sortByStartASC)
  727. // Ensure that no chunks are overlapping.
  728. let lastEnd = 0
  729. for (const [idx, chunk] of dynamicChunks.entries()) {
  730. if (chunk.start < lastEnd) {
  731. throw new OError('detected overlapping dynamic chunks', {
  732. chunk,
  733. lastChunk: dynamicChunks[idx - 1],
  734. })
  735. }
  736. lastEnd = chunk.end
  737. }
  738. }
  739. class Timer {
  740. max: number
  741. total: number
  742. lastStart: number
  743. constructor() {
  744. this.max = 0
  745. this.total = 0
  746. this.lastStart = 0
  747. }
  748. startBlockingCompute() {
  749. this.lastStart = performance.now()
  750. }
  751. finishBlockingCompute() {
  752. if (this.lastStart === 0) return
  753. const last = performance.now() - this.lastStart
  754. if (last > this.max) {
  755. this.max = last
  756. }
  757. this.total += last
  758. this.lastStart = 0
  759. }
  760. reportInto(metrics: PdfCachingMetricsFull) {
  761. const max = Math.ceil(this.max)
  762. const total = Math.ceil(this.total)
  763. if (max > metrics.latencyComputeMax) {
  764. metrics.latencyComputeMax = max
  765. }
  766. metrics.latencyComputeTotal += total
  767. }
  768. }
  769. export async function fetchRange({
  770. url,
  771. start,
  772. end,
  773. file,
  774. queryForChunks,
  775. metrics,
  776. usageScore,
  777. cachedUrls,
  778. verifyChunks,
  779. prefetchingEnabled,
  780. prefetchLargeEnabled,
  781. cachedUrlLookupEnabled,
  782. abortSignal,
  783. canTryFromCache,
  784. fallbackToCacheURL,
  785. recordFallbackToClsiCache,
  786. }: {
  787. url: string
  788. start: number
  789. end: number
  790. file: ProcessedPDFFile
  791. queryForChunks: string
  792. metrics: PdfCachingMetricsFull
  793. usageScore: Map<string, number>
  794. cachedUrls: Map<string, { url: string; init: RequestInit }>
  795. verifyChunks: boolean
  796. prefetchingEnabled: boolean
  797. prefetchLargeEnabled: boolean
  798. cachedUrlLookupEnabled: boolean
  799. abortSignal: AbortSignal
  800. canTryFromCache: (error: any) => boolean
  801. fallbackToCacheURL: string
  802. recordFallbackToClsiCache: () => void
  803. }) {
  804. const timer = new Timer()
  805. timer.startBlockingCompute()
  806. const startXRefTableRange =
  807. file.startXRefTable !== undefined
  808. ? Math.floor(file.startXRefTable / PDF_JS_CHUNK_SIZE) * PDF_JS_CHUNK_SIZE
  809. : 0
  810. const prefetchXRefTable =
  811. prefetchingEnabled && startXRefTableRange > 0 && start === 0
  812. const prefetched = getMatchingChunks(file.prefetched, start, end)
  813. // Check that handling the range request won't trigger excessive sub-requests,
  814. // (to avoid unwanted latency compared to the original request).
  815. const chunks: PDFRange[] = cutRequestAmplification({
  816. potentialChunks: skipPrefetched(
  817. getMatchingChunks(file.ranges, start, end),
  818. prefetched,
  819. start,
  820. end
  821. ),
  822. usageScore,
  823. metrics,
  824. start,
  825. end,
  826. prefetchLargeEnabled,
  827. })
  828. const dynamicChunks = skipPrefetched(
  829. getInterleavingDynamicChunks(chunks, start, end),
  830. prefetched,
  831. start,
  832. end
  833. )
  834. const size = end - start
  835. if (
  836. chunks.length === 0 &&
  837. prefetched.length === 0 &&
  838. dynamicChunks.length === 1 &&
  839. !prefetchXRefTable
  840. ) {
  841. // fall back to the original range request when no chunks are cached.
  842. // Exception: The first range should fetch the xref table as well.
  843. timer.finishBlockingCompute()
  844. timer.reportInto(metrics)
  845. trackDownloadStats(metrics, {
  846. size,
  847. cachedCount: 0,
  848. cachedBytes: 0,
  849. fetchedCount: 1,
  850. fetchedBytes: size,
  851. })
  852. return fallbackRequest({ file, url, start, end, abortSignal })
  853. }
  854. if (prefetchingEnabled) {
  855. addPrefetchingChunks({
  856. file,
  857. start,
  858. end,
  859. dynamicChunks,
  860. prefetchXRefTable,
  861. startXRefTableRange,
  862. })
  863. }
  864. const byteRanges = dynamicChunks
  865. .map(chunk => `${chunk.start}-${chunk.end - 1}`)
  866. .join(',')
  867. const coalescedDynamicChunks: {
  868. chunk: Chunk | Chunk[]
  869. url: string
  870. init: RequestInit
  871. }[] = []
  872. switch (true) {
  873. case dynamicChunks.length === 0:
  874. break
  875. case dynamicChunks.length === 1:
  876. coalescedDynamicChunks.push({
  877. chunk: dynamicChunks[0],
  878. url,
  879. init: {
  880. cache: cacheFlag,
  881. headers: new Headers({ Range: `bytes=${byteRanges}` }),
  882. },
  883. })
  884. break
  885. case dynamicChunks.length <= MULTI_PART_THRESHOLD:
  886. // There will always be an OPTIONS request for multi-ranges requests.
  887. // It is faster to request few ranges in parallel instead of waiting for
  888. // the OPTIONS request to round trip.
  889. dynamicChunks.forEach(chunk => {
  890. coalescedDynamicChunks.push({
  891. chunk,
  892. url,
  893. init: {
  894. cache: cacheFlag,
  895. headers: new Headers({
  896. Range: `bytes=${chunk.start}-${chunk.end - 1}`,
  897. }),
  898. },
  899. })
  900. })
  901. break
  902. default:
  903. coalescedDynamicChunks.push({
  904. chunk: dynamicChunks,
  905. url,
  906. init: {
  907. cache: cacheFlag,
  908. headers: new Headers({ Range: `bytes=${byteRanges}` }),
  909. },
  910. })
  911. }
  912. // The schema of `url` is https://domain/project/:id/user/:id/build/... for
  913. // authenticated and https://domain/project/:id/build/... for
  914. // unauthenticated users. Cut it before /build/.
  915. // The path may have an optional /zone/b prefix too.
  916. const perUserPrefix = url.slice(0, url.indexOf('/build/'))
  917. const requests = chunks
  918. .map(chunk => ({
  919. chunk: chunk as Chunk | Chunk[],
  920. url: `${perUserPrefix}/content/${file.contentId}/${chunk.hash}?${queryForChunks}`,
  921. init: {},
  922. }))
  923. .concat(coalescedDynamicChunks) as {
  924. chunk: PDFRange | Chunk | Chunk[]
  925. url: string
  926. init: RequestInit
  927. }[]
  928. let cachedCount = 0
  929. let cachedBytes = 0
  930. let fetchedCount = 0
  931. let fetchedBytes = 0
  932. const reassembledBlob = new Uint8Array(size)
  933. // Pause while performing network IO
  934. timer.finishBlockingCompute()
  935. const rawResponses = await Promise.all(
  936. requests.map(async ({ chunk, url, init }) => {
  937. try {
  938. const response = await fetchChunk({
  939. chunk,
  940. url,
  941. init: { ...init, signal: abortSignal },
  942. cachedUrls,
  943. metrics,
  944. cachedUrlLookupEnabled,
  945. canTryFromCache,
  946. fallbackToCacheURL,
  947. file,
  948. recordFallbackToClsiCache,
  949. })
  950. timer.startBlockingCompute()
  951. const boundary = getMultipartBoundary(response, chunk)
  952. const blobFetchDate = getServerTime(response)
  953. const blobSize = getResponseSize(response)
  954. if (blobFetchDate && blobSize) {
  955. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  956. // | pdf.js chunk |
  957. // | A BIG IMAGE BLOB |
  958. // | THE FULL PDF |
  959. if ('hash' in chunk && chunk.hash && blobFetchDate < file.createdAt) {
  960. const usedChunkSection =
  961. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  962. cachedCount++
  963. cachedBytes += usedChunkSection
  964. // Roll the position of the hash in the Map.
  965. usageScore.delete(chunk.hash)
  966. usageScore.set(chunk.hash, CHUNK_USAGE_THRESHOLD_CACHED)
  967. } else {
  968. // Blobs are fetched in bulk, record the full size.
  969. fetchedCount++
  970. fetchedBytes += blobSize
  971. }
  972. }
  973. timer.finishBlockingCompute()
  974. const buf = await response.arrayBuffer()
  975. timer.startBlockingCompute()
  976. const data = backFillObjectContext(chunk, buf)
  977. if (!Array.isArray(chunk)) {
  978. return [{ chunk, data }]
  979. }
  980. return resolveMultiPartResponses({
  981. file,
  982. chunks: chunk,
  983. data,
  984. boundary,
  985. metrics,
  986. })
  987. } catch (err) {
  988. throw OError.tag(err, 'cannot fetch chunk', { chunk, url, init })
  989. } finally {
  990. timer.finishBlockingCompute()
  991. }
  992. })
  993. )
  994. timer.startBlockingCompute()
  995. rawResponses
  996. .flat() // flatten after splitting multipart responses
  997. .concat(prefetched.map(chunk => ({ chunk, data: chunk.buffer })))
  998. .forEach(
  999. ({
  1000. chunk,
  1001. data,
  1002. }: {
  1003. chunk: Chunk | PrefetchedChunk<Chunk | PDFRange<Uint8Array>>
  1004. data: Uint8Array
  1005. }) => {
  1006. if ('hash' in chunk && chunk.end > end) {
  1007. // This is a (partially) prefetched chunk.
  1008. chunk.buffer = data
  1009. file.prefetched.push(chunk)
  1010. if (chunk.start > end) return // This is a fully prefetched chunk.
  1011. }
  1012. // overlap:
  1013. // | REQUESTED_RANGE |
  1014. // | CHUNK |
  1015. const offsetStart = Math.max(start - chunk.start, 0)
  1016. // overlap:
  1017. // | REQUESTED_RANGE |
  1018. // | CHUNK |
  1019. const offsetEnd = Math.max(chunk.end - end, 0)
  1020. const oldDataLength = data.length
  1021. if (offsetStart > 0 || offsetEnd > 0) {
  1022. // compute index positions for slice to handle case where offsetEnd=0
  1023. const chunkSize = chunk.end - chunk.start
  1024. data = data.subarray(offsetStart, chunkSize - offsetEnd)
  1025. }
  1026. const newDataLength = data.length
  1027. const insertPosition = Math.max(chunk.start - start, 0)
  1028. try {
  1029. reassembledBlob.set(data, insertPosition)
  1030. } catch (err) {
  1031. const reassembledBlobLength = reassembledBlob.length
  1032. const trimmedChunk = {
  1033. start: chunk.start,
  1034. end: chunk.end,
  1035. hash: 'hash' in chunk ? chunk.hash : undefined,
  1036. objectId:
  1037. 'objectId' in chunk
  1038. ? new TextDecoder().decode(chunk.objectId)
  1039. : undefined,
  1040. }
  1041. throw OError.tag(err, 'broken reassembly', {
  1042. start,
  1043. end,
  1044. chunk: trimmedChunk,
  1045. oldDataLength,
  1046. newDataLength,
  1047. offsetStart,
  1048. offsetEnd,
  1049. insertPosition,
  1050. reassembledBlobLength,
  1051. })
  1052. }
  1053. }
  1054. )
  1055. timer.finishBlockingCompute()
  1056. timer.reportInto(metrics)
  1057. trackDownloadStats(metrics, {
  1058. size,
  1059. cachedCount,
  1060. cachedBytes,
  1061. fetchedCount,
  1062. fetchedBytes,
  1063. })
  1064. if (verifyChunks) {
  1065. return await verifyRange({
  1066. file,
  1067. url,
  1068. start,
  1069. end,
  1070. metrics,
  1071. actual: reassembledBlob,
  1072. abortSignal,
  1073. })
  1074. }
  1075. return reassembledBlob
  1076. }