@fluidframework/task-manager 2.53.1 → 2.61.0-355054
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.cjs +1 -2
- package/CHANGELOG.md +4 -0
- package/api-report/{task-manager.legacy.alpha.api.md → task-manager.legacy.beta.api.md} +6 -6
- package/dist/interfaces.d.ts +6 -11
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/interfaces.js.map +1 -1
- package/dist/legacy.d.ts +2 -1
- package/dist/packageVersion.d.ts +1 -1
- package/dist/packageVersion.d.ts.map +1 -1
- package/dist/packageVersion.js +1 -1
- package/dist/packageVersion.js.map +1 -1
- package/dist/taskManager.d.ts +22 -9
- package/dist/taskManager.d.ts.map +1 -1
- package/dist/taskManager.js +267 -172
- package/dist/taskManager.js.map +1 -1
- package/dist/taskManagerFactory.d.ts +2 -4
- package/dist/taskManagerFactory.d.ts.map +1 -1
- package/dist/taskManagerFactory.js +1 -2
- package/dist/taskManagerFactory.js.map +1 -1
- package/internal.d.ts +1 -1
- package/legacy.d.ts +1 -1
- package/lib/interfaces.d.ts +6 -11
- package/lib/interfaces.d.ts.map +1 -1
- package/lib/interfaces.js.map +1 -1
- package/lib/legacy.d.ts +2 -1
- package/lib/packageVersion.d.ts +1 -1
- package/lib/packageVersion.d.ts.map +1 -1
- package/lib/packageVersion.js +1 -1
- package/lib/packageVersion.js.map +1 -1
- package/lib/taskManager.d.ts +22 -9
- package/lib/taskManager.d.ts.map +1 -1
- package/lib/taskManager.js +254 -159
- package/lib/taskManager.js.map +1 -1
- package/lib/taskManagerFactory.d.ts +2 -4
- package/lib/taskManagerFactory.d.ts.map +1 -1
- package/lib/taskManagerFactory.js +1 -2
- package/lib/taskManagerFactory.js.map +1 -1
- package/lib/tsdoc-metadata.json +1 -1
- package/package.json +39 -26
- package/src/interfaces.ts +6 -11
- package/src/packageVersion.ts +1 -1
- package/src/taskManager.ts +294 -180
- package/src/taskManagerFactory.ts +2 -4
package/src/taskManager.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { EventEmitter } from "@fluid-internal/client-utils";
|
|
7
|
-
import
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
AttachState,
|
|
9
|
+
type ReadOnlyInfo,
|
|
10
|
+
} from "@fluidframework/container-definitions/internal";
|
|
11
|
+
import { assert } from "@fluidframework/core-utils/internal";
|
|
9
12
|
import type {
|
|
10
13
|
IChannelAttributes,
|
|
11
14
|
IFluidDataStoreRuntime,
|
|
@@ -51,6 +54,18 @@ interface IPendingOp {
|
|
|
51
54
|
messageId: number;
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
function assertIsTaskManagerOperation(op: unknown): asserts op is ITaskManagerOperation {
|
|
58
|
+
assert(
|
|
59
|
+
typeof op === "object" &&
|
|
60
|
+
op !== null &&
|
|
61
|
+
"taskId" in op &&
|
|
62
|
+
typeof op.taskId === "string" &&
|
|
63
|
+
"type" in op &&
|
|
64
|
+
(op.type === "volunteer" || op.type === "abandon" || op.type === "complete"),
|
|
65
|
+
"Not a TaskManager operation",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
const snapshotFileName = "header";
|
|
55
70
|
|
|
56
71
|
/**
|
|
@@ -62,8 +77,7 @@ const placeholderClientId = "placeholder";
|
|
|
62
77
|
* {@inheritDoc ITaskManager}
|
|
63
78
|
*
|
|
64
79
|
* @sealed
|
|
65
|
-
* @legacy
|
|
66
|
-
* @alpha
|
|
80
|
+
* @legacy @beta
|
|
67
81
|
*/
|
|
68
82
|
export class TaskManagerClass
|
|
69
83
|
extends SharedObject<ITaskManagerEvents>
|
|
@@ -85,23 +99,20 @@ export class TaskManagerClass
|
|
|
85
99
|
private readonly connectionWatcher: EventEmitter = new EventEmitter();
|
|
86
100
|
// completedWatcher emits an event whenever the local client receives a completed op.
|
|
87
101
|
private readonly completedWatcher: EventEmitter = new EventEmitter();
|
|
102
|
+
// rollbackWatcher emits an event whenever a pending op is rolled back.
|
|
103
|
+
private readonly rollbackWatcher: EventEmitter = new EventEmitter();
|
|
88
104
|
|
|
89
|
-
private
|
|
105
|
+
private nextPendingMessageId: number = 0;
|
|
90
106
|
/**
|
|
91
107
|
* Tracks the most recent pending op for a given task
|
|
92
108
|
*/
|
|
93
|
-
private readonly latestPendingOps = new Map<string, IPendingOp>();
|
|
109
|
+
private readonly latestPendingOps = new Map<string, IPendingOp[]>();
|
|
94
110
|
|
|
95
111
|
/**
|
|
96
112
|
* Tracks tasks that are this client is currently subscribed to.
|
|
97
113
|
*/
|
|
98
114
|
private readonly subscribedTasks = new Set<string>();
|
|
99
115
|
|
|
100
|
-
/**
|
|
101
|
-
* Map to track tasks that have pending complete ops.
|
|
102
|
-
*/
|
|
103
|
-
private readonly pendingCompletedTasks = new Map<string, number[]>();
|
|
104
|
-
|
|
105
116
|
/**
|
|
106
117
|
* Returns the clientId. Will return a placeholder if the runtime is detached and not yet assigned a clientId.
|
|
107
118
|
*/
|
|
@@ -132,16 +143,17 @@ export class TaskManagerClass
|
|
|
132
143
|
|
|
133
144
|
this.opWatcher.on(
|
|
134
145
|
"volunteer",
|
|
135
|
-
(taskId: string, clientId: string, local: boolean, messageId: number) => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const pendingOp =
|
|
140
|
-
assert(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
(taskId: string, clientId: string, local: boolean, messageId: number | undefined) => {
|
|
147
|
+
if (local) {
|
|
148
|
+
const latestPendingOps = this.latestPendingOps.get(taskId);
|
|
149
|
+
assert(latestPendingOps !== undefined, "No pending ops for task");
|
|
150
|
+
const pendingOp = latestPendingOps.shift();
|
|
151
|
+
assert(
|
|
152
|
+
pendingOp !== undefined && pendingOp.messageId === messageId,
|
|
153
|
+
"Unexpected op",
|
|
154
|
+
);
|
|
155
|
+
assert(pendingOp.type === "volunteer", 0x07c /* "Unexpected op type" */);
|
|
156
|
+
if (latestPendingOps.length === 0) {
|
|
145
157
|
this.latestPendingOps.delete(taskId);
|
|
146
158
|
}
|
|
147
159
|
}
|
|
@@ -152,16 +164,20 @@ export class TaskManagerClass
|
|
|
152
164
|
|
|
153
165
|
this.opWatcher.on(
|
|
154
166
|
"abandon",
|
|
155
|
-
(taskId: string, clientId: string, local: boolean, messageId: number) => {
|
|
156
|
-
if (
|
|
157
|
-
const
|
|
158
|
-
assert(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
(taskId: string, clientId: string, local: boolean, messageId: number | undefined) => {
|
|
168
|
+
if (local) {
|
|
169
|
+
const latestPendingOps = this.latestPendingOps.get(taskId);
|
|
170
|
+
assert(latestPendingOps !== undefined, "No pending ops for task");
|
|
171
|
+
const pendingOp = latestPendingOps.shift();
|
|
172
|
+
assert(
|
|
173
|
+
pendingOp !== undefined && pendingOp.messageId === messageId,
|
|
174
|
+
"Unexpected op",
|
|
175
|
+
);
|
|
176
|
+
assert(pendingOp.type === "abandon", 0x07e /* "Unexpected op type" */);
|
|
177
|
+
if (latestPendingOps.length === 0) {
|
|
163
178
|
this.latestPendingOps.delete(taskId);
|
|
164
179
|
}
|
|
180
|
+
this.abandonWatcher.emit("abandon", taskId, messageId);
|
|
165
181
|
}
|
|
166
182
|
|
|
167
183
|
this.removeClientFromQueue(taskId, clientId);
|
|
@@ -170,33 +186,24 @@ export class TaskManagerClass
|
|
|
170
186
|
|
|
171
187
|
this.opWatcher.on(
|
|
172
188
|
"complete",
|
|
173
|
-
(taskId: string, clientId: string, local: boolean, messageId: number) => {
|
|
174
|
-
if (
|
|
175
|
-
const
|
|
176
|
-
assert(
|
|
177
|
-
|
|
178
|
-
if (messageId === pendingOp.messageId) {
|
|
179
|
-
assert(pendingOp.type === "complete", 0x401 /* Unexpected op type */);
|
|
180
|
-
// Delete the pending, because we no longer have an outstanding op
|
|
181
|
-
this.latestPendingOps.delete(taskId);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Remove complete op from this.pendingCompletedTasks
|
|
185
|
-
const pendingIds = this.pendingCompletedTasks.get(taskId);
|
|
189
|
+
(taskId: string, clientId: string, local: boolean, messageId: number | undefined) => {
|
|
190
|
+
if (local) {
|
|
191
|
+
const latestPendingOps = this.latestPendingOps.get(taskId);
|
|
192
|
+
assert(latestPendingOps !== undefined, "No pending ops for task");
|
|
193
|
+
const pendingOp = latestPendingOps.shift();
|
|
186
194
|
assert(
|
|
187
|
-
|
|
188
|
-
|
|
195
|
+
pendingOp !== undefined && pendingOp.messageId === messageId,
|
|
196
|
+
"Unexpected op",
|
|
189
197
|
);
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
assert(pendingOp.type === "complete", 0x401 /* Unexpected op type */);
|
|
199
|
+
if (latestPendingOps.length === 0) {
|
|
200
|
+
this.latestPendingOps.delete(taskId);
|
|
201
|
+
}
|
|
192
202
|
}
|
|
193
203
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this.completedWatcher.emit("completed", taskId);
|
|
198
|
-
this.emit("completed", taskId);
|
|
199
|
-
}
|
|
204
|
+
this.taskQueues.delete(taskId);
|
|
205
|
+
this.completedWatcher.emit("completed", taskId, messageId);
|
|
206
|
+
this.emit("completed", taskId);
|
|
200
207
|
},
|
|
201
208
|
);
|
|
202
209
|
|
|
@@ -229,18 +236,16 @@ export class TaskManagerClass
|
|
|
229
236
|
this.connectionWatcher.on("disconnect", () => {
|
|
230
237
|
assert(this.clientId !== undefined, 0x1d3 /* "Missing client id on disconnect" */);
|
|
231
238
|
|
|
232
|
-
//
|
|
233
|
-
// After reconnect these will get cleaned up by observing the clientLeaves.
|
|
234
|
-
// However we do need to recognize that we lost the lock if we had it. Calls to .queued() and
|
|
235
|
-
// .assigned() are also connection-state-aware to be consistent.
|
|
239
|
+
// Emit "lost" for any tasks we were assigned to.
|
|
236
240
|
for (const [taskId, clientQueue] of this.taskQueues.entries()) {
|
|
237
241
|
if (this.isAttached() && clientQueue[0] === this.clientId) {
|
|
238
242
|
this.emit("lost", taskId);
|
|
239
243
|
}
|
|
240
244
|
}
|
|
241
245
|
|
|
242
|
-
//
|
|
243
|
-
this.
|
|
246
|
+
// Remove this client from all queues to reflect the new state, since being disconnected automatically removes
|
|
247
|
+
// this client from all queues.
|
|
248
|
+
this.removeClientFromAllQueues(this.clientId);
|
|
244
249
|
});
|
|
245
250
|
}
|
|
246
251
|
|
|
@@ -251,10 +256,15 @@ export class TaskManagerClass
|
|
|
251
256
|
};
|
|
252
257
|
const pendingOp: IPendingOp = {
|
|
253
258
|
type: "volunteer",
|
|
254
|
-
messageId:
|
|
259
|
+
messageId: this.nextPendingMessageId++,
|
|
255
260
|
};
|
|
256
261
|
this.submitLocalMessage(op, pendingOp.messageId);
|
|
257
|
-
this.latestPendingOps.
|
|
262
|
+
const latestPendingOps = this.latestPendingOps.get(taskId);
|
|
263
|
+
if (latestPendingOps === undefined) {
|
|
264
|
+
this.latestPendingOps.set(taskId, [pendingOp]);
|
|
265
|
+
} else {
|
|
266
|
+
latestPendingOps.push(pendingOp);
|
|
267
|
+
}
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
private submitAbandonOp(taskId: string): void {
|
|
@@ -264,10 +274,15 @@ export class TaskManagerClass
|
|
|
264
274
|
};
|
|
265
275
|
const pendingOp: IPendingOp = {
|
|
266
276
|
type: "abandon",
|
|
267
|
-
messageId:
|
|
277
|
+
messageId: this.nextPendingMessageId++,
|
|
268
278
|
};
|
|
269
279
|
this.submitLocalMessage(op, pendingOp.messageId);
|
|
270
|
-
this.latestPendingOps.
|
|
280
|
+
const latestPendingOps = this.latestPendingOps.get(taskId);
|
|
281
|
+
if (latestPendingOps === undefined) {
|
|
282
|
+
this.latestPendingOps.set(taskId, [pendingOp]);
|
|
283
|
+
} else {
|
|
284
|
+
latestPendingOps.push(pendingOp);
|
|
285
|
+
}
|
|
271
286
|
}
|
|
272
287
|
|
|
273
288
|
private submitCompleteOp(taskId: string): void {
|
|
@@ -277,25 +292,26 @@ export class TaskManagerClass
|
|
|
277
292
|
};
|
|
278
293
|
const pendingOp: IPendingOp = {
|
|
279
294
|
type: "complete",
|
|
280
|
-
messageId:
|
|
295
|
+
messageId: this.nextPendingMessageId++,
|
|
281
296
|
};
|
|
282
297
|
|
|
283
|
-
|
|
284
|
-
|
|
298
|
+
this.submitLocalMessage(op, pendingOp.messageId);
|
|
299
|
+
const latestPendingOps = this.latestPendingOps.get(taskId);
|
|
300
|
+
if (latestPendingOps === undefined) {
|
|
301
|
+
this.latestPendingOps.set(taskId, [pendingOp]);
|
|
285
302
|
} else {
|
|
286
|
-
|
|
303
|
+
latestPendingOps.push(pendingOp);
|
|
287
304
|
}
|
|
288
|
-
|
|
289
|
-
this.submitLocalMessage(op, pendingOp.messageId);
|
|
290
|
-
this.latestPendingOps.set(taskId, pendingOp);
|
|
291
305
|
}
|
|
292
306
|
|
|
293
307
|
/**
|
|
294
308
|
* {@inheritDoc ITaskManager.volunteerForTask}
|
|
295
309
|
*/
|
|
296
310
|
public async volunteerForTask(taskId: string): Promise<boolean> {
|
|
297
|
-
// If we have the lock
|
|
298
|
-
|
|
311
|
+
// If we are both queued and assigned, then we have the lock and do not
|
|
312
|
+
// have any pending abandon/complete ops. In this case we can resolve
|
|
313
|
+
// true immediately.
|
|
314
|
+
if (this.queuedOptimistically(taskId) && this.assigned(taskId)) {
|
|
299
315
|
return true;
|
|
300
316
|
}
|
|
301
317
|
|
|
@@ -307,7 +323,7 @@ export class TaskManagerClass
|
|
|
307
323
|
throw error;
|
|
308
324
|
}
|
|
309
325
|
|
|
310
|
-
if (
|
|
326
|
+
if (this.isDetached()) {
|
|
311
327
|
// Simulate auto-ack in detached scenario
|
|
312
328
|
assert(this.clientId !== undefined, 0x472 /* clientId should not be undefined */);
|
|
313
329
|
this.addClientToQueue(taskId, this.clientId);
|
|
@@ -320,62 +336,82 @@ export class TaskManagerClass
|
|
|
320
336
|
|
|
321
337
|
// This promise works even if we already have an outstanding volunteer op.
|
|
322
338
|
const lockAcquireP = new Promise<boolean>((resolve, reject) => {
|
|
339
|
+
// If we don't send an op (meaning the latest pending op is "volunteer"), nextPendingMessageId
|
|
340
|
+
// will be greater than that prior "volunteer" op's messageId. This is OK because
|
|
341
|
+
// we only use it to filter stale abandon/complete, and not when determining if we
|
|
342
|
+
// acquired the lock.
|
|
343
|
+
const nextPendingMessageId = this.nextPendingMessageId;
|
|
344
|
+
const setupListeners = (): void => {
|
|
345
|
+
this.queueWatcher.on("queueChange", checkIfAcquiredLock);
|
|
346
|
+
this.abandonWatcher.on("abandon", checkIfAbandoned);
|
|
347
|
+
this.connectionWatcher.on("disconnect", rejectOnDisconnect);
|
|
348
|
+
this.completedWatcher.on("completed", checkIfCompleted);
|
|
349
|
+
this.rollbackWatcher.on("rollback", checkIfRolledBack);
|
|
350
|
+
};
|
|
351
|
+
const removeListeners = (): void => {
|
|
352
|
+
this.queueWatcher.off("queueChange", checkIfAcquiredLock);
|
|
353
|
+
this.abandonWatcher.off("abandon", checkIfAbandoned);
|
|
354
|
+
this.connectionWatcher.off("disconnect", rejectOnDisconnect);
|
|
355
|
+
this.completedWatcher.off("completed", checkIfCompleted);
|
|
356
|
+
this.rollbackWatcher.off("rollback", checkIfRolledBack);
|
|
357
|
+
};
|
|
358
|
+
|
|
323
359
|
const checkIfAcquiredLock = (eventTaskId: string): void => {
|
|
324
360
|
if (eventTaskId !== taskId) {
|
|
325
361
|
return;
|
|
326
362
|
}
|
|
327
|
-
|
|
328
363
|
// Also check pending ops here because it's possible we are currently in the queue from a previous
|
|
329
364
|
// lock attempt, but have an outstanding abandon AND the outstanding volunteer for this lock attempt.
|
|
330
365
|
// If we reach the head of the queue based on the previous lock attempt, we don't want to resolve.
|
|
331
|
-
if (this.assigned(taskId)
|
|
332
|
-
|
|
333
|
-
this.abandonWatcher.off("abandon", checkIfAbandoned);
|
|
334
|
-
this.connectionWatcher.off("disconnect", rejectOnDisconnect);
|
|
335
|
-
this.completedWatcher.off("completed", checkIfCompleted);
|
|
366
|
+
if (this.assigned(taskId)) {
|
|
367
|
+
removeListeners();
|
|
336
368
|
resolve(true);
|
|
337
369
|
}
|
|
338
370
|
};
|
|
339
371
|
|
|
340
|
-
const checkIfAbandoned = (eventTaskId: string): void => {
|
|
372
|
+
const checkIfAbandoned = (eventTaskId: string, messageId: number | undefined): void => {
|
|
341
373
|
if (eventTaskId !== taskId) {
|
|
342
374
|
return;
|
|
343
375
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
376
|
+
if (messageId !== undefined && messageId <= nextPendingMessageId) {
|
|
377
|
+
// Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
removeListeners();
|
|
349
381
|
reject(new Error("Abandoned before acquiring task assignment"));
|
|
350
382
|
};
|
|
351
383
|
|
|
352
384
|
const rejectOnDisconnect = (): void => {
|
|
353
|
-
|
|
354
|
-
this.abandonWatcher.off("abandon", checkIfAbandoned);
|
|
355
|
-
this.connectionWatcher.off("disconnect", rejectOnDisconnect);
|
|
356
|
-
this.completedWatcher.off("completed", checkIfCompleted);
|
|
385
|
+
removeListeners();
|
|
357
386
|
reject(new Error("Disconnected before acquiring task assignment"));
|
|
358
387
|
};
|
|
359
388
|
|
|
360
|
-
const checkIfCompleted = (eventTaskId: string): void => {
|
|
389
|
+
const checkIfCompleted = (eventTaskId: string, messageId: number | undefined): void => {
|
|
361
390
|
if (eventTaskId !== taskId) {
|
|
362
391
|
return;
|
|
363
392
|
}
|
|
393
|
+
if (messageId !== undefined && messageId <= nextPendingMessageId) {
|
|
394
|
+
// Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
removeListeners();
|
|
398
|
+
resolve(false);
|
|
399
|
+
};
|
|
364
400
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
401
|
+
const checkIfRolledBack = (eventTaskId: string): void => {
|
|
402
|
+
if (eventTaskId !== taskId) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
removeListeners();
|
|
369
407
|
resolve(false);
|
|
370
408
|
};
|
|
371
409
|
|
|
372
|
-
|
|
373
|
-
this.abandonWatcher.on("abandon", checkIfAbandoned);
|
|
374
|
-
this.connectionWatcher.on("disconnect", rejectOnDisconnect);
|
|
375
|
-
this.completedWatcher.on("completed", checkIfCompleted);
|
|
410
|
+
setupListeners();
|
|
376
411
|
});
|
|
377
412
|
|
|
378
|
-
if (!this.
|
|
413
|
+
if (!this.queuedOptimistically(taskId)) {
|
|
414
|
+
// Only send the volunteer op if we are not already queued.
|
|
379
415
|
this.submitVolunteerOp(taskId);
|
|
380
416
|
}
|
|
381
417
|
return lockAcquireP;
|
|
@@ -393,46 +429,82 @@ export class TaskManagerClass
|
|
|
393
429
|
throw new Error("Attempted to subscribe with read-only permissions");
|
|
394
430
|
}
|
|
395
431
|
|
|
432
|
+
let volunteerOpMessageId: number | undefined;
|
|
433
|
+
|
|
396
434
|
const submitVolunteerOp = (): void => {
|
|
435
|
+
volunteerOpMessageId = this.nextPendingMessageId;
|
|
397
436
|
this.submitVolunteerOp(taskId);
|
|
398
437
|
};
|
|
399
438
|
|
|
439
|
+
const setupListeners = (): void => {
|
|
440
|
+
this.abandonWatcher.on("abandon", checkIfAbandoned);
|
|
441
|
+
this.connectionWatcher.on("disconnect", disconnectHandler);
|
|
442
|
+
this.completedWatcher.on("completed", checkIfCompleted);
|
|
443
|
+
this.rollbackWatcher.on("rollback", checkIfRolledBack);
|
|
444
|
+
};
|
|
445
|
+
const removeListeners = (): void => {
|
|
446
|
+
this.abandonWatcher.off("abandon", checkIfAbandoned);
|
|
447
|
+
this.connectionWatcher.off("disconnect", disconnectHandler);
|
|
448
|
+
this.connectionWatcher.off("connect", submitVolunteerOp);
|
|
449
|
+
this.completedWatcher.off("completed", checkIfCompleted);
|
|
450
|
+
this.rollbackWatcher.off("rollback", checkIfRolledBack);
|
|
451
|
+
};
|
|
452
|
+
|
|
400
453
|
const disconnectHandler = (): void => {
|
|
401
454
|
// Wait to be connected again and then re-submit volunteer op
|
|
402
455
|
this.connectionWatcher.once("connect", submitVolunteerOp);
|
|
403
456
|
};
|
|
404
457
|
|
|
405
|
-
const checkIfAbandoned = (eventTaskId: string): void => {
|
|
458
|
+
const checkIfAbandoned = (eventTaskId: string, messageId: number | undefined): void => {
|
|
406
459
|
if (eventTaskId !== taskId) {
|
|
407
460
|
return;
|
|
408
461
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
this.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
462
|
+
// abandonWatcher emits twice for a local abandon() call. When initially called it
|
|
463
|
+
// will emit with undefined messageId. It will emit a second time when the op is
|
|
464
|
+
// ack'd and processed, this time with the messageId for the ack.
|
|
465
|
+
// This condition accounts ensures we don't ignore the initial abandon() emit and
|
|
466
|
+
// only ignore emits associated with ack'd abandon ops that were sent prior to the
|
|
467
|
+
// current volunteer attempt.
|
|
468
|
+
if (
|
|
469
|
+
messageId !== undefined &&
|
|
470
|
+
volunteerOpMessageId !== undefined &&
|
|
471
|
+
messageId <= volunteerOpMessageId
|
|
472
|
+
) {
|
|
473
|
+
// Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
removeListeners();
|
|
415
477
|
this.subscribedTasks.delete(taskId);
|
|
416
478
|
};
|
|
417
479
|
|
|
418
|
-
const checkIfCompleted = (eventTaskId: string): void => {
|
|
480
|
+
const checkIfCompleted = (eventTaskId: string, messageId: number | undefined): void => {
|
|
419
481
|
if (eventTaskId !== taskId) {
|
|
420
482
|
return;
|
|
421
483
|
}
|
|
484
|
+
if (
|
|
485
|
+
messageId !== undefined &&
|
|
486
|
+
volunteerOpMessageId !== undefined &&
|
|
487
|
+
messageId <= volunteerOpMessageId
|
|
488
|
+
) {
|
|
489
|
+
// Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
removeListeners();
|
|
493
|
+
this.subscribedTasks.delete(taskId);
|
|
494
|
+
};
|
|
422
495
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
496
|
+
const checkIfRolledBack = (eventTaskId: string): void => {
|
|
497
|
+
if (eventTaskId !== taskId) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
427
500
|
|
|
501
|
+
removeListeners();
|
|
428
502
|
this.subscribedTasks.delete(taskId);
|
|
429
503
|
};
|
|
430
504
|
|
|
431
|
-
|
|
432
|
-
this.connectionWatcher.on("disconnect", disconnectHandler);
|
|
433
|
-
this.completedWatcher.on("completed", checkIfCompleted);
|
|
505
|
+
setupListeners();
|
|
434
506
|
|
|
435
|
-
if (
|
|
507
|
+
if (this.isDetached()) {
|
|
436
508
|
// Simulate auto-ack in detached scenario
|
|
437
509
|
assert(this.clientId !== undefined, 0x473 /* clientId should not be undefined */);
|
|
438
510
|
this.addClientToQueue(taskId, this.clientId);
|
|
@@ -440,22 +512,19 @@ export class TaskManagerClass
|
|
|
440
512
|
// a real clientId. At that point we should re-enter the queue with a real volunteer op (assuming we are
|
|
441
513
|
// connected).
|
|
442
514
|
this.runtime.once("attached", () => {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return;
|
|
447
|
-
} else if (this.connected) {
|
|
515
|
+
// We call scrubClientsNotInQuorum() in case our clientId changed during the attach process.
|
|
516
|
+
this.scrubClientsNotInQuorum();
|
|
517
|
+
if (this.connected) {
|
|
448
518
|
submitVolunteerOp();
|
|
449
519
|
} else {
|
|
450
|
-
this.connectionWatcher.once("connect",
|
|
451
|
-
submitVolunteerOp();
|
|
452
|
-
});
|
|
520
|
+
this.connectionWatcher.once("connect", submitVolunteerOp);
|
|
453
521
|
}
|
|
454
522
|
});
|
|
455
523
|
} else if (!this.connected) {
|
|
456
524
|
// If we are disconnected (and attached), wait to be connected and submit volunteer op
|
|
457
525
|
disconnectHandler();
|
|
458
|
-
} else if (!this.
|
|
526
|
+
} else if (!this.queuedOptimistically(taskId)) {
|
|
527
|
+
// We don't need to send a second volunteer op if we just sent one.
|
|
459
528
|
submitVolunteerOp();
|
|
460
529
|
}
|
|
461
530
|
this.subscribedTasks.add(taskId);
|
|
@@ -466,14 +535,13 @@ export class TaskManagerClass
|
|
|
466
535
|
*/
|
|
467
536
|
public abandon(taskId: string): void {
|
|
468
537
|
// Always allow abandon if the client is subscribed to allow clients to unsubscribe while disconnected.
|
|
469
|
-
// Otherwise, we should check to make sure the client is
|
|
470
|
-
|
|
471
|
-
if (!this.subscribed(taskId) && !this.queued(taskId)) {
|
|
538
|
+
// Otherwise, we should check to make sure the client is optimistically queued for the task before trying to abandon.
|
|
539
|
+
if (!this.queuedOptimistically(taskId) && !this.subscribed(taskId)) {
|
|
472
540
|
// Nothing to do
|
|
473
541
|
return;
|
|
474
542
|
}
|
|
475
543
|
|
|
476
|
-
if (
|
|
544
|
+
if (this.isDetached()) {
|
|
477
545
|
// Simulate auto-ack in detached scenario
|
|
478
546
|
assert(this.clientId !== undefined, 0x474 /* clientId is undefined */);
|
|
479
547
|
this.removeClientFromQueue(taskId, this.clientId);
|
|
@@ -481,10 +549,7 @@ export class TaskManagerClass
|
|
|
481
549
|
return;
|
|
482
550
|
}
|
|
483
551
|
|
|
484
|
-
|
|
485
|
-
if (this.queued(taskId)) {
|
|
486
|
-
this.submitAbandonOp(taskId);
|
|
487
|
-
}
|
|
552
|
+
this.submitAbandonOp(taskId);
|
|
488
553
|
this.abandonWatcher.emit("abandon", taskId);
|
|
489
554
|
}
|
|
490
555
|
|
|
@@ -497,11 +562,7 @@ export class TaskManagerClass
|
|
|
497
562
|
}
|
|
498
563
|
|
|
499
564
|
const currentAssignee = this.taskQueues.get(taskId)?.[0];
|
|
500
|
-
return
|
|
501
|
-
currentAssignee !== undefined &&
|
|
502
|
-
currentAssignee === this.clientId &&
|
|
503
|
-
!this.latestPendingOps.has(taskId)
|
|
504
|
-
);
|
|
565
|
+
return currentAssignee !== undefined && currentAssignee === this.clientId;
|
|
505
566
|
}
|
|
506
567
|
|
|
507
568
|
/**
|
|
@@ -513,14 +574,7 @@ export class TaskManagerClass
|
|
|
513
574
|
}
|
|
514
575
|
|
|
515
576
|
assert(this.clientId !== undefined, 0x07f /* "clientId undefined" */);
|
|
516
|
-
|
|
517
|
-
const clientQueue = this.taskQueues.get(taskId);
|
|
518
|
-
// If we have no queue for the taskId, then no one has signed up for it.
|
|
519
|
-
return (
|
|
520
|
-
((clientQueue?.includes(this.clientId) ?? false) &&
|
|
521
|
-
!this.latestPendingOps.has(taskId)) ||
|
|
522
|
-
this.latestPendingOps.get(taskId)?.type === "volunteer"
|
|
523
|
-
);
|
|
577
|
+
return this.taskQueues.get(taskId)?.includes(this.clientId) ?? false;
|
|
524
578
|
}
|
|
525
579
|
|
|
526
580
|
/**
|
|
@@ -540,16 +594,17 @@ export class TaskManagerClass
|
|
|
540
594
|
|
|
541
595
|
// If we are detached we will simulate auto-ack for the complete op. Therefore we only need to send the op if
|
|
542
596
|
// we are attached. Additionally, we don't need to check if we are connected while detached.
|
|
543
|
-
if (this.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
597
|
+
if (this.isDetached()) {
|
|
598
|
+
this.taskQueues.delete(taskId);
|
|
599
|
+
this.completedWatcher.emit("completed", taskId);
|
|
600
|
+
this.emit("completed", taskId);
|
|
601
|
+
return;
|
|
548
602
|
}
|
|
549
603
|
|
|
550
|
-
this.
|
|
551
|
-
|
|
552
|
-
|
|
604
|
+
if (!this.connected) {
|
|
605
|
+
throw new Error("Attempted to complete task in disconnected state");
|
|
606
|
+
}
|
|
607
|
+
this.submitCompleteOp(taskId);
|
|
553
608
|
}
|
|
554
609
|
|
|
555
610
|
/**
|
|
@@ -619,12 +674,24 @@ export class TaskManagerClass
|
|
|
619
674
|
this.connectionWatcher.emit("connect");
|
|
620
675
|
}
|
|
621
676
|
|
|
622
|
-
//
|
|
623
677
|
/**
|
|
624
678
|
* Override resubmit core to avoid resubmission on reconnect. On disconnect we accept our removal from the
|
|
625
679
|
* queues, and leave it up to the user to decide whether they want to attempt to re-enter a queue on reconnect.
|
|
680
|
+
* However, we do need to update latestPendingOps to account for the ops we will no longer be processing.
|
|
626
681
|
*/
|
|
627
|
-
protected reSubmitCore(): void {
|
|
682
|
+
protected reSubmitCore(content: unknown, localOpMetadata: number): void {
|
|
683
|
+
assertIsTaskManagerOperation(content);
|
|
684
|
+
const pendingOps = this.latestPendingOps.get(content.taskId);
|
|
685
|
+
assert(pendingOps !== undefined, "No pending ops for task on resubmit attempt");
|
|
686
|
+
const pendingOpIndex = pendingOps.findIndex(
|
|
687
|
+
(op) => op.messageId === localOpMetadata && op.type === content.type,
|
|
688
|
+
);
|
|
689
|
+
assert(pendingOpIndex !== -1, "Could not match pending op on resubmit attempt");
|
|
690
|
+
pendingOps.splice(pendingOpIndex, 1);
|
|
691
|
+
if (pendingOps.length === 0) {
|
|
692
|
+
this.latestPendingOps.delete(content.taskId);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
628
695
|
|
|
629
696
|
/**
|
|
630
697
|
* Process a task manager operation
|
|
@@ -637,12 +704,12 @@ export class TaskManagerClass
|
|
|
637
704
|
protected processCore(
|
|
638
705
|
message: ISequencedDocumentMessage,
|
|
639
706
|
local: boolean,
|
|
640
|
-
localOpMetadata:
|
|
707
|
+
localOpMetadata: number | undefined,
|
|
641
708
|
): void {
|
|
642
709
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
|
643
710
|
if (message.type === MessageType.Operation) {
|
|
644
711
|
const op = message.contents as ITaskManagerOperation;
|
|
645
|
-
const messageId = localOpMetadata
|
|
712
|
+
const messageId = localOpMetadata;
|
|
646
713
|
|
|
647
714
|
switch (op.type) {
|
|
648
715
|
case "volunteer": {
|
|
@@ -668,12 +735,6 @@ export class TaskManagerClass
|
|
|
668
735
|
}
|
|
669
736
|
|
|
670
737
|
private addClientToQueue(taskId: string, clientId: string): void {
|
|
671
|
-
const pendingIds = this.pendingCompletedTasks.get(taskId);
|
|
672
|
-
if (pendingIds !== undefined && pendingIds.length > 0) {
|
|
673
|
-
// Ignore the volunteer op if we know this task is about to be completed
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
738
|
// Ensure that the clientId exists in the quorum, or it is placeholderClientId (detached scenario)
|
|
678
739
|
if (
|
|
679
740
|
this.runtime.getQuorum().getMembers().has(clientId) ||
|
|
@@ -686,6 +747,13 @@ export class TaskManagerClass
|
|
|
686
747
|
this.taskQueues.set(taskId, clientQueue);
|
|
687
748
|
}
|
|
688
749
|
|
|
750
|
+
if (clientQueue.includes(clientId)) {
|
|
751
|
+
// We shouldn't re-add the client if it's already in the queue.
|
|
752
|
+
// This may be possible in scenarios where a client was added in
|
|
753
|
+
// while detached.
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
689
757
|
const oldLockHolder = clientQueue[0];
|
|
690
758
|
clientQueue.push(clientId);
|
|
691
759
|
const newLockHolder = clientQueue[0];
|
|
@@ -735,7 +803,12 @@ export class TaskManagerClass
|
|
|
735
803
|
for (const clientQueue of this.taskQueues.values()) {
|
|
736
804
|
const clientIdIndex = clientQueue.indexOf(placeholderClientId);
|
|
737
805
|
if (clientIdIndex !== -1) {
|
|
738
|
-
clientQueue
|
|
806
|
+
if (clientQueue.includes(this.runtime.clientId)) {
|
|
807
|
+
// If the real clientId is already in the queue, just remove the placeholder.
|
|
808
|
+
clientQueue.splice(clientIdIndex, 1);
|
|
809
|
+
} else {
|
|
810
|
+
clientQueue[clientIdIndex] = this.runtime.clientId;
|
|
811
|
+
}
|
|
739
812
|
}
|
|
740
813
|
}
|
|
741
814
|
}
|
|
@@ -759,24 +832,65 @@ export class TaskManagerClass
|
|
|
759
832
|
}
|
|
760
833
|
}
|
|
761
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Checks whether this client is currently assigned or in queue to become assigned, while also accounting
|
|
837
|
+
* for the latest pending ops.
|
|
838
|
+
*/
|
|
839
|
+
private queuedOptimistically(taskId: string): boolean {
|
|
840
|
+
if (this.isAttached() && !this.connected) {
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
assert(this.clientId !== undefined, 0x07f /* "clientId undefined" */);
|
|
845
|
+
|
|
846
|
+
const inQueue = this.taskQueues.get(taskId)?.includes(this.clientId) ?? false;
|
|
847
|
+
const latestPendingOps = this.latestPendingOps.get(taskId);
|
|
848
|
+
|
|
849
|
+
const latestPendingOp =
|
|
850
|
+
latestPendingOps !== undefined && latestPendingOps.length > 0
|
|
851
|
+
? latestPendingOps[latestPendingOps.length - 1]
|
|
852
|
+
: undefined;
|
|
853
|
+
const isPendingVolunteer = latestPendingOp?.type === "volunteer";
|
|
854
|
+
const isPendingAbandonOrComplete =
|
|
855
|
+
latestPendingOp?.type === "abandon" || latestPendingOp?.type === "complete";
|
|
856
|
+
// We return true if the client is either in queue already or the latest pending op for this task is a volunteer op.
|
|
857
|
+
// But we should always return false if the latest pending op is an abandon or complete op.
|
|
858
|
+
return (inQueue && !isPendingAbandonOrComplete) || isPendingVolunteer;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Returns true if the client is detached.
|
|
863
|
+
* This is distinct from !this.isAttached() because `isAttached()` also checks if `this._isBoundToContext`
|
|
864
|
+
* is true. We use `isDetached()` to determine if we should simulate auto-ack behavior for ops, which is
|
|
865
|
+
* mainly concerned with if we have been assigned a real clientId yet.
|
|
866
|
+
*/
|
|
867
|
+
private isDetached(): boolean {
|
|
868
|
+
return this.runtime.attachState === AttachState.Detached;
|
|
869
|
+
}
|
|
870
|
+
|
|
762
871
|
protected applyStashedOp(content: unknown): void {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
872
|
+
// We don't apply any stashed ops since during the rehydration process. Since we lose any assigned tasks
|
|
873
|
+
// during rehydration we cannot be assigned any tasks. Additionally, without the in-memory state of the
|
|
874
|
+
// previous dds, we also cannot re-volunteer based on a previous subscribeToTask() call. Since we are
|
|
875
|
+
// unable to be assigned to any tasks, there is no reason to process abandon/complete ops either.
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* {@inheritDoc @fluidframework/shared-object-base#SharedObject.rollback}
|
|
880
|
+
*/
|
|
881
|
+
protected rollback(content: unknown, localOpMetadata: unknown): void {
|
|
882
|
+
assert(typeof localOpMetadata === "number", "Expect localOpMetadata to be a number");
|
|
883
|
+
assertIsTaskManagerOperation(content);
|
|
884
|
+
const latestPendingOps = this.latestPendingOps.get(content.taskId);
|
|
885
|
+
assert(latestPendingOps !== undefined, "No pending ops when trying to rollback");
|
|
886
|
+
const pendingOpToRollback = latestPendingOps.pop();
|
|
887
|
+
assert(
|
|
888
|
+
pendingOpToRollback !== undefined && pendingOpToRollback.messageId === localOpMetadata,
|
|
889
|
+
"pending op mismatch",
|
|
890
|
+
);
|
|
891
|
+
if (latestPendingOps.length === 0) {
|
|
892
|
+
this.latestPendingOps.delete(content.taskId);
|
|
780
893
|
}
|
|
894
|
+
this.rollbackWatcher.emit("rollback", content.taskId);
|
|
781
895
|
}
|
|
782
896
|
}
|