ProjectHistoryRedisManager.js 5.1 KB

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