@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.
Files changed (43) hide show
  1. package/.mocharc.cjs +1 -2
  2. package/CHANGELOG.md +4 -0
  3. package/api-report/{task-manager.legacy.alpha.api.md → task-manager.legacy.beta.api.md} +6 -6
  4. package/dist/interfaces.d.ts +6 -11
  5. package/dist/interfaces.d.ts.map +1 -1
  6. package/dist/interfaces.js.map +1 -1
  7. package/dist/legacy.d.ts +2 -1
  8. package/dist/packageVersion.d.ts +1 -1
  9. package/dist/packageVersion.d.ts.map +1 -1
  10. package/dist/packageVersion.js +1 -1
  11. package/dist/packageVersion.js.map +1 -1
  12. package/dist/taskManager.d.ts +22 -9
  13. package/dist/taskManager.d.ts.map +1 -1
  14. package/dist/taskManager.js +267 -172
  15. package/dist/taskManager.js.map +1 -1
  16. package/dist/taskManagerFactory.d.ts +2 -4
  17. package/dist/taskManagerFactory.d.ts.map +1 -1
  18. package/dist/taskManagerFactory.js +1 -2
  19. package/dist/taskManagerFactory.js.map +1 -1
  20. package/internal.d.ts +1 -1
  21. package/legacy.d.ts +1 -1
  22. package/lib/interfaces.d.ts +6 -11
  23. package/lib/interfaces.d.ts.map +1 -1
  24. package/lib/interfaces.js.map +1 -1
  25. package/lib/legacy.d.ts +2 -1
  26. package/lib/packageVersion.d.ts +1 -1
  27. package/lib/packageVersion.d.ts.map +1 -1
  28. package/lib/packageVersion.js +1 -1
  29. package/lib/packageVersion.js.map +1 -1
  30. package/lib/taskManager.d.ts +22 -9
  31. package/lib/taskManager.d.ts.map +1 -1
  32. package/lib/taskManager.js +254 -159
  33. package/lib/taskManager.js.map +1 -1
  34. package/lib/taskManagerFactory.d.ts +2 -4
  35. package/lib/taskManagerFactory.d.ts.map +1 -1
  36. package/lib/taskManagerFactory.js +1 -2
  37. package/lib/taskManagerFactory.js.map +1 -1
  38. package/lib/tsdoc-metadata.json +1 -1
  39. package/package.json +39 -26
  40. package/src/interfaces.ts +6 -11
  41. package/src/packageVersion.ts +1 -1
  42. package/src/taskManager.ts +294 -180
  43. package/src/taskManagerFactory.ts +2 -4
@@ -4,8 +4,11 @@
4
4
  */
5
5
 
6
6
  import { EventEmitter } from "@fluid-internal/client-utils";
7
- import type { ReadOnlyInfo } from "@fluidframework/container-definitions/internal";
8
- import { assert, unreachableCase } from "@fluidframework/core-utils/internal";
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 messageId: number = -1;
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
- // We're tracking local ops from this connection. Filter out local ops during "connecting"
137
- // state since these were sent on the prior connection and were already cleared from the latestPendingOps.
138
- if (runtime.connected && local) {
139
- const pendingOp = this.latestPendingOps.get(taskId);
140
- assert(pendingOp !== undefined, 0x07b /* "Unexpected op" */);
141
- // Need to check the id, since it's possible to volunteer and abandon multiple times before the acks
142
- if (messageId === pendingOp.messageId) {
143
- assert(pendingOp.type === "volunteer", 0x07c /* "Unexpected op type" */);
144
- // Delete the pending, because we no longer have an outstanding op
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 (runtime.connected && local) {
157
- const pendingOp = this.latestPendingOps.get(taskId);
158
- assert(pendingOp !== undefined, 0x07d /* "Unexpected op" */);
159
- // Need to check the id, since it's possible to abandon and volunteer multiple times before the acks
160
- if (messageId === pendingOp.messageId) {
161
- assert(pendingOp.type === "abandon", 0x07e /* "Unexpected op type" */);
162
- // Delete the pending, because we no longer have an outstanding op
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 (runtime.connected && local) {
175
- const pendingOp = this.latestPendingOps.get(taskId);
176
- assert(pendingOp !== undefined, 0x400 /* Unexpected op */);
177
- // Need to check the id, since it's possible to complete multiple times before the acks
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
- pendingIds !== undefined && pendingIds.length > 0,
188
- 0x402 /* pendingIds is empty */,
195
+ pendingOp !== undefined && pendingOp.messageId === messageId,
196
+ "Unexpected op",
189
197
  );
190
- const removed = pendingIds.shift();
191
- assert(removed === messageId, 0x403 /* Removed complete op id does not match */);
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
- // For clients in queue, we need to remove them from the queue and raise the proper events.
195
- if (!local) {
196
- this.taskQueues.delete(taskId);
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
- // We don't modify the taskQueues on disconnect (they still reflect the latest known consensus state).
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
- // All of our outstanding ops will be for the old clientId even if they get ack'd
243
- this.latestPendingOps.clear();
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: ++this.messageId,
259
+ messageId: this.nextPendingMessageId++,
255
260
  };
256
261
  this.submitLocalMessage(op, pendingOp.messageId);
257
- this.latestPendingOps.set(taskId, pendingOp);
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: ++this.messageId,
277
+ messageId: this.nextPendingMessageId++,
268
278
  };
269
279
  this.submitLocalMessage(op, pendingOp.messageId);
270
- this.latestPendingOps.set(taskId, pendingOp);
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: ++this.messageId,
295
+ messageId: this.nextPendingMessageId++,
281
296
  };
282
297
 
283
- if (this.pendingCompletedTasks.has(taskId)) {
284
- this.pendingCompletedTasks.get(taskId)?.push(pendingOp.messageId);
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
- this.pendingCompletedTasks.set(taskId, [pendingOp.messageId]);
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, resolve immediately
298
- if (this.assigned(taskId)) {
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 (!this.isAttached()) {
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) && !this.latestPendingOps.has(taskId)) {
332
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
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
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
346
- this.abandonWatcher.off("abandon", checkIfAbandoned);
347
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
348
- this.completedWatcher.off("completed", checkIfCompleted);
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
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
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
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
366
- this.abandonWatcher.off("abandon", checkIfAbandoned);
367
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
368
- this.completedWatcher.off("completed", checkIfCompleted);
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
- this.queueWatcher.on("queueChange", checkIfAcquiredLock);
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.queued(taskId)) {
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
- this.abandonWatcher.off("abandon", checkIfAbandoned);
411
- this.connectionWatcher.off("disconnect", disconnectHandler);
412
- this.connectionWatcher.off("connect", submitVolunteerOp);
413
- this.completedWatcher.off("completed", checkIfCompleted);
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
- this.abandonWatcher.off("abandon", checkIfAbandoned);
424
- this.connectionWatcher.off("disconnect", disconnectHandler);
425
- this.connectionWatcher.off("connect", submitVolunteerOp);
426
- this.completedWatcher.off("completed", checkIfCompleted);
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
- this.abandonWatcher.on("abandon", checkIfAbandoned);
432
- this.connectionWatcher.on("disconnect", disconnectHandler);
433
- this.completedWatcher.on("completed", checkIfCompleted);
505
+ setupListeners();
434
506
 
435
- if (!this.isAttached()) {
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
- if (this.queued(taskId)) {
444
- // If we are already queued, then we were able to replace the placeholderClientId with our real
445
- // clientId and no action is required.
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.assigned(taskId) && !this.queued(taskId)) {
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 both connected queued for the task before sending an
470
- // abandon op.
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 (!this.isAttached()) {
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
- // If we're subscribed but not queued, we don't need to submit an abandon op (probably offline)
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.isAttached()) {
544
- if (!this.connected) {
545
- throw new Error("Attempted to complete task in disconnected state");
546
- }
547
- this.submitCompleteOp(taskId);
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.taskQueues.delete(taskId);
551
- this.completedWatcher.emit("completed", taskId);
552
- this.emit("completed", taskId);
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: unknown,
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 as number;
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[clientIdIndex] = this.runtime.clientId;
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
- const taskOp: ITaskManagerOperation = content as ITaskManagerOperation;
764
- switch (taskOp.type) {
765
- case "abandon": {
766
- this.abandon(taskOp.taskId);
767
- break;
768
- }
769
- case "complete": {
770
- this.complete(taskOp.taskId);
771
- break;
772
- }
773
- case "volunteer": {
774
- this.subscribeToTask(taskOp.taskId);
775
- break;
776
- }
777
- default: {
778
- unreachableCase(taskOp);
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
  }