history_store.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. // @ts-check
  2. 'use strict'
  3. const core = require('overleaf-editor-core')
  4. const config = require('config')
  5. const path = require('node:path')
  6. const Stream = require('node:stream')
  7. const { promisify } = require('node:util')
  8. const zlib = require('node:zlib')
  9. const OError = require('@overleaf/o-error')
  10. const objectPersistor = require('@overleaf/object-persistor')
  11. const logger = require('@overleaf/logger')
  12. const assert = require('./assert')
  13. const persistor = require('./persistor')
  14. const projectKey = require('@overleaf/object-persistor/src/ProjectKey.js')
  15. const streams = require('./streams')
  16. const Chunk = core.Chunk
  17. const gzip = promisify(zlib.gzip)
  18. const gunzip = promisify(zlib.gunzip)
  19. class LoadError extends OError {
  20. /**
  21. * @param {string} projectId
  22. * @param {string} chunkId
  23. * @param {any} cause
  24. */
  25. constructor(projectId, chunkId, cause) {
  26. super(
  27. 'HistoryStore: failed to load chunk history',
  28. { projectId, chunkId },
  29. cause
  30. )
  31. this.projectId = projectId
  32. this.chunkId = chunkId
  33. }
  34. }
  35. class StoreError extends OError {
  36. /**
  37. * @param {string} projectId
  38. * @param {string} chunkId
  39. * @param {any} cause
  40. */
  41. constructor(projectId, chunkId, cause) {
  42. super(
  43. 'HistoryStore: failed to store chunk history',
  44. { projectId, chunkId },
  45. cause
  46. )
  47. this.projectId = projectId
  48. this.chunkId = chunkId
  49. }
  50. }
  51. /**
  52. * @param {string} projectId
  53. * @param {string} chunkId
  54. * @return {string}
  55. */
  56. function getKey(projectId, chunkId) {
  57. return path.join(projectKey.format(projectId), projectKey.pad(chunkId))
  58. }
  59. /**
  60. * Store and retreive raw {@link History} objects from bucket. Mainly used via the
  61. * {@link ChunkStore}.
  62. *
  63. * Histories are stored as gzipped JSON blobs, keyed on the project ID and the
  64. * ID of the Chunk that owns the history. The project ID is currently redundant,
  65. * but I think it might help in future if we have to shard on project ID, and
  66. * it gives us some chance of reconstructing histories even if there is a
  67. * problem with the chunk metadata in the database.
  68. *
  69. * @class
  70. */
  71. class HistoryStore {
  72. #persistor
  73. #bucket
  74. constructor(persistor, bucket) {
  75. this.#persistor = persistor
  76. this.#bucket = bucket
  77. }
  78. /**
  79. * Load the raw object for a History.
  80. *
  81. * @param {string} projectId
  82. * @param {string} chunkId
  83. * @return {Promise<import('overleaf-editor-core/lib/types').RawHistory>}
  84. */
  85. async loadRaw(projectId, chunkId) {
  86. assert.projectId(projectId, 'bad projectId')
  87. assert.chunkId(chunkId, 'bad chunkId')
  88. const key = getKey(projectId, chunkId)
  89. logger.debug({ projectId, chunkId }, 'loadRaw started')
  90. try {
  91. const buf = await streams.gunzipStreamToBuffer(
  92. await this.#persistor.getObjectStream(this.#bucket, key)
  93. )
  94. return JSON.parse(buf.toString('utf-8'))
  95. } catch (err) {
  96. if (err instanceof objectPersistor.Errors.NotFoundError) {
  97. throw new Chunk.NotPersistedError(projectId)
  98. }
  99. throw new LoadError(projectId, chunkId, err)
  100. } finally {
  101. logger.debug({ projectId, chunkId }, 'loadRaw finished')
  102. }
  103. }
  104. async loadRawWithBuffer(projectId, chunkId) {
  105. assert.projectId(projectId, 'bad projectId')
  106. assert.chunkId(chunkId, 'bad chunkId')
  107. const key = getKey(projectId, chunkId)
  108. logger.debug({ projectId, chunkId }, 'loadBuffer started')
  109. try {
  110. const buf = await streams.readStreamToBuffer(
  111. await this.#persistor.getObjectStream(this.#bucket, key)
  112. )
  113. const unzipped = await gunzip(buf)
  114. return {
  115. buffer: buf,
  116. raw: JSON.parse(unzipped.toString('utf-8')),
  117. }
  118. } catch (err) {
  119. if (err instanceof objectPersistor.Errors.NotFoundError) {
  120. throw new Chunk.NotPersistedError(projectId)
  121. }
  122. throw new LoadError(projectId, chunkId, err)
  123. } finally {
  124. logger.debug({ projectId, chunkId }, 'loadBuffer finished')
  125. }
  126. }
  127. /**
  128. * Compress and store a {@link History}.
  129. *
  130. * @param {string} projectId
  131. * @param {string} chunkId
  132. * @param {import('overleaf-editor-core/lib/types').RawHistory} rawHistory
  133. */
  134. async storeRaw(projectId, chunkId, rawHistory) {
  135. assert.projectId(projectId, 'bad projectId')
  136. assert.chunkId(chunkId, 'bad chunkId')
  137. assert.object(rawHistory, 'bad rawHistory')
  138. const key = getKey(projectId, chunkId)
  139. logger.debug({ projectId, chunkId }, 'storeRaw started')
  140. const buf = await gzip(JSON.stringify(rawHistory))
  141. try {
  142. await this.#persistor.sendStream(
  143. this.#bucket,
  144. key,
  145. Stream.Readable.from([buf]),
  146. {
  147. contentType: 'application/json',
  148. contentEncoding: 'gzip',
  149. contentLength: buf.byteLength,
  150. }
  151. )
  152. } catch (err) {
  153. throw new StoreError(projectId, chunkId, err)
  154. } finally {
  155. logger.debug({ projectId, chunkId }, 'storeRaw finished')
  156. }
  157. }
  158. /**
  159. * Compress and store a {@link History}.
  160. *
  161. * @param {string} sourceProjectId
  162. * @param {string} sourceChunkId
  163. * @param {string} targetProjectId
  164. * @param {string} targetChunkId
  165. */
  166. async cloneChunk(
  167. sourceProjectId,
  168. sourceChunkId,
  169. targetProjectId,
  170. targetChunkId
  171. ) {
  172. assert.projectId(targetProjectId, 'bad target projectId')
  173. assert.projectId(sourceProjectId, 'bad source projectId')
  174. assert.chunkId(targetChunkId, 'bad chunkId')
  175. assert.chunkId(sourceChunkId, 'bad chunkId')
  176. const dstKey = getKey(targetProjectId, targetChunkId)
  177. const srcKey = getKey(sourceProjectId, sourceChunkId)
  178. const info = {
  179. targetProjectId,
  180. sourceProjectId,
  181. sourceChunkId,
  182. targetChunkId,
  183. srcKey,
  184. dstKey,
  185. }
  186. logger.debug(info, 'cloneChunk started')
  187. try {
  188. await this.#persistor.copyObject(this.#bucket, srcKey, dstKey)
  189. } catch (err) {
  190. throw new StoreError(sourceProjectId, sourceChunkId, err)
  191. } finally {
  192. logger.debug(info, 'cloneChunk finished')
  193. }
  194. }
  195. /**
  196. * Delete multiple chunks from bucket. Expects an Array of objects with
  197. * projectId and chunkId properties
  198. * @param {Array<{projectId: string,chunkId:string}>} chunks
  199. */
  200. async deleteChunks(chunks) {
  201. logger.debug({ chunks }, 'deleteChunks started')
  202. try {
  203. await Promise.all(
  204. chunks.map(chunk => {
  205. const key = getKey(chunk.projectId, chunk.chunkId)
  206. return this.#persistor.deleteObject(this.#bucket, key)
  207. })
  208. )
  209. } finally {
  210. logger.debug({ chunks }, 'deleteChunks finished')
  211. }
  212. }
  213. }
  214. module.exports = {
  215. HistoryStore,
  216. historyStore: new HistoryStore(persistor, config.get('chunkStore.bucket')),
  217. }