ContentCacheManager.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. /**
  2. * ContentCacheManager - maintains a cache of stream hashes from a PDF file
  3. */
  4. const { callbackify } = require('util')
  5. const fs = require('fs')
  6. const crypto = require('crypto')
  7. const Path = require('path')
  8. const Settings = require('settings-sharelatex')
  9. const pLimit = require('p-limit')
  10. const MIN_CHUNK_SIZE = Settings.pdfCachingMinChunkSize
  11. const START_OF_STREAM_MARKER = 'stream'
  12. const END_OF_STREAM_MARKER = 'endstream'
  13. const START_OF_STREAM_MARKER_LENGTH = START_OF_STREAM_MARKER.length
  14. const END_OF_STREAM_MARKER_LENGTH = END_OF_STREAM_MARKER.length
  15. /**
  16. *
  17. * @param {String} contentDir path to directory where content hash files are cached
  18. * @param {String} filePath the pdf file to scan for streams
  19. */
  20. async function update(contentDir, filePath) {
  21. const stream = fs.createReadStream(filePath)
  22. const extractor = new PdfStreamsExtractor()
  23. const ranges = []
  24. const newRanges = []
  25. // keep track of hashes expire old ones when they reach a generation > N.
  26. const tracker = await HashFileTracker.from(contentDir)
  27. tracker.updateAge()
  28. for await (const chunk of stream) {
  29. const pdfStreams = extractor.consume(chunk)
  30. for (const pdfStream of pdfStreams) {
  31. if (pdfStream.end - pdfStream.start < MIN_CHUNK_SIZE) continue
  32. const hash = pdfStreamHash(pdfStream.buffers)
  33. const range = { start: pdfStream.start, end: pdfStream.end, hash }
  34. ranges.push(range)
  35. // Optimization: Skip writing of duplicate streams.
  36. if (tracker.track(range)) continue
  37. await writePdfStream(contentDir, hash, pdfStream.buffers)
  38. newRanges.push(range)
  39. }
  40. }
  41. const reclaimedSpace = await tracker.deleteStaleHashes(5)
  42. await tracker.flush()
  43. return [ranges, newRanges, reclaimedSpace]
  44. }
  45. function getStatePath(contentDir) {
  46. return Path.join(contentDir, '.state.v0.json')
  47. }
  48. class HashFileTracker {
  49. constructor(contentDir, { hashAge = [], hashSize = [] }) {
  50. this.contentDir = contentDir
  51. this.hashAge = new Map(hashAge)
  52. this.hashSize = new Map(hashSize)
  53. }
  54. static async from(contentDir) {
  55. const statePath = getStatePath(contentDir)
  56. let state = {}
  57. try {
  58. const blob = await fs.promises.readFile(statePath)
  59. state = JSON.parse(blob)
  60. } catch (e) {}
  61. return new HashFileTracker(contentDir, state)
  62. }
  63. track(range) {
  64. const exists = this.hashAge.has(range.hash)
  65. if (!exists) {
  66. this.hashSize.set(range.hash, range.end - range.start)
  67. }
  68. this.hashAge.set(range.hash, 0)
  69. return exists
  70. }
  71. updateAge() {
  72. for (const [hash, age] of this.hashAge) {
  73. this.hashAge.set(hash, age + 1)
  74. }
  75. return this
  76. }
  77. findStale(maxAge) {
  78. const stale = []
  79. for (const [hash, age] of this.hashAge) {
  80. if (age > maxAge) {
  81. stale.push(hash)
  82. }
  83. }
  84. return stale
  85. }
  86. async flush() {
  87. const statePath = getStatePath(this.contentDir)
  88. const blob = JSON.stringify({
  89. hashAge: Array.from(this.hashAge.entries()),
  90. hashSize: Array.from(this.hashSize.entries())
  91. })
  92. const atomicWrite = statePath + '~'
  93. try {
  94. await fs.promises.writeFile(atomicWrite, blob)
  95. } catch (err) {
  96. try {
  97. await fs.promises.unlink(atomicWrite)
  98. } catch (e) {}
  99. throw err
  100. }
  101. try {
  102. await fs.promises.rename(atomicWrite, statePath)
  103. } catch (err) {
  104. try {
  105. await fs.promises.unlink(atomicWrite)
  106. } catch (e) {}
  107. throw err
  108. }
  109. }
  110. async deleteStaleHashes(n) {
  111. // delete any hash file older than N generations
  112. const hashes = this.findStale(n)
  113. let reclaimedSpace = 0
  114. if (hashes.length === 0) {
  115. return reclaimedSpace
  116. }
  117. await promiseMapWithLimit(10, hashes, async (hash) => {
  118. await fs.promises.unlink(Path.join(this.contentDir, hash))
  119. this.hashAge.delete(hash)
  120. reclaimedSpace += this.hashSize.get(hash)
  121. this.hashSize.delete(hash)
  122. })
  123. return reclaimedSpace
  124. }
  125. }
  126. class PdfStreamsExtractor {
  127. constructor() {
  128. this.fileIndex = 0
  129. this.inStream = false
  130. this.streamStartIndex = 0
  131. this.buffers = []
  132. this.lastChunk = Buffer.alloc(0)
  133. }
  134. consume(chunk) {
  135. let chunkIndex = 0
  136. const pdfStreams = []
  137. chunk = Buffer.concat([this.lastChunk, chunk])
  138. while (true) {
  139. if (!this.inStream) {
  140. // Not in a stream, look for stream start
  141. const index = chunk.indexOf(START_OF_STREAM_MARKER, chunkIndex)
  142. if (index === -1) {
  143. // Couldn't find stream start
  144. break
  145. }
  146. // Found stream start, start a stream
  147. this.inStream = true
  148. this.streamStartIndex = this.fileIndex + index
  149. chunkIndex = index
  150. } else {
  151. // In a stream, look for stream end
  152. const index = chunk.indexOf(END_OF_STREAM_MARKER, chunkIndex)
  153. if (index === -1) {
  154. break
  155. }
  156. // add "endstream" part
  157. const endIndex = index + END_OF_STREAM_MARKER_LENGTH
  158. this.buffers.push(chunk.slice(chunkIndex, endIndex))
  159. pdfStreams.push({
  160. start: this.streamStartIndex,
  161. end: this.fileIndex + endIndex,
  162. buffers: this.buffers
  163. })
  164. this.inStream = false
  165. this.buffers = []
  166. chunkIndex = endIndex
  167. }
  168. }
  169. const remaining = chunk.length - chunkIndex
  170. const nextMarkerLength = this.inStream
  171. ? END_OF_STREAM_MARKER_LENGTH
  172. : START_OF_STREAM_MARKER_LENGTH
  173. if (remaining > nextMarkerLength) {
  174. const retainMarkerSection = chunk.length - nextMarkerLength
  175. if (this.inStream) {
  176. this.buffers.push(chunk.slice(chunkIndex, retainMarkerSection))
  177. }
  178. this.lastChunk = chunk.slice(retainMarkerSection)
  179. this.fileIndex += retainMarkerSection
  180. } else {
  181. this.lastChunk = chunk.slice(chunkIndex)
  182. this.fileIndex += chunkIndex
  183. }
  184. return pdfStreams
  185. }
  186. }
  187. function pdfStreamHash(buffers) {
  188. const hash = crypto.createHash('sha256')
  189. for (const buffer of buffers) {
  190. hash.update(buffer)
  191. }
  192. return hash.digest('hex')
  193. }
  194. async function writePdfStream(dir, hash, buffers) {
  195. const filename = Path.join(dir, hash)
  196. const atomicWriteFilename = filename + '~'
  197. const file = await fs.promises.open(atomicWriteFilename, 'w')
  198. if (Settings.enablePdfCachingDark) {
  199. // Write an empty file in dark mode.
  200. buffers = []
  201. }
  202. try {
  203. try {
  204. for (const buffer of buffers) {
  205. await file.write(buffer)
  206. }
  207. } finally {
  208. await file.close()
  209. }
  210. await fs.promises.rename(atomicWriteFilename, filename)
  211. } catch (err) {
  212. try {
  213. await fs.promises.unlink(atomicWriteFilename)
  214. } catch (_) {
  215. throw err
  216. }
  217. }
  218. }
  219. function promiseMapWithLimit(concurrency, array, fn) {
  220. const limit = pLimit(concurrency)
  221. return Promise.all(array.map((x) => limit(() => fn(x))))
  222. }
  223. module.exports = {
  224. HASH_REGEX: /^[0-9a-f]{64}$/,
  225. update: callbackify(update)
  226. }