project-snapshot.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import pLimit from 'p-limit'
  2. import OError from '@overleaf/o-error'
  3. import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
  4. import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
  5. import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
  6. const DOWNLOAD_BLOBS_CONCURRENCY = 10
  7. /**
  8. * Project snapshot container with on-demand refresh
  9. */
  10. export class ProjectSnapshot {
  11. private projectId: string
  12. private snapshot: Snapshot
  13. private version: number
  14. private blobStore: SimpleBlobStore
  15. private refreshPromise: Promise<void>
  16. private queuedRefreshPromise: Promise<void>
  17. private state: ProjectSnapshotState
  18. constructor(projectId: string) {
  19. this.projectId = projectId
  20. this.snapshot = new Snapshot()
  21. this.version = 0
  22. this.refreshPromise = Promise.resolve()
  23. this.queuedRefreshPromise = Promise.resolve()
  24. this.state = new ProjectSnapshotState()
  25. this.blobStore = new SimpleBlobStore(this.projectId)
  26. }
  27. /**
  28. * Request a refresh of the snapshot.
  29. *
  30. * When the returned promise resolves, the snapshot is guaranteed to have been
  31. * updated at least to the version of the document that was current when the
  32. * function was called.
  33. */
  34. async refresh() {
  35. switch (this.state.getState()) {
  36. case 'init':
  37. this.refreshPromise = this.initialize()
  38. await this.refreshPromise
  39. break
  40. case 'ready':
  41. this.refreshPromise = this.loadChanges()
  42. await this.refreshPromise
  43. break
  44. case 'refreshing':
  45. this.queuedRefreshPromise = this.queueRefresh()
  46. await this.queuedRefreshPromise
  47. break
  48. case 'queued-ready':
  49. case 'queued-waiting':
  50. await this.queuedRefreshPromise
  51. break
  52. default:
  53. throw new OError('Unknown state for project snapshot', {
  54. state: this.state.getState(),
  55. })
  56. }
  57. }
  58. /**
  59. * Get the list of paths to editable docs.
  60. */
  61. getDocPaths(): string[] {
  62. const allPaths = this.snapshot.getFilePathnames()
  63. return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
  64. }
  65. /**
  66. * Get the doc content at the given path.
  67. */
  68. getDocContents(path: string): string | null {
  69. const file = this.snapshot.getFile(path)
  70. if (file == null) {
  71. return null
  72. }
  73. return file.getContent({ filterTrackedDeletes: true }) ?? null
  74. }
  75. /**
  76. * Initialize the snapshot using the project's latest chunk.
  77. *
  78. * This is run on the first refresh.
  79. */
  80. private async initialize() {
  81. this.state.startRefresh()
  82. await flushHistory(this.projectId)
  83. const chunk = await fetchLatestChunk(this.projectId)
  84. this.snapshot = chunk.getSnapshot()
  85. this.snapshot.applyAll(chunk.getChanges())
  86. this.version = chunk.getEndVersion()
  87. await this.loadDocs()
  88. this.state.endRefresh()
  89. }
  90. /**
  91. * Apply changes since the last refresh.
  92. *
  93. * This is run on the second and subsequent refreshes
  94. */
  95. private async loadChanges() {
  96. this.state.startRefresh()
  97. await flushHistory(this.projectId)
  98. const changes = await fetchLatestChanges(this.projectId, this.version)
  99. this.snapshot.applyAll(changes)
  100. this.version += changes.length
  101. await this.loadDocs()
  102. this.state.endRefresh()
  103. }
  104. /**
  105. * Wait for the current refresh to complete, then start a refresh.
  106. */
  107. private async queueRefresh() {
  108. this.state.queueRefresh()
  109. await this.refreshPromise
  110. await this.loadChanges()
  111. }
  112. /**
  113. * Load all editable docs in the snapshot.
  114. *
  115. * This is done by converting any lazy file data into an "eager" file data. If
  116. * a doc is already loaded, the load is a no-op.
  117. */
  118. private async loadDocs() {
  119. const paths = this.getDocPaths()
  120. const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
  121. await Promise.all(
  122. paths.map(path =>
  123. limit(async () => {
  124. const file = this.snapshot.getFile(path)
  125. await file?.load('eager', this.blobStore)
  126. })
  127. )
  128. )
  129. }
  130. }
  131. /**
  132. * State machine for the project snapshot
  133. *
  134. * There are 5 states:
  135. *
  136. * - init: when the snapshot is built
  137. * - refreshing: while the snapshot is refreshing
  138. * - queued-waiting: while the snapshot is refreshing and another refresh is queued
  139. * - queued-ready: when a refresh is queued, but no refresh is running
  140. * - ready: when no refresh is running and no refresh is queued
  141. *
  142. * There are three transitions:
  143. *
  144. * - start: start a refresh operation
  145. * - end: end a refresh operation
  146. * - queue: queue a refresh operation
  147. *
  148. * Valid transitions are as follows:
  149. *
  150. * +------------+
  151. * | ready |
  152. * +------------+
  153. * ^ |
  154. * | |
  155. * end start
  156. * | |
  157. * | v
  158. * +------+ +------------+ +----------------+
  159. * | init |----start---->| refreshing |---queue---> | queued-waiting |
  160. * +------+ +------------+ +----------------+
  161. * ^ |
  162. * | |
  163. * start end
  164. * | |
  165. * | +--------------+ |
  166. * +-----| queued-ready |<-------+
  167. * +--------------+
  168. *
  169. * These transitions ensure that there are never two refreshes running
  170. * concurrently. In every path, "start" and "end" transitions always alternate.
  171. * You never have two consecutive "start" or two consecutive "end".
  172. */
  173. class ProjectSnapshotState {
  174. private state:
  175. | 'init'
  176. | 'refreshing'
  177. | 'ready'
  178. | 'queued-waiting'
  179. | 'queued-ready' = 'init'
  180. getState() {
  181. return this.state
  182. }
  183. startRefresh() {
  184. switch (this.state) {
  185. case 'init':
  186. case 'ready':
  187. case 'queued-ready':
  188. this.state = 'refreshing'
  189. break
  190. default:
  191. throw new OError("Can't start a snapshot refresh in this state", {
  192. state: this.state,
  193. })
  194. }
  195. }
  196. endRefresh() {
  197. switch (this.state) {
  198. case 'refreshing':
  199. this.state = 'ready'
  200. break
  201. case 'queued-waiting':
  202. this.state = 'queued-ready'
  203. break
  204. default:
  205. throw new OError("Can't end a snapshot refresh in this state", {
  206. state: this.state,
  207. })
  208. }
  209. }
  210. queueRefresh() {
  211. switch (this.state) {
  212. case 'refreshing':
  213. this.state = 'queued-waiting'
  214. break
  215. default:
  216. throw new OError("Can't queue a snapshot refresh in this state", {
  217. state: this.state,
  218. })
  219. }
  220. }
  221. }
  222. /**
  223. * Blob store that fetches blobs from the history service
  224. */
  225. class SimpleBlobStore {
  226. private projectId: string
  227. constructor(projectId: string) {
  228. this.projectId = projectId
  229. }
  230. async getString(hash: string): Promise<string> {
  231. return await fetchBlob(this.projectId, hash)
  232. }
  233. async getObject(hash: string) {
  234. const blob = await this.getString(hash)
  235. return JSON.parse(blob)
  236. }
  237. }
  238. async function flushHistory(projectId: string) {
  239. await postJSON(`/project/${projectId}/flush`)
  240. }
  241. async function fetchLatestChunk(projectId: string): Promise<Chunk> {
  242. const response = await getJSON<{ chunk: RawChunk }>(
  243. `/project/${projectId}/latest/history`
  244. )
  245. return Chunk.fromRaw(response.chunk)
  246. }
  247. async function fetchLatestChanges(
  248. projectId: string,
  249. version: number
  250. ): Promise<Change[]> {
  251. const response = await getJSON<RawChange[]>(
  252. `/project/${projectId}/changes?since=${version}`
  253. )
  254. return response.map(Change.fromRaw).filter(change => change != null)
  255. }
  256. async function fetchBlob(projectId: string, hash: string): Promise<string> {
  257. const url = `/project/${projectId}/blob/${hash}`
  258. const res = await fetch(url)
  259. if (!res.ok) {
  260. throw new FetchError('Failed to fetch blob', url, undefined, res)
  261. }
  262. return await res.text()
  263. }