SyncManager.js 33 KB

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