ProjectHistoryRedisManager.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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 } = require('./Utils')
  12. const HistoryConversions = require('./HistoryConversions')
  13. const OError = require('@overleaf/o-error')
  14. /**
  15. * @typedef {import('./types').Ranges} Ranges
  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. const multi = rclient.multi()
  24. // Push the ops onto the project history queue
  25. multi.rpush(
  26. projectHistoryKeys.projectHistoryOps({ project_id: projectId }),
  27. ...ops
  28. )
  29. // To record the age of the oldest op on the queue set a timestamp if not
  30. // already present (SETNX).
  31. multi.setnx(
  32. projectHistoryKeys.projectHistoryFirstOpTimestamp({
  33. project_id: projectId,
  34. }),
  35. Date.now()
  36. )
  37. const result = await multi.exec()
  38. return result[0]
  39. },
  40. async queueRenameEntity(
  41. projectId,
  42. projectHistoryId,
  43. entityType,
  44. entityId,
  45. userId,
  46. projectUpdate,
  47. source
  48. ) {
  49. projectUpdate = {
  50. pathname: projectUpdate.pathname,
  51. new_pathname: projectUpdate.newPathname,
  52. meta: {
  53. user_id: userId,
  54. ts: new Date(),
  55. },
  56. version: projectUpdate.version,
  57. projectHistoryId,
  58. }
  59. projectUpdate[entityType] = entityId
  60. if (source != null) {
  61. projectUpdate.meta.source = source
  62. if (source !== 'editor') {
  63. projectUpdate.meta.type = 'external'
  64. }
  65. }
  66. logger.debug(
  67. { projectId, projectUpdate },
  68. 'queue rename operation to project-history'
  69. )
  70. const jsonUpdate = JSON.stringify(projectUpdate)
  71. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  72. },
  73. async queueAddEntity(
  74. projectId,
  75. projectHistoryId,
  76. entityType,
  77. entityId,
  78. userId,
  79. projectUpdate,
  80. source
  81. ) {
  82. let docLines = projectUpdate.docLines
  83. let ranges
  84. if (projectUpdate.historyRangesSupport && projectUpdate.ranges) {
  85. docLines = addTrackedDeletesToContent(
  86. docLines,
  87. projectUpdate.ranges.changes ?? []
  88. )
  89. ranges = HistoryConversions.toHistoryRanges(projectUpdate.ranges)
  90. }
  91. projectUpdate = {
  92. pathname: projectUpdate.pathname,
  93. docLines,
  94. url: projectUpdate.url,
  95. meta: {
  96. user_id: userId,
  97. ts: new Date(),
  98. },
  99. version: projectUpdate.version,
  100. projectHistoryId,
  101. }
  102. if (ranges) {
  103. projectUpdate.ranges = ranges
  104. }
  105. projectUpdate[entityType] = entityId
  106. if (source != null) {
  107. projectUpdate.meta.source = source
  108. if (source !== 'editor') {
  109. projectUpdate.meta.type = 'external'
  110. }
  111. }
  112. logger.debug(
  113. { projectId, projectUpdate },
  114. 'queue add operation to project-history'
  115. )
  116. const jsonUpdate = JSON.stringify(projectUpdate)
  117. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  118. },
  119. async queueResyncProjectStructure(projectId, projectHistoryId, docs, files) {
  120. logger.debug({ projectId, docs, files }, 'queue project structure resync')
  121. const projectUpdate = {
  122. resyncProjectStructure: { docs, files },
  123. projectHistoryId,
  124. meta: {
  125. ts: new Date(),
  126. },
  127. }
  128. const jsonUpdate = JSON.stringify(projectUpdate)
  129. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  130. },
  131. /**
  132. * Add a resync doc update to the project-history queue
  133. *
  134. * @param {string} projectId
  135. * @param {string} projectHistoryId
  136. * @param {string} docId
  137. * @param {string[]} lines
  138. * @param {Ranges} ranges
  139. * @param {string[]} resolvedCommentIds
  140. * @param {number} version
  141. * @param {string} pathname
  142. * @param {boolean} historyRangesSupport
  143. * @return {Promise<number>} the number of ops added
  144. */
  145. async queueResyncDocContent(
  146. projectId,
  147. projectHistoryId,
  148. docId,
  149. lines,
  150. ranges,
  151. resolvedCommentIds,
  152. version,
  153. pathname,
  154. historyRangesSupport
  155. ) {
  156. logger.debug(
  157. { projectId, docId, lines, version, pathname },
  158. 'queue doc content resync'
  159. )
  160. let content = lines.join('\n')
  161. if (historyRangesSupport) {
  162. content = addTrackedDeletesToContent(content, ranges.changes ?? [])
  163. }
  164. const projectUpdate = {
  165. resyncDocContent: { content, version },
  166. projectHistoryId,
  167. path: pathname,
  168. doc: docId,
  169. meta: {
  170. ts: new Date(),
  171. },
  172. }
  173. if (historyRangesSupport) {
  174. projectUpdate.resyncDocContent.ranges =
  175. HistoryConversions.toHistoryRanges(ranges)
  176. projectUpdate.resyncDocContent.resolvedCommentIds = resolvedCommentIds
  177. }
  178. const jsonUpdate = JSON.stringify(projectUpdate)
  179. // Do an optimised size check on the docLines using the serialised
  180. // project update length as an upper bound
  181. const sizeBound = jsonUpdate.length
  182. if (docIsTooLarge(sizeBound, lines, Settings.max_doc_length)) {
  183. throw new OError(
  184. 'blocking resync doc content insert into project history queue: doc is too large',
  185. { projectId, docId, docSize: sizeBound }
  186. )
  187. }
  188. return await ProjectHistoryRedisManager.queueOps(projectId, jsonUpdate)
  189. },
  190. }
  191. module.exports = {
  192. ...callbackifyAll(ProjectHistoryRedisManager),
  193. promises: ProjectHistoryRedisManager,
  194. }