SyncManager.js 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641
  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, TextOperation } from 'overleaf-editor-core'
  10. import {
  11. TooLongError,
  12. UnprocessableError,
  13. } from 'overleaf-editor-core/lib/errors.js'
  14. import {
  15. FileContentEmptyError,
  16. NeedFullProjectStructureResyncError,
  17. SYNC_ONGOING_ERROR_MESSAGE,
  18. SyncError,
  19. } from './Errors.js'
  20. import { db, ObjectId } from './mongodb.js'
  21. import * as SnapshotManager from './SnapshotManager.js'
  22. import * as LockManager from './LockManager.js'
  23. import * as UpdateTranslator from './UpdateTranslator.js'
  24. import * as UpdateCompressor from './UpdateCompressor.js'
  25. import * as WebApiManager from './WebApiManager.js'
  26. import * as ErrorRecorder from './ErrorRecorder.js'
  27. import * as RedisManager from './RedisManager.js'
  28. import * as HistoryStoreManager from './HistoryStoreManager.js'
  29. import * as HashManager from './HashManager.js'
  30. import { isInsert, isDelete } from './Utils.js'
  31. /**
  32. * @import { Comment as HistoryComment, TrackedChange as HistoryTrackedChange } from 'overleaf-editor-core'
  33. * @import { CommentRawData, TrackedChangeRawData } from 'overleaf-editor-core/lib/types'
  34. * @import { Comment, Entity, ResyncDocContentUpdate, RetainOp, TrackedChange } from './types'
  35. * @import { TrackedChangeTransition, TrackingDirective, TrackingType, Update } from './types'
  36. * @import { ProjectStructureUpdate } from './types'
  37. */
  38. const MAX_RESYNC_HISTORY_RECORDS = 100 // keep this many records of previous resyncs
  39. const EXPIRE_RESYNC_HISTORY_INTERVAL_MS = 90 * 24 * 3600 * 1000 // 90 days
  40. const SYNC_STUCK_TIMEOUT_MS = 4 * 60 * 60 * 1000 // 4 hours
  41. const MAX_STUCK_CLEAR_ATTEMPTS = 5
  42. const keys = Settings.redis.lock.key_schema
  43. // db.projectHistorySyncState.ensureIndex({expiresAt: 1}, {expireAfterSeconds: 0, background: true})
  44. // To add expiresAt field to existing entries in collection (choose a suitable future expiry date):
  45. // db.projectHistorySyncState.updateMany({resyncProjectStructure: false, resyncDocContents: [], expiresAt: {$exists:false}}, {$set: {expiresAt: new Date("2019-07-01")}})
  46. async function startResync(projectId, options = {}) {
  47. // We have three options here
  48. //
  49. // 1. If we update mongo before making the call to web then there's a
  50. // chance we ignore all updates indefinitely (there's no foolproff way
  51. // to undo the change in mongo)
  52. //
  53. // 2. If we make the call to web first then there is a small race condition
  54. // where we could process the sync update and then only update mongo
  55. // after, causing all updates to be ignored from then on
  56. //
  57. // 3. We can wrap everything in a project lock
  58. Metrics.inc('project_history_resync')
  59. try {
  60. await LockManager.promises.runWithLock(
  61. keys.projectHistoryLock({ project_id: projectId }),
  62. async extendLock => {
  63. await startResyncWithoutLock(projectId, options)
  64. }
  65. )
  66. } catch (error) {
  67. // record error in starting sync ("sync ongoing")
  68. if (error instanceof Error) {
  69. await ErrorRecorder.promises.record(projectId, -1, error)
  70. }
  71. throw error
  72. }
  73. }
  74. async function startHardResync(projectId, options = {}) {
  75. Metrics.inc('project_history_hard_resync')
  76. try {
  77. await LockManager.promises.runWithLock(
  78. keys.projectHistoryLock({ project_id: projectId }),
  79. async extendLock => {
  80. await clearResyncState(projectId)
  81. await RedisManager.promises.clearFirstOpTimestamp(projectId)
  82. await RedisManager.promises.destroyDocUpdatesQueue(projectId)
  83. await startResyncWithoutLock(projectId, { ...options, hard: true })
  84. }
  85. )
  86. } catch (error) {
  87. // record error in starting sync ("sync ongoing")
  88. if (error instanceof Error) {
  89. await ErrorRecorder.promises.record(projectId, -1, error)
  90. }
  91. throw error
  92. }
  93. }
  94. // The caller must hold the lock and should record any errors via the ErrorRecorder.
  95. async function startResyncWithoutLock(projectId, options) {
  96. await ErrorRecorder.promises.recordSyncStart(projectId)
  97. const syncState = await getResyncState(projectId)
  98. if (syncState.isSyncOngoing()) {
  99. if (syncState.isSyncStuck()) {
  100. const stuckDocPaths = Array.from(syncState.resyncDocContents)
  101. const stuckClearCount = syncState.stuckClearCount
  102. if (stuckClearCount >= MAX_STUCK_CLEAR_ATTEMPTS) {
  103. await _recordStuckClearInSyncState(projectId, stuckDocPaths)
  104. Metrics.inc('project_history_sync_stuck_permanent')
  105. // Log error only on first permanent-stuck detection to avoid spam
  106. if (stuckClearCount === MAX_STUCK_CLEAR_ATTEMPTS) {
  107. logger.error(
  108. {
  109. projectId,
  110. stuckClearCount: stuckClearCount + 1,
  111. stuckDocPaths,
  112. resyncPendingSince: syncState.resyncPendingSince,
  113. },
  114. 'sync permanently stuck — exceeded auto-clear limit'
  115. )
  116. }
  117. throw new OError('sync permanently stuck')
  118. }
  119. logger.warn(
  120. {
  121. projectId,
  122. stuckClearCount: stuckClearCount + 1,
  123. stuckDocPaths,
  124. resyncPendingSince: syncState.resyncPendingSince,
  125. },
  126. 'sync stuck, clearing state and restarting'
  127. )
  128. Metrics.inc('project_history_sync_stuck_cleared')
  129. await _recordStuckClearInSyncState(projectId, stuckDocPaths)
  130. } else {
  131. throw new OError(SYNC_ONGOING_ERROR_MESSAGE)
  132. }
  133. }
  134. syncState.setOrigin(options.origin || { kind: 'history-resync' })
  135. syncState.startProjectStructureSync()
  136. syncState.hardResync = options.hard === true
  137. syncState.recoverCorruptedFiles = options.recoverCorruptedFiles === true
  138. const webOpts = {}
  139. if (options.historyRangesMigration) {
  140. webOpts.historyRangesMigration = options.historyRangesMigration
  141. }
  142. if (options.resyncProjectStructureOnly) {
  143. webOpts.resyncProjectStructureOnly = options.resyncProjectStructureOnly
  144. }
  145. await WebApiManager.promises.requestResync(projectId, webOpts)
  146. await setResyncState(projectId, syncState)
  147. }
  148. /**
  149. * @param {string} sourceProjectId
  150. * @param {string} targetProjectId
  151. * @return {Promise<void>}
  152. */
  153. async function cloneResyncState(sourceProjectId, targetProjectId) {
  154. const rawSyncState = await db.projectHistorySyncState.findOne(
  155. { project_id: new ObjectId(sourceProjectId) },
  156. { projection: { _id: 0, project_id: 0 } }
  157. )
  158. if (!rawSyncState) return
  159. await db.projectHistorySyncState.insertOne({
  160. ...rawSyncState,
  161. project_id: new ObjectId(targetProjectId),
  162. })
  163. }
  164. /**
  165. * @param {string} projectId
  166. * @return {Promise<SyncState>}
  167. */
  168. async function getResyncState(projectId) {
  169. const rawSyncState = await db.projectHistorySyncState.findOne({
  170. project_id: new ObjectId(projectId.toString()),
  171. })
  172. const syncState = SyncState.fromRaw(projectId, rawSyncState)
  173. return syncState
  174. }
  175. async function setResyncState(projectId, syncState) {
  176. // skip if syncState is null (i.e. unchanged)
  177. if (syncState == null) {
  178. return
  179. }
  180. const update = {
  181. $set: syncState.toRaw(),
  182. $push: {
  183. history: {
  184. $each: [{ syncState: syncState.toRaw(), timestamp: new Date() }],
  185. $position: 0,
  186. $slice: MAX_RESYNC_HISTORY_RECORDS,
  187. },
  188. },
  189. $currentDate: { lastUpdated: true },
  190. }
  191. // handle different cases
  192. if (syncState.isSyncOngoing()) {
  193. // starting a new sync; prevent the entry expiring while sync is in ongoing
  194. update.$inc = { resyncCount: 1 }
  195. update.$unset = { expiresAt: true }
  196. update.$min = { resyncPendingSince: new Date() }
  197. } else {
  198. // successful completion of existing sync; set the entry to expire in the
  199. // future
  200. update.$set.expiresAt = new Date(
  201. Date.now() + EXPIRE_RESYNC_HISTORY_INTERVAL_MS
  202. )
  203. update.$unset = {
  204. resyncPendingSince: 1,
  205. stuckClearCount: 1,
  206. lastStuckClearAt: 1,
  207. lastStuckDocPaths: 1,
  208. }
  209. }
  210. // apply the update
  211. await db.projectHistorySyncState.updateOne(
  212. { project_id: new ObjectId(projectId) },
  213. update,
  214. { upsert: true }
  215. )
  216. if (!syncState.isSyncOngoing()) {
  217. await db.projects.updateOne(
  218. { _id: new ObjectId(projectId) },
  219. {
  220. $max: {
  221. 'overleaf.history.lastResyncedAt': new Date(),
  222. },
  223. }
  224. )
  225. }
  226. }
  227. async function clearResyncState(projectId) {
  228. await db.projectHistorySyncState.deleteOne({
  229. project_id: new ObjectId(projectId.toString()),
  230. })
  231. }
  232. async function _recordStuckClearInSyncState(projectId, stuckDocPaths) {
  233. await db.projectHistorySyncState.updateOne(
  234. { project_id: new ObjectId(projectId) },
  235. {
  236. $inc: { stuckClearCount: 1 },
  237. $set: { lastStuckClearAt: new Date(), lastStuckDocPaths: stuckDocPaths },
  238. $unset: { resyncPendingSince: 1 },
  239. }
  240. )
  241. }
  242. /**
  243. * @param {string} projectId
  244. * @param {Date} date
  245. * @return {Promise<void>}
  246. */
  247. async function clearResyncStateIfAllAfter(projectId, date) {
  248. const rawSyncState = await db.projectHistorySyncState.findOne({
  249. project_id: new ObjectId(projectId.toString()),
  250. })
  251. if (!rawSyncState) return // already cleared
  252. const state = SyncState.fromRaw(projectId, rawSyncState)
  253. if (state.isSyncOngoing()) return // new sync started
  254. for (const { timestamp } of rawSyncState.history) {
  255. if (timestamp < date) return // preserve old resync states
  256. }
  257. // expiresAt is cleared when starting a sync and bumped when making changes.
  258. // Use expiresAt as read to ensure we only clear the confirmed state.
  259. await db.projectHistorySyncState.deleteOne({
  260. project_id: new ObjectId(projectId.toString()),
  261. expiresAt: rawSyncState.expiresAt,
  262. })
  263. }
  264. async function skipUpdatesDuringSync(projectId, updates) {
  265. const syncState = await getResyncState(projectId)
  266. if (!syncState.isSyncOngoing()) {
  267. logger.debug({ projectId }, 'not skipping updates: no resync in progress')
  268. // don't return syncState when unchanged
  269. return { updates, syncState: null }
  270. }
  271. const filteredUpdates = []
  272. for (const update of updates) {
  273. syncState.updateState(update)
  274. const shouldSkipUpdate = syncState.shouldSkipUpdate(update)
  275. if (!shouldSkipUpdate) {
  276. filteredUpdates.push(update)
  277. } else {
  278. Metrics.inc('project_history_sync_update_skipped')
  279. logger.debug({ projectId, update }, 'skipping update due to resync')
  280. }
  281. }
  282. return { updates: filteredUpdates, syncState }
  283. }
  284. /**
  285. * @param {string} projectId
  286. * @param {string} projectHistoryId
  287. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} mostRecentChunk
  288. * @param {Array<Update>} updates
  289. * @param {() => Promise<void>} extendLock
  290. * @return {Promise<Array<Update>>}
  291. */
  292. async function expandSyncUpdates(
  293. projectId,
  294. projectHistoryId,
  295. mostRecentChunk,
  296. updates,
  297. extendLock
  298. ) {
  299. const areSyncUpdatesQueued =
  300. _.some(updates, 'resyncProjectStructure') ||
  301. _.some(updates, 'resyncDocContent')
  302. if (!areSyncUpdatesQueued) {
  303. logger.debug({ projectId }, 'no resync updates to expand')
  304. return updates
  305. }
  306. const syncState = await getResyncState(projectId)
  307. // compute the current snapshot from the most recent chunk
  308. const snapshotFiles =
  309. await SnapshotManager.promises.getLatestSnapshotFilesForChunk(
  310. projectHistoryId,
  311. mostRecentChunk
  312. )
  313. // check if snapshot files are valid
  314. const invalidFiles = _.pickBy(
  315. snapshotFiles,
  316. (v, k) => v == null || typeof v.isEditable !== 'function'
  317. )
  318. if (_.size(invalidFiles) > 0) {
  319. throw new SyncError('file is missing isEditable method', {
  320. projectId,
  321. invalidFiles,
  322. })
  323. }
  324. const expander = new SyncUpdateExpander(
  325. projectId,
  326. snapshotFiles,
  327. syncState.origin,
  328. syncState.hardResync,
  329. syncState.recoverCorruptedFiles
  330. )
  331. // expand updates asynchronously to avoid blocking
  332. for (const update of updates) {
  333. await expander.expandUpdate(update)
  334. await extendLock()
  335. }
  336. return expander.getExpandedUpdates()
  337. }
  338. class SyncState {
  339. constructor(
  340. projectId,
  341. resyncProjectStructure,
  342. resyncDocContents,
  343. origin,
  344. resyncCount,
  345. resyncPendingSince,
  346. lastUpdated,
  347. history,
  348. stuckClearCount,
  349. lastStuckClearAt,
  350. lastStuckDocPaths,
  351. hardResync,
  352. recoverCorruptedFiles
  353. ) {
  354. this.projectId = projectId
  355. this.resyncProjectStructure = resyncProjectStructure
  356. this.resyncDocContents = resyncDocContents
  357. this.origin = origin
  358. this.resyncCount = resyncCount
  359. this.resyncPendingSince = resyncPendingSince
  360. this.lastUpdated = lastUpdated
  361. this.history = history
  362. this.stuckClearCount = stuckClearCount
  363. this.lastStuckClearAt = lastStuckClearAt
  364. this.lastStuckDocPaths = lastStuckDocPaths
  365. this.hardResync = hardResync || false
  366. this.recoverCorruptedFiles = recoverCorruptedFiles || false
  367. }
  368. static fromRaw(projectId, rawSyncState) {
  369. rawSyncState = rawSyncState || {}
  370. const resyncProjectStructure = rawSyncState.resyncProjectStructure || false
  371. const resyncDocContents = new Set(rawSyncState.resyncDocContents || [])
  372. const origin = rawSyncState.origin
  373. const resyncCount = rawSyncState.resyncCount || 0
  374. let resyncPendingSince = rawSyncState.resyncPendingSince
  375. const history = rawSyncState.history || []
  376. if (
  377. (resyncProjectStructure || resyncDocContents.size > 0) &&
  378. !resyncPendingSince &&
  379. history.length > 0
  380. ) {
  381. // The resyncPendingSince field was added later.
  382. // Back-fill it as the next ts after a successful sync. History is DESC.
  383. for (const other of history.slice().reverse()) {
  384. const isSyncOngoing =
  385. other.syncState.resyncProjectStructure ||
  386. other.syncState.resyncDocContents.length > 0
  387. if (isSyncOngoing) {
  388. resyncPendingSince = resyncPendingSince || other.timestamp
  389. } else {
  390. resyncPendingSince = undefined
  391. }
  392. }
  393. }
  394. const lastUpdated = rawSyncState.lastUpdated
  395. const stuckClearCount = rawSyncState.stuckClearCount ?? 0
  396. const lastStuckClearAt = rawSyncState.lastStuckClearAt
  397. const lastStuckDocPaths = rawSyncState.lastStuckDocPaths
  398. const hardResync = rawSyncState.hardResync || false
  399. const recoverCorruptedFiles = rawSyncState.recoverCorruptedFiles || false
  400. return new SyncState(
  401. projectId,
  402. resyncProjectStructure,
  403. resyncDocContents,
  404. origin,
  405. resyncCount,
  406. resyncPendingSince,
  407. lastUpdated,
  408. history,
  409. stuckClearCount,
  410. lastStuckClearAt,
  411. lastStuckDocPaths,
  412. hardResync,
  413. recoverCorruptedFiles
  414. )
  415. }
  416. toRaw() {
  417. return {
  418. resyncProjectStructure: this.resyncProjectStructure,
  419. resyncDocContents: Array.from(this.resyncDocContents),
  420. origin: this.origin,
  421. hardResync: this.hardResync,
  422. recoverCorruptedFiles: this.recoverCorruptedFiles,
  423. }
  424. }
  425. updateState(update) {
  426. if (update.resyncProjectStructure != null) {
  427. if (!this.isProjectStructureSyncing()) {
  428. throw new SyncError('unexpected resyncProjectStructure update', {
  429. projectId: this.projectId,
  430. resyncProjectStructure: this.resyncProjectStructure,
  431. })
  432. }
  433. if (this.isAnyDocContentSyncing()) {
  434. throw new SyncError('unexpected resyncDocContents update', {
  435. projectId: this.projectId,
  436. resyncDocContents: this.resyncDocContents,
  437. })
  438. }
  439. if (!update.resyncProjectStructureOnly) {
  440. for (const doc of update.resyncProjectStructure.docs) {
  441. this.startDocContentSync(doc.path)
  442. }
  443. }
  444. this.stopProjectStructureSync()
  445. } else if (update.resyncDocContent != null) {
  446. if (this.isProjectStructureSyncing()) {
  447. throw new SyncError('unexpected resyncDocContent update', {
  448. projectId: this.projectId,
  449. resyncProjectStructure: this.resyncProjectStructure,
  450. })
  451. }
  452. if (!this.isDocContentSyncing(update.path)) {
  453. throw new SyncError('unexpected resyncDocContent update', {
  454. projectId: this.projectId,
  455. resyncDocContents: this.resyncDocContents,
  456. path: update.path,
  457. })
  458. }
  459. this.stopDocContentSync(update.path)
  460. }
  461. }
  462. setOrigin(origin) {
  463. this.origin = origin
  464. }
  465. shouldSkipUpdate(update) {
  466. // don't skip sync updates
  467. if (
  468. update.resyncProjectStructure != null ||
  469. update.resyncDocContent != null
  470. ) {
  471. return false
  472. }
  473. // if syncing project structure skip update
  474. if (this.isProjectStructureSyncing()) {
  475. return true
  476. }
  477. // skip text updates for a docs being synced
  478. if (UpdateTranslator.isTextUpdate(update)) {
  479. if (this.isDocContentSyncing(update.meta.pathname)) {
  480. return true
  481. }
  482. }
  483. // preserve all other updates
  484. return false
  485. }
  486. startProjectStructureSync() {
  487. this.resyncProjectStructure = true
  488. this.resyncDocContents = new Set([])
  489. }
  490. stopProjectStructureSync() {
  491. this.resyncProjectStructure = false
  492. }
  493. stopDocContentSync(pathname) {
  494. this.resyncDocContents.delete(pathname)
  495. }
  496. startDocContentSync(pathname) {
  497. this.resyncDocContents.add(pathname)
  498. }
  499. isProjectStructureSyncing() {
  500. return this.resyncProjectStructure
  501. }
  502. isDocContentSyncing(pathname) {
  503. return this.resyncDocContents.has(pathname)
  504. }
  505. isAnyDocContentSyncing() {
  506. return this.resyncDocContents.size > 0
  507. }
  508. isSyncOngoing() {
  509. return this.isProjectStructureSyncing() || this.isAnyDocContentSyncing()
  510. }
  511. isSyncStuck() {
  512. if (!this.isSyncOngoing()) {
  513. return false
  514. }
  515. if (!this.resyncPendingSince) {
  516. // No timestamp recorded — treat long-running syncs without a timestamp
  517. // as potentially stuck (legacy state from before this field was added)
  518. return true
  519. }
  520. return (
  521. Date.now() - this.resyncPendingSince.getTime() > SYNC_STUCK_TIMEOUT_MS
  522. )
  523. }
  524. }
  525. class SyncUpdateExpander {
  526. /**
  527. * Build a SyncUpdateExpander
  528. *
  529. * @param {string} projectId
  530. * @param {Record<string, File>} snapshotFiles
  531. * @param {import('overleaf-editor-core/lib/types.js').RawOrigin} origin
  532. * @param {boolean} hardResync
  533. * @param {boolean} recoverCorruptedFiles
  534. */
  535. constructor(
  536. projectId,
  537. snapshotFiles,
  538. origin,
  539. hardResync,
  540. recoverCorruptedFiles
  541. ) {
  542. this.projectId = projectId
  543. this.files = snapshotFiles
  544. this.expandedUpdates = /** @type ProjectStructureUpdate[] */ []
  545. this.origin = origin
  546. this.hardResync = hardResync || false
  547. this.recoverCorruptedFiles = recoverCorruptedFiles || false
  548. }
  549. // If there's an expected *file* with the same path and either the same hash
  550. // or no hash, treat this as not editable even if history thinks it is.
  551. isEditable(filePath, file, expectedFiles) {
  552. if (!file.isEditable()) {
  553. return false
  554. }
  555. const fileHash = _.get(file, ['data', 'hash'])
  556. const matchedExpectedFile = expectedFiles.some(item => {
  557. const expectedFileHash = item._hash
  558. if (expectedFileHash && fileHash !== expectedFileHash) {
  559. // expected file has a hash and it doesn't match
  560. return false
  561. }
  562. return UpdateTranslator._convertPathname(item.path) === filePath
  563. })
  564. // consider editable file in history as binary, since it matches a binary file in the project
  565. return !matchedExpectedFile
  566. }
  567. /**
  568. * @param {Update} update
  569. */
  570. async expandUpdate(update) {
  571. if ('resyncProjectStructure' in update) {
  572. logger.debug(
  573. { projectId: this.projectId, update },
  574. 'expanding resyncProjectStructure update'
  575. )
  576. const persistedNonBinaryFileEntries = _.pickBy(this.files, (v, k) =>
  577. this.isEditable(k, v, update.resyncProjectStructure.files)
  578. )
  579. const persistedNonBinaryFiles = _.map(
  580. Object.keys(persistedNonBinaryFileEntries),
  581. path => ({
  582. path,
  583. })
  584. )
  585. const persistedBinaryFileEntries = _.omitBy(this.files, (v, k) =>
  586. this.isEditable(k, v, update.resyncProjectStructure.files)
  587. )
  588. // preserve file properties on binary files, for future comparison.
  589. const persistedBinaryFiles = _.map(
  590. persistedBinaryFileEntries,
  591. (entity, key) => Object.assign({}, entity, { path: key })
  592. )
  593. const expectedNonBinaryFiles = _.map(
  594. update.resyncProjectStructure.docs,
  595. entity =>
  596. Object.assign({}, entity, {
  597. path: UpdateTranslator._convertPathname(entity.path),
  598. })
  599. )
  600. const expectedBinaryFiles = _.map(
  601. update.resyncProjectStructure.files,
  602. entity =>
  603. Object.assign({}, entity, {
  604. path: UpdateTranslator._convertPathname(entity.path),
  605. })
  606. )
  607. // We need to detect and fix consistency issues where web and
  608. // history-store disagree on whether an entity is binary or not. Thus we
  609. // need to remove and add the two separately.
  610. this.queueRemoveOpsForUnexpectedFiles(
  611. update,
  612. expectedBinaryFiles,
  613. persistedBinaryFiles
  614. )
  615. this.queueRemoveOpsForUnexpectedFiles(
  616. update,
  617. expectedNonBinaryFiles,
  618. persistedNonBinaryFiles
  619. )
  620. this.queueAddOpsForMissingFiles(
  621. update,
  622. expectedBinaryFiles,
  623. persistedBinaryFiles
  624. )
  625. this.queueAddOpsForMissingFiles(
  626. update,
  627. expectedNonBinaryFiles,
  628. persistedNonBinaryFiles
  629. )
  630. this.queueUpdateForOutOfSyncBinaryFiles(
  631. update,
  632. expectedBinaryFiles,
  633. persistedBinaryFiles
  634. )
  635. this.queueSetMetadataOpsForLinkedFiles(update)
  636. if (update.resyncProjectStructureOnly) {
  637. const docPaths = new Set()
  638. for (const entity of update.resyncProjectStructure.docs) {
  639. const path = UpdateTranslator._convertPathname(entity.path)
  640. docPaths.add(path)
  641. }
  642. for (const expandedUpdate of this.expandedUpdates) {
  643. if (docPaths.has(expandedUpdate.pathname)) {
  644. // Clear the resync state and queue entry, we need to start over.
  645. this.expandedUpdates = []
  646. await clearResyncState(this.projectId)
  647. await RedisManager.promises.deleteAppliedDocUpdate(
  648. this.projectId,
  649. update
  650. )
  651. throw new NeedFullProjectStructureResyncError(
  652. 'aborting partial resync: touched doc'
  653. )
  654. }
  655. }
  656. }
  657. } else if ('resyncDocContent' in update) {
  658. logger.debug(
  659. { projectId: this.projectId, update },
  660. 'expanding resyncDocContent update'
  661. )
  662. await this.expandResyncDocContentUpdate(update)
  663. } else {
  664. this.expandedUpdates.push(update)
  665. }
  666. }
  667. getExpandedUpdates() {
  668. return this.expandedUpdates
  669. }
  670. /**
  671. * @param {Entity[]} expectedFiles
  672. * @param {{ path: string }[]} persistedFiles
  673. */
  674. queueRemoveOpsForUnexpectedFiles(update, expectedFiles, persistedFiles) {
  675. const unexpectedFiles = _.differenceBy(
  676. persistedFiles,
  677. expectedFiles,
  678. 'path'
  679. )
  680. for (const entity of unexpectedFiles) {
  681. update = {
  682. pathname: entity.path,
  683. new_pathname: '',
  684. meta: {
  685. resync: true,
  686. origin: this.origin,
  687. ts: update.meta.ts,
  688. },
  689. }
  690. this.expandedUpdates.push(update)
  691. Metrics.inc('project_history_resync_operation', 1, {
  692. status: 'remove unexpected file',
  693. })
  694. }
  695. }
  696. /**
  697. * @param {Entity[]} expectedFiles
  698. * @param {{ path: string }[]} persistedFiles
  699. */
  700. queueAddOpsForMissingFiles(update, expectedFiles, persistedFiles) {
  701. const missingFiles = _.differenceBy(expectedFiles, persistedFiles, 'path')
  702. for (const entity of missingFiles) {
  703. update = {
  704. pathname: entity.path,
  705. meta: {
  706. resync: true,
  707. origin: this.origin,
  708. ts: update.meta.ts,
  709. },
  710. }
  711. if ('doc' in entity) {
  712. update.doc = entity.doc
  713. update.docLines = ''
  714. // we have to create a dummy entry here because later we will need the content in the diff computation
  715. this.files[update.pathname] = File.fromString('')
  716. } else {
  717. update.file = entity.file
  718. if (entity.url) update.url = entity.url
  719. if (entity._hash) update.hash = entity._hash
  720. if (entity.createdBlob) update.createdBlob = entity.createdBlob
  721. if (entity.metadata) update.metadata = entity.metadata
  722. }
  723. this.expandedUpdates.push(update)
  724. Metrics.inc('project_history_resync_operation', 1, {
  725. status: 'add missing file',
  726. })
  727. }
  728. }
  729. queueSetMetadataOpsForLinkedFiles(update) {
  730. const allEntities = update.resyncProjectStructure.docs.concat(
  731. update.resyncProjectStructure.files
  732. )
  733. for (const file of allEntities) {
  734. const pathname = UpdateTranslator._convertPathname(file.path)
  735. const matchingAddFileOperation = this.expandedUpdates.some(
  736. // Look for an addFile operation that already syncs the metadata.
  737. u => u.pathname === pathname && u.metadata === file.metadata
  738. )
  739. if (matchingAddFileOperation) continue
  740. const metaData = this.files[pathname].getMetadata()
  741. let shouldUpdate = false
  742. if (file.metadata) {
  743. // check for in place update of linked-file
  744. shouldUpdate = Object.entries(file.metadata).some(
  745. ([k, v]) => metaData[k] !== v
  746. )
  747. } else if (metaData.provider) {
  748. // overwritten by non-linked-file with same hash
  749. // or overwritten by doc
  750. shouldUpdate = true
  751. }
  752. if (!shouldUpdate) continue
  753. this.expandedUpdates.push({
  754. pathname,
  755. meta: {
  756. resync: true,
  757. origin: this.origin,
  758. ts: update.meta.ts,
  759. },
  760. metadata: file.metadata || {},
  761. })
  762. Metrics.inc('project_history_resync_operation', 1, {
  763. status: 'update metadata',
  764. })
  765. }
  766. }
  767. queueUpdateForOutOfSyncBinaryFiles(update, expectedFiles, persistedFiles) {
  768. // create a map to lookup persisted files by their path
  769. const persistedFileMap = new Map(persistedFiles.map(x => [x.path, x]))
  770. // now search for files with same path but different hash values
  771. const differentFiles = expectedFiles.filter(expected => {
  772. // check for a persisted file at the same path
  773. const expectedPath = expected.path
  774. const persistedFileAtSamePath = persistedFileMap.get(expectedPath)
  775. if (!persistedFileAtSamePath) return false
  776. // check if the persisted file at the same path has a different hash
  777. const expectedHash = _.get(expected, '_hash')
  778. const persistedHash = _.get(persistedFileAtSamePath, ['data', 'hash'])
  779. const hashesPresent = expectedHash && persistedHash
  780. return hashesPresent && persistedHash !== expectedHash
  781. })
  782. for (const entity of differentFiles) {
  783. // remove the outdated persisted file
  784. const removeUpdate = {
  785. pathname: entity.path,
  786. new_pathname: '',
  787. meta: {
  788. resync: true,
  789. origin: this.origin,
  790. ts: update.meta.ts,
  791. },
  792. }
  793. this.expandedUpdates.push(removeUpdate)
  794. // add the new file content
  795. const addUpdate = {
  796. pathname: entity.path,
  797. meta: {
  798. resync: true,
  799. origin: this.origin,
  800. ts: update.meta.ts,
  801. },
  802. file: entity.file,
  803. }
  804. if (entity.url) addUpdate.url = entity.url
  805. if (entity._hash) addUpdate.hash = entity._hash
  806. if (entity.createdBlob) addUpdate.createdBlob = entity.createdBlob
  807. if (entity.metadata) addUpdate.metadata = entity.metadata
  808. this.expandedUpdates.push(addUpdate)
  809. Metrics.inc('project_history_resync_operation', 1, {
  810. status: 'update binary file contents',
  811. })
  812. }
  813. }
  814. /**
  815. * Expand a resyncDocContentUpdate
  816. *
  817. * @param {ResyncDocContentUpdate} update
  818. */
  819. async expandResyncDocContentUpdate(update) {
  820. const pathname = UpdateTranslator._convertPathname(update.path)
  821. const snapshotFile = this.files[pathname]
  822. const expectedFile = update.resyncDocContent
  823. const expectedContent = expectedFile.content
  824. if (!snapshotFile) {
  825. throw new OError('unrecognised file: not in snapshot')
  826. }
  827. // Compare hashes to see if the persisted file matches the expected content.
  828. // The hash of the persisted files is stored in the snapshot.
  829. // Note getHash() returns the hash only when the persisted file has
  830. // no changes in the snapshot, the hash is null if there are changes
  831. // that apply to it.
  832. let hashesMatch = false
  833. const persistedHash = snapshotFile.getHash()
  834. if (persistedHash != null) {
  835. const expectedHash = HashManager._getBlobHashFromString(expectedContent)
  836. if (persistedHash === expectedHash) {
  837. logger.debug(
  838. { projectId: this.projectId, persistedHash, expectedHash },
  839. 'skipping diff because hashes match and persisted file has no ops'
  840. )
  841. hashesMatch = true
  842. }
  843. } else {
  844. logger.debug('cannot compare hashes, will retrieve content')
  845. }
  846. // compute the difference between the expected and persisted content
  847. const historyId = await WebApiManager.promises.getHistoryId(this.projectId)
  848. let file
  849. try {
  850. file = await snapshotFile.load(
  851. 'eager',
  852. HistoryStoreManager.getBlobStore(historyId)
  853. )
  854. const persistedContent = file.getContent()
  855. if (persistedContent == null) {
  856. throw new FileContentEmptyError('File was not properly loaded')
  857. }
  858. } catch (err) {
  859. // When the recoverCorruptedFiles flag is set (requires hard resync),
  860. // recover from known data corruption errors by removing the file and
  861. // re-adding it from docstore. For soft resyncs, hard resyncs without
  862. // the flag, or transient errors, re-throw so the operation can be retried.
  863. // Also bail out if the expected content exceeds the max string length,
  864. // as the re-add would fail when applying text operations. This check
  865. // must happen here (not in isDataCorruptionError) because the original
  866. // error may be a different corruption type — excluding TooLongError
  867. // from detection would cause the file to be removed but not re-added.
  868. if (!this.recoverCorruptedFiles || !isDataCorruptionError(err)) {
  869. logger.error({
  870. name: 'failed to load file from history during resync',
  871. projectId: this.projectId,
  872. pathname,
  873. err,
  874. })
  875. throw err
  876. }
  877. if (expectedContent.length > TextOperation.MAX_STRING_LENGTH) {
  878. throw new TooLongError(null, expectedContent.length)
  879. .withInfo({
  880. projectId: this.projectId,
  881. pathname,
  882. maxLength: TextOperation.MAX_STRING_LENGTH,
  883. })
  884. .withCause(err)
  885. }
  886. logger.warn(
  887. { projectId: this.projectId, pathname, err },
  888. 'failed to load file from history during hard resync, removing and re-adding from docstore'
  889. )
  890. Metrics.inc('project_history_resync_operation', 1, {
  891. status: 'recover corrupted file',
  892. })
  893. this.expandedUpdates.push({
  894. pathname,
  895. new_pathname: '',
  896. meta: {
  897. resync: true,
  898. origin: this.origin,
  899. ts: update.meta.ts,
  900. },
  901. })
  902. this.expandedUpdates.push({
  903. pathname,
  904. doc: update.doc,
  905. docLines: expectedContent,
  906. meta: {
  907. resync: true,
  908. origin: this.origin,
  909. ts: update.meta.ts,
  910. },
  911. })
  912. // Replace in-memory file so the range sync below diffs the empty
  913. // persisted state against docstore ranges and restores them.
  914. this.files[pathname] = File.fromString(expectedContent)
  915. file = this.files[pathname]
  916. }
  917. const persistedContent = /** @type {string} */ (file.getContent())
  918. if (!hashesMatch) {
  919. const expandedUpdate = await this.queueUpdateForOutOfSyncContent(
  920. update,
  921. pathname,
  922. persistedContent,
  923. expectedContent
  924. )
  925. if (expandedUpdate != null) {
  926. // Adjust the ranges for the changes that have been made to the content
  927. for (const op of expandedUpdate.op) {
  928. if (isInsert(op)) {
  929. file.getComments().applyInsert(new Range(op.p, op.i.length))
  930. file.getTrackedChanges().applyInsert(op.p, op.i)
  931. } else if (isDelete(op)) {
  932. file.getComments().applyDelete(new Range(op.p, op.d.length))
  933. file.getTrackedChanges().applyDelete(op.p, op.d.length)
  934. }
  935. }
  936. }
  937. }
  938. const persistedComments = file.getComments().toArray()
  939. if (update.resyncDocContent.historyOTRanges) {
  940. this.queueUpdatesForOutOfSyncCommentsHistoryOT(
  941. update,
  942. pathname,
  943. file.getComments().toRaw()
  944. )
  945. } else {
  946. await this.queueUpdatesForOutOfSyncComments(
  947. update,
  948. pathname,
  949. persistedComments
  950. )
  951. }
  952. const persistedChanges = file.getTrackedChanges().asSorted()
  953. await this.queueUpdatesForOutOfSyncTrackedChanges(
  954. update,
  955. pathname,
  956. persistedChanges
  957. )
  958. }
  959. /**
  960. * Queue update for out of sync content
  961. *
  962. * @param {ResyncDocContentUpdate} update
  963. * @param {string} pathname
  964. * @param {string} persistedContent
  965. * @param {string} expectedContent
  966. */
  967. async queueUpdateForOutOfSyncContent(
  968. update,
  969. pathname,
  970. persistedContent,
  971. expectedContent
  972. ) {
  973. logger.debug(
  974. { projectId: this.projectId, persistedContent, expectedContent },
  975. 'diffing doc contents'
  976. )
  977. const op = UpdateCompressor.diffAsShareJsOps(
  978. persistedContent,
  979. expectedContent
  980. )
  981. if (op.length === 0) {
  982. return null
  983. }
  984. const expandedUpdate = {
  985. doc: update.doc,
  986. op,
  987. meta: {
  988. resync: true,
  989. origin: this.origin,
  990. ts: update.meta.ts,
  991. pathname,
  992. doc_length: persistedContent.length,
  993. },
  994. }
  995. logger.debug(
  996. { projectId: this.projectId, diffCount: op.length },
  997. 'doc contents differ'
  998. )
  999. this.expandedUpdates.push(expandedUpdate)
  1000. Metrics.inc('project_history_resync_operation', 1, {
  1001. status: 'update text file contents',
  1002. })
  1003. return expandedUpdate
  1004. }
  1005. /**
  1006. * Queue updates for out of sync comments
  1007. *
  1008. * @param {ResyncDocContentUpdate} update
  1009. * @param {string} pathname
  1010. * @param {CommentRawData[]} persistedComments
  1011. */
  1012. queueUpdatesForOutOfSyncCommentsHistoryOT(
  1013. update,
  1014. pathname,
  1015. persistedComments
  1016. ) {
  1017. const expectedComments =
  1018. update.resyncDocContent.historyOTRanges?.comments ?? []
  1019. const expectedCommentsById = new Map(
  1020. expectedComments.map(comment => [comment.id, comment])
  1021. )
  1022. const persistedCommentsById = new Map(
  1023. persistedComments.map(comment => [comment.id, comment])
  1024. )
  1025. // Delete any persisted comment that is not in the expected comment list.
  1026. for (const persistedComment of persistedComments) {
  1027. if (!expectedCommentsById.has(persistedComment.id)) {
  1028. this.expandedUpdates.push({
  1029. doc: update.doc,
  1030. op: [{ deleteComment: persistedComment.id }],
  1031. meta: {
  1032. pathname,
  1033. resync: true,
  1034. origin: this.origin,
  1035. ts: update.meta.ts,
  1036. },
  1037. })
  1038. }
  1039. }
  1040. for (const expectedComment of expectedComments) {
  1041. const persistedComment = persistedCommentsById.get(expectedComment.id)
  1042. if (
  1043. persistedComment &&
  1044. commentRangesAreInSyncHistoryOT(persistedComment, expectedComment)
  1045. ) {
  1046. if (expectedComment.resolved === persistedComment.resolved) {
  1047. // Both comments are identical; do nothing
  1048. } else {
  1049. // Only the resolved state differs
  1050. this.expandedUpdates.push({
  1051. doc: update.doc,
  1052. op: [
  1053. {
  1054. commentId: expectedComment.id,
  1055. resolved: expectedComment.resolved,
  1056. },
  1057. ],
  1058. meta: {
  1059. pathname,
  1060. resync: true,
  1061. origin: this.origin,
  1062. ts: update.meta.ts,
  1063. },
  1064. })
  1065. }
  1066. } else {
  1067. // New comment or ranges differ
  1068. this.expandedUpdates.push({
  1069. doc: update.doc,
  1070. op: [
  1071. {
  1072. commentId: expectedComment.id,
  1073. ranges: expectedComment.ranges,
  1074. resolved: expectedComment.resolved,
  1075. },
  1076. ],
  1077. meta: {
  1078. pathname,
  1079. resync: true,
  1080. origin: this.origin,
  1081. ts: update.meta.ts,
  1082. },
  1083. })
  1084. }
  1085. }
  1086. }
  1087. /**
  1088. * Queue updates for out of sync comments
  1089. *
  1090. * @param {ResyncDocContentUpdate} update
  1091. * @param {string} pathname
  1092. * @param {HistoryComment[]} persistedComments
  1093. */
  1094. async queueUpdatesForOutOfSyncComments(update, pathname, persistedComments) {
  1095. const expectedContent = update.resyncDocContent.content
  1096. const expectedComments = update.resyncDocContent.ranges?.comments ?? []
  1097. const resolvedCommentIds = new Set(
  1098. update.resyncDocContent.resolvedCommentIds ?? []
  1099. )
  1100. const expectedCommentsById = new Map(
  1101. expectedComments.map(comment => [comment.id, comment])
  1102. )
  1103. const persistedCommentsById = new Map(
  1104. persistedComments.map(comment => [comment.id, comment])
  1105. )
  1106. // Delete any persisted comment that is not in the expected comment list.
  1107. for (const persistedComment of persistedComments) {
  1108. if (!expectedCommentsById.has(persistedComment.id)) {
  1109. this.expandedUpdates.push({
  1110. pathname,
  1111. deleteComment: persistedComment.id,
  1112. meta: {
  1113. resync: true,
  1114. origin: this.origin,
  1115. ts: update.meta.ts,
  1116. },
  1117. })
  1118. }
  1119. }
  1120. for (const expectedComment of expectedComments) {
  1121. const persistedComment = persistedCommentsById.get(expectedComment.id)
  1122. const expectedCommentResolved = resolvedCommentIds.has(expectedComment.id)
  1123. if (
  1124. persistedComment != null &&
  1125. commentRangesAreInSync(persistedComment, expectedComment)
  1126. ) {
  1127. if (expectedCommentResolved === persistedComment.resolved) {
  1128. // Both comments are identical; do nothing
  1129. } else {
  1130. // Only the resolved state differs
  1131. this.expandedUpdates.push({
  1132. pathname,
  1133. commentId: expectedComment.id,
  1134. resolved: expectedCommentResolved,
  1135. meta: {
  1136. resync: true,
  1137. origin: this.origin,
  1138. ts: update.meta.ts,
  1139. },
  1140. })
  1141. }
  1142. } else {
  1143. const op = { ...expectedComment.op, resolved: expectedCommentResolved }
  1144. // New comment or ranges differ
  1145. this.expandedUpdates.push({
  1146. doc: update.doc,
  1147. op: [op],
  1148. meta: {
  1149. resync: true,
  1150. origin: this.origin,
  1151. ts: update.meta.ts,
  1152. pathname,
  1153. doc_length: expectedContent.length,
  1154. },
  1155. })
  1156. }
  1157. }
  1158. }
  1159. /**
  1160. * Queue updates for out of sync tracked changes
  1161. *
  1162. * @param {ResyncDocContentUpdate} update
  1163. * @param {string} pathname
  1164. * @param {readonly HistoryTrackedChange[]} persistedChanges
  1165. */
  1166. async queueUpdatesForOutOfSyncTrackedChanges(
  1167. update,
  1168. pathname,
  1169. persistedChanges
  1170. ) {
  1171. const expectedChanges = update.resyncDocContent.ranges?.changes ?? []
  1172. const expectedContent = update.resyncDocContent.content
  1173. /**
  1174. * A cursor on the expected content
  1175. */
  1176. let cursor = 0
  1177. /**
  1178. * The persisted tracking at cursor
  1179. *
  1180. * @type {TrackingDirective}
  1181. */
  1182. let persistedTracking = { type: 'none' }
  1183. /**
  1184. * The expected tracking at cursor
  1185. *
  1186. * @type {TrackingDirective}
  1187. */
  1188. let expectedTracking = { type: 'none' }
  1189. /**
  1190. * The retain ops for the update
  1191. *
  1192. * @type {RetainOp[]}
  1193. */
  1194. const ops = []
  1195. /**
  1196. * The retain op being built
  1197. *
  1198. * @type {RetainOp | null}
  1199. */
  1200. let currentOp = null
  1201. for (const transition of getTrackedChangesTransitions(
  1202. persistedChanges,
  1203. expectedChanges,
  1204. update.resyncDocContent.historyOTRanges?.trackedChanges || [],
  1205. expectedContent.length
  1206. )) {
  1207. if (transition.pos > cursor) {
  1208. // The next transition will move the cursor. Decide what to do with the interval.
  1209. if (trackingDirectivesEqual(expectedTracking, persistedTracking)) {
  1210. // Expected tracking and persisted tracking are in sync. Emit the
  1211. // current op and skip this interval.
  1212. if (currentOp != null) {
  1213. ops.push(currentOp)
  1214. currentOp = null
  1215. }
  1216. } else {
  1217. // Expected tracking and persisted tracking are different.
  1218. const retainedText = expectedContent.slice(cursor, transition.pos)
  1219. if (
  1220. currentOp?.tracking != null &&
  1221. trackingDirectivesEqual(expectedTracking, currentOp.tracking)
  1222. ) {
  1223. // The current op has the right tracking. Extend it.
  1224. currentOp.r += retainedText
  1225. } else {
  1226. // The current op doesn't have the right tracking. Emit the current
  1227. // op and start a new one.
  1228. if (currentOp != null) {
  1229. ops.push(currentOp)
  1230. }
  1231. currentOp = {
  1232. r: retainedText,
  1233. p: cursor,
  1234. tracking: expectedTracking,
  1235. }
  1236. }
  1237. }
  1238. // Advance cursor
  1239. cursor = transition.pos
  1240. }
  1241. // Update the expected and persisted tracking
  1242. if (transition.stage === 'persisted') {
  1243. persistedTracking = transition.tracking
  1244. } else {
  1245. expectedTracking = transition.tracking
  1246. }
  1247. }
  1248. // Emit the last op
  1249. if (currentOp != null) {
  1250. ops.push(currentOp)
  1251. }
  1252. if (ops.length > 0) {
  1253. this.expandedUpdates.push({
  1254. doc: update.doc,
  1255. op: ops,
  1256. meta: {
  1257. resync: true,
  1258. origin: this.origin,
  1259. ts: update.meta.ts,
  1260. pathname,
  1261. doc_length: expectedContent.length,
  1262. },
  1263. })
  1264. }
  1265. }
  1266. }
  1267. /**
  1268. * Compares the ranges in the persisted and expected comments
  1269. *
  1270. * @param {CommentRawData} persistedComment
  1271. * @param {CommentRawData} expectedComment
  1272. */
  1273. function commentRangesAreInSyncHistoryOT(persistedComment, expectedComment) {
  1274. if (persistedComment.ranges.length !== expectedComment.ranges.length) {
  1275. return false
  1276. }
  1277. for (let i = 0; i < persistedComment.ranges.length; i++) {
  1278. const persistedRange = persistedComment.ranges[i]
  1279. const expectedRange = expectedComment.ranges[i]
  1280. if (persistedRange.pos !== expectedRange.pos) return false
  1281. if (persistedRange.length !== expectedRange.length) return false
  1282. }
  1283. return true
  1284. }
  1285. /**
  1286. * Compares the ranges in the persisted and expected comments
  1287. *
  1288. * @param {HistoryComment} persistedComment
  1289. * @param {Comment} expectedComment
  1290. */
  1291. function commentRangesAreInSync(persistedComment, expectedComment) {
  1292. const expectedPos = expectedComment.op.hpos ?? expectedComment.op.p
  1293. const expectedLength = expectedComment.op.hlen ?? expectedComment.op.c.length
  1294. if (expectedLength === 0) {
  1295. // A zero length comment from RangesManager is a detached comment in history
  1296. return persistedComment.ranges.length === 0
  1297. }
  1298. if (persistedComment.ranges.length !== 1) {
  1299. // The editor only supports single range comments
  1300. return false
  1301. }
  1302. const persistedRange = persistedComment.ranges[0]
  1303. return (
  1304. persistedRange.pos === expectedPos &&
  1305. persistedRange.length === expectedLength
  1306. )
  1307. }
  1308. /**
  1309. * Iterates through expected tracked changes and persisted tracked changes and
  1310. * returns all transitions, sorted by position.
  1311. *
  1312. * @param {readonly HistoryTrackedChange[]} persistedChanges
  1313. * @param {TrackedChange[]} expectedChanges
  1314. * @param {TrackedChangeRawData[]} persistedChangesHistoryOT
  1315. * @param {number} docLength
  1316. */
  1317. function getTrackedChangesTransitions(
  1318. persistedChanges,
  1319. expectedChanges,
  1320. persistedChangesHistoryOT,
  1321. docLength
  1322. ) {
  1323. /** @type {TrackedChangeTransition[]} */
  1324. const transitions = []
  1325. for (const change of persistedChanges) {
  1326. transitions.push({
  1327. stage: 'persisted',
  1328. pos: change.range.start,
  1329. tracking: {
  1330. type: change.tracking.type,
  1331. userId: change.tracking.userId,
  1332. ts: change.tracking.ts.toISOString(),
  1333. },
  1334. })
  1335. transitions.push({
  1336. stage: 'persisted',
  1337. pos: change.range.end,
  1338. tracking: { type: 'none' },
  1339. })
  1340. }
  1341. for (const change of persistedChangesHistoryOT) {
  1342. transitions.push({
  1343. stage: 'expected',
  1344. pos: change.range.pos,
  1345. tracking: change.tracking,
  1346. })
  1347. transitions.push({
  1348. stage: 'expected',
  1349. pos: change.range.pos + change.range.length,
  1350. tracking: { type: 'none' },
  1351. })
  1352. }
  1353. for (const change of expectedChanges) {
  1354. const op = change.op
  1355. const pos = op.hpos ?? op.p
  1356. if (isInsert(op)) {
  1357. transitions.push({
  1358. stage: 'expected',
  1359. pos,
  1360. tracking: {
  1361. type: 'insert',
  1362. userId: change.metadata.user_id,
  1363. ts: change.metadata.ts,
  1364. },
  1365. })
  1366. transitions.push({
  1367. stage: 'expected',
  1368. pos: pos + op.i.length,
  1369. tracking: { type: 'none' },
  1370. })
  1371. } else {
  1372. transitions.push({
  1373. stage: 'expected',
  1374. pos,
  1375. tracking: {
  1376. type: 'delete',
  1377. userId: change.metadata.user_id,
  1378. ts: change.metadata.ts,
  1379. },
  1380. })
  1381. transitions.push({
  1382. stage: 'expected',
  1383. pos: pos + op.d.length,
  1384. tracking: { type: 'none' },
  1385. })
  1386. }
  1387. }
  1388. transitions.push({
  1389. stage: 'expected',
  1390. pos: docLength,
  1391. tracking: { type: 'none' },
  1392. })
  1393. transitions.sort((a, b) => {
  1394. if (a.pos < b.pos) {
  1395. return -1
  1396. } else if (a.pos > b.pos) {
  1397. return 1
  1398. } else if (a.tracking.type === 'none' && b.tracking.type !== 'none') {
  1399. // none type comes before other types so that it can be overridden at the
  1400. // same position
  1401. return -1
  1402. } else if (a.tracking.type !== 'none' && b.tracking.type === 'none') {
  1403. // none type comes before other types so that it can be overridden at the
  1404. // same position
  1405. return 1
  1406. } else {
  1407. return 0
  1408. }
  1409. })
  1410. return transitions
  1411. }
  1412. /**
  1413. * Returns true if both tracking directives are equal
  1414. *
  1415. * @param {TrackingDirective} a
  1416. * @param {TrackingDirective} b
  1417. */
  1418. function trackingDirectivesEqual(a, b) {
  1419. if (a.type === 'none') {
  1420. return b.type === 'none'
  1421. } else {
  1422. return a.type === b.type && a.userId === b.userId && a.ts === b.ts
  1423. }
  1424. }
  1425. /**
  1426. * Determines whether an error from loading a file's blob indicates data
  1427. * corruption (safe to recover from by removing and re-adding the file) as
  1428. * opposed to a transient infrastructure failure (which should be retried).
  1429. *
  1430. * Known corruption indicators:
  1431. * - UnprocessableError (and subclasses ApplyError, InvalidInsertionError,
  1432. * TooLongError): the stored operations are inconsistent with the blob content
  1433. * - SyntaxError: the ranges blob contains invalid JSON
  1434. * - FileContentEmptyError: blob loaded but returned null content
  1435. *
  1436. * @param {unknown} err
  1437. * @returns {boolean}
  1438. */
  1439. function isDataCorruptionError(err) {
  1440. if (!(err instanceof Error)) {
  1441. return false
  1442. }
  1443. // Operation apply failures (op/content mismatch). This covers ApplyError,
  1444. // InvalidInsertionError, and TooLongError from overleaf-editor-core.
  1445. if (err instanceof UnprocessableError) {
  1446. return true
  1447. }
  1448. // Corrupted ranges blob (invalid JSON from BlobStore.getObject)
  1449. if (err instanceof SyntaxError) {
  1450. return true
  1451. }
  1452. // Null content after loading
  1453. if (err instanceof FileContentEmptyError) {
  1454. return true
  1455. }
  1456. return false
  1457. }
  1458. // EXPORTS
  1459. const cloneResyncStateCb = callbackify(cloneResyncState)
  1460. const getResyncStateCb = callbackify(getResyncState)
  1461. const startResyncCb = callbackify(startResync)
  1462. const startResyncWithoutLockCb = callbackify(startResyncWithoutLock)
  1463. const startHardResyncCb = callbackify(startHardResync)
  1464. const setResyncStateCb = callbackify(setResyncState)
  1465. const clearResyncStateCb = callbackify(clearResyncState)
  1466. const skipUpdatesDuringSyncCb = callbackifyMultiResult(skipUpdatesDuringSync, [
  1467. 'updates',
  1468. 'syncState',
  1469. ])
  1470. /**
  1471. * @param {string} projectId
  1472. * @param {string} projectHistoryId
  1473. * @param {{chunk: import('overleaf-editor-core/lib/types.js').RawChunk}} mostRecentChunk
  1474. * @param {Array<Update>} updates
  1475. * @param {() => void} extendLock
  1476. * @param {(err: Error | null, updates?: Array<Update>) => void} callback
  1477. */
  1478. const expandSyncUpdatesCb = (
  1479. projectId,
  1480. projectHistoryId,
  1481. mostRecentChunk,
  1482. updates,
  1483. extendLock,
  1484. callback
  1485. ) => {
  1486. const extendLockPromises = promisify(extendLock)
  1487. expandSyncUpdates(
  1488. projectId,
  1489. projectHistoryId,
  1490. mostRecentChunk,
  1491. updates,
  1492. extendLockPromises
  1493. )
  1494. .then(result => {
  1495. callback(null, result)
  1496. })
  1497. .catch(err => {
  1498. callback(err)
  1499. })
  1500. }
  1501. export {
  1502. cloneResyncStateCb as cloneResyncState,
  1503. getResyncStateCb as getResyncState,
  1504. startResyncCb as startResync,
  1505. startResyncWithoutLockCb as startResyncWithoutLock,
  1506. startHardResyncCb as startHardResync,
  1507. setResyncStateCb as setResyncState,
  1508. clearResyncStateCb as clearResyncState,
  1509. skipUpdatesDuringSyncCb as skipUpdatesDuringSync,
  1510. expandSyncUpdatesCb as expandSyncUpdates,
  1511. }
  1512. export const promises = {
  1513. cloneResyncState,
  1514. getResyncState,
  1515. startResync,
  1516. startResyncWithoutLock,
  1517. startHardResync,
  1518. setResyncState,
  1519. clearResyncState,
  1520. clearResyncStateIfAllAfter,
  1521. skipUpdatesDuringSync,
  1522. expandSyncUpdates,
  1523. }