DocumentManager.js 18 KB

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