Merge pull request #22845 from overleaf/em-project-snapshot-queue-refreshes
Concurrency control in ProjectSnapshot GitOrigin-RevId: b62b886b59a67f2c694ef7cefcff5c32da3e4457
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import pLimit from 'p-limit'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
|
||||
import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
|
||||
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
|
||||
@@ -12,45 +13,68 @@ export class ProjectSnapshot {
|
||||
private projectId: string
|
||||
private snapshot: Snapshot
|
||||
private version: number
|
||||
private state: 'init' | 'refreshing' | 'ready'
|
||||
private blobStore: SimpleBlobStore
|
||||
private refreshPromise: Promise<void>
|
||||
private queuedRefreshPromise: Promise<void>
|
||||
private state: ProjectSnapshotState
|
||||
|
||||
constructor(projectId: string) {
|
||||
this.projectId = projectId
|
||||
this.snapshot = new Snapshot()
|
||||
this.version = 0
|
||||
this.state = 'init'
|
||||
this.refreshPromise = Promise.resolve()
|
||||
this.queuedRefreshPromise = Promise.resolve()
|
||||
this.state = new ProjectSnapshotState()
|
||||
this.blobStore = new SimpleBlobStore(this.projectId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a refresh of the snapshot.
|
||||
*
|
||||
* When the returned promise resolves, the snapshot is guaranteed to have been
|
||||
* updated at least to the version of the document that was current when the
|
||||
* function was called.
|
||||
*/
|
||||
async refresh() {
|
||||
if (this.state === 'refreshing') {
|
||||
// Prevent concurrent refreshes
|
||||
return
|
||||
switch (this.state.getState()) {
|
||||
case 'init':
|
||||
this.refreshPromise = this.initialize()
|
||||
await this.refreshPromise
|
||||
break
|
||||
|
||||
case 'ready':
|
||||
this.refreshPromise = this.loadChanges()
|
||||
await this.refreshPromise
|
||||
break
|
||||
|
||||
case 'refreshing':
|
||||
this.queuedRefreshPromise = this.queueRefresh()
|
||||
await this.queuedRefreshPromise
|
||||
break
|
||||
|
||||
case 'queued-ready':
|
||||
case 'queued-waiting':
|
||||
await this.queuedRefreshPromise
|
||||
break
|
||||
|
||||
default:
|
||||
throw new OError('Unknown state for project snapshot', {
|
||||
state: this.state.getState(),
|
||||
})
|
||||
}
|
||||
|
||||
await flushHistory(this.projectId)
|
||||
|
||||
if (this.state === 'init') {
|
||||
const chunk = await fetchLatestChunk(this.projectId)
|
||||
this.snapshot = chunk.getSnapshot()
|
||||
this.snapshot.applyAll(chunk.getChanges())
|
||||
this.version = chunk.getEndVersion()
|
||||
} else {
|
||||
const changes = await fetchLatestChanges(this.projectId, this.version)
|
||||
this.snapshot.applyAll(changes)
|
||||
this.version += changes.length
|
||||
}
|
||||
|
||||
this.state = 'ready'
|
||||
await this.loadDocs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of paths to editable docs.
|
||||
*/
|
||||
getDocPaths(): string[] {
|
||||
const allPaths = this.snapshot.getFilePathnames()
|
||||
return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the doc content at the given path.
|
||||
*/
|
||||
getDocContents(path: string): string | null {
|
||||
const file = this.snapshot.getFile(path)
|
||||
if (file == null) {
|
||||
@@ -59,6 +83,52 @@ export class ProjectSnapshot {
|
||||
return file.getContent({ filterTrackedDeletes: true }) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the snapshot using the project's latest chunk.
|
||||
*
|
||||
* This is run on the first refresh.
|
||||
*/
|
||||
private async initialize() {
|
||||
this.state.startRefresh()
|
||||
await flushHistory(this.projectId)
|
||||
const chunk = await fetchLatestChunk(this.projectId)
|
||||
this.snapshot = chunk.getSnapshot()
|
||||
this.snapshot.applyAll(chunk.getChanges())
|
||||
this.version = chunk.getEndVersion()
|
||||
await this.loadDocs()
|
||||
this.state.endRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply changes since the last refresh.
|
||||
*
|
||||
* This is run on the second and subsequent refreshes
|
||||
*/
|
||||
private async loadChanges() {
|
||||
this.state.startRefresh()
|
||||
await flushHistory(this.projectId)
|
||||
const changes = await fetchLatestChanges(this.projectId, this.version)
|
||||
this.snapshot.applyAll(changes)
|
||||
this.version += changes.length
|
||||
await this.loadDocs()
|
||||
this.state.endRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the current refresh to complete, then start a refresh.
|
||||
*/
|
||||
private async queueRefresh() {
|
||||
this.state.queueRefresh()
|
||||
await this.refreshPromise
|
||||
await this.loadChanges()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all editable docs in the snapshot.
|
||||
*
|
||||
* This is done by converting any lazy file data into an "eager" file data. If
|
||||
* a doc is already loaded, the load is a no-op.
|
||||
*/
|
||||
private async loadDocs() {
|
||||
const paths = this.getDocPaths()
|
||||
const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
|
||||
@@ -73,6 +143,106 @@ export class ProjectSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State machine for the project snapshot
|
||||
*
|
||||
* There are 5 states:
|
||||
*
|
||||
* - init: when the snapshot is built
|
||||
* - refreshing: while the snapshot is refreshing
|
||||
* - queued-waiting: while the snapshot is refreshing and another refresh is queued
|
||||
* - queued-ready: when a refresh is queued, but no refresh is running
|
||||
* - ready: when no refresh is running and no refresh is queued
|
||||
*
|
||||
* There are three transitions:
|
||||
*
|
||||
* - start: start a refresh operation
|
||||
* - end: end a refresh operation
|
||||
* - queue: queue a refresh operation
|
||||
*
|
||||
* Valid transitions are as follows:
|
||||
*
|
||||
* +------------+
|
||||
* | ready |
|
||||
* +------------+
|
||||
* ^ |
|
||||
* | |
|
||||
* end start
|
||||
* | |
|
||||
* | v
|
||||
* +------+ +------------+ +----------------+
|
||||
* | init |----start---->| refreshing |---queue---> | queued-waiting |
|
||||
* +------+ +------------+ +----------------+
|
||||
* ^ |
|
||||
* | |
|
||||
* start end
|
||||
* | |
|
||||
* | +--------------+ |
|
||||
* +-----| queued-ready |<-------+
|
||||
* +--------------+
|
||||
*
|
||||
* These transitions ensure that there are never two refreshes running
|
||||
* concurrently. In every path, "start" and "end" transitions always alternate.
|
||||
* You never have two consecutive "start" or two consecutive "end".
|
||||
*/
|
||||
class ProjectSnapshotState {
|
||||
private state:
|
||||
| 'init'
|
||||
| 'refreshing'
|
||||
| 'ready'
|
||||
| 'queued-waiting'
|
||||
| 'queued-ready' = 'init'
|
||||
|
||||
getState() {
|
||||
return this.state
|
||||
}
|
||||
|
||||
startRefresh() {
|
||||
switch (this.state) {
|
||||
case 'init':
|
||||
case 'ready':
|
||||
case 'queued-ready':
|
||||
this.state = 'refreshing'
|
||||
break
|
||||
|
||||
default:
|
||||
throw new OError("Can't start a snapshot refresh in this state", {
|
||||
state: this.state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
endRefresh() {
|
||||
switch (this.state) {
|
||||
case 'refreshing':
|
||||
this.state = 'ready'
|
||||
break
|
||||
|
||||
case 'queued-waiting':
|
||||
this.state = 'queued-ready'
|
||||
break
|
||||
|
||||
default:
|
||||
throw new OError("Can't end a snapshot refresh in this state", {
|
||||
state: this.state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
queueRefresh() {
|
||||
switch (this.state) {
|
||||
case 'refreshing':
|
||||
this.state = 'queued-waiting'
|
||||
break
|
||||
|
||||
default:
|
||||
throw new OError("Can't queue a snapshot refresh in this state", {
|
||||
state: this.state,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob store that fetches blobs from the history service
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user