SyncManager.js 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219
  1. // @ts-check
  2. import _ from 'lodash'
  3. import { callbackify, promisify } from 'node:util'
  4. import { callbackifyMultiResult } from '@overleaf/promise-utils'
  5. import Settings from '@overleaf/settings'
  6. import logger from '@overleaf/logger'
  7. import Metrics from '@overleaf/metrics'
  8. import OError from '@overleaf/o-error'
  9. import { File, Range } from 'overleaf-editor-core'
  10. import { NeedFullProjectStructureResyncError, SyncError } from './Errors.js'
  11. import { db, ObjectId } from './mongodb.js'
  12. import * as SnapshotManager from './SnapshotManager.js'
  13. import * as LockManager from './LockManager.js'
  14. import * as UpdateTranslator from './UpdateTranslator.js'
  15. import * as UpdateCompressor from './UpdateCompressor.js'
  16. import * as WebApiManager from './WebApiManager.js'
  17. import * as ErrorRecorder from './ErrorRecorder.js'
  18. import * as RedisManager from './RedisManager.js'
  19. import * as HistoryStoreManager from './HistoryStoreManager.js'
  20. import * as HashManager from './HashManager.js'
  21. import { isInsert, isDelete } from './Utils.js'
  22. /**
  23. * @import { Comment as HistoryComment, TrackedChange as HistoryTrackedChange } from 'overleaf-editor-core'
  24. * @import { Comment, Entity, ResyncDocContentUpdate, RetainOp, TrackedChange } from './types'
  25. * @import { TrackedChangeTransition, TrackingDirective, TrackingType, Update } from './types'
  26. * @import { ProjectStructureUpdate } from './types'
  27. */
  28. const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs
  29. const EXPIRE_RESYNC_HISTORY_INTERVAL_MS = 90 * 24 * 3600 * 1000 // 90 days
  30. const keys = Settings.redis.lock.key_schema
  31. // db.projectHistorySyncState.ensureIndex({expiresAt: 1}, {expireAfterSeconds: 0, background: true})
  32. // To add expiresAt field to existing entries in collection (choose a suitable future expiry date):
  33. // db.projectHistorySyncState.updateMany({resyncProjectStructure: false, resyncDocContents: [], expiresAt: {$exists:false}}, {$set: {expiresAt: new Date("2019-07-01")}})
  34. async function startResync(projectId, options = {}) {
  35. // We have three options here
  36. //
  37. // 1. If we update mongo before making the call to web then there's a
  38. // chance we ignore all updates indefinitely (there's no foolproff way
  39. // to undo the change in mongo)
  40. //
  41. // 2. If we make the call to web first then there is a small race condition
  42. // where we could process the sync update and then only update mongo
  43. // after, causing all updates to be ignored from then on
  44. //
  45. // 3. We can wrap everything in a project lock
  46. Metrics.inc('project_history_resync')
  47. try {
  48. await LockManager.promises.runWithLock(
  49. keys.projectHistoryLock({ project_id: projectId }),
  50. async extendLock => {
  51. await startResyncWithoutLock(projectId, options)
  52. }
  53. )
  54. } catch (error) {
  55. // record error in starting sync ("sync ongoing")
  56. try {
  57. await ErrorRecorder.promises.record(projectId, -1, error)
  58. } catch (err) {
  59. // swallow any error thrown by ErrorRecorder.record()
  60. }
  61. throw error
  62. }
  63. }
  64. async function startHardResync(projectId, options = {}) {
  65. Metrics.inc('project_history_hard_resync')
  66. try {
  67. await LockManager.promises.runWithLock(
  68. keys.projectHistoryLock({ project_id: projectId }),
  69. async extendLock => {
  70. await clearResyncState(projectId)
  71. await RedisManager.promises.clearFirstOpTimestamp(projectId)
  72. await RedisManager.promises.destroyDocUpdatesQueue(projectId)
  73. await startResyncWithoutLock(projectId, options)
  74. }
  75. )
  76. } catch (error) {
  77. // record error in starting sync ("sync ongoing")
  78. await ErrorRecorder.promises.record(projectId, -1, error)
  79. throw error
  80. }
  81. }
  82. // The caller must hold the lock and should record any errors via the ErrorRecorder.
  83. async function startResyncWithoutLock(projectId, options) {
  84. await ErrorRecorder.promises.recordSyncStart(projectId)
  85. const syncState = await _getResyncState(projectId)
  86. if (syncState.isSyncOngoing()) {
  87. throw new OError('sync ongoing')
  88. }
  89. syncState.setOrigin(options.origin || { kind: 'history-resync' })
  90. syncState.startProjectStructureSync()
  91. const webOpts = {}
  92. if (options.historyRangesMigration) {
  93. webOpts.historyRangesMigration = options.historyRangesMigration
  94. }
  95. if (options.resyncProjectStructureOnly) {
  96. webOpts.resyncProjectStructureOnly = options.resyncProjectStructureOnly
  97. }
  98. await WebApiManager.promises.requestResync(projectId, webOpts)
  99. await setResyncState(projectId, syncState)
  100. }
  101. async function _getResyncState(projectId) {
  102. const rawSyncState = await db.projectHistorySyncState.findOne({
  103. project_id: new ObjectId(projectId.toString()),
  104. })
  105. const syncState = SyncState.fromRaw(projectId, rawSyncState)
  106. return syncState
  107. }
  108. async function setResyncState(projectId, syncState) {
  109. // skip if syncState is null (i.e. unchanged)
  110. if (syncState == null) {
  111. return
  112. }
  113. const update = {
  114. $set: syncState.toRaw(),
  115. $push: {
  116. history: {
  117. $each: [{ syncState: syncState.toRaw(), timestamp: new Date() }],
  118. $position: 0,
  119. $slice: MAX_RESYNC_HISTORY_RECORDS,
  120. },
  121. },
  122. $currentDate: { lastUpdated: true },
  123. }
  124. // handle different cases
  125. if (syncState.isSyncOngoing()) {
  126. // starting a new sync; prevent the entry expiring while sync is in ongoing
  127. update.$inc = { resyncCount: 1 }
  128. update.$unset = { expiresAt: true }
  129. } else {
  130. // successful completion of existing sync; set the entry to expire in the
  131. // future
  132. update.$set.expiresAt = new Date(
  133. Date.now() + EXPIRE_RESYNC_HISTORY_INTERVAL_MS
  134. )
  135. }
  136. // apply the update
  137. await db.projectHistorySyncState.updateOne(
  138. { project_id: new ObjectId(projectId) },
  139. update,
  140. { upsert: true }
  141. )
  142. }
  143. async function clearResyncState(projectId) {
  144. await db.projectHistorySyncState.deleteOne({
  145. project_id: new ObjectId(projectId.toString()),
  146. })
  147. }
  148. /**
  149. * @param {string} projectId
  150. * @param {Date} date
  151. * @return {Promise<void>}
  152. */
  153. async function clearResyncStateIfAllAfter(projectId, date) {
  154. const rawSyncState = await db.projectHistorySyncState.findOne({
  155. project_id: new ObjectId(projectId.toString()),
  156. })
  157. if (!rawSyncState) return // already cleared
  158. const state = SyncState.fromRaw(projectId, rawSyncState)
  159. if (state.isSyncOngoing()) return // new sync started
  160. for (const { timestamp } of rawSyncState.history) {
  161. if (timestamp < date) return // preserve old resync states
  162. }
  163. // expiresAt is cleared when starting a sync and bumped when making changes.
  164. // Use expiresAt as read to ensure we only clear the confirmed state.
  165. await db.projectHistorySyncState.deleteOne({
  166. project_id: new ObjectId(projectId.toString()),
  167. expiresAt: rawSyncState.expiresAt,
  168. })
  169. }
  170. async function skipUpdatesDuringSync(projectId, updates) {
  171. const syncState = await _getResyncState(projectId)
  172. if (!syncState.isSyncOngoing()) {
  173. logger.debug({ projectId }, 'not skipping updates: no resync in progress')
  174. // don't return syncState when unchanged
  175. return { updates, syncState: null }
  176. }
  177. const filteredUpdates = []
  178. for (const update of updates) {
  179. syncState.updateState(update)
  180. const shouldSkipUpdate = syncState.shouldSkipUpdate(update)
  181. if (!shouldSkipUpdate) {
  182. filteredUpdates.push(update)
  183. } else {
  184. logger.debug({ projectId, update }, 'skipping update due to resync')
  185. }
  186. }
  187. return { updates: filteredUpdates, syncState }
  188. }
  189. /**
  190. * @param {string} projectId
  191. * @param {string} projectHistoryId
  192. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} mostRecentChunk
  193. * @param {Array<Update>} updates
  194. * @param {() => Promise<void>} extendLock
  195. * @return {Promise<Array<Update>>}
  196. */
  197. async function expandSyncUpdates(
  198. projectId,
  199. projectHistoryId,
  200. mostRecentChunk,
  201. updates,
  202. extendLock
  203. ) {
  204. const areSyncUpdatesQueued =
  205. _.some(updates, 'resyncProjectStructure') ||
  206. _.some(updates, 'resyncDocContent')
  207. if (!areSyncUpdatesQueued) {
  208. logger.debug({ projectId }, 'no resync updates to expand')
  209. return updates
  210. }
  211. const syncState = await _getResyncState(projectId)
  212. // compute the current snapshot from the most recent chunk
  213. const snapshotFiles =
  214. await SnapshotManager.promises.getLatestSnapshotFilesForChunk(
  215. projectHistoryId,
  216. mostRecentChunk
  217. )
  218. // check if snapshot files are valid
  219. const invalidFiles = _.pickBy(
  220. snapshotFiles,
  221. (v, k) => v == null || typeof v.isEditable !== 'function'
  222. )
  223. if (_.size(invalidFiles) > 0) {
  224. throw new SyncError('file is missing isEditable method', {
  225. projectId,
  226. invalidFiles,
  227. })
  228. }
  229. const expander = new SyncUpdateExpander(
  230. projectId,
  231. snapshotFiles,
  232. syncState.origin
  233. )
  234. // expand updates asynchronously to avoid blocking
  235. for (const update of updates) {
  236. await expander.expandUpdate(update)
  237. await extendLock()
  238. }
  239. return expander.getExpandedUpdates()
  240. }
  241. class SyncState {
  242. constructor(projectId, resyncProjectStructure, resyncDocContents, origin) {
  243. this.projectId = projectId
  244. this.resyncProjectStructure = resyncProjectStructure
  245. this.resyncDocContents = resyncDocContents
  246. this.origin = origin
  247. }
  248. static fromRaw(projectId, rawSyncState) {
  249. rawSyncState = rawSyncState || {}
  250. const resyncProjectStructure = rawSyncState.resyncProjectStructure || false
  251. const resyncDocContents = new Set(rawSyncState.resyncDocContents || [])
  252. const origin = rawSyncState.origin
  253. return new SyncState(
  254. projectId,
  255. resyncProjectStructure,
  256. resyncDocContents,
  257. origin
  258. )
  259. }
  260. toRaw() {
  261. return {
  262. resyncProjectStructure: this.resyncProjectStructure,
  263. resyncDocContents: Array.from(this.resyncDocContents),
  264. origin: this.origin,
  265. }
  266. }
  267. updateState(update) {
  268. if (update.resyncProjectStructure != null) {
  269. if (!this.isProjectStructureSyncing()) {
  270. throw new SyncError('unexpected resyncProjectStructure update', {
  271. projectId: this.projectId,
  272. resyncProjectStructure: this.resyncProjectStructure,
  273. })
  274. }
  275. if (this.isAnyDocContentSyncing()) {
  276. throw new SyncError('unexpected resyncDocContents update', {
  277. projectId: this.projectId,
  278. resyncDocContents: this.resyncDocContents,
  279. })
  280. }
  281. if (!update.resyncProjectStructureOnly) {
  282. for (const doc of update.resyncProjectStructure.docs) {
  283. this.startDocContentSync(doc.path)
  284. }
  285. }
  286. this.stopProjectStructureSync()
  287. } else if (update.resyncDocContent != null) {
  288. if (this.isProjectStructureSyncing()) {
  289. throw new SyncError('unexpected resyncDocContent update', {
  290. projectId: this.projectId,
  291. resyncProjectStructure: this.resyncProjectStructure,
  292. })
  293. }
  294. if (!this.isDocContentSyncing(update.path)) {
  295. throw new SyncError('unexpected resyncDocContent update', {
  296. projectId: this.projectId,
  297. resyncDocContents: this.resyncDocContents,
  298. path: update.path,
  299. })
  300. }
  301. this.stopDocContentSync(update.path)
  302. }
  303. }
  304. setOrigin(origin) {
  305. this.origin = origin
  306. }
  307. shouldSkipUpdate(update) {
  308. // don't skip sync updates
  309. if (
  310. update.resyncProjectStructure != null ||
  311. update.resyncDocContent != null
  312. ) {
  313. return false
  314. }
  315. // if syncing project structure skip update
  316. if (this.isProjectStructureSyncing()) {
  317. return true
  318. }
  319. // skip text updates for a docs being synced
  320. if (UpdateTranslator.isTextUpdate(update)) {
  321. if (this.isDocContentSyncing(update.meta.pathname)) {
  322. return true
  323. }
  324. }
  325. // preserve all other updates
  326. return false
  327. }
  328. startProjectStructureSync() {
  329. this.resyncProjectStructure = true
  330. this.resyncDocContents = new Set([])
  331. }
  332. stopProjectStructureSync() {
  333. this.resyncProjectStructure = false
  334. }
  335. stopDocContentSync(pathname) {
  336. this.resyncDocContents.delete(pathname)
  337. }
  338. startDocContentSync(pathname) {
  339. this.resyncDocContents.add(pathname)
  340. }
  341. isProjectStructureSyncing() {
  342. return this.resyncProjectStructure
  343. }
  344. isDocContentSyncing(pathname) {
  345. return this.resyncDocContents.has(pathname)
  346. }
  347. isAnyDocContentSyncing() {
  348. return this.resyncDocContents.size > 0
  349. }
  350. isSyncOngoing() {
  351. return this.isProjectStructureSyncing() || this.isAnyDocContentSyncing()
  352. }
  353. }
  354. class SyncUpdateExpander {
  355. /**
  356. * Build a SyncUpdateExpander
  357. *
  358. * @param {string} projectId
  359. * @param {Record<string, File>} snapshotFiles
  360. * @param {string} origin
  361. */
  362. constructor(projectId, snapshotFiles, origin) {
  363. this.projectId = projectId
  364. this.files = snapshotFiles
  365. this.expandedUpdates = /** @type ProjectStructureUpdate[] */ []
  366. this.origin = origin
  367. }
  368. // If there's an expected *file* with the same path and either the same hash
  369. // or no hash, treat this as not editable even if history thinks it is.
  370. isEditable(filePath, file, expectedFiles) {
  371. if (!file.isEditable()) {
  372. return false
  373. }
  374. const fileHash = _.get(file, ['data', 'hash'])
  375. const matchedExpectedFile = expectedFiles.some(item => {
  376. const expectedFileHash = item._hash
  377. if (expectedFileHash && fileHash !== expectedFileHash) {
  378. // expected file has a hash and it doesn't match
  379. return false
  380. }
  381. return UpdateTranslator._convertPathname(item.path) === filePath
  382. })
  383. // consider editable file in history as binary, since it matches a binary file in the project
  384. return !matchedExpectedFile
  385. }
  386. /**
  387. * @param {Update} update
  388. */
  389. async expandUpdate(update) {
  390. if ('resyncProjectStructure' in update) {
  391. logger.debug(
  392. { projectId: this.projectId, update },
  393. 'expanding resyncProjectStructure update'
  394. )
  395. const persistedNonBinaryFileEntries = _.pickBy(this.files, (v, k) =>
  396. this.isEditable(k, v, update.resyncProjectStructure.files)
  397. )
  398. const persistedNonBinaryFiles = _.map(
  399. Object.keys(persistedNonBinaryFileEntries),
  400. path => ({
  401. path,
  402. })
  403. )
  404. const persistedBinaryFileEntries = _.omitBy(this.files, (v, k) =>
  405. this.isEditable(k, v, update.resyncProjectStructure.files)
  406. )
  407. // preserve file properties on binary files, for future comparison.
  408. const persistedBinaryFiles = _.map(
  409. persistedBinaryFileEntries,
  410. (entity, key) => Object.assign({}, entity, { path: key })
  411. )
  412. const expectedNonBinaryFiles = _.map(
  413. update.resyncProjectStructure.docs,
  414. entity =>
  415. Object.assign({}, entity, {
  416. path: UpdateTranslator._convertPathname(entity.path),
  417. })
  418. )
  419. const expectedBinaryFiles = _.map(
  420. update.resyncProjectStructure.files,
  421. entity =>
  422. Object.assign({}, entity, {
  423. path: UpdateTranslator._convertPathname(entity.path),
  424. })
  425. )
  426. // We need to detect and fix consistency issues where web and
  427. // history-store disagree on whether an entity is binary or not. Thus we
  428. // need to remove and add the two separately.
  429. this.queueRemoveOpsForUnexpectedFiles(
  430. update,
  431. expectedBinaryFiles,
  432. persistedBinaryFiles
  433. )
  434. this.queueRemoveOpsForUnexpectedFiles(
  435. update,
  436. expectedNonBinaryFiles,
  437. persistedNonBinaryFiles
  438. )
  439. this.queueAddOpsForMissingFiles(
  440. update,
  441. expectedBinaryFiles,
  442. persistedBinaryFiles
  443. )
  444. this.queueAddOpsForMissingFiles(
  445. update,
  446. expectedNonBinaryFiles,
  447. persistedNonBinaryFiles
  448. )
  449. this.queueUpdateForOutOfSyncBinaryFiles(
  450. update,
  451. expectedBinaryFiles,
  452. persistedBinaryFiles
  453. )
  454. this.queueSetMetadataOpsForLinkedFiles(update)
  455. if (update.resyncProjectStructureOnly) {
  456. const docPaths = new Set()
  457. for (const entity of update.resyncProjectStructure.docs) {
  458. const path = UpdateTranslator._convertPathname(entity.path)
  459. docPaths.add(path)
  460. }
  461. for (const expandedUpdate of this.expandedUpdates) {
  462. if (docPaths.has(expandedUpdate.pathname)) {
  463. // Clear the resync state and queue entry, we need to start over.
  464. this.expandedUpdates = []
  465. await clearResyncState(this.projectId)
  466. await RedisManager.promises.deleteAppliedDocUpdate(
  467. this.projectId,
  468. update
  469. )
  470. throw new NeedFullProjectStructureResyncError(
  471. 'aborting partial resync: touched doc'
  472. )
  473. }
  474. }
  475. }
  476. } else if ('resyncDocContent' in update) {
  477. logger.debug(
  478. { projectId: this.projectId, update },
  479. 'expanding resyncDocContent update'
  480. )
  481. await this.expandResyncDocContentUpdate(update)
  482. } else {
  483. this.expandedUpdates.push(update)
  484. }
  485. }
  486. getExpandedUpdates() {
  487. return this.expandedUpdates
  488. }
  489. /**
  490. * @param {Entity[]} expectedFiles
  491. * @param {{ path: string }[]} persistedFiles
  492. */
  493. queueRemoveOpsForUnexpectedFiles(update, expectedFiles, persistedFiles) {
  494. const unexpectedFiles = _.differenceBy(
  495. persistedFiles,
  496. expectedFiles,
  497. 'path'
  498. )
  499. for (const entity of unexpectedFiles) {
  500. update = {
  501. pathname: entity.path,
  502. new_pathname: '',
  503. meta: {
  504. resync: true,
  505. origin: this.origin,
  506. ts: update.meta.ts,
  507. },
  508. }
  509. this.expandedUpdates.push(update)
  510. Metrics.inc('project_history_resync_operation', 1, {
  511. status: 'remove unexpected file',
  512. })
  513. }
  514. }
  515. /**
  516. * @param {Entity[]} expectedFiles
  517. * @param {{ path: string }[]} persistedFiles
  518. */
  519. queueAddOpsForMissingFiles(update, expectedFiles, persistedFiles) {
  520. const missingFiles = _.differenceBy(expectedFiles, persistedFiles, 'path')
  521. for (const entity of missingFiles) {
  522. update = {
  523. pathname: entity.path,
  524. meta: {
  525. resync: true,
  526. origin: this.origin,
  527. ts: update.meta.ts,
  528. },
  529. }
  530. if ('doc' in entity) {
  531. update.doc = entity.doc
  532. update.docLines = ''
  533. // we have to create a dummy entry here because later we will need the content in the diff computation
  534. this.files[update.pathname] = File.fromString('')
  535. } else {
  536. update.file = entity.file
  537. if (entity.url) update.url = entity.url
  538. if (entity._hash) update.hash = entity._hash
  539. if (entity.createdBlob) update.createdBlob = entity.createdBlob
  540. if (entity.metadata) update.metadata = entity.metadata
  541. }
  542. this.expandedUpdates.push(update)
  543. Metrics.inc('project_history_resync_operation', 1, {
  544. status: 'add missing file',
  545. })
  546. }
  547. }
  548. queueSetMetadataOpsForLinkedFiles(update) {
  549. const allEntities = update.resyncProjectStructure.docs.concat(
  550. update.resyncProjectStructure.files
  551. )
  552. for (const file of allEntities) {
  553. const pathname = UpdateTranslator._convertPathname(file.path)
  554. const matchingAddFileOperation = this.expandedUpdates.some(
  555. // Look for an addFile operation that already syncs the metadata.
  556. u => u.pathname === pathname && u.metadata === file.metadata
  557. )
  558. if (matchingAddFileOperation) continue
  559. const metaData = this.files[pathname].getMetadata()
  560. let shouldUpdate = false
  561. if (file.metadata) {
  562. // check for in place update of linked-file
  563. shouldUpdate = Object.entries(file.metadata).some(
  564. ([k, v]) => metaData[k] !== v
  565. )
  566. } else if (metaData.provider) {
  567. // overwritten by non-linked-file with same hash
  568. // or overwritten by doc
  569. shouldUpdate = true
  570. }
  571. if (!shouldUpdate) continue
  572. this.expandedUpdates.push({
  573. pathname,
  574. meta: {
  575. resync: true,
  576. origin: this.origin,
  577. ts: update.meta.ts,
  578. },
  579. metadata: file.metadata || {},
  580. })
  581. Metrics.inc('project_history_resync_operation', 1, {
  582. status: 'update metadata',
  583. })
  584. }
  585. }
  586. queueUpdateForOutOfSyncBinaryFiles(update, expectedFiles, persistedFiles) {
  587. // create a map to lookup persisted files by their path
  588. const persistedFileMap = new Map(persistedFiles.map(x => [x.path, x]))
  589. // now search for files with same path but different hash values
  590. const differentFiles = expectedFiles.filter(expected => {
  591. // check for a persisted file at the same path
  592. const expectedPath = expected.path
  593. const persistedFileAtSamePath = persistedFileMap.get(expectedPath)
  594. if (!persistedFileAtSamePath) return false
  595. // check if the persisted file at the same path has a different hash
  596. const expectedHash = _.get(expected, '_hash')
  597. const persistedHash = _.get(persistedFileAtSamePath, ['data', 'hash'])
  598. const hashesPresent = expectedHash && persistedHash
  599. return hashesPresent && persistedHash !== expectedHash
  600. })
  601. for (const entity of differentFiles) {
  602. // remove the outdated persisted file
  603. const removeUpdate = {
  604. pathname: entity.path,
  605. new_pathname: '',
  606. meta: {
  607. resync: true,
  608. origin: this.origin,
  609. ts: update.meta.ts,
  610. },
  611. }
  612. this.expandedUpdates.push(removeUpdate)
  613. // add the new file content
  614. const addUpdate = {
  615. pathname: entity.path,
  616. meta: {
  617. resync: true,
  618. origin: this.origin,
  619. ts: update.meta.ts,
  620. },
  621. file: entity.file,
  622. }
  623. if (entity.url) addUpdate.url = entity.url
  624. if (entity._hash) addUpdate.hash = entity._hash
  625. if (entity.createdBlob) addUpdate.createdBlob = entity.createdBlob
  626. if (entity.metadata) addUpdate.metadata = entity.metadata
  627. this.expandedUpdates.push(addUpdate)
  628. Metrics.inc('project_history_resync_operation', 1, {
  629. status: 'update binary file contents',
  630. })
  631. }
  632. }
  633. /**
  634. * Expand a resyncDocContentUpdate
  635. *
  636. * @param {ResyncDocContentUpdate} update
  637. */
  638. async expandResyncDocContentUpdate(update) {
  639. const pathname = UpdateTranslator._convertPathname(update.path)
  640. const snapshotFile = this.files[pathname]
  641. const expectedFile = update.resyncDocContent
  642. const expectedContent = expectedFile.content
  643. if (!snapshotFile) {
  644. throw new OError('unrecognised file: not in snapshot')
  645. }
  646. // Compare hashes to see if the persisted file matches the expected content.
  647. // The hash of the persisted files is stored in the snapshot.
  648. // Note getHash() returns the hash only when the persisted file has
  649. // no changes in the snapshot, the hash is null if there are changes
  650. // that apply to it.
  651. let hashesMatch = false
  652. const persistedHash = snapshotFile.getHash()
  653. if (persistedHash != null) {
  654. const expectedHash = HashManager._getBlobHashFromString(expectedContent)
  655. if (persistedHash === expectedHash) {
  656. logger.debug(
  657. { projectId: this.projectId, persistedHash, expectedHash },
  658. 'skipping diff because hashes match and persisted file has no ops'
  659. )
  660. hashesMatch = true
  661. }
  662. } else {
  663. logger.debug('cannot compare hashes, will retrieve content')
  664. }
  665. // compute the difference between the expected and persisted content
  666. const historyId = await WebApiManager.promises.getHistoryId(this.projectId)
  667. const file = await snapshotFile.load(
  668. 'eager',
  669. HistoryStoreManager.getBlobStore(historyId)
  670. )
  671. const persistedContent = file.getContent()
  672. if (persistedContent == null) {
  673. // This should not happen given that we loaded the file eagerly. We could
  674. // probably refine the types in overleaf-editor-core so that this check
  675. // wouldn't be necessary.
  676. throw new Error('File was not properly loaded')
  677. }
  678. if (!hashesMatch) {
  679. const expandedUpdate = await this.queueUpdateForOutOfSyncContent(
  680. update,
  681. pathname,
  682. persistedContent,
  683. expectedContent
  684. )
  685. if (expandedUpdate != null) {
  686. // Adjust the ranges for the changes that have been made to the content
  687. for (const op of expandedUpdate.op) {
  688. if (isInsert(op)) {
  689. file.getComments().applyInsert(new Range(op.p, op.i.length))
  690. file.getTrackedChanges().applyInsert(op.p, op.i)
  691. } else if (isDelete(op)) {
  692. file.getComments().applyDelete(new Range(op.p, op.d.length))
  693. file.getTrackedChanges().applyDelete(op.p, op.d.length)
  694. }
  695. }
  696. }
  697. }
  698. const persistedComments = file.getComments().toArray()
  699. await this.queueUpdatesForOutOfSyncComments(
  700. update,
  701. pathname,
  702. persistedComments
  703. )
  704. const persistedChanges = file.getTrackedChanges().asSorted()
  705. await this.queueUpdatesForOutOfSyncTrackedChanges(
  706. update,
  707. pathname,
  708. persistedChanges
  709. )
  710. }
  711. /**
  712. * Queue update for out of sync content
  713. *
  714. * @param {ResyncDocContentUpdate} update
  715. * @param {string} pathname
  716. * @param {string} persistedContent
  717. * @param {string} expectedContent
  718. */
  719. async queueUpdateForOutOfSyncContent(
  720. update,
  721. pathname,
  722. persistedContent,
  723. expectedContent
  724. ) {
  725. logger.debug(
  726. { projectId: this.projectId, persistedContent, expectedContent },
  727. 'diffing doc contents'
  728. )
  729. const op = UpdateCompressor.diffAsShareJsOps(
  730. persistedContent,
  731. expectedContent
  732. )
  733. if (op.length === 0) {
  734. return null
  735. }
  736. const expandedUpdate = {
  737. doc: update.doc,
  738. op,
  739. meta: {
  740. resync: true,
  741. origin: this.origin,
  742. ts: update.meta.ts,
  743. pathname,
  744. doc_length: persistedContent.length,
  745. },
  746. }
  747. logger.debug(
  748. { projectId: this.projectId, diffCount: op.length },
  749. 'doc contents differ'
  750. )
  751. this.expandedUpdates.push(expandedUpdate)
  752. Metrics.inc('project_history_resync_operation', 1, {
  753. status: 'update text file contents',
  754. })
  755. return expandedUpdate
  756. }
  757. /**
  758. * Queue updates for out of sync comments
  759. *
  760. * @param {ResyncDocContentUpdate} update
  761. * @param {string} pathname
  762. * @param {HistoryComment[]} persistedComments
  763. */
  764. async queueUpdatesForOutOfSyncComments(update, pathname, persistedComments) {
  765. const expectedContent = update.resyncDocContent.content
  766. const expectedComments = update.resyncDocContent.ranges?.comments ?? []
  767. const resolvedCommentIds = new Set(
  768. update.resyncDocContent.resolvedCommentIds ?? []
  769. )
  770. const expectedCommentsById = new Map(
  771. expectedComments.map(comment => [comment.id, comment])
  772. )
  773. const persistedCommentsById = new Map(
  774. persistedComments.map(comment => [comment.id, comment])
  775. )
  776. // Delete any persisted comment that is not in the expected comment list.
  777. for (const persistedComment of persistedComments) {
  778. if (!expectedCommentsById.has(persistedComment.id)) {
  779. this.expandedUpdates.push({
  780. pathname,
  781. deleteComment: persistedComment.id,
  782. meta: {
  783. resync: true,
  784. origin: this.origin,
  785. ts: update.meta.ts,
  786. },
  787. })
  788. }
  789. }
  790. for (const expectedComment of expectedComments) {
  791. const persistedComment = persistedCommentsById.get(expectedComment.id)
  792. const expectedCommentResolved = resolvedCommentIds.has(expectedComment.id)
  793. if (
  794. persistedComment != null &&
  795. commentRangesAreInSync(persistedComment, expectedComment)
  796. ) {
  797. if (expectedCommentResolved === persistedComment.resolved) {
  798. // Both comments are identical; do nothing
  799. } else {
  800. // Only the resolved state differs
  801. this.expandedUpdates.push({
  802. pathname,
  803. commentId: expectedComment.id,
  804. resolved: expectedCommentResolved,
  805. meta: {
  806. resync: true,
  807. origin: this.origin,
  808. ts: update.meta.ts,
  809. },
  810. })
  811. }
  812. } else {
  813. const op = { ...expectedComment.op, resolved: expectedCommentResolved }
  814. // New comment or ranges differ
  815. this.expandedUpdates.push({
  816. doc: update.doc,
  817. op: [op],
  818. meta: {
  819. resync: true,
  820. origin: this.origin,
  821. ts: update.meta.ts,
  822. pathname,
  823. doc_length: expectedContent.length,
  824. },
  825. })
  826. }
  827. }
  828. }
  829. /**
  830. * Queue updates for out of sync tracked changes
  831. *
  832. * @param {ResyncDocContentUpdate} update
  833. * @param {string} pathname
  834. * @param {readonly HistoryTrackedChange[]} persistedChanges
  835. */
  836. async queueUpdatesForOutOfSyncTrackedChanges(
  837. update,
  838. pathname,
  839. persistedChanges
  840. ) {
  841. const expectedChanges = update.resyncDocContent.ranges?.changes ?? []
  842. const expectedContent = update.resyncDocContent.content
  843. /**
  844. * A cursor on the expected content
  845. */
  846. let cursor = 0
  847. /**
  848. * The persisted tracking at cursor
  849. *
  850. * @type {TrackingDirective}
  851. */
  852. let persistedTracking = { type: 'none' }
  853. /**
  854. * The expected tracking at cursor
  855. *
  856. * @type {TrackingDirective}
  857. */
  858. let expectedTracking = { type: 'none' }
  859. /**
  860. * The retain ops for the update
  861. *
  862. * @type {RetainOp[]}
  863. */
  864. const ops = []
  865. /**
  866. * The retain op being built
  867. *
  868. * @type {RetainOp | null}
  869. */
  870. let currentOp = null
  871. for (const transition of getTrackedChangesTransitions(
  872. persistedChanges,
  873. expectedChanges,
  874. expectedContent.length
  875. )) {
  876. if (transition.pos > cursor) {
  877. // The next transition will move the cursor. Decide what to do with the interval.
  878. if (trackingDirectivesEqual(expectedTracking, persistedTracking)) {
  879. // Expected tracking and persisted tracking are in sync. Emit the
  880. // current op and skip this interval.
  881. if (currentOp != null) {
  882. ops.push(currentOp)
  883. currentOp = null
  884. }
  885. } else {
  886. // Expected tracking and persisted tracking are different.
  887. const retainedText = expectedContent.slice(cursor, transition.pos)
  888. if (
  889. currentOp?.tracking != null &&
  890. trackingDirectivesEqual(expectedTracking, currentOp.tracking)
  891. ) {
  892. // The current op has the right tracking. Extend it.
  893. currentOp.r += retainedText
  894. } else {
  895. // The current op doesn't have the right tracking. Emit the current
  896. // op and start a new one.
  897. if (currentOp != null) {
  898. ops.push(currentOp)
  899. }
  900. currentOp = {
  901. r: retainedText,
  902. p: cursor,
  903. tracking: expectedTracking,
  904. }
  905. }
  906. }
  907. // Advance cursor
  908. cursor = transition.pos
  909. }
  910. // Update the expected and persisted tracking
  911. if (transition.stage === 'persisted') {
  912. persistedTracking = transition.tracking
  913. } else {
  914. expectedTracking = transition.tracking
  915. }
  916. }
  917. // Emit the last op
  918. if (currentOp != null) {
  919. ops.push(currentOp)
  920. }
  921. if (ops.length > 0) {
  922. this.expandedUpdates.push({
  923. doc: update.doc,
  924. op: ops,
  925. meta: {
  926. resync: true,
  927. origin: this.origin,
  928. ts: update.meta.ts,
  929. pathname,
  930. doc_length: expectedContent.length,
  931. },
  932. })
  933. }
  934. }
  935. }
  936. /**
  937. * Compares the ranges in the persisted and expected comments
  938. *
  939. * @param {HistoryComment} persistedComment
  940. * @param {Comment} expectedComment
  941. */
  942. function commentRangesAreInSync(persistedComment, expectedComment) {
  943. const expectedPos = expectedComment.op.hpos ?? expectedComment.op.p
  944. const expectedLength = expectedComment.op.hlen ?? expectedComment.op.c.length
  945. if (expectedLength === 0) {
  946. // A zero length comment from RangesManager is a detached comment in history
  947. return persistedComment.ranges.length === 0
  948. }
  949. if (persistedComment.ranges.length !== 1) {
  950. // The editor only supports single range comments
  951. return false
  952. }
  953. const persistedRange = persistedComment.ranges[0]
  954. return (
  955. persistedRange.pos === expectedPos &&
  956. persistedRange.length === expectedLength
  957. )
  958. }
  959. /**
  960. * Iterates through expected tracked changes and persisted tracked changes and
  961. * returns all transitions, sorted by position.
  962. *
  963. * @param {readonly HistoryTrackedChange[]} persistedChanges
  964. * @param {TrackedChange[]} expectedChanges
  965. * @param {number} docLength
  966. */
  967. function getTrackedChangesTransitions(
  968. persistedChanges,
  969. expectedChanges,
  970. docLength
  971. ) {
  972. /** @type {TrackedChangeTransition[]} */
  973. const transitions = []
  974. for (const change of persistedChanges) {
  975. transitions.push({
  976. stage: 'persisted',
  977. pos: change.range.start,
  978. tracking: {
  979. type: change.tracking.type,
  980. userId: change.tracking.userId,
  981. ts: change.tracking.ts.toISOString(),
  982. },
  983. })
  984. transitions.push({
  985. stage: 'persisted',
  986. pos: change.range.end,
  987. tracking: { type: 'none' },
  988. })
  989. }
  990. for (const change of expectedChanges) {
  991. const op = change.op
  992. const pos = op.hpos ?? op.p
  993. if (isInsert(op)) {
  994. transitions.push({
  995. stage: 'expected',
  996. pos,
  997. tracking: {
  998. type: 'insert',
  999. userId: change.metadata.user_id,
  1000. ts: change.metadata.ts,
  1001. },
  1002. })
  1003. transitions.push({
  1004. stage: 'expected',
  1005. pos: pos + op.i.length,
  1006. tracking: { type: 'none' },
  1007. })
  1008. } else {
  1009. transitions.push({
  1010. stage: 'expected',
  1011. pos,
  1012. tracking: {
  1013. type: 'delete',
  1014. userId: change.metadata.user_id,
  1015. ts: change.metadata.ts,
  1016. },
  1017. })
  1018. transitions.push({
  1019. stage: 'expected',
  1020. pos: pos + op.d.length,
  1021. tracking: { type: 'none' },
  1022. })
  1023. }
  1024. }
  1025. transitions.push({
  1026. stage: 'expected',
  1027. pos: docLength,
  1028. tracking: { type: 'none' },
  1029. })
  1030. transitions.sort((a, b) => {
  1031. if (a.pos < b.pos) {
  1032. return -1
  1033. } else if (a.pos > b.pos) {
  1034. return 1
  1035. } else if (a.tracking.type === 'none' && b.tracking.type !== 'none') {
  1036. // none type comes before other types so that it can be overridden at the
  1037. // same position
  1038. return -1
  1039. } else if (a.tracking.type !== 'none' && b.tracking.type === 'none') {
  1040. // none type comes before other types so that it can be overridden at the
  1041. // same position
  1042. return 1
  1043. } else {
  1044. return 0
  1045. }
  1046. })
  1047. return transitions
  1048. }
  1049. /**
  1050. * Returns true if both tracking directives are equal
  1051. *
  1052. * @param {TrackingDirective} a
  1053. * @param {TrackingDirective} b
  1054. */
  1055. function trackingDirectivesEqual(a, b) {
  1056. if (a.type === 'none') {
  1057. return b.type === 'none'
  1058. } else {
  1059. return a.type === b.type && a.userId === b.userId && a.ts === b.ts
  1060. }
  1061. }
  1062. // EXPORTS
  1063. const startResyncCb = callbackify(startResync)
  1064. const startResyncWithoutLockCb = callbackify(startResyncWithoutLock)
  1065. const startHardResyncCb = callbackify(startHardResync)
  1066. const setResyncStateCb = callbackify(setResyncState)
  1067. const clearResyncStateCb = callbackify(clearResyncState)
  1068. const skipUpdatesDuringSyncCb = callbackifyMultiResult(skipUpdatesDuringSync, [
  1069. 'updates',
  1070. 'syncState',
  1071. ])
  1072. /**
  1073. * @param {string} projectId
  1074. * @param {string} projectHistoryId
  1075. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} mostRecentChunk
  1076. * @param {Array<Update>} updates
  1077. * @param {() => void} extendLock
  1078. * @param {(err: Error | null, updates?: Array<Update>) => void} callback
  1079. */
  1080. const expandSyncUpdatesCb = (
  1081. projectId,
  1082. projectHistoryId,
  1083. mostRecentChunk,
  1084. updates,
  1085. extendLock,
  1086. callback
  1087. ) => {
  1088. const extendLockPromises = promisify(extendLock)
  1089. expandSyncUpdates(
  1090. projectId,
  1091. projectHistoryId,
  1092. mostRecentChunk,
  1093. updates,
  1094. extendLockPromises
  1095. )
  1096. .then(result => {
  1097. callback(null, result)
  1098. })
  1099. .catch(err => {
  1100. callback(err)
  1101. })
  1102. }
  1103. export {
  1104. startResyncCb as startResync,
  1105. startResyncWithoutLockCb as startResyncWithoutLock,
  1106. startHardResyncCb as startHardResync,
  1107. setResyncStateCb as setResyncState,
  1108. clearResyncStateCb as clearResyncState,
  1109. skipUpdatesDuringSyncCb as skipUpdatesDuringSync,
  1110. expandSyncUpdatesCb as expandSyncUpdates,
  1111. }
  1112. export const promises = {
  1113. startResync,
  1114. startResyncWithoutLock,
  1115. startHardResync,
  1116. setResyncState,
  1117. clearResyncState,
  1118. clearResyncStateIfAllAfter,
  1119. skipUpdatesDuringSync,
  1120. expandSyncUpdates,
  1121. }