SummarizedUpdatesManager.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import _ from 'lodash'
  2. import async from 'async'
  3. import logger from '@overleaf/logger'
  4. import OError from '@overleaf/o-error'
  5. import * as ChunkTranslator from './ChunkTranslator.js'
  6. import * as HistoryApiManager from './HistoryApiManager.js'
  7. import * as HistoryStoreManager from './HistoryStoreManager.js'
  8. import * as LabelsManager from './LabelsManager.js'
  9. import * as UpdatesProcessor from './UpdatesProcessor.js'
  10. import * as WebApiManager from './WebApiManager.js'
  11. const MAX_CHUNK_REQUESTS = 5
  12. const TIME_BETWEEN_DISTINCT_UPDATES = 5 * 60 * 1000 // five minutes
  13. export function getSummarizedProjectUpdates(projectId, options, callback) {
  14. // Some notes on versions:
  15. //
  16. // Versions of the project are like the fenceposts between updates.
  17. // An update applies to a certain version of the project, and gives us the
  18. // next version.
  19. //
  20. // When we ask for updates 'before' a version, this includes the update
  21. // that created the version equal to 'before'.
  22. //
  23. // A chunk in OL has a 'startVersion', which is the version of the project
  24. // before any of the updates in it were applied. This is the same version as
  25. // the last update in the previous chunk would have created.
  26. //
  27. // If we ask the OL history store for the chunk with version that is the end of one
  28. // chunk and the start of another, it will return the older chunk, i.e.
  29. // the chunk with the updates that led up to that version.
  30. //
  31. // So once we read in the updates from a chunk, and want to get the updates from
  32. // the previous chunk, we ask OL for the chunk with the version equal to the
  33. // 'startVersion' of the newer chunk we just read.
  34. let nextVersionToRequest
  35. if (options == null) {
  36. options = {}
  37. }
  38. if (!options.min_count) {
  39. options.min_count = 25
  40. }
  41. if (options.before != null) {
  42. // The version is of the doc, so we want the updates before that version,
  43. // which includes the update that created that version.
  44. nextVersionToRequest = options.before
  45. } else {
  46. // Return the latest updates first if no nextVersionToRequest is set.
  47. nextVersionToRequest = null
  48. }
  49. UpdatesProcessor.processUpdatesForProject(projectId, function (error) {
  50. if (error) {
  51. return callback(OError.tag(error))
  52. }
  53. LabelsManager.getLabels(projectId, function (error, labels) {
  54. if (error) {
  55. return callback(OError.tag(error))
  56. }
  57. const labelsByVersion = {}
  58. for (const label of labels) {
  59. if (labelsByVersion[label.version] == null) {
  60. labelsByVersion[label.version] = []
  61. }
  62. labelsByVersion[label.version].push(label)
  63. }
  64. WebApiManager.getHistoryId(projectId, function (error, historyId) {
  65. if (error) return callback(error)
  66. let chunksRequested = 0
  67. let summarizedUpdates = []
  68. let toV = null
  69. const shouldRequestMoreUpdates = cb => {
  70. return cb(
  71. null,
  72. chunksRequested < MAX_CHUNK_REQUESTS &&
  73. (nextVersionToRequest == null || nextVersionToRequest > 0) &&
  74. summarizedUpdates.length < options.min_count
  75. )
  76. }
  77. const getNextBatchOfUpdates = cb =>
  78. _getProjectUpdates(
  79. projectId,
  80. historyId,
  81. nextVersionToRequest,
  82. function (error, updateSet, startVersion) {
  83. if (error) {
  84. return cb(OError.tag(error))
  85. }
  86. // Updates are returned in time order, but we want to go back in time
  87. updateSet.reverse()
  88. updateSet = discardUnwantedUpdates(updateSet)
  89. ;({ summarizedUpdates, toV } = _summarizeUpdates(
  90. updateSet,
  91. labelsByVersion,
  92. summarizedUpdates,
  93. toV
  94. ))
  95. nextVersionToRequest = startVersion
  96. chunksRequested += 1
  97. cb()
  98. }
  99. )
  100. function discardUnwantedUpdates(updateSet) {
  101. // We're getting whole chunks from the OL history store, but we might
  102. // only want updates from before a certain version
  103. if (options.before == null) {
  104. return updateSet
  105. } else {
  106. return updateSet.filter(u => u.v < options.before)
  107. }
  108. }
  109. // If the project doesn't have a history then we can bail out here
  110. HistoryApiManager.shouldUseProjectHistory(
  111. projectId,
  112. function (error, shouldUseProjectHistory) {
  113. if (error) {
  114. return callback(OError.tag(error))
  115. }
  116. if (shouldUseProjectHistory) {
  117. async.whilst(
  118. shouldRequestMoreUpdates,
  119. getNextBatchOfUpdates,
  120. function (error) {
  121. if (error) {
  122. return callback(OError.tag(error))
  123. }
  124. callback(
  125. null,
  126. summarizedUpdates,
  127. nextVersionToRequest > 0 ? nextVersionToRequest : undefined
  128. )
  129. }
  130. )
  131. } else {
  132. logger.debug(
  133. { projectId },
  134. 'returning no updates as project does not use history'
  135. )
  136. callback(null, [])
  137. }
  138. }
  139. )
  140. })
  141. })
  142. })
  143. }
  144. function _getProjectUpdates(projectId, historyId, version, callback) {
  145. function getChunk(cb) {
  146. if (version != null) {
  147. HistoryStoreManager.getChunkAtVersion(projectId, historyId, version, cb)
  148. } else {
  149. HistoryStoreManager.getMostRecentChunk(projectId, historyId, cb)
  150. }
  151. }
  152. getChunk(function (error, chunk) {
  153. if (error) {
  154. return callback(OError.tag(error))
  155. }
  156. const oldestVersion = chunk.chunk.startVersion
  157. ChunkTranslator.convertToSummarizedUpdates(
  158. chunk,
  159. function (error, updateSet) {
  160. if (error) {
  161. return callback(OError.tag(error))
  162. }
  163. callback(error, updateSet, oldestVersion)
  164. }
  165. )
  166. })
  167. }
  168. function _summarizeUpdates(updates, labels, existingSummarizedUpdates, toV) {
  169. if (existingSummarizedUpdates == null) {
  170. existingSummarizedUpdates = []
  171. }
  172. const summarizedUpdates = existingSummarizedUpdates.slice()
  173. for (const update of updates) {
  174. if (toV == null) {
  175. // This is the first update we've seen. Initialize toV.
  176. toV = update.v + 1
  177. }
  178. // Skip empty updates (only record their version). Empty updates are
  179. // updates that only contain comment operations. We don't have a UI for
  180. // these yet.
  181. if (isUpdateEmpty(update)) {
  182. continue
  183. }
  184. // The client needs to know the exact version that a delete happened, in order
  185. // to be able to restore. So even when summarizing, retain the version that each
  186. // projectOp happened at.
  187. for (const projectOp of update.project_ops) {
  188. projectOp.atV = update.v
  189. }
  190. const summarizedUpdate = summarizedUpdates[summarizedUpdates.length - 1]
  191. const labelsForVersion = labels[update.v + 1] || []
  192. if (
  193. summarizedUpdate &&
  194. _shouldMergeUpdate(update, summarizedUpdate, labelsForVersion)
  195. ) {
  196. _mergeUpdate(update, summarizedUpdate)
  197. } else {
  198. const newUpdate = {
  199. fromV: update.v,
  200. toV,
  201. meta: {
  202. users: update.meta.users,
  203. start_ts: update.meta.start_ts,
  204. end_ts: update.meta.end_ts,
  205. },
  206. labels: labelsForVersion,
  207. pathnames: new Set(update.pathnames),
  208. project_ops: update.project_ops.slice(), // Clone since we'll modify
  209. }
  210. if (update.meta.origin) {
  211. newUpdate.meta.origin = update.meta.origin
  212. }
  213. summarizedUpdates.push(newUpdate)
  214. }
  215. toV = update.v
  216. }
  217. return { summarizedUpdates, toV }
  218. }
  219. /**
  220. * Given an update, the latest summarized update, and the labels that apply to
  221. * the update, figure out if we can merge the update into the summarized
  222. * update.
  223. */
  224. function _shouldMergeUpdate(update, summarizedUpdate, labels) {
  225. // Split updates on labels
  226. if (labels.length > 0) {
  227. return false
  228. }
  229. // Split updates on origin
  230. if (update.meta.origin) {
  231. if (summarizedUpdate.meta.origin) {
  232. if (update.meta.origin.kind !== summarizedUpdate.meta.origin.kind) {
  233. return false
  234. }
  235. if (update.meta.origin.path !== summarizedUpdate.meta.origin.path) {
  236. return false
  237. }
  238. if (
  239. update.meta.origin.kind === 'file-restore' &&
  240. update.meta.origin.timestamp !== summarizedUpdate.meta.origin.timestamp
  241. ) {
  242. return false
  243. }
  244. if (
  245. update.meta.origin.kind === 'project-restore' &&
  246. update.meta.origin.timestamp !== summarizedUpdate.meta.origin.timestamp
  247. ) {
  248. return false
  249. }
  250. } else {
  251. return false
  252. }
  253. } else if (summarizedUpdate.meta.origin) {
  254. return false
  255. }
  256. // Split updates if it's been too long since the last update. We're going
  257. // backwards in time through the updates, so the update comes before the summarized update.
  258. if (
  259. summarizedUpdate.meta.end_ts - update.meta.start_ts >=
  260. TIME_BETWEEN_DISTINCT_UPDATES
  261. ) {
  262. return false
  263. }
  264. // Do not merge text operations and file operations, except for history resyncs
  265. const updateHasTextOps = update.pathnames.length > 0
  266. const updateHasFileOps = update.project_ops.length > 0
  267. const summarizedUpdateHasTextOps = summarizedUpdate.pathnames.size > 0
  268. const summarizedUpdateHasFileOps = summarizedUpdate.project_ops.length > 0
  269. const isHistoryResync =
  270. update.meta.origin &&
  271. ['history-resync', 'history-migration'].includes(update.meta.origin.kind)
  272. if (
  273. !isHistoryResync &&
  274. ((updateHasTextOps && summarizedUpdateHasFileOps) ||
  275. (updateHasFileOps && summarizedUpdateHasTextOps))
  276. ) {
  277. return false
  278. }
  279. return true
  280. }
  281. /**
  282. * Merge an update into a summarized update.
  283. *
  284. * This mutates the summarized update.
  285. */
  286. function _mergeUpdate(update, summarizedUpdate) {
  287. // check if the user in this update is already present in the earliest update,
  288. // if not, add them to the users list of the earliest update
  289. summarizedUpdate.meta.users = _.uniqBy(
  290. _.union(summarizedUpdate.meta.users, update.meta.users),
  291. function (user) {
  292. if (user == null) {
  293. return null
  294. }
  295. if (user.id == null) {
  296. return user
  297. }
  298. return user.id
  299. }
  300. )
  301. summarizedUpdate.fromV = Math.min(summarizedUpdate.fromV, update.v)
  302. summarizedUpdate.toV = Math.max(summarizedUpdate.toV, update.v + 1)
  303. summarizedUpdate.meta.start_ts = Math.min(
  304. summarizedUpdate.meta.start_ts,
  305. update.meta.start_ts
  306. )
  307. summarizedUpdate.meta.end_ts = Math.max(
  308. summarizedUpdate.meta.end_ts,
  309. update.meta.end_ts
  310. )
  311. // Add file operations
  312. for (const op of update.project_ops || []) {
  313. summarizedUpdate.project_ops.push(op)
  314. if (op.add) {
  315. // Merging a file creation. Remove any corresponding edit since that's redundant.
  316. summarizedUpdate.pathnames.delete(op.add.pathname)
  317. }
  318. }
  319. // Add edit operations
  320. for (const pathname of update.pathnames || []) {
  321. summarizedUpdate.pathnames.add(pathname)
  322. }
  323. }
  324. function isUpdateEmpty(update) {
  325. return update.project_ops.length === 0 && update.pathnames.length === 0
  326. }