DocumentManager.js 18 KB

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