DocumentManager.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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 { lines, version, ranges } = await DocumentManager.getDoc(
  225. projectId,
  226. docId
  227. )
  228. if (lines == null || version == null) {
  229. throw new Errors.NotFoundError(`document not found: ${docId}`)
  230. }
  231. const newRanges = RangesManager.acceptChanges(changeIds, ranges)
  232. await RedisManager.promises.updateDocument(
  233. projectId,
  234. docId,
  235. lines,
  236. version,
  237. [],
  238. newRanges,
  239. {}
  240. )
  241. },
  242. async deleteComment(projectId, docId, commentId, userId) {
  243. const { lines, version, ranges, pathname, 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.deleteComment(commentId, ranges)
  249. await RedisManager.promises.updateDocument(
  250. projectId,
  251. docId,
  252. lines,
  253. version,
  254. [],
  255. newRanges,
  256. {}
  257. )
  258. if (historyRangesSupport) {
  259. await ProjectHistoryRedisManager.promises.queueOps(
  260. projectId,
  261. JSON.stringify({
  262. pathname,
  263. deleteComment: commentId,
  264. meta: {
  265. ts: new Date(),
  266. user_id: userId,
  267. },
  268. })
  269. )
  270. }
  271. },
  272. async renameDoc(projectId, docId, userId, update, projectHistoryId) {
  273. await RedisManager.promises.renameDoc(
  274. projectId,
  275. docId,
  276. userId,
  277. update,
  278. projectHistoryId
  279. )
  280. },
  281. async getDocAndFlushIfOld(projectId, docId) {
  282. const { lines, version, unflushedTime, alreadyLoaded } =
  283. await DocumentManager.getDoc(projectId, docId)
  284. // if doc was already loaded see if it needs to be flushed
  285. if (
  286. alreadyLoaded &&
  287. unflushedTime != null &&
  288. Date.now() - unflushedTime > MAX_UNFLUSHED_AGE
  289. ) {
  290. await DocumentManager.flushDocIfLoaded(projectId, docId)
  291. }
  292. return { lines, version }
  293. },
  294. async resyncDocContents(projectId, docId, path) {
  295. logger.debug({ projectId, docId, path }, 'start resyncing doc contents')
  296. let { lines, version, projectHistoryId } =
  297. await RedisManager.promises.getDoc(projectId, docId)
  298. // To avoid issues where the same docId appears with different paths,
  299. // we use the path from the resyncProjectStructure update. If we used
  300. // the path from the getDoc call to web then the two occurences of the
  301. // docId would map to the same path, and this would be rejected by
  302. // project-history as an unexpected resyncDocContent update.
  303. if (lines == null || version == null) {
  304. logger.debug(
  305. { projectId, docId },
  306. 'resyncing doc contents - not found in redis - retrieving from web'
  307. )
  308. ;({ lines, version, projectHistoryId } =
  309. await PersistenceManager.promises.getDoc(projectId, docId, {
  310. peek: true,
  311. }))
  312. } else {
  313. logger.debug(
  314. { projectId, docId },
  315. 'resyncing doc contents - doc in redis - will queue in redis'
  316. )
  317. }
  318. await ProjectHistoryRedisManager.promises.queueResyncDocContent(
  319. projectId,
  320. projectHistoryId,
  321. docId,
  322. lines,
  323. version,
  324. // use the path from the resyncProjectStructure update
  325. path
  326. )
  327. },
  328. async getDocWithLock(projectId, docId) {
  329. const UpdateManager = require('./UpdateManager')
  330. return await UpdateManager.promises.lockUpdatesAndDo(
  331. DocumentManager.getDoc,
  332. projectId,
  333. docId
  334. )
  335. },
  336. async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) {
  337. const UpdateManager = require('./UpdateManager')
  338. return await UpdateManager.promises.lockUpdatesAndDo(
  339. DocumentManager.getDocAndRecentOps,
  340. projectId,
  341. docId,
  342. fromVersion
  343. )
  344. },
  345. async getDocAndFlushIfOldWithLock(projectId, docId) {
  346. const UpdateManager = require('./UpdateManager')
  347. return await UpdateManager.promises.lockUpdatesAndDo(
  348. DocumentManager.getDocAndFlushIfOld,
  349. projectId,
  350. docId
  351. )
  352. },
  353. async setDocWithLock(projectId, docId, lines, source, userId, undoing) {
  354. const UpdateManager = require('./UpdateManager')
  355. return await UpdateManager.promises.lockUpdatesAndDo(
  356. DocumentManager.setDoc,
  357. projectId,
  358. docId,
  359. lines,
  360. source,
  361. userId,
  362. undoing
  363. )
  364. },
  365. async flushDocIfLoadedWithLock(projectId, docId) {
  366. const UpdateManager = require('./UpdateManager')
  367. return await UpdateManager.promises.lockUpdatesAndDo(
  368. DocumentManager.flushDocIfLoaded,
  369. projectId,
  370. docId
  371. )
  372. },
  373. async flushAndDeleteDocWithLock(projectId, docId, options) {
  374. const UpdateManager = require('./UpdateManager')
  375. return await UpdateManager.promises.lockUpdatesAndDo(
  376. DocumentManager.flushAndDeleteDoc,
  377. projectId,
  378. docId,
  379. options
  380. )
  381. },
  382. async acceptChangesWithLock(projectId, docId, changeIds) {
  383. const UpdateManager = require('./UpdateManager')
  384. await UpdateManager.promises.lockUpdatesAndDo(
  385. DocumentManager.acceptChanges,
  386. projectId,
  387. docId,
  388. changeIds
  389. )
  390. },
  391. async deleteCommentWithLock(projectId, docId, threadId, userId) {
  392. const UpdateManager = require('./UpdateManager')
  393. await UpdateManager.promises.lockUpdatesAndDo(
  394. DocumentManager.deleteComment,
  395. projectId,
  396. docId,
  397. threadId,
  398. userId
  399. )
  400. },
  401. async renameDocWithLock(projectId, docId, userId, update, projectHistoryId) {
  402. const UpdateManager = require('./UpdateManager')
  403. await UpdateManager.promises.lockUpdatesAndDo(
  404. DocumentManager.renameDoc,
  405. projectId,
  406. docId,
  407. userId,
  408. update,
  409. projectHistoryId
  410. )
  411. },
  412. async resyncDocContentsWithLock(projectId, docId, path, callback) {
  413. const UpdateManager = require('./UpdateManager')
  414. await UpdateManager.promises.lockUpdatesAndDo(
  415. DocumentManager.resyncDocContents,
  416. projectId,
  417. docId,
  418. path,
  419. callback
  420. )
  421. },
  422. }
  423. module.exports = {
  424. ...callbackifyAll(DocumentManager, {
  425. multiResult: {
  426. getDoc: [
  427. 'lines',
  428. 'version',
  429. 'ranges',
  430. 'pathname',
  431. 'projectHistoryId',
  432. 'unflushedTime',
  433. 'alreadyLoaded',
  434. 'historyRangesSupport',
  435. ],
  436. getDocWithLock: [
  437. 'lines',
  438. 'version',
  439. 'ranges',
  440. 'pathname',
  441. 'projectHistoryId',
  442. 'unflushedTime',
  443. 'alreadyLoaded',
  444. 'historyRangesSupport',
  445. ],
  446. getDocAndFlushIfOld: ['lines', 'version'],
  447. getDocAndFlushIfOldWithLock: ['lines', 'version'],
  448. getDocAndRecentOps: [
  449. 'lines',
  450. 'version',
  451. 'ops',
  452. 'ranges',
  453. 'pathname',
  454. 'projectHistoryId',
  455. ],
  456. getDocAndRecentOpsWithLock: [
  457. 'lines',
  458. 'version',
  459. 'ops',
  460. 'ranges',
  461. 'pathname',
  462. 'projectHistoryId',
  463. ],
  464. },
  465. }),
  466. promises: DocumentManager,
  467. }