SyncManager.js 33 KB

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