DocumentManager.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. const { callbackifyAll } = require('@overleaf/promise-utils')
  2. const RedisManager = require('./RedisManager')
  3. const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
  4. const PersistenceManager = require('./PersistenceManager')
  5. const DiffCodec = require('./DiffCodec')
  6. const logger = require('@overleaf/logger')
  7. const Metrics = require('./Metrics')
  8. const HistoryManager = require('./HistoryManager')
  9. const Errors = require('./Errors')
  10. const RangesManager = require('./RangesManager')
  11. const MAX_UNFLUSHED_AGE = 300 * 1000 // 5 mins, document should be flushed to mongo this time after a change
  12. const DocumentManager = {
  13. async getDoc(projectId, docId) {
  14. const {
  15. lines,
  16. version,
  17. ranges,
  18. resolvedCommentIds,
  19. pathname,
  20. projectHistoryId,
  21. unflushedTime,
  22. historyRangesSupport,
  23. } = await RedisManager.promises.getDoc(projectId, docId)
  24. if (lines == null || version == null) {
  25. logger.debug(
  26. { projectId, docId },
  27. 'doc not in redis so getting from persistence API'
  28. )
  29. const {
  30. lines,
  31. version,
  32. ranges,
  33. resolvedCommentIds,
  34. pathname,
  35. projectHistoryId,
  36. historyRangesSupport,
  37. } = await PersistenceManager.promises.getDoc(projectId, docId)
  38. logger.debug(
  39. {
  40. projectId,
  41. docId,
  42. lines,
  43. ranges,
  44. resolvedCommentIds,
  45. version,
  46. pathname,
  47. projectHistoryId,
  48. historyRangesSupport,
  49. },
  50. 'got doc from persistence API'
  51. )
  52. await RedisManager.promises.putDocInMemory(
  53. projectId,
  54. docId,
  55. lines,
  56. version,
  57. ranges,
  58. resolvedCommentIds,
  59. pathname,
  60. projectHistoryId,
  61. historyRangesSupport
  62. )
  63. return {
  64. lines,
  65. version,
  66. ranges: ranges || {},
  67. resolvedCommentIds,
  68. pathname,
  69. projectHistoryId,
  70. unflushedTime: null,
  71. alreadyLoaded: false,
  72. historyRangesSupport,
  73. }
  74. } else {
  75. return {
  76. lines,
  77. version,
  78. ranges,
  79. pathname,
  80. projectHistoryId,
  81. resolvedCommentIds,
  82. unflushedTime,
  83. alreadyLoaded: true,
  84. historyRangesSupport,
  85. }
  86. }
  87. },
  88. async getDocAndRecentOps(projectId, docId, fromVersion) {
  89. const { lines, version, ranges, pathname, projectHistoryId } =
  90. await DocumentManager.getDoc(projectId, docId)
  91. if (fromVersion === -1) {
  92. return { lines, version, ops: [], ranges, pathname, projectHistoryId }
  93. } else {
  94. const ops = await RedisManager.promises.getPreviousDocOps(
  95. docId,
  96. fromVersion,
  97. version
  98. )
  99. return {
  100. lines,
  101. version,
  102. ops,
  103. ranges,
  104. pathname,
  105. projectHistoryId,
  106. }
  107. }
  108. },
  109. async setDoc(projectId, docId, newLines, source, userId, undoing) {
  110. if (newLines == null) {
  111. throw new Error('No lines were provided to setDoc')
  112. }
  113. const UpdateManager = require('./UpdateManager')
  114. const {
  115. lines: oldLines,
  116. version,
  117. alreadyLoaded,
  118. } = await DocumentManager.getDoc(projectId, docId)
  119. if (oldLines != null && oldLines.length > 0 && oldLines[0].text != null) {
  120. logger.debug(
  121. { docId, projectId, oldLines, newLines },
  122. 'document is JSON so not updating'
  123. )
  124. return
  125. }
  126. logger.debug(
  127. { docId, projectId, oldLines, newLines },
  128. 'setting a document via http'
  129. )
  130. const op = DiffCodec.diffAsShareJsOp(oldLines, newLines)
  131. if (undoing) {
  132. for (const o of op || []) {
  133. o.u = true
  134. } // Turn on undo flag for each op for track changes
  135. }
  136. const update = {
  137. doc: docId,
  138. op,
  139. v: version,
  140. meta: {
  141. type: 'external',
  142. source,
  143. user_id: userId,
  144. },
  145. }
  146. // Keep track of external updates, whether they are for live documents
  147. // (flush) or unloaded documents (evict), and whether the update is a no-op.
  148. Metrics.inc('external-update', 1, {
  149. status: op.length > 0 ? 'diff' : 'noop',
  150. method: alreadyLoaded ? 'flush' : 'evict',
  151. path: source,
  152. })
  153. // Do not notify the frontend about a noop update.
  154. // We still want to execute the code below
  155. // to evict the doc if we loaded it into redis for
  156. // this update, otherwise the doc would never be
  157. // removed from redis.
  158. if (op.length > 0) {
  159. await UpdateManager.promises.applyUpdate(projectId, docId, update)
  160. }
  161. // If the document was loaded already, then someone has it open
  162. // in a project, and the usual flushing mechanism will happen.
  163. // Otherwise we should remove it immediately since nothing else
  164. // is using it.
  165. if (alreadyLoaded) {
  166. return await DocumentManager.flushDocIfLoaded(projectId, docId)
  167. } else {
  168. try {
  169. return await DocumentManager.flushAndDeleteDoc(projectId, docId, {})
  170. } finally {
  171. // There is no harm in flushing project history if the previous
  172. // call failed and sometimes it is required
  173. HistoryManager.flushProjectChangesAsync(projectId)
  174. }
  175. }
  176. },
  177. async flushDocIfLoaded(projectId, docId) {
  178. const {
  179. lines,
  180. version,
  181. ranges,
  182. unflushedTime,
  183. lastUpdatedAt,
  184. lastUpdatedBy,
  185. } = await RedisManager.promises.getDoc(projectId, docId)
  186. if (lines == null || version == null) {
  187. Metrics.inc('flush-doc-if-loaded', 1, { status: 'not-loaded' })
  188. logger.debug({ projectId, docId }, 'doc is not loaded so not flushing')
  189. // TODO: return a flag to bail out, as we go on to remove doc from memory?
  190. return
  191. } else if (unflushedTime == null) {
  192. Metrics.inc('flush-doc-if-loaded', 1, { status: 'unmodified' })
  193. logger.debug({ projectId, docId }, 'doc is not modified so not flushing')
  194. return
  195. }
  196. logger.debug({ projectId, docId, version }, 'flushing doc')
  197. Metrics.inc('flush-doc-if-loaded', 1, { status: 'modified' })
  198. const result = await PersistenceManager.promises.setDoc(
  199. projectId,
  200. docId,
  201. lines,
  202. version,
  203. ranges,
  204. lastUpdatedAt,
  205. lastUpdatedBy
  206. )
  207. await RedisManager.promises.clearUnflushedTime(docId)
  208. return result
  209. },
  210. async flushAndDeleteDoc(projectId, docId, options) {
  211. let result
  212. try {
  213. result = await DocumentManager.flushDocIfLoaded(projectId, docId)
  214. } catch (error) {
  215. if (options.ignoreFlushErrors) {
  216. logger.warn(
  217. { projectId, docId, err: error },
  218. 'ignoring flush error while deleting document'
  219. )
  220. } else {
  221. throw error
  222. }
  223. }
  224. await RedisManager.promises.removeDocFromMemory(projectId, docId)
  225. return result
  226. },
  227. async acceptChanges(projectId, docId, changeIds) {
  228. if (changeIds == null) {
  229. changeIds = []
  230. }
  231. const {
  232. lines,
  233. version,
  234. ranges,
  235. pathname,
  236. projectHistoryId,
  237. historyRangesSupport,
  238. } = await DocumentManager.getDoc(projectId, docId)
  239. if (lines == null || version == null) {
  240. throw new Errors.NotFoundError(`document not found: ${docId}`)
  241. }
  242. const newRanges = RangesManager.acceptChanges(changeIds, ranges)
  243. await RedisManager.promises.updateDocument(
  244. projectId,
  245. docId,
  246. lines,
  247. version,
  248. [],
  249. newRanges,
  250. {}
  251. )
  252. if (historyRangesSupport) {
  253. const historyUpdates = RangesManager.getHistoryUpdatesForAcceptedChanges({
  254. docId,
  255. acceptedChangeIds: changeIds,
  256. changes: ranges.changes || [],
  257. lines,
  258. pathname,
  259. projectHistoryId,
  260. })
  261. if (historyUpdates.length === 0) {
  262. return
  263. }
  264. await ProjectHistoryRedisManager.promises.queueOps(
  265. projectId,
  266. ...historyUpdates.map(op => JSON.stringify(op))
  267. )
  268. }
  269. },
  270. async updateCommentState(projectId, docId, commentId, userId, resolved) {
  271. const { lines, version, pathname, historyRangesSupport } =
  272. await DocumentManager.getDoc(projectId, docId)
  273. if (lines == null || version == null) {
  274. throw new Errors.NotFoundError(`document not found: ${docId}`)
  275. }
  276. if (historyRangesSupport) {
  277. await RedisManager.promises.updateCommentState(docId, commentId, resolved)
  278. await ProjectHistoryRedisManager.promises.queueOps(
  279. projectId,
  280. JSON.stringify({
  281. pathname,
  282. commentId,
  283. resolved,
  284. meta: {
  285. ts: new Date(),
  286. user_id: userId,
  287. },
  288. })
  289. )
  290. }
  291. },
  292. async deleteComment(projectId, docId, commentId, userId) {
  293. const { lines, version, ranges, pathname, historyRangesSupport } =
  294. await DocumentManager.getDoc(projectId, docId)
  295. if (lines == null || version == null) {
  296. throw new Errors.NotFoundError(`document not found: ${docId}`)
  297. }
  298. const newRanges = RangesManager.deleteComment(commentId, ranges)
  299. await RedisManager.promises.updateDocument(
  300. projectId,
  301. docId,
  302. lines,
  303. version,
  304. [],
  305. newRanges,
  306. {}
  307. )
  308. if (historyRangesSupport) {
  309. await RedisManager.promises.updateCommentState(docId, commentId, false)
  310. await ProjectHistoryRedisManager.promises.queueOps(
  311. projectId,
  312. JSON.stringify({
  313. pathname,
  314. deleteComment: commentId,
  315. meta: {
  316. ts: new Date(),
  317. user_id: userId,
  318. },
  319. })
  320. )
  321. }
  322. },
  323. async renameDoc(projectId, docId, userId, update, projectHistoryId) {
  324. await RedisManager.promises.renameDoc(
  325. projectId,
  326. docId,
  327. userId,
  328. update,
  329. projectHistoryId
  330. )
  331. },
  332. async getDocAndFlushIfOld(projectId, docId) {
  333. const { lines, version, unflushedTime, alreadyLoaded } =
  334. await DocumentManager.getDoc(projectId, docId)
  335. // if doc was already loaded see if it needs to be flushed
  336. if (
  337. alreadyLoaded &&
  338. unflushedTime != null &&
  339. Date.now() - unflushedTime > MAX_UNFLUSHED_AGE
  340. ) {
  341. await DocumentManager.flushDocIfLoaded(projectId, docId)
  342. }
  343. return { lines, version }
  344. },
  345. async resyncDocContents(projectId, docId, path) {
  346. logger.debug({ projectId, docId, path }, 'start resyncing doc contents')
  347. let {
  348. lines,
  349. ranges,
  350. resolvedCommentIds,
  351. version,
  352. projectHistoryId,
  353. historyRangesSupport,
  354. } = await RedisManager.promises.getDoc(projectId, docId)
  355. // To avoid issues where the same docId appears with different paths,
  356. // we use the path from the resyncProjectStructure update. If we used
  357. // the path from the getDoc call to web then the two occurences of the
  358. // docId would map to the same path, and this would be rejected by
  359. // project-history as an unexpected resyncDocContent update.
  360. if (lines == null || version == null) {
  361. logger.debug(
  362. { projectId, docId },
  363. 'resyncing doc contents - not found in redis - retrieving from web'
  364. )
  365. ;({
  366. lines,
  367. ranges,
  368. resolvedCommentIds,
  369. version,
  370. projectHistoryId,
  371. historyRangesSupport,
  372. } = await PersistenceManager.promises.getDoc(projectId, docId, {
  373. peek: true,
  374. }))
  375. } else {
  376. logger.debug(
  377. { projectId, docId },
  378. 'resyncing doc contents - doc in redis - will queue in redis'
  379. )
  380. }
  381. await ProjectHistoryRedisManager.promises.queueResyncDocContent(
  382. projectId,
  383. projectHistoryId,
  384. docId,
  385. lines,
  386. ranges,
  387. resolvedCommentIds,
  388. version,
  389. // use the path from the resyncProjectStructure update
  390. path,
  391. historyRangesSupport
  392. )
  393. },
  394. async getDocWithLock(projectId, docId) {
  395. const UpdateManager = require('./UpdateManager')
  396. return await UpdateManager.promises.lockUpdatesAndDo(
  397. DocumentManager.getDoc,
  398. projectId,
  399. docId
  400. )
  401. },
  402. async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) {
  403. const UpdateManager = require('./UpdateManager')
  404. return await UpdateManager.promises.lockUpdatesAndDo(
  405. DocumentManager.getDocAndRecentOps,
  406. projectId,
  407. docId,
  408. fromVersion
  409. )
  410. },
  411. async getDocAndFlushIfOldWithLock(projectId, docId) {
  412. const UpdateManager = require('./UpdateManager')
  413. return await UpdateManager.promises.lockUpdatesAndDo(
  414. DocumentManager.getDocAndFlushIfOld,
  415. projectId,
  416. docId
  417. )
  418. },
  419. async setDocWithLock(projectId, docId, lines, source, userId, undoing) {
  420. const UpdateManager = require('./UpdateManager')
  421. return await UpdateManager.promises.lockUpdatesAndDo(
  422. DocumentManager.setDoc,
  423. projectId,
  424. docId,
  425. lines,
  426. source,
  427. userId,
  428. undoing
  429. )
  430. },
  431. async flushDocIfLoadedWithLock(projectId, docId) {
  432. const UpdateManager = require('./UpdateManager')
  433. return await UpdateManager.promises.lockUpdatesAndDo(
  434. DocumentManager.flushDocIfLoaded,
  435. projectId,
  436. docId
  437. )
  438. },
  439. async flushAndDeleteDocWithLock(projectId, docId, options) {
  440. const UpdateManager = require('./UpdateManager')
  441. return await UpdateManager.promises.lockUpdatesAndDo(
  442. DocumentManager.flushAndDeleteDoc,
  443. projectId,
  444. docId,
  445. options
  446. )
  447. },
  448. async acceptChangesWithLock(projectId, docId, changeIds) {
  449. const UpdateManager = require('./UpdateManager')
  450. await UpdateManager.promises.lockUpdatesAndDo(
  451. DocumentManager.acceptChanges,
  452. projectId,
  453. docId,
  454. changeIds
  455. )
  456. },
  457. async updateCommentStateWithLock(
  458. projectId,
  459. docId,
  460. threadId,
  461. userId,
  462. resolved
  463. ) {
  464. const UpdateManager = require('./UpdateManager')
  465. await UpdateManager.promises.lockUpdatesAndDo(
  466. DocumentManager.updateCommentState,
  467. projectId,
  468. docId,
  469. threadId,
  470. userId,
  471. resolved
  472. )
  473. },
  474. async deleteCommentWithLock(projectId, docId, threadId, userId) {
  475. const UpdateManager = require('./UpdateManager')
  476. await UpdateManager.promises.lockUpdatesAndDo(
  477. DocumentManager.deleteComment,
  478. projectId,
  479. docId,
  480. threadId,
  481. userId
  482. )
  483. },
  484. async renameDocWithLock(projectId, docId, userId, update, projectHistoryId) {
  485. const UpdateManager = require('./UpdateManager')
  486. await UpdateManager.promises.lockUpdatesAndDo(
  487. DocumentManager.renameDoc,
  488. projectId,
  489. docId,
  490. userId,
  491. update,
  492. projectHistoryId
  493. )
  494. },
  495. async resyncDocContentsWithLock(projectId, docId, path, callback) {
  496. const UpdateManager = require('./UpdateManager')
  497. await UpdateManager.promises.lockUpdatesAndDo(
  498. DocumentManager.resyncDocContents,
  499. projectId,
  500. docId,
  501. path,
  502. callback
  503. )
  504. },
  505. }
  506. module.exports = {
  507. ...callbackifyAll(DocumentManager, {
  508. multiResult: {
  509. getDoc: [
  510. 'lines',
  511. 'version',
  512. 'ranges',
  513. 'pathname',
  514. 'projectHistoryId',
  515. 'unflushedTime',
  516. 'alreadyLoaded',
  517. 'historyRangesSupport',
  518. ],
  519. getDocWithLock: [
  520. 'lines',
  521. 'version',
  522. 'ranges',
  523. 'pathname',
  524. 'projectHistoryId',
  525. 'unflushedTime',
  526. 'alreadyLoaded',
  527. 'historyRangesSupport',
  528. ],
  529. getDocAndFlushIfOld: ['lines', 'version'],
  530. getDocAndFlushIfOldWithLock: ['lines', 'version'],
  531. getDocAndRecentOps: [
  532. 'lines',
  533. 'version',
  534. 'ops',
  535. 'ranges',
  536. 'pathname',
  537. 'projectHistoryId',
  538. ],
  539. getDocAndRecentOpsWithLock: [
  540. 'lines',
  541. 'version',
  542. 'ops',
  543. 'ranges',
  544. 'pathname',
  545. 'projectHistoryId',
  546. ],
  547. },
  548. }),
  549. promises: DocumentManager,
  550. }