SyncManager.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  1. import _ from 'lodash'
  2. import async from 'async'
  3. import { promisify } from 'util'
  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. export function startResync(projectId, options, callback) {
  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. if (typeof options === 'function') {
  39. callback = options
  40. options = {}
  41. }
  42. Metrics.inc('project_history_resync')
  43. LockManager.runWithLock(
  44. keys.projectHistoryLock({ project_id: projectId }),
  45. (extendLock, releaseLock) =>
  46. _startResyncWithoutLock(projectId, options, releaseLock),
  47. function (error) {
  48. if (error) {
  49. OError.tag(error)
  50. // record error in starting sync ("sync ongoing")
  51. ErrorRecorder.record(projectId, -1, error, () => callback(error))
  52. } else {
  53. callback()
  54. }
  55. }
  56. )
  57. }
  58. export function startHardResync(projectId, options, callback) {
  59. if (typeof options === 'function') {
  60. callback = options
  61. options = {}
  62. }
  63. Metrics.inc('project_history_hard_resync')
  64. LockManager.runWithLock(
  65. keys.projectHistoryLock({ project_id: projectId }),
  66. (extendLock, releaseLock) =>
  67. clearResyncState(projectId, function (err) {
  68. if (err) {
  69. return releaseLock(OError.tag(err))
  70. }
  71. RedisManager.clearFirstOpTimestamp(projectId, function (err) {
  72. if (err) {
  73. return releaseLock(OError.tag(err))
  74. }
  75. RedisManager.destroyDocUpdatesQueue(projectId, function (err) {
  76. if (err) {
  77. return releaseLock(OError.tag(err))
  78. }
  79. _startResyncWithoutLock(projectId, options, releaseLock)
  80. })
  81. })
  82. }),
  83. function (error) {
  84. if (error) {
  85. OError.tag(error)
  86. // record error in starting sync ("sync ongoing")
  87. ErrorRecorder.record(projectId, -1, error, () => callback(error))
  88. } else {
  89. callback()
  90. }
  91. }
  92. )
  93. }
  94. function _startResyncWithoutLock(projectId, options, callback) {
  95. ErrorRecorder.recordSyncStart(projectId, function (error) {
  96. if (error) {
  97. return callback(OError.tag(error))
  98. }
  99. _getResyncState(projectId, function (error, syncState) {
  100. if (error) {
  101. return callback(OError.tag(error))
  102. }
  103. if (syncState.isSyncOngoing()) {
  104. return callback(new OError('sync ongoing'))
  105. }
  106. syncState.setOrigin(options.origin || { kind: 'history-resync' })
  107. syncState.startProjectStructureSync()
  108. WebApiManager.requestResync(projectId, function (error) {
  109. if (error) {
  110. return callback(OError.tag(error))
  111. }
  112. setResyncState(projectId, syncState, callback)
  113. })
  114. })
  115. })
  116. }
  117. function _getResyncState(projectId, callback) {
  118. db.projectHistorySyncState.findOne(
  119. {
  120. project_id: ObjectId(projectId.toString()),
  121. },
  122. function (error, rawSyncState) {
  123. if (error) {
  124. return callback(OError.tag(error))
  125. }
  126. const syncState = SyncState.fromRaw(projectId, rawSyncState)
  127. callback(null, syncState)
  128. }
  129. )
  130. }
  131. export function setResyncState(projectId, syncState, callback) {
  132. if (syncState == null) {
  133. return callback()
  134. } // skip if syncState is null (i.e. unchanged)
  135. const update = {
  136. $set: syncState.toRaw(),
  137. $push: {
  138. history: {
  139. $each: [{ syncState: syncState.toRaw(), timestamp: new Date() }],
  140. $position: 0,
  141. $slice: MAX_RESYNC_HISTORY_RECORDS,
  142. },
  143. },
  144. $currentDate: { lastUpdated: true },
  145. }
  146. // handle different cases
  147. if (syncState.isSyncOngoing()) {
  148. // starting a new sync
  149. update.$inc = { resyncCount: 1 }
  150. update.$unset = { expiresAt: true } // prevent the entry expiring while sync is in ongoing
  151. } else {
  152. // successful completion of existing sync
  153. update.$set.expiresAt = new Date(
  154. Date.now() + EXPIRE_RESYNC_HISTORY_INTERVAL_MS
  155. ) // set the entry to expire in the future
  156. }
  157. // apply the update
  158. db.projectHistorySyncState.updateOne(
  159. {
  160. project_id: ObjectId(projectId),
  161. },
  162. update,
  163. {
  164. upsert: true,
  165. },
  166. callback
  167. )
  168. }
  169. export function clearResyncState(projectId, callback) {
  170. db.projectHistorySyncState.deleteOne(
  171. {
  172. project_id: ObjectId(projectId.toString()),
  173. },
  174. callback
  175. )
  176. }
  177. export function skipUpdatesDuringSync(projectId, updates, callback) {
  178. _getResyncState(projectId, function (error, syncState) {
  179. if (error) {
  180. return callback(OError.tag(error))
  181. }
  182. if (!syncState.isSyncOngoing()) {
  183. logger.debug({ projectId }, 'not skipping updates: no resync in progress')
  184. return callback(null, updates) // don't return synsState when unchanged
  185. }
  186. const filteredUpdates = []
  187. for (const update of updates) {
  188. try {
  189. syncState.updateState(update)
  190. } catch (error1) {
  191. error = OError.tag(error1)
  192. if (error instanceof SyncError) {
  193. return callback(error)
  194. } else {
  195. throw error
  196. }
  197. }
  198. const shouldSkipUpdate = syncState.shouldSkipUpdate(update)
  199. if (!shouldSkipUpdate) {
  200. filteredUpdates.push(update)
  201. } else {
  202. logger.debug({ projectId, update }, 'skipping update due to resync')
  203. }
  204. }
  205. callback(null, filteredUpdates, syncState)
  206. })
  207. }
  208. export function expandSyncUpdates(
  209. projectId,
  210. projectHistoryId,
  211. updates,
  212. extendLock,
  213. callback
  214. ) {
  215. const areSyncUpdatesQueued =
  216. _.some(updates, 'resyncProjectStructure') ||
  217. _.some(updates, 'resyncDocContent')
  218. if (!areSyncUpdatesQueued) {
  219. logger.debug({ projectId }, 'no resync updates to expand')
  220. return callback(null, updates)
  221. }
  222. _getResyncState(projectId, (error, syncState) => {
  223. if (error) {
  224. return callback(OError.tag(error))
  225. }
  226. // compute the current snapshot from the most recent chunk
  227. SnapshotManager.getLatestSnapshot(
  228. projectId,
  229. projectHistoryId,
  230. (error, snapshotFiles) => {
  231. if (error) {
  232. return callback(OError.tag(error))
  233. }
  234. // check if snapshot files are valid
  235. const invalidFiles = _.pickBy(
  236. snapshotFiles,
  237. (v, k) => v == null || typeof v.isEditable !== 'function'
  238. )
  239. if (_.size(invalidFiles) > 0) {
  240. return callback(
  241. new SyncError('file is missing isEditable method', {
  242. projectId,
  243. invalidFiles,
  244. })
  245. )
  246. }
  247. const expander = new SyncUpdateExpander(
  248. projectId,
  249. snapshotFiles,
  250. syncState.origin
  251. )
  252. // expand updates asynchronously to avoid blocking
  253. const handleUpdate = (
  254. update,
  255. cb // n.b. lock manager calls cb asynchronously
  256. ) =>
  257. expander.expandUpdate(update, error => {
  258. if (error) {
  259. return cb(OError.tag(error))
  260. }
  261. extendLock(cb)
  262. })
  263. async.eachSeries(updates, handleUpdate, error => {
  264. if (error) {
  265. return callback(OError.tag(error))
  266. }
  267. callback(null, expander.getExpandedUpdates())
  268. })
  269. }
  270. )
  271. })
  272. }
  273. class SyncState {
  274. constructor(projectId, resyncProjectStructure, resyncDocContents, origin) {
  275. this.projectId = projectId
  276. this.resyncProjectStructure = resyncProjectStructure
  277. this.resyncDocContents = resyncDocContents
  278. this.origin = origin
  279. }
  280. static fromRaw(projectId, rawSyncState) {
  281. rawSyncState = rawSyncState || {}
  282. const resyncProjectStructure = rawSyncState.resyncProjectStructure || false
  283. const resyncDocContents = new Set(rawSyncState.resyncDocContents || [])
  284. const origin = rawSyncState.origin
  285. return new SyncState(
  286. projectId,
  287. resyncProjectStructure,
  288. resyncDocContents,
  289. origin
  290. )
  291. }
  292. toRaw() {
  293. return {
  294. resyncProjectStructure: this.resyncProjectStructure,
  295. resyncDocContents: Array.from(this.resyncDocContents),
  296. origin: this.origin,
  297. }
  298. }
  299. updateState(update) {
  300. if (update.resyncProjectStructure != null) {
  301. if (!this.isProjectStructureSyncing()) {
  302. throw new SyncError('unexpected resyncProjectStructure update', {
  303. projectId: this.projectId,
  304. resyncProjectStructure: this.resyncProjectStructure,
  305. })
  306. }
  307. if (this.isAnyDocContentSyncing()) {
  308. throw new SyncError('unexpected resyncDocContents update', {
  309. projectId: this.projectId,
  310. resyncDocContents: this.resyncDocContents,
  311. })
  312. }
  313. for (const doc of update.resyncProjectStructure.docs) {
  314. this.startDocContentSync(doc.path)
  315. }
  316. this.stopProjectStructureSync()
  317. } else if (update.resyncDocContent != null) {
  318. if (this.isProjectStructureSyncing()) {
  319. throw new SyncError('unexpected resyncDocContent update', {
  320. projectId: this.projectId,
  321. resyncProjectStructure: this.resyncProjectStructure,
  322. })
  323. }
  324. if (!this.isDocContentSyncing(update.path)) {
  325. throw new SyncError('unexpected resyncDocContent update', {
  326. projectId: this.projectId,
  327. resyncDocContents: this.resyncDocContents,
  328. path: update.path,
  329. })
  330. }
  331. this.stopDocContentSync(update.path)
  332. }
  333. }
  334. setOrigin(origin) {
  335. this.origin = origin
  336. }
  337. shouldSkipUpdate(update) {
  338. // don't skip sync updates
  339. if (
  340. update.resyncProjectStructure != null ||
  341. update.resyncDocContent != null
  342. ) {
  343. return false
  344. }
  345. // if syncing project structure skip update
  346. if (this.isProjectStructureSyncing()) {
  347. return true
  348. }
  349. // skip text updates for a docs being synced
  350. if (UpdateTranslator.isTextUpdate(update)) {
  351. if (this.isDocContentSyncing(update.meta.pathname)) {
  352. return true
  353. }
  354. }
  355. // preserve all other updates
  356. return false
  357. }
  358. startProjectStructureSync() {
  359. this.resyncProjectStructure = true
  360. this.resyncDocContents = new Set([])
  361. }
  362. stopProjectStructureSync() {
  363. this.resyncProjectStructure = false
  364. }
  365. stopDocContentSync(pathname) {
  366. this.resyncDocContents.delete(pathname)
  367. }
  368. startDocContentSync(pathname) {
  369. this.resyncDocContents.add(pathname)
  370. }
  371. isProjectStructureSyncing() {
  372. return this.resyncProjectStructure
  373. }
  374. isDocContentSyncing(pathname) {
  375. return this.resyncDocContents.has(pathname)
  376. }
  377. isAnyDocContentSyncing() {
  378. return this.resyncDocContents.size > 0
  379. }
  380. isSyncOngoing() {
  381. return this.isProjectStructureSyncing() || this.isAnyDocContentSyncing()
  382. }
  383. }
  384. class SyncUpdateExpander {
  385. constructor(projectId, snapshotFiles, origin) {
  386. this.projectId = projectId
  387. this.files = snapshotFiles
  388. this.expandedUpdates = []
  389. this.origin = origin
  390. }
  391. // If there's an expected *file* with the same path and either the same hash
  392. // or no hash, treat this as not editable even if history thinks it is.
  393. isEditable(filePath, file, expectedFiles) {
  394. if (!file.isEditable()) {
  395. return false
  396. }
  397. const fileHash = _.get(file, ['data', 'hash'])
  398. const matchedExpectedFile = expectedFiles.some(item => {
  399. const expectedFileHash = item._hash
  400. if (expectedFileHash && fileHash !== expectedFileHash) {
  401. // expected file has a hash and it doesn't match
  402. return false
  403. }
  404. return UpdateTranslator._convertPathname(item.path) === filePath
  405. })
  406. // consider editable file in history as binary, since it matches a binary file in the project
  407. return !matchedExpectedFile
  408. }
  409. expandUpdate(update, cb) {
  410. if (update.resyncProjectStructure != null) {
  411. logger.debug(
  412. { projectId: this.projectId, update },
  413. 'expanding resyncProjectStructure update'
  414. )
  415. const persistedNonBinaryFileEntries = _.pickBy(this.files, (v, k) =>
  416. this.isEditable(k, v, update.resyncProjectStructure.files)
  417. )
  418. const persistedNonBinaryFiles = _.map(
  419. Object.keys(persistedNonBinaryFileEntries),
  420. path => ({
  421. path,
  422. })
  423. )
  424. const persistedBinaryFileEntries = _.omitBy(this.files, (v, k) =>
  425. this.isEditable(k, v, update.resyncProjectStructure.files)
  426. )
  427. // preserve file properties on binary files, for future comparison.
  428. const persistedBinaryFiles = _.map(
  429. persistedBinaryFileEntries,
  430. (entity, key) => Object.assign({}, entity, { path: key })
  431. )
  432. const expectedNonBinaryFiles = _.map(
  433. update.resyncProjectStructure.docs,
  434. entity =>
  435. Object.assign({}, entity, {
  436. path: UpdateTranslator._convertPathname(entity.path),
  437. })
  438. )
  439. const expectedBinaryFiles = _.map(
  440. update.resyncProjectStructure.files,
  441. entity =>
  442. Object.assign({}, entity, {
  443. path: UpdateTranslator._convertPathname(entity.path),
  444. })
  445. )
  446. // We need to detect and fix consistency issues where web and
  447. // history-store disagree on whether an entity is binary or not. Thus we
  448. // need to remove and add the two separately.
  449. this.queueRemoveOpsForUnexpectedFiles(
  450. update,
  451. expectedBinaryFiles,
  452. persistedBinaryFiles
  453. )
  454. this.queueRemoveOpsForUnexpectedFiles(
  455. update,
  456. expectedNonBinaryFiles,
  457. persistedNonBinaryFiles
  458. )
  459. this.queueAddOpsForMissingFiles(
  460. update,
  461. expectedBinaryFiles,
  462. persistedBinaryFiles
  463. )
  464. this.queueAddOpsForMissingFiles(
  465. update,
  466. expectedNonBinaryFiles,
  467. persistedNonBinaryFiles
  468. )
  469. this.queueUpdateForOutOfSyncBinaryFiles(
  470. update,
  471. expectedBinaryFiles,
  472. persistedBinaryFiles
  473. )
  474. cb()
  475. } else if (update.resyncDocContent != null) {
  476. logger.debug(
  477. { projectId: this.projectId, update },
  478. 'expanding resyncDocContent update'
  479. )
  480. this.queueTextOpForOutOfSyncContents(update, cb)
  481. } else {
  482. this.expandedUpdates.push(update)
  483. cb()
  484. }
  485. }
  486. getExpandedUpdates() {
  487. return this.expandedUpdates
  488. }
  489. queueRemoveOpsForUnexpectedFiles(update, expectedFiles, persistedFiles) {
  490. const unexpectedFiles = _.differenceBy(
  491. persistedFiles,
  492. expectedFiles,
  493. 'path'
  494. )
  495. for (const entity of unexpectedFiles) {
  496. update = {
  497. pathname: entity.path,
  498. new_pathname: '',
  499. meta: {
  500. resync: true,
  501. origin: this.origin,
  502. ts: update.meta.ts,
  503. },
  504. }
  505. this.expandedUpdates.push(update)
  506. Metrics.inc('project_history_resync_operation', 1, {
  507. status: 'remove unexpected file',
  508. })
  509. }
  510. }
  511. queueAddOpsForMissingFiles(update, expectedFiles, persistedFiles) {
  512. const missingFiles = _.differenceBy(expectedFiles, persistedFiles, 'path')
  513. for (const entity of missingFiles) {
  514. update = {
  515. pathname: entity.path,
  516. meta: {
  517. resync: true,
  518. origin: this.origin,
  519. ts: update.meta.ts,
  520. },
  521. }
  522. if (entity.doc != null) {
  523. update.doc = entity.doc
  524. update.docLines = ''
  525. // we have to create a dummy entry here because later we will need the content in the diff computation
  526. this.files[update.pathname] = File.fromString('')
  527. } else {
  528. update.file = entity.file
  529. update.url = entity.url
  530. }
  531. this.expandedUpdates.push(update)
  532. Metrics.inc('project_history_resync_operation', 1, {
  533. status: 'add missing file',
  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. url: entity.url,
  574. }
  575. this.expandedUpdates.push(addUpdate)
  576. Metrics.inc('project_history_resync_operation', 1, {
  577. status: 'update binary file contents',
  578. })
  579. }
  580. }
  581. queueTextOpForOutOfSyncContents(update, cb) {
  582. const pathname = UpdateTranslator._convertPathname(update.path)
  583. const snapshotFile = this.files[pathname]
  584. const expectedFile = update.resyncDocContent
  585. if (!snapshotFile) {
  586. return cb(new OError('unrecognised file: not in snapshot'))
  587. }
  588. // Compare hashes to see if the persisted file matches the expected content.
  589. // The hash of the persisted files is stored in the snapshot.
  590. // Note getHash() returns the hash only when the persisted file has
  591. // no changes in the snapshot, the hash is null if there are changes
  592. // that apply to it.
  593. const persistedHash =
  594. typeof snapshotFile.getHash === 'function'
  595. ? snapshotFile.getHash()
  596. : undefined
  597. if (persistedHash != null) {
  598. const expectedHash = HashManager._getBlobHashFromString(
  599. expectedFile.content
  600. )
  601. if (persistedHash === expectedHash) {
  602. logger.debug(
  603. { projectId: this.projectId, persistedHash, expectedHash },
  604. 'skipping diff because hashes match and persisted file has no ops'
  605. )
  606. return cb()
  607. }
  608. } else {
  609. logger.debug('cannot compare hashes, will retrieve content')
  610. }
  611. const expectedContent = update.resyncDocContent.content
  612. const computeDiff = (persistedContent, cb) => {
  613. let op
  614. logger.debug(
  615. { projectId: this.projectId, persistedContent, expectedContent },
  616. 'diffing doc contents'
  617. )
  618. try {
  619. op = UpdateCompressor.diffAsShareJsOps(
  620. persistedContent,
  621. expectedContent
  622. )
  623. } catch (error) {
  624. return cb(
  625. OError.tag(error, 'error from diffAsShareJsOps', {
  626. projectId: this.projectId,
  627. persistedContent,
  628. expectedContent,
  629. })
  630. )
  631. }
  632. if (op.length === 0) {
  633. return cb()
  634. }
  635. update = {
  636. doc: update.doc,
  637. op,
  638. meta: {
  639. resync: true,
  640. origin: this.origin,
  641. ts: update.meta.ts,
  642. pathname,
  643. doc_length: persistedContent.length,
  644. },
  645. }
  646. logger.debug(
  647. { projectId: this.projectId, diffCount: op.length },
  648. 'doc contents differ'
  649. )
  650. this.expandedUpdates.push(update)
  651. Metrics.inc('project_history_resync_operation', 1, {
  652. status: 'update text file contents',
  653. })
  654. cb()
  655. }
  656. // compute the difference between the expected and persisted content
  657. if (snapshotFile.load != null) {
  658. WebApiManager.getHistoryId(this.projectId, (err, historyId) => {
  659. if (err) {
  660. return cb(OError.tag(err))
  661. }
  662. const loadFile = snapshotFile.load(
  663. 'eager',
  664. HistoryStoreManager.getBlobStore(historyId)
  665. )
  666. loadFile
  667. .then(file => computeDiff(file.getContent(), cb))
  668. .catch(err => cb(err)) // error loading file or computing diff
  669. })
  670. } else if (snapshotFile.content != null) {
  671. // use dummy content from queueAddOpsForMissingFiles for added missing files
  672. computeDiff(snapshotFile.content, cb)
  673. } else {
  674. cb(new OError('unrecognised file'))
  675. }
  676. }
  677. }
  678. export const promises = {
  679. startResync: promisify(startResync),
  680. startHardResync: promisify(startHardResync),
  681. setResyncState: promisify(setResyncState),
  682. clearResyncState: promisify(clearResyncState),
  683. skipUpdatesDuringSync: promisify(skipUpdatesDuringSync),
  684. expandSyncUpdates: promisify(expandSyncUpdates),
  685. }