| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750 |
- import _ from 'lodash'
- import async from 'async'
- import { promisify } from 'util'
- import Settings from '@overleaf/settings'
- import logger from '@overleaf/logger'
- import Metrics from '@overleaf/metrics'
- import OError from '@overleaf/o-error'
- import { File } from 'overleaf-editor-core'
- import { SyncError } from './Errors.js'
- import { db, ObjectId } from './mongodb.js'
- import * as SnapshotManager from './SnapshotManager.js'
- import * as LockManager from './LockManager.js'
- import * as UpdateTranslator from './UpdateTranslator.js'
- import * as UpdateCompressor from './UpdateCompressor.js'
- import * as WebApiManager from './WebApiManager.js'
- import * as ErrorRecorder from './ErrorRecorder.js'
- import * as RedisManager from './RedisManager.js'
- import * as HistoryStoreManager from './HistoryStoreManager.js'
- import * as HashManager from './HashManager.js'
- const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs
- const EXPIRE_RESYNC_HISTORY_INTERVAL_MS = 90 * 24 * 3600 * 1000 // 90 days
- const keys = Settings.redis.lock.key_schema
- // db.projectHistorySyncState.ensureIndex({expiresAt: 1}, {expireAfterSeconds: 0, background: true})
- // To add expiresAt field to existing entries in collection (choose a suitable future expiry date):
- // db.projectHistorySyncState.updateMany({resyncProjectStructure: false, resyncDocContents: [], expiresAt: {$exists:false}}, {$set: {expiresAt: new Date("2019-07-01")}})
- export function startResync(projectId, options, callback) {
- // We have three options here
- //
- // 1. If we update mongo before making the call to web then there's a
- // chance we ignore all updates indefinitely (there's no foolproff way
- // to undo the change in mongo)
- //
- // 2. If we make the call to web first then there is a small race condition
- // where we could process the sync update and then only update mongo
- // after, causing all updates to be ignored from then on
- //
- // 3. We can wrap everything in a project lock
- if (typeof options === 'function') {
- callback = options
- options = {}
- }
- Metrics.inc('project_history_resync')
- LockManager.runWithLock(
- keys.projectHistoryLock({ project_id: projectId }),
- (extendLock, releaseLock) =>
- _startResyncWithoutLock(projectId, options, releaseLock),
- function (error) {
- if (error) {
- OError.tag(error)
- // record error in starting sync ("sync ongoing")
- ErrorRecorder.record(projectId, -1, error, () => callback(error))
- } else {
- callback()
- }
- }
- )
- }
- export function startHardResync(projectId, options, callback) {
- if (typeof options === 'function') {
- callback = options
- options = {}
- }
- Metrics.inc('project_history_hard_resync')
- LockManager.runWithLock(
- keys.projectHistoryLock({ project_id: projectId }),
- (extendLock, releaseLock) =>
- clearResyncState(projectId, function (err) {
- if (err) {
- return releaseLock(OError.tag(err))
- }
- RedisManager.clearFirstOpTimestamp(projectId, function (err) {
- if (err) {
- return releaseLock(OError.tag(err))
- }
- RedisManager.destroyDocUpdatesQueue(projectId, function (err) {
- if (err) {
- return releaseLock(OError.tag(err))
- }
- _startResyncWithoutLock(projectId, options, releaseLock)
- })
- })
- }),
- function (error) {
- if (error) {
- OError.tag(error)
- // record error in starting sync ("sync ongoing")
- ErrorRecorder.record(projectId, -1, error, () => callback(error))
- } else {
- callback()
- }
- }
- )
- }
- function _startResyncWithoutLock(projectId, options, callback) {
- ErrorRecorder.recordSyncStart(projectId, function (error) {
- if (error) {
- return callback(OError.tag(error))
- }
- _getResyncState(projectId, function (error, syncState) {
- if (error) {
- return callback(OError.tag(error))
- }
- if (syncState.isSyncOngoing()) {
- return callback(new OError('sync ongoing'))
- }
- syncState.setOrigin(options.origin || { kind: 'history-resync' })
- syncState.startProjectStructureSync()
- WebApiManager.requestResync(projectId, function (error) {
- if (error) {
- return callback(OError.tag(error))
- }
- setResyncState(projectId, syncState, callback)
- })
- })
- })
- }
- function _getResyncState(projectId, callback) {
- db.projectHistorySyncState.findOne(
- {
- project_id: ObjectId(projectId.toString()),
- },
- function (error, rawSyncState) {
- if (error) {
- return callback(OError.tag(error))
- }
- const syncState = SyncState.fromRaw(projectId, rawSyncState)
- callback(null, syncState)
- }
- )
- }
- export function setResyncState(projectId, syncState, callback) {
- if (syncState == null) {
- return callback()
- } // skip if syncState is null (i.e. unchanged)
- const update = {
- $set: syncState.toRaw(),
- $push: {
- history: {
- $each: [{ syncState: syncState.toRaw(), timestamp: new Date() }],
- $position: 0,
- $slice: MAX_RESYNC_HISTORY_RECORDS,
- },
- },
- $currentDate: { lastUpdated: true },
- }
- // handle different cases
- if (syncState.isSyncOngoing()) {
- // starting a new sync
- update.$inc = { resyncCount: 1 }
- update.$unset = { expiresAt: true } // prevent the entry expiring while sync is in ongoing
- } else {
- // successful completion of existing sync
- update.$set.expiresAt = new Date(
- Date.now() + EXPIRE_RESYNC_HISTORY_INTERVAL_MS
- ) // set the entry to expire in the future
- }
- // apply the update
- db.projectHistorySyncState.updateOne(
- {
- project_id: ObjectId(projectId),
- },
- update,
- {
- upsert: true,
- },
- callback
- )
- }
- export function clearResyncState(projectId, callback) {
- db.projectHistorySyncState.deleteOne(
- {
- project_id: ObjectId(projectId.toString()),
- },
- callback
- )
- }
- export function skipUpdatesDuringSync(projectId, updates, callback) {
- _getResyncState(projectId, function (error, syncState) {
- if (error) {
- return callback(OError.tag(error))
- }
- if (!syncState.isSyncOngoing()) {
- logger.debug({ projectId }, 'not skipping updates: no resync in progress')
- return callback(null, updates) // don't return synsState when unchanged
- }
- const filteredUpdates = []
- for (const update of updates) {
- try {
- syncState.updateState(update)
- } catch (error1) {
- error = OError.tag(error1)
- if (error instanceof SyncError) {
- return callback(error)
- } else {
- throw error
- }
- }
- const shouldSkipUpdate = syncState.shouldSkipUpdate(update)
- if (!shouldSkipUpdate) {
- filteredUpdates.push(update)
- } else {
- logger.debug({ projectId, update }, 'skipping update due to resync')
- }
- }
- callback(null, filteredUpdates, syncState)
- })
- }
- export function expandSyncUpdates(
- projectId,
- projectHistoryId,
- updates,
- extendLock,
- callback
- ) {
- const areSyncUpdatesQueued =
- _.some(updates, 'resyncProjectStructure') ||
- _.some(updates, 'resyncDocContent')
- if (!areSyncUpdatesQueued) {
- logger.debug({ projectId }, 'no resync updates to expand')
- return callback(null, updates)
- }
- _getResyncState(projectId, (error, syncState) => {
- if (error) {
- return callback(OError.tag(error))
- }
- // compute the current snapshot from the most recent chunk
- SnapshotManager.getLatestSnapshot(
- projectId,
- projectHistoryId,
- (error, snapshotFiles) => {
- if (error) {
- return callback(OError.tag(error))
- }
- // check if snapshot files are valid
- const invalidFiles = _.pickBy(
- snapshotFiles,
- (v, k) => v == null || typeof v.isEditable !== 'function'
- )
- if (_.size(invalidFiles) > 0) {
- return callback(
- new SyncError('file is missing isEditable method', {
- projectId,
- invalidFiles,
- })
- )
- }
- const expander = new SyncUpdateExpander(
- projectId,
- snapshotFiles,
- syncState.origin
- )
- // expand updates asynchronously to avoid blocking
- const handleUpdate = (
- update,
- cb // n.b. lock manager calls cb asynchronously
- ) =>
- expander.expandUpdate(update, error => {
- if (error) {
- return cb(OError.tag(error))
- }
- extendLock(cb)
- })
- async.eachSeries(updates, handleUpdate, error => {
- if (error) {
- return callback(OError.tag(error))
- }
- callback(null, expander.getExpandedUpdates())
- })
- }
- )
- })
- }
- class SyncState {
- constructor(projectId, resyncProjectStructure, resyncDocContents, origin) {
- this.projectId = projectId
- this.resyncProjectStructure = resyncProjectStructure
- this.resyncDocContents = resyncDocContents
- this.origin = origin
- }
- static fromRaw(projectId, rawSyncState) {
- rawSyncState = rawSyncState || {}
- const resyncProjectStructure = rawSyncState.resyncProjectStructure || false
- const resyncDocContents = new Set(rawSyncState.resyncDocContents || [])
- const origin = rawSyncState.origin
- return new SyncState(
- projectId,
- resyncProjectStructure,
- resyncDocContents,
- origin
- )
- }
- toRaw() {
- return {
- resyncProjectStructure: this.resyncProjectStructure,
- resyncDocContents: Array.from(this.resyncDocContents),
- origin: this.origin,
- }
- }
- updateState(update) {
- if (update.resyncProjectStructure != null) {
- if (!this.isProjectStructureSyncing()) {
- throw new SyncError('unexpected resyncProjectStructure update', {
- projectId: this.projectId,
- resyncProjectStructure: this.resyncProjectStructure,
- })
- }
- if (this.isAnyDocContentSyncing()) {
- throw new SyncError('unexpected resyncDocContents update', {
- projectId: this.projectId,
- resyncDocContents: this.resyncDocContents,
- })
- }
- for (const doc of update.resyncProjectStructure.docs) {
- this.startDocContentSync(doc.path)
- }
- this.stopProjectStructureSync()
- } else if (update.resyncDocContent != null) {
- if (this.isProjectStructureSyncing()) {
- throw new SyncError('unexpected resyncDocContent update', {
- projectId: this.projectId,
- resyncProjectStructure: this.resyncProjectStructure,
- })
- }
- if (!this.isDocContentSyncing(update.path)) {
- throw new SyncError('unexpected resyncDocContent update', {
- projectId: this.projectId,
- resyncDocContents: this.resyncDocContents,
- path: update.path,
- })
- }
- this.stopDocContentSync(update.path)
- }
- }
- setOrigin(origin) {
- this.origin = origin
- }
- shouldSkipUpdate(update) {
- // don't skip sync updates
- if (
- update.resyncProjectStructure != null ||
- update.resyncDocContent != null
- ) {
- return false
- }
- // if syncing project structure skip update
- if (this.isProjectStructureSyncing()) {
- return true
- }
- // skip text updates for a docs being synced
- if (UpdateTranslator.isTextUpdate(update)) {
- if (this.isDocContentSyncing(update.meta.pathname)) {
- return true
- }
- }
- // preserve all other updates
- return false
- }
- startProjectStructureSync() {
- this.resyncProjectStructure = true
- this.resyncDocContents = new Set([])
- }
- stopProjectStructureSync() {
- this.resyncProjectStructure = false
- }
- stopDocContentSync(pathname) {
- this.resyncDocContents.delete(pathname)
- }
- startDocContentSync(pathname) {
- this.resyncDocContents.add(pathname)
- }
- isProjectStructureSyncing() {
- return this.resyncProjectStructure
- }
- isDocContentSyncing(pathname) {
- return this.resyncDocContents.has(pathname)
- }
- isAnyDocContentSyncing() {
- return this.resyncDocContents.size > 0
- }
- isSyncOngoing() {
- return this.isProjectStructureSyncing() || this.isAnyDocContentSyncing()
- }
- }
- class SyncUpdateExpander {
- constructor(projectId, snapshotFiles, origin) {
- this.projectId = projectId
- this.files = snapshotFiles
- this.expandedUpdates = []
- this.origin = origin
- }
- // If there's an expected *file* with the same path and either the same hash
- // or no hash, treat this as not editable even if history thinks it is.
- isEditable(filePath, file, expectedFiles) {
- if (!file.isEditable()) {
- return false
- }
- const fileHash = _.get(file, ['data', 'hash'])
- const matchedExpectedFile = expectedFiles.some(item => {
- const expectedFileHash = item._hash
- if (expectedFileHash && fileHash !== expectedFileHash) {
- // expected file has a hash and it doesn't match
- return false
- }
- return UpdateTranslator._convertPathname(item.path) === filePath
- })
- // consider editable file in history as binary, since it matches a binary file in the project
- return !matchedExpectedFile
- }
- expandUpdate(update, cb) {
- if (update.resyncProjectStructure != null) {
- logger.debug(
- { projectId: this.projectId, update },
- 'expanding resyncProjectStructure update'
- )
- const persistedNonBinaryFileEntries = _.pickBy(this.files, (v, k) =>
- this.isEditable(k, v, update.resyncProjectStructure.files)
- )
- const persistedNonBinaryFiles = _.map(
- Object.keys(persistedNonBinaryFileEntries),
- path => ({
- path,
- })
- )
- const persistedBinaryFileEntries = _.omitBy(this.files, (v, k) =>
- this.isEditable(k, v, update.resyncProjectStructure.files)
- )
- // preserve file properties on binary files, for future comparison.
- const persistedBinaryFiles = _.map(
- persistedBinaryFileEntries,
- (entity, key) => Object.assign({}, entity, { path: key })
- )
- const expectedNonBinaryFiles = _.map(
- update.resyncProjectStructure.docs,
- entity =>
- Object.assign({}, entity, {
- path: UpdateTranslator._convertPathname(entity.path),
- })
- )
- const expectedBinaryFiles = _.map(
- update.resyncProjectStructure.files,
- entity =>
- Object.assign({}, entity, {
- path: UpdateTranslator._convertPathname(entity.path),
- })
- )
- // We need to detect and fix consistency issues where web and
- // history-store disagree on whether an entity is binary or not. Thus we
- // need to remove and add the two separately.
- this.queueRemoveOpsForUnexpectedFiles(
- update,
- expectedBinaryFiles,
- persistedBinaryFiles
- )
- this.queueRemoveOpsForUnexpectedFiles(
- update,
- expectedNonBinaryFiles,
- persistedNonBinaryFiles
- )
- this.queueAddOpsForMissingFiles(
- update,
- expectedBinaryFiles,
- persistedBinaryFiles
- )
- this.queueAddOpsForMissingFiles(
- update,
- expectedNonBinaryFiles,
- persistedNonBinaryFiles
- )
- this.queueUpdateForOutOfSyncBinaryFiles(
- update,
- expectedBinaryFiles,
- persistedBinaryFiles
- )
- cb()
- } else if (update.resyncDocContent != null) {
- logger.debug(
- { projectId: this.projectId, update },
- 'expanding resyncDocContent update'
- )
- this.queueTextOpForOutOfSyncContents(update, cb)
- } else {
- this.expandedUpdates.push(update)
- cb()
- }
- }
- getExpandedUpdates() {
- return this.expandedUpdates
- }
- queueRemoveOpsForUnexpectedFiles(update, expectedFiles, persistedFiles) {
- const unexpectedFiles = _.differenceBy(
- persistedFiles,
- expectedFiles,
- 'path'
- )
- for (const entity of unexpectedFiles) {
- update = {
- pathname: entity.path,
- new_pathname: '',
- meta: {
- resync: true,
- origin: this.origin,
- ts: update.meta.ts,
- },
- }
- this.expandedUpdates.push(update)
- Metrics.inc('project_history_resync_operation', 1, {
- status: 'remove unexpected file',
- })
- }
- }
- queueAddOpsForMissingFiles(update, expectedFiles, persistedFiles) {
- const missingFiles = _.differenceBy(expectedFiles, persistedFiles, 'path')
- for (const entity of missingFiles) {
- update = {
- pathname: entity.path,
- meta: {
- resync: true,
- origin: this.origin,
- ts: update.meta.ts,
- },
- }
- if (entity.doc != null) {
- update.doc = entity.doc
- update.docLines = ''
- // we have to create a dummy entry here because later we will need the content in the diff computation
- this.files[update.pathname] = File.fromString('')
- } else {
- update.file = entity.file
- update.url = entity.url
- }
- this.expandedUpdates.push(update)
- Metrics.inc('project_history_resync_operation', 1, {
- status: 'add missing file',
- })
- }
- }
- queueUpdateForOutOfSyncBinaryFiles(update, expectedFiles, persistedFiles) {
- // create a map to lookup persisted files by their path
- const persistedFileMap = new Map(persistedFiles.map(x => [x.path, x]))
- // now search for files with same path but different hash values
- const differentFiles = expectedFiles.filter(expected => {
- // check for a persisted file at the same path
- const expectedPath = expected.path
- const persistedFileAtSamePath = persistedFileMap.get(expectedPath)
- if (!persistedFileAtSamePath) return false
- // check if the persisted file at the same path has a different hash
- const expectedHash = _.get(expected, '_hash')
- const persistedHash = _.get(persistedFileAtSamePath, ['data', 'hash'])
- const hashesPresent = expectedHash && persistedHash
- return hashesPresent && persistedHash !== expectedHash
- })
- for (const entity of differentFiles) {
- // remove the outdated persisted file
- const removeUpdate = {
- pathname: entity.path,
- new_pathname: '',
- meta: {
- resync: true,
- origin: this.origin,
- ts: update.meta.ts,
- },
- }
- this.expandedUpdates.push(removeUpdate)
- // add the new file content
- const addUpdate = {
- pathname: entity.path,
- meta: {
- resync: true,
- origin: this.origin,
- ts: update.meta.ts,
- },
- file: entity.file,
- url: entity.url,
- }
- this.expandedUpdates.push(addUpdate)
- Metrics.inc('project_history_resync_operation', 1, {
- status: 'update binary file contents',
- })
- }
- }
- queueTextOpForOutOfSyncContents(update, cb) {
- const pathname = UpdateTranslator._convertPathname(update.path)
- const snapshotFile = this.files[pathname]
- const expectedFile = update.resyncDocContent
- if (!snapshotFile) {
- return cb(new OError('unrecognised file: not in snapshot'))
- }
- // Compare hashes to see if the persisted file matches the expected content.
- // The hash of the persisted files is stored in the snapshot.
- // Note getHash() returns the hash only when the persisted file has
- // no changes in the snapshot, the hash is null if there are changes
- // that apply to it.
- const persistedHash =
- typeof snapshotFile.getHash === 'function'
- ? snapshotFile.getHash()
- : undefined
- if (persistedHash != null) {
- const expectedHash = HashManager._getBlobHashFromString(
- expectedFile.content
- )
- if (persistedHash === expectedHash) {
- logger.debug(
- { projectId: this.projectId, persistedHash, expectedHash },
- 'skipping diff because hashes match and persisted file has no ops'
- )
- return cb()
- }
- } else {
- logger.debug('cannot compare hashes, will retrieve content')
- }
- const expectedContent = update.resyncDocContent.content
- const computeDiff = (persistedContent, cb) => {
- let op
- logger.debug(
- { projectId: this.projectId, persistedContent, expectedContent },
- 'diffing doc contents'
- )
- try {
- op = UpdateCompressor.diffAsShareJsOps(
- persistedContent,
- expectedContent
- )
- } catch (error) {
- return cb(
- OError.tag(error, 'error from diffAsShareJsOps', {
- projectId: this.projectId,
- persistedContent,
- expectedContent,
- })
- )
- }
- if (op.length === 0) {
- return cb()
- }
- update = {
- doc: update.doc,
- op,
- meta: {
- resync: true,
- origin: this.origin,
- ts: update.meta.ts,
- pathname,
- doc_length: persistedContent.length,
- },
- }
- logger.debug(
- { projectId: this.projectId, diffCount: op.length },
- 'doc contents differ'
- )
- this.expandedUpdates.push(update)
- Metrics.inc('project_history_resync_operation', 1, {
- status: 'update text file contents',
- })
- cb()
- }
- // compute the difference between the expected and persisted content
- if (snapshotFile.load != null) {
- WebApiManager.getHistoryId(this.projectId, (err, historyId) => {
- if (err) {
- return cb(OError.tag(err))
- }
- const loadFile = snapshotFile.load(
- 'eager',
- HistoryStoreManager.getBlobStore(historyId)
- )
- loadFile
- .then(file => computeDiff(file.getContent(), cb))
- .catch(err => cb(err)) // error loading file or computing diff
- })
- } else if (snapshotFile.content != null) {
- // use dummy content from queueAddOpsForMissingFiles for added missing files
- computeDiff(snapshotFile.content, cb)
- } else {
- cb(new OError('unrecognised file'))
- }
- }
- }
- export const promises = {
- startResync: promisify(startResync),
- startHardResync: promisify(startHardResync),
- setResyncState: promisify(setResyncState),
- clearResyncState: promisify(clearResyncState),
- skipUpdatesDuringSync: promisify(skipUpdatesDuringSync),
- expandSyncUpdates: promisify(expandSyncUpdates),
- }
|