ProjectHistoryRedisManager.js 7.4 KB

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