mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
[web] Recover offline doc backup (#35647)
GitOrigin-RevId: 999ecc534821ab6f1bdc1bc4f7757316dab1658d
This commit is contained in:
committed by
Copybot
parent
64b6b28f69
commit
17363fcc59
@@ -29,7 +29,11 @@ import {
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import getMeta from '@/utils/meta'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { OfflineDocBackup } from '@/features/ide-react/editor/offline-doc-backup'
|
||||
import {
|
||||
OfflineDocBackup,
|
||||
OfflineDocBackupRecord,
|
||||
} from '@/features/ide-react/editor/offline-doc-backup'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import {
|
||||
HistoryOTShareDoc,
|
||||
ShareLatexOTShareDoc,
|
||||
@@ -526,46 +530,149 @@ export class DocumentContainer extends EventEmitter {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
this.socket.emit(
|
||||
'joinDoc',
|
||||
const record = this.getOfflineBackupForRecovery()
|
||||
if (record) {
|
||||
this.recoverAtBaseline(record, callback)
|
||||
} else {
|
||||
this.joinFreshDoc(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private joinFreshDoc(callback?: JoinCallback) {
|
||||
this.socket.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
{ encodeRanges: true, supportsHistoryOT: true },
|
||||
(error, docLines, version, updates, ranges, type, options) => {
|
||||
if (error) {
|
||||
callback?.(error)
|
||||
return
|
||||
}
|
||||
this.createJoinedDoc(docLines, version, ranges, type, options)
|
||||
callback?.()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private createJoinedDoc(
|
||||
docLines: string[],
|
||||
version: number,
|
||||
ranges: RangesTracker,
|
||||
type: OTType = 'sharejs-text-ot',
|
||||
options: JoinDocResponseOptions = {}
|
||||
) {
|
||||
this.joined = true
|
||||
this.canSkipLeaveDoc = options.canSkipLeaveDoc ?? false
|
||||
this.doc = new ShareJsDoc(
|
||||
this.doc_id,
|
||||
docLines,
|
||||
version,
|
||||
this.socket,
|
||||
this.globalEditorWatchdogManager,
|
||||
this.ideEventEmitter,
|
||||
type
|
||||
)
|
||||
if (type === 'sharejs-text-ot') {
|
||||
this.decodeRanges(ranges)
|
||||
}
|
||||
this.ranges = new RangesTracker(ranges?.changes, ranges?.comments)
|
||||
this.bindToShareJsDocEvents()
|
||||
}
|
||||
|
||||
private getOfflineBackupForRecovery(): OfflineDocBackupRecord | null {
|
||||
if (!isSplitTestEnabled('intermittent-connection-improvements')) {
|
||||
return null
|
||||
}
|
||||
const record = OfflineDocBackup.read(getMeta('ol-project_id'), this.doc_id)
|
||||
// TODO(35594): tracked-change edits need their track-changes state (user id
|
||||
// + seeds) wired up during recovery to replay as tracked; skip them for now
|
||||
// rather than silently recovering them as untracked edits.
|
||||
if (!record || record.trackChanges) {
|
||||
return null
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
// Recover a doc from its offline backup: rebuild it at the backed-up baseline
|
||||
// with the buffered edits restored, then re-join from that version so the
|
||||
// server streams the ops that landed while we were away. catchUp transforms
|
||||
// and dedupes the buffered edits onto the current version, which we then
|
||||
// send. Any failure falls back to a normal join, so a bad backup can never
|
||||
// block opening the doc.
|
||||
private recoverAtBaseline(
|
||||
record: OfflineDocBackupRecord,
|
||||
callback?: JoinCallback
|
||||
) {
|
||||
const loadWithoutRecovery = (error: unknown) => {
|
||||
// TODO(35594): surface the "changes can't be restored" modal.
|
||||
debugConsole.error('[recovery] failed, loading without recovery', error)
|
||||
OfflineDocBackup.remove(getMeta('ol-project_id'), record.docId)
|
||||
this.doc?.clearInflightAndPendingOps()
|
||||
this.doc = undefined
|
||||
this.joinFreshDoc(callback)
|
||||
}
|
||||
|
||||
try {
|
||||
this.doc = new ShareJsDoc(
|
||||
this.doc_id,
|
||||
{
|
||||
encodeRanges: true,
|
||||
supportsHistoryOT: true,
|
||||
},
|
||||
(
|
||||
error,
|
||||
docLines,
|
||||
version,
|
||||
updates,
|
||||
ranges,
|
||||
type: OTType = 'sharejs-text-ot',
|
||||
options: JoinDocResponseOptions = {}
|
||||
) => {
|
||||
if (error) {
|
||||
callback?.(error)
|
||||
return
|
||||
}
|
||||
[],
|
||||
record.version,
|
||||
this.socket,
|
||||
this.globalEditorWatchdogManager,
|
||||
this.ideEventEmitter,
|
||||
'sharejs-text-ot'
|
||||
)
|
||||
|
||||
this.doc.restoreFromOfflineBackup(record)
|
||||
this.ranges = new RangesTracker()
|
||||
this.bindToShareJsDocEvents()
|
||||
} catch (error) {
|
||||
loadWithoutRecovery(error)
|
||||
return
|
||||
}
|
||||
|
||||
this.socket.emit(
|
||||
'joinDoc',
|
||||
this.doc_id,
|
||||
record.version,
|
||||
{ encodeRanges: true, supportsHistoryOT: true },
|
||||
(
|
||||
error,
|
||||
docLines,
|
||||
version,
|
||||
updates,
|
||||
ranges,
|
||||
type = 'sharejs-text-ot',
|
||||
options: JoinDocResponseOptions = {}
|
||||
) => {
|
||||
// Versioned join failed (e.g. the baseline aged out of the server's op
|
||||
// window); we can't catch up, so load normally.
|
||||
if (error) {
|
||||
loadWithoutRecovery(error)
|
||||
return
|
||||
}
|
||||
// The backup only holds sharejs-text-ot state; if the doc has since
|
||||
// migrated (e.g. to history-ot) we can't replay onto it.
|
||||
if (type !== 'sharejs-text-ot') {
|
||||
loadWithoutRecovery(new Error(`unexpected doc type: ${type}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.joined = true
|
||||
this.canSkipLeaveDoc = options.canSkipLeaveDoc ?? false
|
||||
this.doc = new ShareJsDoc(
|
||||
this.doc_id,
|
||||
docLines,
|
||||
version,
|
||||
this.socket,
|
||||
this.globalEditorWatchdogManager,
|
||||
this.ideEventEmitter,
|
||||
type
|
||||
)
|
||||
if (type === 'sharejs-text-ot') {
|
||||
this.decodeRanges(ranges)
|
||||
}
|
||||
this.ranges = new RangesTracker(ranges?.changes, ranges?.comments)
|
||||
this.bindToShareJsDocEvents()
|
||||
callback?.()
|
||||
// rethrow errors to catch them here and revert to fresh load
|
||||
this.doc!.catchUp(updates, { rethrow: true })
|
||||
this.decodeRanges(ranges)
|
||||
this.catchUpRanges(ranges?.changes, ranges?.comments)
|
||||
this.doc!.sendRecoveredOps()
|
||||
} catch (err) {
|
||||
loadWithoutRecovery(err)
|
||||
return
|
||||
}
|
||||
)
|
||||
}
|
||||
callback?.()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private decodeRanges(ranges: RangesTracker) {
|
||||
@@ -633,7 +740,7 @@ export class DocumentContainer extends EventEmitter {
|
||||
|
||||
this.detachDoc(this.doc_id, this)
|
||||
|
||||
this.offlineBackup?.destroy()
|
||||
this.offlineBackup?.disconnect()
|
||||
|
||||
this.unBindFromEditorEvents()
|
||||
this.unBindFromSocketEvents()
|
||||
|
||||
@@ -46,6 +46,10 @@ export class OfflineDocBackup {
|
||||
)
|
||||
}
|
||||
|
||||
static remove(projectId: string, docId: string): void {
|
||||
customSessionStorage.removeItem(OfflineDocBackup.buildKey(projectId, docId))
|
||||
}
|
||||
|
||||
// Sweep every user's records. sessionStorage is per-tab and can't be reached
|
||||
// from the tab that handles logout for any other tab, so we clear the whole
|
||||
// prefix here rather than scoping to the current user.
|
||||
@@ -152,7 +156,7 @@ export class OfflineDocBackup {
|
||||
customSessionStorage.removeItem(this.key)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
disconnect() {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
} from 'overleaf-editor-core/lib/types'
|
||||
import { HistoryOTShareDoc } from '../../../../../types/share-doc'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { OfflineDocBackupRecord } from './offline-doc-backup'
|
||||
|
||||
const intermittentConnectionImprovementsEnabled = isSplitTestEnabled(
|
||||
'intermittent-connection-improvements'
|
||||
@@ -275,7 +276,7 @@ export class ShareJsDoc extends EventEmitter {
|
||||
|
||||
// FIXME: This is the original method. Switch back to this when redis
|
||||
// issues are resolved.
|
||||
processUpdateFromServer(message: Message) {
|
||||
processUpdateFromServer(message: Message, { rethrow = false } = {}) {
|
||||
try {
|
||||
if (this.type === 'history-ot' && message.op != null) {
|
||||
const shareDoc = this._doc as HistoryOTShareDoc
|
||||
@@ -304,6 +305,9 @@ export class ShareJsDoc extends EventEmitter {
|
||||
} catch (error) {
|
||||
// Version mismatches are thrown as errors
|
||||
debugConsole.log(error)
|
||||
if (rethrow) {
|
||||
throw error
|
||||
}
|
||||
this.handleError(error)
|
||||
return error // return the error for queue handling
|
||||
}
|
||||
@@ -313,11 +317,11 @@ export class ShareJsDoc extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
catchUp(updates: Message[]) {
|
||||
catchUp(updates: Message[], { rethrow = false } = {}) {
|
||||
return updates.map(update => {
|
||||
update.v = this._doc.version
|
||||
update.doc = this.doc_id
|
||||
return this.processUpdateFromServer(update)
|
||||
return this.processUpdateFromServer(update, { rethrow })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -351,6 +355,50 @@ export class ShareJsDoc extends EventEmitter {
|
||||
return this._doc.flush()
|
||||
}
|
||||
|
||||
// Rebuild the client state captured in an offline backup: the server-acked
|
||||
// baseline snapshot plus the ops that were buffered on top of it. Recovery
|
||||
// constructs a doc at the baseline version, restores this, then catches the
|
||||
// doc up to the current server version.
|
||||
restoreFromOfflineBackup(backup: OfflineDocBackupRecord) {
|
||||
// The snapshot must be the local view (baseline with the buffered ops
|
||||
// applied), matching ShareJS's invariant that snapshot = acked version +
|
||||
// inflight + pending. Seeding only the baseline would show the server text
|
||||
// without the recovered edits.
|
||||
let snapshot = backup.snapshot
|
||||
if (backup.inflightOp) {
|
||||
snapshot = sharejs.types.text.apply(snapshot, backup.inflightOp)
|
||||
}
|
||||
if (backup.pendingOp) {
|
||||
snapshot = sharejs.types.text.apply(snapshot, backup.pendingOp)
|
||||
}
|
||||
this._doc.snapshot = snapshot
|
||||
this._doc.inflightOp = backup.inflightOp
|
||||
this._doc.pendingOp = backup.pendingOp
|
||||
this._doc.inflightSubmittedIds = [...backup.inflightSubmittedIds]
|
||||
}
|
||||
|
||||
// Push the buffered ops of a recovered doc to the server. A doc rebuilt from
|
||||
// a backup never went through submitOp, so no retry timer was armed: resend a
|
||||
// surviving inflight op explicitly (deduped server-side via
|
||||
// inflightSubmittedIds, and re-arming the timer via connection.send); a lone
|
||||
// pending op just needs a flush.
|
||||
sendRecoveredOps() {
|
||||
if (this._doc.inflightOp?.length === 0) {
|
||||
this._doc.inflightOp = null
|
||||
this._doc.inflightSubmittedIds = []
|
||||
}
|
||||
if (this._doc.inflightOp) {
|
||||
this.connection.send({
|
||||
doc: this.doc_id,
|
||||
op: this._doc.inflightOp,
|
||||
v: this._doc.version,
|
||||
dupIfSource: [...this._doc.inflightSubmittedIds],
|
||||
})
|
||||
} else {
|
||||
this.flushPendingOps()
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionState(state: ShareJsConnectionState) {
|
||||
debugConsole.log(`[updateConnectionState] Setting state to ${state}`)
|
||||
this.connection.state = state
|
||||
|
||||
Reference in New Issue
Block a user