DocumentManager.js 15 KB

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