SyncManager.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. import _ from 'lodash'
  2. import { callbackify, promisify } from 'util'
  3. import { callbackifyMultiResult } from '@overleaf/promise-utils'
  4. import Settings from '@overleaf/settings'
  5. import logger from '@overleaf/logger'
  6. import Metrics from '@overleaf/metrics'
  7. import OError from '@overleaf/o-error'
  8. import { File } from 'overleaf-editor-core'
  9. import { SyncError } from './Errors.js'
  10. import { db, ObjectId } from './mongodb.js'
  11. import * as SnapshotManager from './SnapshotManager.js'
  12. import * as LockManager from './LockManager.js'
  13. import * as UpdateTranslator from './UpdateTranslator.js'
  14. import * as UpdateCompressor from './UpdateCompressor.js'
  15. import * as WebApiManager from './WebApiManager.js'
  16. import * as ErrorRecorder from './ErrorRecorder.js'
  17. import * as RedisManager from './RedisManager.js'
  18. import * as HistoryStoreManager from './HistoryStoreManager.js'
  19. import * as HashManager from './HashManager.js'
  20. const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs
  21. const EXPIRE_RESYNC_HISTORY_INTERVAL_MS = 90 * 24 * 3600 * 1000 // 90 days
  22. const keys = Settings.redis.lock.key_schema
  23. // db.projectHistorySyncState.ensureIndex({expiresAt: 1}, {expireAfterSeconds: 0, background: true})
  24. // To add expiresAt field to existing entries in collection (choose a suitable future expiry date):
  25. // db.projectHistorySyncState.updateMany({resyncProjectStructure: false, resyncDocContents: [], expiresAt: {$exists:false}}, {$set: {expiresAt: new Date("2019-07-01")}})
  26. async function startResync(projectId, options = {}) {
  27. // We have three options here
  28. //
  29. // 1. If we update mongo before making the call to web then there's a
  30. // chance we ignore all updates indefinitely (there's no foolproff way
  31. // to undo the change in mongo)
  32. //
  33. // 2. If we make the call to web first then there is a small race condition
  34. // where we could process the sync update and then only update mongo
  35. // after, causing all updates to be ignored from then on
  36. //
  37. // 3. We can wrap everything in a project lock
  38. Metrics.inc('project_history_resync')
  39. try {
  40. await LockManager.promises.runWithLock(
  41. keys.projectHistoryLock({ project_id: projectId }),
  42. async extendLock => {
  43. await _startResyncWithoutLock(projectId, options)
  44. }
  45. )
  46. } catch (error) {
  47. // record error in starting sync ("sync ongoing")
  48. try {
  49. await ErrorRecorder.promises.record(projectId, -1, error)
  50. } catch (err) {
  51. // swallow any error thrown by ErrorRecorder.record()
  52. }
  53. throw error
  54. }
  55. }
  56. async function startHardResync(projectId, options = {}) {
  57. Metrics.inc('project_history_hard_resync')
  58. try {
  59. await LockManager.promises.runWithLock(
  60. keys.projectHistoryLock({ project_id: projectId }),
  61. async extendLock => {
  62. await clearResyncState(projectId)
  63. await RedisManager.promises.clearFirstOpTimestamp(projectId)
  64. await RedisManager.promises.destroyDocUpdatesQueue(projectId)
  65. await _startResyncWithoutLock(projectId, options)
  66. }
  67. )
  68. } catch (error) {
  69. // record error in starting sync ("sync ongoing")
  70. await ErrorRecorder.promises.record(projectId, -1, error)
  71. throw error
  72. }
  73. }
  74. async function _startResyncWithoutLock(projectId, options) {
  75. await ErrorRecorder.promises.recordSyncStart(projectId)
  76. const syncState = await _getResyncState(projectId)
  77. if (syncState.isSyncOngoing()) {
  78. throw new OError('sync ongoing')
  79. }
  80. syncState.setOrigin(options.origin || { kind: 'history-resync' })
  81. syncState.startProjectStructureSync()
  82. await WebApiManager.promises.requestResync(projectId)
  83. await setResyncState(projectId, syncState)
  84. }
  85. async function _getResyncState(projectId) {
  86. const rawSyncState = await db.projectHistorySyncState.findOne({
  87. project_id: new ObjectId(projectId.toString()),
  88. })
  89. const syncState = SyncState.fromRaw(projectId, rawSyncState)
  90. return syncState
  91. }
  92. async function setResyncState(projectId, syncState) {
  93. // skip if syncState is null (i.e. unchanged)
  94. if (syncState == null) {
  95. return
  96. }
  97. const update = {
  98. $set: syncState.toRaw(),
  99. $push: {
  100. history: {
  101. $each: [{ syncState: syncState.toRaw(), timestamp: new Date() }],
  102. $position: 0,
  103. $slice: MAX_RESYNC_HISTORY_RECORDS,
  104. },
  105. },
  106. $currentDate: { lastUpdated: true },
  107. }
  108. // handle different cases
  109. if (syncState.isSyncOngoing()) {
  110. // starting a new sync; prevent the entry expiring while sync is in ongoing
  111. update.$inc = { resyncCount: 1 }
  112. update.$unset = { expiresAt: true }
  113. } else {
  114. // successful completion of existing sync; set the entry to expire in the
  115. // future
  116. update.$set.expiresAt = new Date(
  117. Date.now() + EXPIRE_RESYNC_HISTORY_INTERVAL_MS
  118. )
  119. }
  120. // apply the update
  121. await db.projectHistorySyncState.updateOne(
  122. { project_id: new ObjectId(projectId) },
  123. update,
  124. { upsert: true }
  125. )
  126. }
  127. async function clearResyncState(projectId) {
  128. await db.projectHistorySyncState.deleteOne({
  129. project_id: new ObjectId(projectId.toString()),
  130. })
  131. }
  132. async function skipUpdatesDuringSync(projectId, updates) {
  133. const syncState = await _getResyncState(projectId)
  134. if (!syncState.isSyncOngoing()) {
  135. logger.debug({ projectId }, 'not skipping updates: no resync in progress')
  136. // don't return syncState when unchanged
  137. return { updates, syncState: null }
  138. }
  139. const filteredUpdates = []
  140. for (const update of updates) {
  141. syncState.updateState(update)
  142. const shouldSkipUpdate = syncState.shouldSkipUpdate(update)
  143. if (!shouldSkipUpdate) {
  144. filteredUpdates.push(update)
  145. } else {
  146. logger.debug({ projectId, update }, 'skipping update due to resync')
  147. }
  148. }
  149. return { updates: filteredUpdates, syncState }
  150. }
  151. async function expandSyncUpdates(
  152. projectId,
  153. projectHistoryId,
  154. updates,
  155. extendLock
  156. ) {
  157. const areSyncUpdatesQueued =
  158. _.some(updates, 'resyncProjectStructure') ||
  159. _.some(updates, 'resyncDocContent')
  160. if (!areSyncUpdatesQueued) {
  161. logger.debug({ projectId }, 'no resync updates to expand')
  162. return updates
  163. }
  164. const syncState = await _getResyncState(projectId)
  165. // compute the current snapshot from the most recent chunk
  166. const snapshotFiles = await SnapshotManager.promises.getLatestSnapshot(
  167. projectId,
  168. projectHistoryId
  169. )
  170. // check if snapshot files are valid
  171. const invalidFiles = _.pickBy(
  172. snapshotFiles,
  173. (v, k) => v == null || typeof v.isEditable !== 'function'
  174. )
  175. if (_.size(invalidFiles) > 0) {
  176. throw new SyncError('file is missing isEditable method', {
  177. projectId,
  178. invalidFiles,
  179. })
  180. }
  181. const expander = new SyncUpdateExpander(
  182. projectId,
  183. snapshotFiles,
  184. syncState.origin
  185. )
  186. // expand updates asynchronously to avoid blocking
  187. for (const update of updates) {
  188. await expander.expandUpdate(update)
  189. await extendLock()
  190. }
  191. return expander.getExpandedUpdates()
  192. }
  193. class SyncState {
  194. constructor(projectId, resyncProjectStructure, resyncDocContents, origin) {
  195. this.projectId = projectId
  196. this.resyncProjectStructure = resyncProjectStructure
  197. this.resyncDocContents = resyncDocContents
  198. this.origin = origin
  199. }
  200. static fromRaw(projectId, rawSyncState) {
  201. rawSyncState = rawSyncState || {}
  202. const resyncProjectStructure = rawSyncState.resyncProjectStructure || false
  203. const resyncDocContents = new Set(rawSyncState.resyncDocContents || [])
  204. const origin = rawSyncState.origin
  205. return new SyncState(
  206. projectId,
  207. resyncProjectStructure,
  208. resyncDocContents,
  209. origin
  210. )
  211. }
  212. toRaw() {
  213. return {
  214. resyncProjectStructure: this.resyncProjectStructure,
  215. resyncDocContents: Array.from(this.resyncDocContents),
  216. origin: this.origin,
  217. }
  218. }
  219. updateState(update) {
  220. if (update.resyncProjectStructure != null) {
  221. if (!this.isProjectStructureSyncing()) {
  222. throw new SyncError('unexpected resyncProjectStructure update', {
  223. projectId: this.projectId,
  224. resyncProjectStructure: this.resyncProjectStructure,
  225. })
  226. }
  227. if (this.isAnyDocContentSyncing()) {
  228. throw new SyncError('unexpected resyncDocContents update', {
  229. projectId: this.projectId,
  230. resyncDocContents: this.resyncDocContents,
  231. })
  232. }
  233. for (const doc of update.resyncProjectStructure.docs) {
  234. this.startDocContentSync(doc.path)
  235. }
  236. this.stopProjectStructureSync()
  237. } else if (update.resyncDocContent != null) {
  238. if (this.isProjectStructureSyncing()) {
  239. throw new SyncError('unexpected resyncDocContent update', {
  240. projectId: this.projectId,
  241. resyncProjectStructure: this.resyncProjectStructure,
  242. })
  243. }
  244. if (!this.isDocContentSyncing(update.path)) {
  245. throw new SyncError('unexpected resyncDocContent update', {
  246. projectId: this.projectId,
  247. resyncDocContents: this.resyncDocContents,
  248. path: update.path,
  249. })
  250. }
  251. this.stopDocContentSync(update.path)
  252. }
  253. }
  254. setOrigin(origin) {
  255. this.origin = origin
  256. }
  257. shouldSkipUpdate(update) {
  258. // don't skip sync updates
  259. if (
  260. update.resyncProjectStructure != null ||
  261. update.resyncDocContent != null
  262. ) {
  263. return false
  264. }
  265. // if syncing project structure skip update
  266. if (this.isProjectStructureSyncing()) {
  267. return true
  268. }
  269. // skip text updates for a docs being synced
  270. if (UpdateTranslator.isTextUpdate(update)) {
  271. if (this.isDocContentSyncing(update.meta.pathname)) {
  272. return true
  273. }
  274. }
  275. // preserve all other updates
  276. return false
  277. }
  278. startProjectStructureSync() {
  279. this.resyncProjectStructure = true
  280. this.resyncDocContents = new Set([])
  281. }
  282. stopProjectStructureSync() {
  283. this.resyncProjectStructure = false
  284. }
  285. stopDocContentSync(pathname) {
  286. this.resyncDocContents.delete(pathname)
  287. }
  288. startDocContentSync(pathname) {
  289. this.resyncDocContents.add(pathname)
  290. }
  291. isProjectStructureSyncing() {
  292. return this.resyncProjectStructure
  293. }
  294. isDocContentSyncing(pathname) {
  295. return this.resyncDocContents.has(pathname)
  296. }
  297. isAnyDocContentSyncing() {
  298. return this.resyncDocContents.size > 0
  299. }
  300. isSyncOngoing() {
  301. return this.isProjectStructureSyncing() || this.isAnyDocContentSyncing()
  302. }
  303. }
  304. class SyncUpdateExpander {
  305. constructor(projectId, snapshotFiles, origin) {
  306. this.projectId = projectId
  307. this.files = snapshotFiles
  308. this.expandedUpdates = []
  309. this.origin = origin
  310. }
  311. // If there's an expected *file* with the same path and either the same hash
  312. // or no hash, treat this as not editable even if history thinks it is.
  313. isEditable(filePath, file, expectedFiles) {
  314. if (!file.isEditable()) {
  315. return false
  316. }
  317. const fileHash = _.get(file, ['data', 'hash'])
  318. const matchedExpectedFile = expectedFiles.some(item => {
  319. const expectedFileHash = item._hash
  320. if (expectedFileHash && fileHash !== expectedFileHash) {
  321. // expected file has a hash and it doesn't match
  322. return false
  323. }
  324. return UpdateTranslator._convertPathname(item.path) === filePath
  325. })
  326. // consider editable file in history as binary, since it matches a binary file in the project
  327. return !matchedExpectedFile
  328. }
  329. async expandUpdate(update) {
  330. if (update.resyncProjectStructure != null) {
  331. logger.debug(
  332. { projectId: this.projectId, update },
  333. 'expanding resyncProjectStructure update'
  334. )
  335. const persistedNonBinaryFileEntries = _.pickBy(this.files, (v, k) =>
  336. this.isEditable(k, v, update.resyncProjectStructure.files)
  337. )
  338. const persistedNonBinaryFiles = _.map(
  339. Object.keys(persistedNonBinaryFileEntries),
  340. path => ({
  341. path,
  342. })
  343. )
  344. const persistedBinaryFileEntries = _.omitBy(this.files, (v, k) =>
  345. this.isEditable(k, v, update.resyncProjectStructure.files)
  346. )
  347. // preserve file properties on binary files, for future comparison.
  348. const persistedBinaryFiles = _.map(
  349. persistedBinaryFileEntries,
  350. (entity, key) => Object.assign({}, entity, { path: key })
  351. )
  352. const expectedNonBinaryFiles = _.map(
  353. update.resyncProjectStructure.docs,
  354. entity =>
  355. Object.assign({}, entity, {
  356. path: UpdateTranslator._convertPathname(entity.path),
  357. })
  358. )
  359. const expectedBinaryFiles = _.map(
  360. update.resyncProjectStructure.files,
  361. entity =>
  362. Object.assign({}, entity, {
  363. path: UpdateTranslator._convertPathname(entity.path),
  364. })
  365. )
  366. // We need to detect and fix consistency issues where web and
  367. // history-store disagree on whether an entity is binary or not. Thus we
  368. // need to remove and add the two separately.
  369. this.queueRemoveOpsForUnexpectedFiles(
  370. update,
  371. expectedBinaryFiles,
  372. persistedBinaryFiles
  373. )
  374. this.queueRemoveOpsForUnexpectedFiles(
  375. update,
  376. expectedNonBinaryFiles,
  377. persistedNonBinaryFiles
  378. )
  379. this.queueAddOpsForMissingFiles(
  380. update,
  381. expectedBinaryFiles,
  382. persistedBinaryFiles
  383. )
  384. this.queueAddOpsForMissingFiles(
  385. update,
  386. expectedNonBinaryFiles,
  387. persistedNonBinaryFiles
  388. )
  389. this.queueUpdateForOutOfSyncBinaryFiles(
  390. update,
  391. expectedBinaryFiles,
  392. persistedBinaryFiles
  393. )
  394. } else if (update.resyncDocContent != null) {
  395. logger.debug(
  396. { projectId: this.projectId, update },
  397. 'expanding resyncDocContent update'
  398. )
  399. await this.queueTextOpForOutOfSyncContents(update)
  400. } else {
  401. this.expandedUpdates.push(update)
  402. }
  403. }
  404. getExpandedUpdates() {
  405. return this.expandedUpdates
  406. }
  407. queueRemoveOpsForUnexpectedFiles(update, expectedFiles, persistedFiles) {
  408. const unexpectedFiles = _.differenceBy(
  409. persistedFiles,
  410. expectedFiles,
  411. 'path'
  412. )
  413. for (const entity of unexpectedFiles) {
  414. update = {
  415. pathname: entity.path,
  416. new_pathname: '',
  417. meta: {
  418. resync: true,
  419. origin: this.origin,
  420. ts: update.meta.ts,
  421. },
  422. }
  423. this.expandedUpdates.push(update)
  424. Metrics.inc('project_history_resync_operation', 1, {
  425. status: 'remove unexpected file',
  426. })
  427. }
  428. }
  429. queueAddOpsForMissingFiles(update, expectedFiles, persistedFiles) {
  430. const missingFiles = _.differenceBy(expectedFiles, persistedFiles, 'path')
  431. for (const entity of missingFiles) {
  432. update = {
  433. pathname: entity.path,
  434. meta: {
  435. resync: true,
  436. origin: this.origin,
  437. ts: update.meta.ts,
  438. },
  439. }
  440. if (entity.doc != null) {
  441. update.doc = entity.doc
  442. update.docLines = ''
  443. // we have to create a dummy entry here because later we will need the content in the diff computation
  444. this.files[update.pathname] = File.fromString('')
  445. } else {
  446. update.file = entity.file
  447. update.url = entity.url
  448. }
  449. this.expandedUpdates.push(update)
  450. Metrics.inc('project_history_resync_operation', 1, {
  451. status: 'add missing file',
  452. })
  453. }
  454. }
  455. queueUpdateForOutOfSyncBinaryFiles(update, expectedFiles, persistedFiles) {
  456. // create a map to lookup persisted files by their path
  457. const persistedFileMap = new Map(persistedFiles.map(x => [x.path, x]))
  458. // now search for files with same path but different hash values
  459. const differentFiles = expectedFiles.filter(expected => {
  460. // check for a persisted file at the same path
  461. const expectedPath = expected.path
  462. const persistedFileAtSamePath = persistedFileMap.get(expectedPath)
  463. if (!persistedFileAtSamePath) return false
  464. // check if the persisted file at the same path has a different hash
  465. const expectedHash = _.get(expected, '_hash')
  466. const persistedHash = _.get(persistedFileAtSamePath, ['data', 'hash'])
  467. const hashesPresent = expectedHash && persistedHash
  468. return hashesPresent && persistedHash !== expectedHash
  469. })
  470. for (const entity of differentFiles) {
  471. // remove the outdated persisted file
  472. const removeUpdate = {
  473. pathname: entity.path,
  474. new_pathname: '',
  475. meta: {
  476. resync: true,
  477. origin: this.origin,
  478. ts: update.meta.ts,
  479. },
  480. }
  481. this.expandedUpdates.push(removeUpdate)
  482. // add the new file content
  483. const addUpdate = {
  484. pathname: entity.path,
  485. meta: {
  486. resync: true,
  487. origin: this.origin,
  488. ts: update.meta.ts,
  489. },
  490. file: entity.file,
  491. url: entity.url,
  492. }
  493. this.expandedUpdates.push(addUpdate)
  494. Metrics.inc('project_history_resync_operation', 1, {
  495. status: 'update binary file contents',
  496. })
  497. }
  498. }
  499. async queueTextOpForOutOfSyncContents(update) {
  500. const pathname = UpdateTranslator._convertPathname(update.path)
  501. const snapshotFile = this.files[pathname]
  502. const expectedFile = update.resyncDocContent
  503. if (!snapshotFile) {
  504. throw new OError('unrecognised file: not in snapshot')
  505. }
  506. // Compare hashes to see if the persisted file matches the expected content.
  507. // The hash of the persisted files is stored in the snapshot.
  508. // Note getHash() returns the hash only when the persisted file has
  509. // no changes in the snapshot, the hash is null if there are changes
  510. // that apply to it.
  511. const persistedHash =
  512. typeof snapshotFile.getHash === 'function'
  513. ? snapshotFile.getHash()
  514. : undefined
  515. if (persistedHash != null) {
  516. const expectedHash = HashManager._getBlobHashFromString(
  517. expectedFile.content
  518. )
  519. if (persistedHash === expectedHash) {
  520. logger.debug(
  521. { projectId: this.projectId, persistedHash, expectedHash },
  522. 'skipping diff because hashes match and persisted file has no ops'
  523. )
  524. return
  525. }
  526. } else {
  527. logger.debug('cannot compare hashes, will retrieve content')
  528. }
  529. const expectedContent = update.resyncDocContent.content
  530. let persistedContent
  531. // compute the difference between the expected and persisted content
  532. if (snapshotFile.load != null) {
  533. const historyId = await WebApiManager.promises.getHistoryId(
  534. this.projectId
  535. )
  536. const file = await snapshotFile.load(
  537. 'eager',
  538. HistoryStoreManager.getBlobStore(historyId)
  539. )
  540. persistedContent = file.getContent()
  541. } else if (snapshotFile.content != null) {
  542. // use dummy content from queueAddOpsForMissingFiles for added missing files
  543. persistedContent = snapshotFile.content
  544. } else {
  545. throw new OError('unrecognised file')
  546. }
  547. let op
  548. logger.debug(
  549. { projectId: this.projectId, persistedContent, expectedContent },
  550. 'diffing doc contents'
  551. )
  552. try {
  553. op = UpdateCompressor.diffAsShareJsOps(persistedContent, expectedContent)
  554. } catch (error) {
  555. throw OError.tag(error, 'error from diffAsShareJsOps', {
  556. projectId: this.projectId,
  557. persistedContent,
  558. expectedContent,
  559. })
  560. }
  561. if (op.length === 0) {
  562. return
  563. }
  564. update = {
  565. doc: update.doc,
  566. op,
  567. meta: {
  568. resync: true,
  569. origin: this.origin,
  570. ts: update.meta.ts,
  571. pathname,
  572. doc_length: persistedContent.length,
  573. },
  574. }
  575. logger.debug(
  576. { projectId: this.projectId, diffCount: op.length },
  577. 'doc contents differ'
  578. )
  579. this.expandedUpdates.push(update)
  580. Metrics.inc('project_history_resync_operation', 1, {
  581. status: 'update text file contents',
  582. })
  583. }
  584. }
  585. // EXPORTS
  586. const startResyncCb = callbackify(startResync)
  587. const startHardResyncCb = callbackify(startHardResync)
  588. const setResyncStateCb = callbackify(setResyncState)
  589. const clearResyncStateCb = callbackify(clearResyncState)
  590. const skipUpdatesDuringSyncCb = callbackifyMultiResult(skipUpdatesDuringSync, [
  591. 'updates',
  592. 'syncState',
  593. ])
  594. const expandSyncUpdatesCb = (
  595. projectId,
  596. projectHistoryId,
  597. updates,
  598. extendLock,
  599. callback
  600. ) => {
  601. const extendLockPromises = promisify(extendLock)
  602. expandSyncUpdates(projectId, projectHistoryId, updates, extendLockPromises)
  603. .then(result => {
  604. callback(null, result)
  605. })
  606. .catch(err => {
  607. callback(err)
  608. })
  609. }
  610. export {
  611. startResyncCb as startResync,
  612. startHardResyncCb as startHardResync,
  613. setResyncStateCb as setResyncState,
  614. clearResyncStateCb as clearResyncState,
  615. skipUpdatesDuringSyncCb as skipUpdatesDuringSync,
  616. expandSyncUpdatesCb as expandSyncUpdates,
  617. }
  618. export const promises = {
  619. startResync,
  620. startHardResync,
  621. setResyncState,
  622. clearResyncState,
  623. skipUpdatesDuringSync,
  624. expandSyncUpdates,
  625. }