DocumentManager.js 20 KB

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