ProjectHistoryRedisManager.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. // @ts-check
  2. const Settings = require('@overleaf/settings')
  3. const { callbackifyAll } = require('@overleaf/promise-utils')
  4. const projectHistoryKeys = Settings.redis?.project_history?.key_schema
  5. const rclient = require('@overleaf/redis-wrapper').createClient(
  6. Settings.redis.project_history
  7. )
  8. const logger = require('@overleaf/logger')
  9. const metrics = require('./Metrics')
  10. const { docIsTooLarge } = require('./Limits')
  11. const { addTrackedDeletesToContent, extractOriginOrSource } = require('./Utils')
  12. const HistoryConversions = require('./HistoryConversions')
  13. const OError = require('@overleaf/o-error')
  14. /**
  15. * @import { Ranges } from './types'
  16. */
  17. const ProjectHistoryRedisManager = {
  18. async queueOps(projectId, ...ops) {
  19. // Record metric for ops pushed onto queue
  20. for (const op of ops) {
  21. metrics.summary('redis.projectHistoryOps', op.length, { status: 'push' })
  22. }
  23. // Make sure that this MULTI operation only operates on project
  24. // specific keys, i.e. keys that have the project id in curly braces.
  25. // The curly braces identify a hash key for Redis and ensures that
  26. // the MULTI's operations are all done on the same node in a
  27. // cluster environment.
  28. const multi = rclient.multi()
  29. // Push the ops onto the project history queue
  30. multi.rpush(
  31. projectHistoryKeys.projectHistoryOps({ project_id: projectId }),
  32. ...ops
  33. )
  34. // To record the age of the oldest op on the queue set a timestamp if not
  35. // already present (SETNX).
  36. multi.setnx(
  37. projectHistoryKeys.projectHistoryFirstOpTimestamp({
  38. project_id: projectId,
  39. }),
  40. Date.now()
  41. )
  42. const result = await multi.exec()
  43. return result[0]
  44. },
  45. async queueRenameEntity(
  46. projectId,
  47. projectHistoryId,
  48. entityType,
  49. entityId,
  50. userId,
  51. projectUpdate,
  52. originOrSource
  53. ) {
  54. projectUpdate = {
  55. pathname: projectUpdate.pathname,
  56. new_pathname: projectUpdate.newPathname,
  57. meta: {
  58. user_id: userId,
  59. ts: new Date(),
  60. },
  61. version: projectUpdate.version,
  62. projectHistoryId,
  63. }
  64. projectUpdate[entityType] = entityId
  65. const { origin, source } = extractOriginOrSource(originOrSource)
  66. if (origin != null) {
  67. projectUpdate.meta.origin = origin
  68. if (origin.kind !== 'editor') {
  69. projectUpdate.meta.type = 'external'
  70. }
  71. } else if (source != null) {
  72. projectUpdate.meta.source = source
  73. if (source !== 'editor') {
  74. projectUpdate.meta.type = 'external'
  75. }
  76. }
  77. logger.debug(
  78. { projectId, projectUpdate },
  79. 'queue rename operation to project-history'
  80. )
  81. const jsonUpdate = JSON.stringify(projectUpdate)
  82. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  83. },
  84. async queueAddEntity(
  85. projectId,
  86. projectHistoryId,
  87. entityType,
  88. entityId,
  89. userId,
  90. projectUpdate,
  91. originOrSource
  92. ) {
  93. let docLines = projectUpdate.docLines
  94. let ranges
  95. if (projectUpdate.historyRangesSupport && projectUpdate.ranges) {
  96. docLines = addTrackedDeletesToContent(
  97. docLines,
  98. projectUpdate.ranges.changes ?? []
  99. )
  100. ranges = HistoryConversions.toHistoryRanges(projectUpdate.ranges)
  101. }
  102. projectUpdate = {
  103. pathname: projectUpdate.pathname,
  104. docLines,
  105. url: projectUpdate.url,
  106. meta: {
  107. user_id: userId,
  108. ts: new Date(),
  109. },
  110. version: projectUpdate.version,
  111. hash: projectUpdate.hash,
  112. metadata: projectUpdate.metadata,
  113. projectHistoryId,
  114. createdBlob: projectUpdate.createdBlob ?? false,
  115. }
  116. if (ranges) {
  117. projectUpdate.ranges = ranges
  118. }
  119. projectUpdate[entityType] = entityId
  120. const { origin, source } = extractOriginOrSource(originOrSource)
  121. if (origin != null) {
  122. projectUpdate.meta.origin = origin
  123. if (origin.kind !== 'editor') {
  124. projectUpdate.meta.type = 'external'
  125. }
  126. } else if (source != null) {
  127. projectUpdate.meta.source = source
  128. if (source !== 'editor') {
  129. projectUpdate.meta.type = 'external'
  130. }
  131. }
  132. logger.debug(
  133. { projectId, projectUpdate },
  134. 'queue add operation to project-history'
  135. )
  136. const jsonUpdate = JSON.stringify(projectUpdate)
  137. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  138. },
  139. async queueResyncProjectStructure(
  140. projectId,
  141. projectHistoryId,
  142. docs,
  143. files,
  144. opts
  145. ) {
  146. logger.debug({ projectId, docs, files }, 'queue project structure resync')
  147. const projectUpdate = {
  148. resyncProjectStructure: { docs, files },
  149. projectHistoryId,
  150. meta: {
  151. ts: new Date(),
  152. },
  153. }
  154. if (opts.resyncProjectStructureOnly) {
  155. projectUpdate.resyncProjectStructureOnly = opts.resyncProjectStructureOnly
  156. }
  157. const jsonUpdate = JSON.stringify(projectUpdate)
  158. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  159. },
  160. /**
  161. * Add a resync doc update to the project-history queue
  162. *
  163. * @param {string} projectId
  164. * @param {string} projectHistoryId
  165. * @param {string} docId
  166. * @param {string[]} lines
  167. * @param {Ranges} ranges
  168. * @param {string[]} resolvedCommentIds
  169. * @param {number} version
  170. * @param {string} pathname
  171. * @param {boolean} historyRangesSupport
  172. * @return {Promise<number>} the number of ops added
  173. */
  174. async queueResyncDocContent(
  175. projectId,
  176. projectHistoryId,
  177. docId,
  178. lines,
  179. ranges,
  180. resolvedCommentIds,
  181. version,
  182. pathname,
  183. historyRangesSupport
  184. ) {
  185. logger.debug(
  186. { projectId, docId, lines, version, pathname },
  187. 'queue doc content resync'
  188. )
  189. let content = lines.join('\n')
  190. if (historyRangesSupport) {
  191. content = addTrackedDeletesToContent(content, ranges.changes ?? [])
  192. }
  193. const projectUpdate = {
  194. resyncDocContent: { content, version },
  195. projectHistoryId,
  196. path: pathname,
  197. doc: docId,
  198. meta: {
  199. ts: new Date(),
  200. },
  201. }
  202. if (historyRangesSupport) {
  203. projectUpdate.resyncDocContent.ranges =
  204. HistoryConversions.toHistoryRanges(ranges)
  205. projectUpdate.resyncDocContent.resolvedCommentIds = resolvedCommentIds
  206. }
  207. const jsonUpdate = JSON.stringify(projectUpdate)
  208. // Do an optimised size check on the docLines using the serialised
  209. // project update length as an upper bound
  210. const sizeBound = jsonUpdate.length
  211. if (docIsTooLarge(sizeBound, lines, Settings.max_doc_length)) {
  212. throw new OError(
  213. 'blocking resync doc content insert into project history queue: doc is too large',
  214. { projectId, docId, docSize: sizeBound }
  215. )
  216. }
  217. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  218. },
  219. }
  220. module.exports = {
  221. ...callbackifyAll(ProjectHistoryRedisManager),
  222. promises: ProjectHistoryRedisManager,
  223. }