SyncManager.js 34 KB

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