diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-08-26 01:34:40 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-08-26 17:45:20 +0200 |
commit | d5f6643b9e0e675748ca6ec48e49355ca99453ca (patch) | |
tree | 288f5538be58ea499a0b1d5ce5314c7698c52060 /subprojects/frontend/src/xtext/UpdateStateTracker.ts | |
parent | chore(deps): bump dependencies (diff) | |
download | refinery-d5f6643b9e0e675748ca6ec48e49355ca99453ca.tar.gz refinery-d5f6643b9e0e675748ca6ec48e49355ca99453ca.tar.zst refinery-d5f6643b9e0e675748ca6ec48e49355ca99453ca.zip |
refactor(frontend): simplify UpdateService further
Diffstat (limited to 'subprojects/frontend/src/xtext/UpdateStateTracker.ts')
-rw-r--r-- | subprojects/frontend/src/xtext/UpdateStateTracker.ts | 300 |
1 files changed, 94 insertions, 206 deletions
diff --git a/subprojects/frontend/src/xtext/UpdateStateTracker.ts b/subprojects/frontend/src/xtext/UpdateStateTracker.ts index 04359060..a529f9a0 100644 --- a/subprojects/frontend/src/xtext/UpdateStateTracker.ts +++ b/subprojects/frontend/src/xtext/UpdateStateTracker.ts | |||
@@ -1,18 +1,3 @@ | |||
1 | /** | ||
2 | * @file State tracker for pushing updates to the Xtext server. | ||
3 | * | ||
4 | * This file implements complex logic to avoid missing or overwriting state updates | ||
5 | * and to avoid sending conflicting updates to the Xtext server. | ||
6 | * | ||
7 | * The `LockedState` and `PendingUpdate` objects are used as capabilities to | ||
8 | * signify whether the socket to the Xtext server is locked for updates and | ||
9 | * whether an update is in progress, respectively. | ||
10 | * Always use these objects only received as an argument of a lambda expression | ||
11 | * or method and never leak them into class field or global variables. | ||
12 | * The presence of such an objects in the scope should always imply that | ||
13 | * the corresponding condition holds. | ||
14 | */ | ||
15 | |||
16 | import { | 1 | import { |
17 | type ChangeDesc, | 2 | type ChangeDesc, |
18 | ChangeSet, | 3 | ChangeSet, |
@@ -23,12 +8,9 @@ import { | |||
23 | import { E_CANCELED, Mutex, withTimeout } from 'async-mutex'; | 8 | import { E_CANCELED, Mutex, withTimeout } from 'async-mutex'; |
24 | 9 | ||
25 | import type EditorStore from '../editor/EditorStore'; | 10 | import type EditorStore from '../editor/EditorStore'; |
26 | import getLogger from '../utils/getLogger'; | ||
27 | 11 | ||
28 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | 12 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; |
29 | 13 | ||
30 | const log = getLogger('xtext.UpdateStateTracker'); | ||
31 | |||
32 | /** | 14 | /** |
33 | * State effect used to override the dirty changes after a transaction. | 15 | * State effect used to override the dirty changes after a transaction. |
34 | * | 16 | * |
@@ -40,57 +22,6 @@ const log = getLogger('xtext.UpdateStateTracker'); | |||
40 | */ | 22 | */ |
41 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | 23 | const setDirtyChanges = StateEffect.define<ChangeSet>(); |
42 | 24 | ||
43 | export interface StateUpdateResult<T> { | ||
44 | /** The new state ID on the server or `undefined` if no update was performed. */ | ||
45 | newStateId: string | undefined; | ||
46 | |||
47 | /** Optional data payload received during the update. */ | ||
48 | data: T; | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * Signifies a capability that the Xtext server state is locked for update. | ||
53 | */ | ||
54 | export interface LockedState { | ||
55 | /** | ||
56 | * | ||
57 | * @param callback the asynchronous callback that updates the server state | ||
58 | * @returns a promise resolving after the update | ||
59 | */ | ||
60 | updateExclusive( | ||
61 | callback: (pendingUpdate: PendingUpdate) => Promise<string | undefined>, | ||
62 | ): Promise<void>; | ||
63 | |||
64 | /** | ||
65 | * Executes an asynchronous callback that updates the state on the server. | ||
66 | * | ||
67 | * If the callback returns `undefined` as the `newStateId`, | ||
68 | * the update is assumed to be aborted and any pending changes will be marked as dirt again. | ||
69 | * Any exceptions thrown in `callback` will also cause the update to be aborted. | ||
70 | * | ||
71 | * Ensures that updates happen sequentially and manages `pendingUpdate` | ||
72 | * and `dirtyChanges` to reflect changes being synchronized to the server | ||
73 | * and not yet synchronized to the server, respectively. | ||
74 | * | ||
75 | * Optionally, `callback` may return a second value that is retured by this function. | ||
76 | * | ||
77 | * Once the remote procedure call to update the server state finishes | ||
78 | * and returns the new `stateId`, `callback` must return _immediately_ | ||
79 | * to ensure that the local `stateId` is updated likewise to be able to handle | ||
80 | * push messages referring to the new `stateId` from the server. | ||
81 | * If additional asynchronous work is needed to compute the second value in some cases, | ||
82 | * use `T | undefined` instead of `T` as a return type and signal the need for additional | ||
83 | * computations by returning `undefined`. Thus additional computations can be performed | ||
84 | * outside of the critical section. | ||
85 | * | ||
86 | * @param callback the asynchronous callback that updates the server state | ||
87 | * @returns a promise resolving to the second value returned by `callback` | ||
88 | */ | ||
89 | withUpdateExclusive<T>( | ||
90 | callback: (pendingUpdate: PendingUpdate) => Promise<StateUpdateResult<T>>, | ||
91 | ): Promise<T>; | ||
92 | } | ||
93 | |||
94 | export interface Delta { | 25 | export interface Delta { |
95 | deltaOffset: number; | 26 | deltaOffset: number; |
96 | 27 | ||
@@ -99,32 +30,19 @@ export interface Delta { | |||
99 | deltaText: string; | 30 | deltaText: string; |
100 | } | 31 | } |
101 | 32 | ||
102 | /** | ||
103 | * Signifies a capability that dirty changes are being marked for uploading. | ||
104 | */ | ||
105 | export interface PendingUpdate { | ||
106 | prepareDeltaUpdateExclusive(): Delta | undefined; | ||
107 | |||
108 | extendPendingUpdateExclusive(): void; | ||
109 | |||
110 | applyBeforeDirtyChangesExclusive(changeSpec: ChangeSpec): void; | ||
111 | } | ||
112 | |||
113 | export default class UpdateStateTracker { | 33 | export default class UpdateStateTracker { |
114 | xtextStateId: string | undefined; | 34 | xtextStateId: string | undefined; |
115 | 35 | ||
116 | /** | 36 | /** |
117 | * The changes being synchronized to the server if a full or delta text update is running | 37 | * The changes marked for synchronization to the server if a full or delta text update |
118 | * withing a `withUpdateExclusive` block, `undefined` otherwise. | 38 | * is running, `undefined` otherwise. |
119 | * | 39 | * |
120 | * Must be `undefined` before and after entering the critical section of `mutex` | 40 | * Must be `undefined` upon entering the critical section of `mutex`, |
121 | * and may only be changes in the critical section of `mutex`. | 41 | * may only be changed in the critical section of `mutex`, |
42 | * and will be set to `undefined` (marking any changes as dirty again) when leaving it. | ||
122 | * | 43 | * |
123 | * Methods named with an `Exclusive` suffix in this class assume that the mutex is held | 44 | * Methods named with an `Exclusive` suffix in this class assume that the mutex is held |
124 | * and may call `updateExclusive` or `withUpdateExclusive` to mutate this field. | 45 | * and may mutate this field. |
125 | * | ||
126 | * Methods named with a `do` suffix assume that they are called in a `withUpdateExclusive` | ||
127 | * block and require this field to be non-`undefined`. | ||
128 | */ | 46 | */ |
129 | private pendingChanges: ChangeSet | undefined; | 47 | private pendingChanges: ChangeSet | undefined; |
130 | 48 | ||
@@ -142,24 +60,24 @@ export default class UpdateStateTracker { | |||
142 | this.dirtyChanges = this.newEmptyChangeSet(); | 60 | this.dirtyChanges = this.newEmptyChangeSet(); |
143 | } | 61 | } |
144 | 62 | ||
145 | get locekdForUpdate(): boolean { | 63 | private get hasDirtyChanges(): boolean { |
146 | return this.mutex.isLocked(); | 64 | return !this.dirtyChanges.empty; |
147 | } | 65 | } |
148 | 66 | ||
149 | get hasDirtyChanges(): boolean { | 67 | get needsUpdate(): boolean { |
150 | return !this.dirtyChanges.empty; | 68 | return this.hasDirtyChanges || this.xtextStateId === undefined; |
69 | } | ||
70 | |||
71 | get lockedForUpdate(): boolean { | ||
72 | return this.mutex.isLocked(); | ||
151 | } | 73 | } |
152 | 74 | ||
153 | get upToDate(): boolean { | 75 | get hasPendingChanges(): boolean { |
154 | return !this.locekdForUpdate && !this.hasDirtyChanges; | 76 | return this.lockedForUpdate || this.needsUpdate; |
155 | } | 77 | } |
156 | 78 | ||
157 | hasChangesSince(xtextStateId: string): boolean { | 79 | hasChangesSince(xtextStateId: string): boolean { |
158 | return ( | 80 | return this.xtextStateId !== xtextStateId || this.hasPendingChanges; |
159 | this.xtextStateId !== xtextStateId || | ||
160 | this.locekdForUpdate || | ||
161 | this.hasDirtyChanges | ||
162 | ); | ||
163 | } | 81 | } |
164 | 82 | ||
165 | /** | 83 | /** |
@@ -211,132 +129,102 @@ export default class UpdateStateTracker { | |||
211 | ); | 129 | ); |
212 | } | 130 | } |
213 | 131 | ||
214 | private newEmptyChangeSet(): ChangeSet { | 132 | prepareDeltaUpdateExclusive(): Delta | undefined { |
215 | return ChangeSet.of([], this.store.state.doc.length); | 133 | this.ensureLocked(); |
134 | this.markDirtyChangesAsPendingExclusive(); | ||
135 | if (this.pendingChanges === undefined || this.pendingChanges.empty) { | ||
136 | return undefined; | ||
137 | } | ||
138 | let minFromA = Number.MAX_SAFE_INTEGER; | ||
139 | let maxToA = 0; | ||
140 | let minFromB = Number.MAX_SAFE_INTEGER; | ||
141 | let maxToB = 0; | ||
142 | this.pendingChanges.iterChangedRanges((fromA, toA, fromB, toB) => { | ||
143 | minFromA = Math.min(minFromA, fromA); | ||
144 | maxToA = Math.max(maxToA, toA); | ||
145 | minFromB = Math.min(minFromB, fromB); | ||
146 | maxToB = Math.max(maxToB, toB); | ||
147 | }); | ||
148 | return { | ||
149 | deltaOffset: minFromA, | ||
150 | deltaReplaceLength: maxToA - minFromA, | ||
151 | deltaText: this.store.state.doc.sliceString(minFromB, maxToB), | ||
152 | }; | ||
216 | } | 153 | } |
217 | 154 | ||
218 | private readonly pendingUpdate: PendingUpdate = { | 155 | prepareFullTextUpdateExclusive(): void { |
219 | prepareDeltaUpdateExclusive: (): Delta | undefined => { | 156 | this.ensureLocked(); |
220 | this.pendingUpdate.extendPendingUpdateExclusive(); | 157 | this.markDirtyChangesAsPendingExclusive(); |
221 | if (this.pendingChanges === undefined || this.pendingChanges.empty) { | 158 | } |
222 | return undefined; | 159 | |
223 | } | 160 | private markDirtyChangesAsPendingExclusive(): void { |
224 | let minFromA = Number.MAX_SAFE_INTEGER; | 161 | if (!this.lockedForUpdate) { |
225 | let maxToA = 0; | 162 | throw new Error('Cannot update state without locking the mutex'); |
226 | let minFromB = Number.MAX_SAFE_INTEGER; | 163 | } |
227 | let maxToB = 0; | 164 | if (this.hasDirtyChanges) { |
228 | this.pendingChanges.iterChangedRanges((fromA, toA, fromB, toB) => { | 165 | this.pendingChanges = |
229 | minFromA = Math.min(minFromA, fromA); | ||
230 | maxToA = Math.max(maxToA, toA); | ||
231 | minFromB = Math.min(minFromB, fromB); | ||
232 | maxToB = Math.max(maxToB, toB); | ||
233 | }); | ||
234 | return { | ||
235 | deltaOffset: minFromA, | ||
236 | deltaReplaceLength: maxToA - minFromA, | ||
237 | deltaText: this.store.state.doc.sliceString(minFromB, maxToB), | ||
238 | }; | ||
239 | }, | ||
240 | extendPendingUpdateExclusive: (): void => { | ||
241 | if (!this.locekdForUpdate) { | ||
242 | throw new Error('Cannot update state without locking the mutex'); | ||
243 | } | ||
244 | if (this.hasDirtyChanges) { | ||
245 | this.pendingChanges = | ||
246 | this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges; | ||
247 | this.dirtyChanges = this.newEmptyChangeSet(); | ||
248 | } | ||
249 | }, | ||
250 | applyBeforeDirtyChangesExclusive: (changeSpec: ChangeSpec): void => { | ||
251 | if (!this.locekdForUpdate) { | ||
252 | throw new Error('Cannot update state without locking the mutex'); | ||
253 | } | ||
254 | const pendingChanges = | ||
255 | this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges; | 166 | this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges; |
256 | const revertChanges = pendingChanges.invert(this.store.state.doc); | 167 | this.dirtyChanges = this.newEmptyChangeSet(); |
257 | const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); | 168 | } |
258 | const redoChanges = pendingChanges.map(applyBefore.desc); | 169 | } |
259 | const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); | ||
260 | this.store.dispatch({ | ||
261 | changes: changeSet, | ||
262 | // Keep the current set of dirty changes (but update them according the re-formatting) | ||
263 | // and to not add the formatting the dirty changes. | ||
264 | effects: [setDirtyChanges.of(redoChanges)], | ||
265 | }); | ||
266 | }, | ||
267 | }; | ||
268 | 170 | ||
269 | private readonly lockedState: LockedState = { | 171 | private newEmptyChangeSet(): ChangeSet { |
270 | updateExclusive: ( | 172 | return ChangeSet.of([], this.store.state.doc.length); |
271 | callback: (pendingUpdate: PendingUpdate) => Promise<string | undefined>, | 173 | } |
272 | ): Promise<void> => { | ||
273 | return this.lockedState.withUpdateExclusive<void>( | ||
274 | async (pendingUpdate) => { | ||
275 | const newStateId = await callback(pendingUpdate); | ||
276 | return { newStateId, data: undefined }; | ||
277 | }, | ||
278 | ); | ||
279 | }, | ||
280 | withUpdateExclusive: async <T>( | ||
281 | callback: (pendingUpdate: PendingUpdate) => Promise<StateUpdateResult<T>>, | ||
282 | ): Promise<T> => { | ||
283 | if (!this.locekdForUpdate) { | ||
284 | throw new Error('Cannot update state without locking the mutex'); | ||
285 | } | ||
286 | if (this.pendingChanges !== undefined) { | ||
287 | throw new Error('Delta updates are not reentrant'); | ||
288 | } | ||
289 | let newStateId: string | undefined; | ||
290 | let data: T; | ||
291 | try { | ||
292 | ({ newStateId, data } = await callback(this.pendingUpdate)); | ||
293 | } catch (e) { | ||
294 | log.error('Error while update', e); | ||
295 | this.cancelUpdate(); | ||
296 | throw e; | ||
297 | } | ||
298 | if (newStateId === undefined) { | ||
299 | this.cancelUpdate(); | ||
300 | } else { | ||
301 | this.xtextStateId = newStateId; | ||
302 | this.pendingChanges = undefined; | ||
303 | } | ||
304 | return data; | ||
305 | }, | ||
306 | }; | ||
307 | 174 | ||
308 | private cancelUpdate(): void { | 175 | setStateIdExclusive( |
309 | if (this.pendingChanges === undefined) { | 176 | newStateId: string, |
310 | return; | 177 | remoteChanges?: ChangeSpec | undefined, |
178 | ): void { | ||
179 | this.ensureLocked(); | ||
180 | if (remoteChanges !== undefined) { | ||
181 | this.applyRemoteChangesExclusive(remoteChanges); | ||
311 | } | 182 | } |
312 | this.dirtyChanges = this.pendingChanges.compose(this.dirtyChanges); | 183 | this.xtextStateId = newStateId; |
313 | this.pendingChanges = undefined; | 184 | this.pendingChanges = undefined; |
314 | } | 185 | } |
315 | 186 | ||
316 | runExclusive<T>( | 187 | private applyRemoteChangesExclusive(changeSpec: ChangeSpec): void { |
317 | callback: (lockedState: LockedState) => Promise<T>, | 188 | const pendingChanges = |
318 | ): Promise<T> { | 189 | this.pendingChanges?.compose(this.dirtyChanges) ?? this.dirtyChanges; |
190 | const revertChanges = pendingChanges.invert(this.store.state.doc); | ||
191 | const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); | ||
192 | const redoChanges = pendingChanges.map(applyBefore.desc); | ||
193 | const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); | ||
194 | this.store.dispatch({ | ||
195 | changes: changeSet, | ||
196 | // Keep the current set of dirty changes (but update them according the re-formatting) | ||
197 | // and to not add the formatting the dirty changes. | ||
198 | effects: [setDirtyChanges.of(redoChanges)], | ||
199 | }); | ||
200 | } | ||
201 | |||
202 | private ensureLocked(): void { | ||
203 | if (!this.lockedForUpdate) { | ||
204 | throw new Error('Cannot update state without locking the mutex'); | ||
205 | } | ||
206 | } | ||
207 | |||
208 | runExclusive<T>(callback: () => Promise<T>): Promise<T> { | ||
319 | return this.mutex.runExclusive(async () => { | 209 | return this.mutex.runExclusive(async () => { |
320 | if (this.pendingChanges !== undefined) { | 210 | try { |
321 | throw new Error('Update is pending before entering critical section'); | 211 | return await callback(); |
322 | } | 212 | } finally { |
323 | const result = await callback(this.lockedState); | 213 | if (this.pendingChanges !== undefined) { |
324 | if (this.pendingChanges !== undefined) { | 214 | this.dirtyChanges = this.pendingChanges.compose(this.dirtyChanges); |
325 | throw new Error('Update is pending after entering critical section'); | 215 | this.pendingChanges = undefined; |
216 | } | ||
326 | } | 217 | } |
327 | return result; | ||
328 | }); | 218 | }); |
329 | } | 219 | } |
330 | 220 | ||
331 | runExclusiveHighPriority<T>( | 221 | runExclusiveHighPriority<T>(callback: () => Promise<T>): Promise<T> { |
332 | callback: (lockedState: LockedState) => Promise<T>, | ||
333 | ): Promise<T> { | ||
334 | this.mutex.cancel(); | 222 | this.mutex.cancel(); |
335 | return this.runExclusive(callback); | 223 | return this.runExclusive(callback); |
336 | } | 224 | } |
337 | 225 | ||
338 | async runExclusiveWithRetries<T>( | 226 | async runExclusiveWithRetries<T>( |
339 | callback: (lockedState: LockedState) => Promise<T>, | 227 | callback: () => Promise<T>, |
340 | maxRetries = 5, | 228 | maxRetries = 5, |
341 | ): Promise<T> { | 229 | ): Promise<T> { |
342 | let retries = 0; | 230 | let retries = 0; |