DocumentManager.js 16 KB

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