pdf-caching.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. import OError from '@overleaf/o-error'
  2. const PDF_JS_CHUNK_SIZE = 128 * 1024
  3. const MAX_SUB_REQUEST_COUNT = 4
  4. const MAX_SUB_REQUEST_BYTES = 4 * PDF_JS_CHUNK_SIZE
  5. const INCREMENTAL_CACHE_SIZE = 1000
  6. const ENCODER = new TextEncoder()
  7. function backfillEdgeBounds(file) {
  8. if (!file.backfilledEdgeBoundsOnce) {
  9. for (const range of file.ranges) {
  10. if (range.objectId) {
  11. range.objectId = ENCODER.encode(range.objectId)
  12. range.start -= range.objectId.byteLength
  13. }
  14. }
  15. }
  16. file.backfilledEdgeBoundsOnce = true
  17. }
  18. /**
  19. * @param {Array} chunks
  20. */
  21. function countBytes(chunks) {
  22. return chunks.reduce((totalBytes, chunk) => {
  23. return totalBytes + (chunk.end - chunk.start)
  24. }, 0)
  25. }
  26. /**
  27. *
  28. * @param {Object} metrics
  29. * @param {number} size
  30. * @param {number} cachedCount
  31. * @param {number} cachedBytes
  32. * @param {number} fetchedCount
  33. * @param {number} fetchedBytes
  34. */
  35. function trackDownloadStats(
  36. metrics,
  37. { size, cachedCount, cachedBytes, fetchedCount, fetchedBytes }
  38. ) {
  39. metrics.cachedCount += cachedCount
  40. metrics.cachedBytes += cachedBytes
  41. metrics.fetchedCount += fetchedCount
  42. metrics.fetchedBytes += fetchedBytes
  43. metrics.requestedCount++
  44. metrics.requestedBytes += size
  45. }
  46. /**
  47. * @param {Object} metrics
  48. * @param {boolean} sizeDiffers
  49. * @param {boolean} mismatch
  50. * @param {boolean} success
  51. */
  52. function trackChunkVerify(metrics, { sizeDiffers, mismatch, success }) {
  53. if (sizeDiffers) {
  54. metrics.chunkVerifySizeDiffers |= 0
  55. metrics.chunkVerifySizeDiffers += 1
  56. }
  57. if (mismatch) {
  58. metrics.chunkVerifyMismatch |= 0
  59. metrics.chunkVerifyMismatch += 1
  60. }
  61. if (success) {
  62. metrics.chunkVerifySuccess |= 0
  63. metrics.chunkVerifySuccess += 1
  64. }
  65. }
  66. /**
  67. * @param chunk
  68. * @param {ArrayBuffer} arrayBuffer
  69. * @return {Uint8Array}
  70. */
  71. function backFillObjectContext(chunk, arrayBuffer) {
  72. if (!chunk.objectId) {
  73. // This is a dynamic chunk
  74. return new Uint8Array(arrayBuffer)
  75. }
  76. const { start, end, objectId } = chunk
  77. const header = Uint8Array.from(objectId)
  78. const fullBuffer = new Uint8Array(end - start)
  79. fullBuffer.set(header, 0)
  80. fullBuffer.set(new Uint8Array(arrayBuffer), objectId.length)
  81. return fullBuffer
  82. }
  83. /**
  84. * @param {Array} chunks
  85. * @param {number} start
  86. * @param {number} end
  87. * @returns {Array}
  88. */
  89. function getMatchingChunks(chunks, start, end) {
  90. const matchingChunks = []
  91. for (const chunk of chunks) {
  92. if (chunk.end <= start) {
  93. // no overlap:
  94. // | REQUESTED_RANGE |
  95. // | CHUNK |
  96. continue
  97. }
  98. if (chunk.start >= end) {
  99. // no overlap:
  100. // | REQUESTED_RANGE |
  101. // | CHUNK |
  102. break
  103. }
  104. matchingChunks.push(chunk)
  105. }
  106. return matchingChunks
  107. }
  108. /**
  109. * @param {Array} potentialChunks
  110. * @param {Set} cached
  111. * @param {Object} metrics
  112. */
  113. function cutRequestAmplification(potentialChunks, cached, metrics) {
  114. const chunks = []
  115. const newChunks = []
  116. let tooManyRequests = false
  117. for (const chunk of potentialChunks) {
  118. if (cached.has(chunk.hash)) {
  119. chunks.push(chunk)
  120. continue
  121. }
  122. if (newChunks.length < MAX_SUB_REQUEST_COUNT) {
  123. chunks.push(chunk)
  124. newChunks.push(chunk)
  125. } else {
  126. tooManyRequests = true
  127. }
  128. }
  129. if (tooManyRequests) {
  130. metrics.tooManyRequestsCount++
  131. }
  132. if (cached.size > INCREMENTAL_CACHE_SIZE) {
  133. for (const key of cached) {
  134. if (cached.size < INCREMENTAL_CACHE_SIZE) {
  135. break
  136. }
  137. // Map keys are stored in insertion order.
  138. // We re-insert keys on cache hit, 'cached' is a cheap LRU.
  139. cached.delete(key)
  140. }
  141. }
  142. return { chunks, newChunks }
  143. }
  144. /**
  145. * @param {Array} chunks
  146. * @param {number} start
  147. * @param {number} end
  148. * @returns {Array}
  149. */
  150. function getInterleavingDynamicChunks(chunks, start, end) {
  151. const dynamicChunks = []
  152. for (const chunk of chunks) {
  153. if (start < chunk.start) {
  154. dynamicChunks.push({ start, end: chunk.start })
  155. }
  156. start = chunk.end
  157. }
  158. if (start < end) {
  159. dynamicChunks.push({ start, end })
  160. }
  161. return dynamicChunks
  162. }
  163. /**
  164. *
  165. * @param {Response} response
  166. */
  167. function getServerTime(response) {
  168. const raw = response.headers.get('Date')
  169. if (!raw) return new Date()
  170. return new Date(raw)
  171. }
  172. /**
  173. *
  174. * @param {Response} response
  175. */
  176. function getResponseSize(response) {
  177. const raw = response.headers.get('Content-Length')
  178. if (!raw) return 0
  179. return parseInt(raw, 10)
  180. }
  181. /**
  182. *
  183. * @param {Response} response
  184. * @param chunk
  185. */
  186. function getMultipartBoundary(response, chunk) {
  187. if (!Array.isArray(chunk)) return ''
  188. const raw = response.headers.get('Content-Type')
  189. if (raw.includes('multipart/byteranges')) {
  190. const idx = raw.indexOf('boundary=')
  191. if (idx !== -1) return raw.slice(idx + 'boundary='.length)
  192. }
  193. throw new OError('missing boundary on multipart request', {
  194. headers: Object.fromEntries(response.headers.entries()),
  195. chunk,
  196. })
  197. }
  198. /**
  199. * @param {Object} response
  200. * @param {Object} file
  201. * @param {Object} metrics
  202. */
  203. function resolveMultiPartResponses(response, file, metrics) {
  204. const { chunk: chunks, data, boundary } = response
  205. if (!boundary) {
  206. return [response]
  207. }
  208. const responses = []
  209. let offsetStart = 0
  210. for (const chunk of chunks) {
  211. const header = `\r\n--${boundary}\r\nContent-Type: application/pdf\r\nContent-Range: bytes ${
  212. chunk.start
  213. }-${chunk.end - 1}/${file.size}\r\n\r\n`
  214. const headerSize = header.length
  215. // Verify header content. A proxy might have tampered with it.
  216. const headerRaw = ENCODER.encode(header)
  217. if (
  218. !data
  219. .subarray(offsetStart, offsetStart + headerSize)
  220. .every((v, idx) => v === headerRaw[idx])
  221. ) {
  222. metrics.headerVerifyFailure |= 0
  223. metrics.headerVerifyFailure++
  224. throw new OError('multipart response header does not match', {
  225. actual: new TextDecoder().decode(
  226. data.subarray(offsetStart, offsetStart + headerSize)
  227. ),
  228. expected: header,
  229. })
  230. }
  231. offsetStart += headerSize
  232. const chunkSize = chunk.end - chunk.start
  233. responses.push({
  234. chunk,
  235. data: data.subarray(offsetStart, offsetStart + chunkSize),
  236. })
  237. offsetStart += chunkSize
  238. }
  239. return responses
  240. }
  241. /**
  242. *
  243. * @param {Response} response
  244. */
  245. function checkChunkResponse(response) {
  246. if (!(response.status === 206 || response.status === 200)) {
  247. throw new OError('non successful response status: ' + response.status)
  248. }
  249. }
  250. /**
  251. *
  252. * @param {string} url
  253. * @param {number} start
  254. * @param {number} end
  255. */
  256. export function fallbackRequest({ url, start, end }) {
  257. return fetch(url, { headers: { Range: `bytes=${start}-${end - 1}` } }).then(
  258. response => {
  259. checkChunkResponse(response)
  260. return response.arrayBuffer()
  261. }
  262. )
  263. }
  264. /**
  265. *
  266. * @param {string} url
  267. * @param {number} start
  268. * @param {number} end
  269. * @param {Object} metrics
  270. * @param {Uint8Array} actual
  271. */
  272. async function verifyRange({ url, start, end, metrics, actual }) {
  273. let expectedRaw
  274. try {
  275. expectedRaw = await fallbackRequest({ url, start, end })
  276. } catch (error) {
  277. throw OError.tag(error, 'cannot verify range', { url, start, end })
  278. }
  279. const expected = new Uint8Array(expectedRaw)
  280. const stats = {}
  281. if (actual.byteLength !== expected.byteLength) {
  282. stats.sizeDiffers = true
  283. } else if (!expected.every((v, idx) => v === actual[idx])) {
  284. stats.mismatch = true
  285. } else {
  286. stats.success = true
  287. }
  288. trackChunkVerify(metrics, stats)
  289. return expected
  290. }
  291. /**
  292. *
  293. * @param {string} url
  294. * @param {number} start
  295. * @param {number} end
  296. * @param {Object} file
  297. * @param {Object} metrics
  298. * @param {Set} cached
  299. * @param {boolean} verifyChunks
  300. */
  301. export async function fetchRange({
  302. url,
  303. start,
  304. end,
  305. file,
  306. metrics,
  307. cached,
  308. verifyChunks,
  309. }) {
  310. file.createdAt = new Date(file.createdAt)
  311. backfillEdgeBounds(file)
  312. // Check that handling the range request won't trigger excessive sub-requests,
  313. // (to avoid unwanted latency compared to the original request).
  314. const { chunks, newChunks } = cutRequestAmplification(
  315. getMatchingChunks(file.ranges, start, end),
  316. cached,
  317. metrics
  318. )
  319. const dynamicChunks = getInterleavingDynamicChunks(chunks, start, end)
  320. const chunksSize = countBytes(newChunks)
  321. const size = end - start
  322. if (chunks.length === 0 && dynamicChunks.length === 1) {
  323. // fall back to the original range request when no chunks are cached.
  324. trackDownloadStats(metrics, {
  325. size,
  326. cachedCount: 0,
  327. cachedBytes: 0,
  328. fetchedCount: 1,
  329. fetchedBytes: size,
  330. })
  331. return fallbackRequest({ url, start, end })
  332. }
  333. if (
  334. chunksSize > MAX_SUB_REQUEST_BYTES &&
  335. !(dynamicChunks.length === 0 && newChunks.length <= 1)
  336. ) {
  337. // fall back to the original range request when a very large amount of
  338. // object data would be requested, unless it is the only object in the
  339. // request or everything is already cached.
  340. metrics.tooLargeOverheadCount++
  341. trackDownloadStats(metrics, {
  342. size,
  343. cachedCount: 0,
  344. cachedBytes: 0,
  345. fetchedCount: 1,
  346. fetchedBytes: size,
  347. })
  348. return fallbackRequest({ url, start, end })
  349. }
  350. const byteRanges = dynamicChunks
  351. .map(chunk => `${chunk.start}-${chunk.end - 1}`)
  352. .join(',')
  353. const coalescedDynamicChunks = []
  354. switch (dynamicChunks.length) {
  355. case 0:
  356. break
  357. case 1:
  358. coalescedDynamicChunks.push({
  359. chunk: dynamicChunks[0],
  360. url,
  361. init: { headers: { Range: `bytes=${byteRanges}` } },
  362. })
  363. break
  364. default:
  365. coalescedDynamicChunks.push({
  366. chunk: dynamicChunks,
  367. url,
  368. init: { headers: { Range: `bytes=${byteRanges}` } },
  369. })
  370. }
  371. const params = new URL(url).searchParams
  372. // drop no needed params
  373. params.delete('enable_pdf_caching')
  374. params.delete('verify_chunks')
  375. const query = params.toString()
  376. // The schema of `url` is https://domain/project/:id/user/:id/build/... for
  377. // authenticated and https://domain/project/:id/build/... for
  378. // unauthenticated users. Cut it before /build/.
  379. // The path may have an optional /zone/b prefix too.
  380. const perUserPrefix = url.slice(0, url.indexOf('/build/'))
  381. const requests = chunks
  382. .map(chunk => ({
  383. chunk,
  384. url: `${perUserPrefix}/content/${file.contentId}/${chunk.hash}?${query}`,
  385. }))
  386. .concat(coalescedDynamicChunks)
  387. let cachedCount = 0
  388. let cachedBytes = 0
  389. let fetchedCount = 0
  390. let fetchedBytes = 0
  391. const reassembledBlob = new Uint8Array(size)
  392. const rawResponses = await Promise.all(
  393. requests.map(async ({ chunk, url, init }) => {
  394. try {
  395. const response = await fetch(url, init)
  396. checkChunkResponse(response)
  397. const boundary = getMultipartBoundary(response, chunk)
  398. const blobFetchDate = getServerTime(response)
  399. const blobSize = getResponseSize(response)
  400. if (blobFetchDate && blobSize) {
  401. // Example: 2MB PDF, 1MB image, 128KB PDF.js chunk.
  402. // | pdf.js chunk |
  403. // | A BIG IMAGE BLOB |
  404. // | THE FULL PDF |
  405. if (chunk.hash && blobFetchDate < file.createdAt) {
  406. const usedChunkSection =
  407. Math.min(end, chunk.end) - Math.max(start, chunk.start)
  408. cachedCount++
  409. cachedBytes += usedChunkSection
  410. // Roll the position of the hash in the Map.
  411. cached.delete(chunk.hash)
  412. cached.add(chunk.hash)
  413. } else {
  414. // Blobs are fetched in bulk, record the full size.
  415. fetchedCount++
  416. fetchedBytes += blobSize
  417. }
  418. }
  419. return {
  420. boundary,
  421. chunk,
  422. data: backFillObjectContext(
  423. chunk,
  424. // response.arrayBuffer() yields the first multipart section only.
  425. await (await response.blob()).arrayBuffer()
  426. ),
  427. }
  428. } catch (error) {
  429. throw OError.tag(error, 'cannot fetch chunk', { url })
  430. }
  431. })
  432. )
  433. rawResponses
  434. .flatMap(r => resolveMultiPartResponses(r, file, metrics))
  435. .forEach(({ chunk, data }) => {
  436. // overlap:
  437. // | REQUESTED_RANGE |
  438. // | CHUNK |
  439. const offsetStart = Math.max(start - chunk.start, 0)
  440. // overlap:
  441. // | REQUESTED_RANGE |
  442. // | CHUNK |
  443. const offsetEnd = Math.max(chunk.end - end, 0)
  444. if (offsetStart > 0 || offsetEnd > 0) {
  445. // compute index positions for slice to handle case where offsetEnd=0
  446. const chunkSize = chunk.end - chunk.start
  447. data = data.subarray(offsetStart, chunkSize - offsetEnd)
  448. }
  449. const insertPosition = Math.max(chunk.start - start, 0)
  450. reassembledBlob.set(data, insertPosition)
  451. })
  452. trackDownloadStats(metrics, {
  453. size,
  454. cachedCount,
  455. cachedBytes,
  456. fetchedCount,
  457. fetchedBytes,
  458. })
  459. if (verifyChunks) {
  460. return await verifyRange({
  461. url,
  462. start,
  463. end,
  464. metrics,
  465. actual: reassembledBlob,
  466. })
  467. }
  468. return reassembledBlob
  469. }