@fluidframework/task-manager 2.60.0 → 2.61.0-355516

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.
@@ -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
  /**
@@ -84,23 +99,20 @@ export class TaskManagerClass
84
99
  private readonly connectionWatcher: EventEmitter = new EventEmitter();
85
100
  // completedWatcher emits an event whenever the local client receives a completed op.
86
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();
87
104
 
88
- private messageId: number = -1;
105
+ private nextPendingMessageId: number = 0;
89
106
  /**
90
107
  * Tracks the most recent pending op for a given task
91
108
  */
92
- private readonly latestPendingOps = new Map<string, IPendingOp>();
109
+ private readonly latestPendingOps = new Map<string, IPendingOp[]>();
93
110
 
94
111
  /**
95
112
  * Tracks tasks that are this client is currently subscribed to.
96
113
  */
97
114
  private readonly subscribedTasks = new Set<string>();
98
115
 
99
- /**
100
- * Map to track tasks that have pending complete ops.
101
- */
102
- private readonly pendingCompletedTasks = new Map<string, number[]>();
103
-
104
116
  /**
105
117
  * Returns the clientId. Will return a placeholder if the runtime is detached and not yet assigned a clientId.
106
118
  */
@@ -131,16 +143,17 @@ export class TaskManagerClass
131
143
 
132
144
  this.opWatcher.on(
133
145
  "volunteer",
134
- (taskId: string, clientId: string, local: boolean, messageId: number) => {
135
- // We're tracking local ops from this connection. Filter out local ops during "connecting"
136
- // state since these were sent on the prior connection and were already cleared from the latestPendingOps.
137
- if (runtime.connected && local) {
138
- const pendingOp = this.latestPendingOps.get(taskId);
139
- assert(pendingOp !== undefined, 0x07b /* "Unexpected op" */);
140
- // Need to check the id, since it's possible to volunteer and abandon multiple times before the acks
141
- if (messageId === pendingOp.messageId) {
142
- assert(pendingOp.type === "volunteer", 0x07c /* "Unexpected op type" */);
143
- // 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) {
144
157
  this.latestPendingOps.delete(taskId);
145
158
  }
146
159
  }
@@ -151,16 +164,20 @@ export class TaskManagerClass
151
164
 
152
165
  this.opWatcher.on(
153
166
  "abandon",
154
- (taskId: string, clientId: string, local: boolean, messageId: number) => {
155
- if (runtime.connected && local) {
156
- const pendingOp = this.latestPendingOps.get(taskId);
157
- assert(pendingOp !== undefined, 0x07d /* "Unexpected op" */);
158
- // Need to check the id, since it's possible to abandon and volunteer multiple times before the acks
159
- if (messageId === pendingOp.messageId) {
160
- assert(pendingOp.type === "abandon", 0x07e /* "Unexpected op type" */);
161
- // 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) {
162
178
  this.latestPendingOps.delete(taskId);
163
179
  }
180
+ this.abandonWatcher.emit("abandon", taskId, messageId);
164
181
  }
165
182
 
166
183
  this.removeClientFromQueue(taskId, clientId);
@@ -169,33 +186,24 @@ export class TaskManagerClass
169
186
 
170
187
  this.opWatcher.on(
171
188
  "complete",
172
- (taskId: string, clientId: string, local: boolean, messageId: number) => {
173
- if (runtime.connected && local) {
174
- const pendingOp = this.latestPendingOps.get(taskId);
175
- assert(pendingOp !== undefined, 0x400 /* Unexpected op */);
176
- // Need to check the id, since it's possible to complete multiple times before the acks
177
- if (messageId === pendingOp.messageId) {
178
- assert(pendingOp.type === "complete", 0x401 /* Unexpected op type */);
179
- // Delete the pending, because we no longer have an outstanding op
180
- this.latestPendingOps.delete(taskId);
181
- }
182
-
183
- // Remove complete op from this.pendingCompletedTasks
184
- 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();
185
194
  assert(
186
- pendingIds !== undefined && pendingIds.length > 0,
187
- 0x402 /* pendingIds is empty */,
195
+ pendingOp !== undefined && pendingOp.messageId === messageId,
196
+ "Unexpected op",
188
197
  );
189
- const removed = pendingIds.shift();
190
- 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
+ }
191
202
  }
192
203
 
193
- // For clients in queue, we need to remove them from the queue and raise the proper events.
194
- if (!local) {
195
- this.taskQueues.delete(taskId);
196
- this.completedWatcher.emit("completed", taskId);
197
- this.emit("completed", taskId);
198
- }
204
+ this.taskQueues.delete(taskId);
205
+ this.completedWatcher.emit("completed", taskId, messageId);
206
+ this.emit("completed", taskId);
199
207
  },
200
208
  );
201
209
 
@@ -228,18 +236,16 @@ export class TaskManagerClass
228
236
  this.connectionWatcher.on("disconnect", () => {
229
237
  assert(this.clientId !== undefined, 0x1d3 /* "Missing client id on disconnect" */);
230
238
 
231
- // We don't modify the taskQueues on disconnect (they still reflect the latest known consensus state).
232
- // After reconnect these will get cleaned up by observing the clientLeaves.
233
- // However we do need to recognize that we lost the lock if we had it. Calls to .queued() and
234
- // .assigned() are also connection-state-aware to be consistent.
239
+ // Emit "lost" for any tasks we were assigned to.
235
240
  for (const [taskId, clientQueue] of this.taskQueues.entries()) {
236
241
  if (this.isAttached() && clientQueue[0] === this.clientId) {
237
242
  this.emit("lost", taskId);
238
243
  }
239
244
  }
240
245
 
241
- // All of our outstanding ops will be for the old clientId even if they get ack'd
242
- 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);
243
249
  });
244
250
  }
245
251
 
@@ -250,10 +256,15 @@ export class TaskManagerClass
250
256
  };
251
257
  const pendingOp: IPendingOp = {
252
258
  type: "volunteer",
253
- messageId: ++this.messageId,
259
+ messageId: this.nextPendingMessageId++,
254
260
  };
255
261
  this.submitLocalMessage(op, pendingOp.messageId);
256
- 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
+ }
257
268
  }
258
269
 
259
270
  private submitAbandonOp(taskId: string): void {
@@ -263,10 +274,15 @@ export class TaskManagerClass
263
274
  };
264
275
  const pendingOp: IPendingOp = {
265
276
  type: "abandon",
266
- messageId: ++this.messageId,
277
+ messageId: this.nextPendingMessageId++,
267
278
  };
268
279
  this.submitLocalMessage(op, pendingOp.messageId);
269
- 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
+ }
270
286
  }
271
287
 
272
288
  private submitCompleteOp(taskId: string): void {
@@ -276,25 +292,26 @@ export class TaskManagerClass
276
292
  };
277
293
  const pendingOp: IPendingOp = {
278
294
  type: "complete",
279
- messageId: ++this.messageId,
295
+ messageId: this.nextPendingMessageId++,
280
296
  };
281
297
 
282
- if (this.pendingCompletedTasks.has(taskId)) {
283
- 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]);
284
302
  } else {
285
- this.pendingCompletedTasks.set(taskId, [pendingOp.messageId]);
303
+ latestPendingOps.push(pendingOp);
286
304
  }
287
-
288
- this.submitLocalMessage(op, pendingOp.messageId);
289
- this.latestPendingOps.set(taskId, pendingOp);
290
305
  }
291
306
 
292
307
  /**
293
308
  * {@inheritDoc ITaskManager.volunteerForTask}
294
309
  */
295
310
  public async volunteerForTask(taskId: string): Promise<boolean> {
296
- // If we have the lock, resolve immediately
297
- 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)) {
298
315
  return true;
299
316
  }
300
317
 
@@ -306,7 +323,7 @@ export class TaskManagerClass
306
323
  throw error;
307
324
  }
308
325
 
309
- if (!this.isAttached()) {
326
+ if (this.isDetached()) {
310
327
  // Simulate auto-ack in detached scenario
311
328
  assert(this.clientId !== undefined, 0x472 /* clientId should not be undefined */);
312
329
  this.addClientToQueue(taskId, this.clientId);
@@ -319,62 +336,82 @@ export class TaskManagerClass
319
336
 
320
337
  // This promise works even if we already have an outstanding volunteer op.
321
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
+
322
359
  const checkIfAcquiredLock = (eventTaskId: string): void => {
323
360
  if (eventTaskId !== taskId) {
324
361
  return;
325
362
  }
326
-
327
363
  // Also check pending ops here because it's possible we are currently in the queue from a previous
328
364
  // lock attempt, but have an outstanding abandon AND the outstanding volunteer for this lock attempt.
329
365
  // If we reach the head of the queue based on the previous lock attempt, we don't want to resolve.
330
- if (this.assigned(taskId) && !this.latestPendingOps.has(taskId)) {
331
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
332
- this.abandonWatcher.off("abandon", checkIfAbandoned);
333
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
334
- this.completedWatcher.off("completed", checkIfCompleted);
366
+ if (this.assigned(taskId)) {
367
+ removeListeners();
335
368
  resolve(true);
336
369
  }
337
370
  };
338
371
 
339
- const checkIfAbandoned = (eventTaskId: string): void => {
372
+ const checkIfAbandoned = (eventTaskId: string, messageId: number | undefined): void => {
340
373
  if (eventTaskId !== taskId) {
341
374
  return;
342
375
  }
343
-
344
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
345
- this.abandonWatcher.off("abandon", checkIfAbandoned);
346
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
347
- 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();
348
381
  reject(new Error("Abandoned before acquiring task assignment"));
349
382
  };
350
383
 
351
384
  const rejectOnDisconnect = (): 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);
385
+ removeListeners();
356
386
  reject(new Error("Disconnected before acquiring task assignment"));
357
387
  };
358
388
 
359
- const checkIfCompleted = (eventTaskId: string): void => {
389
+ const checkIfCompleted = (eventTaskId: string, messageId: number | undefined): void => {
360
390
  if (eventTaskId !== taskId) {
361
391
  return;
362
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
+ };
363
400
 
364
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
365
- this.abandonWatcher.off("abandon", checkIfAbandoned);
366
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
367
- this.completedWatcher.off("completed", checkIfCompleted);
401
+ const checkIfRolledBack = (eventTaskId: string): void => {
402
+ if (eventTaskId !== taskId) {
403
+ return;
404
+ }
405
+
406
+ removeListeners();
368
407
  resolve(false);
369
408
  };
370
409
 
371
- this.queueWatcher.on("queueChange", checkIfAcquiredLock);
372
- this.abandonWatcher.on("abandon", checkIfAbandoned);
373
- this.connectionWatcher.on("disconnect", rejectOnDisconnect);
374
- this.completedWatcher.on("completed", checkIfCompleted);
410
+ setupListeners();
375
411
  });
376
412
 
377
- if (!this.queued(taskId)) {
413
+ if (!this.queuedOptimistically(taskId)) {
414
+ // Only send the volunteer op if we are not already queued.
378
415
  this.submitVolunteerOp(taskId);
379
416
  }
380
417
  return lockAcquireP;
@@ -392,46 +429,82 @@ export class TaskManagerClass
392
429
  throw new Error("Attempted to subscribe with read-only permissions");
393
430
  }
394
431
 
432
+ let volunteerOpMessageId: number | undefined;
433
+
395
434
  const submitVolunteerOp = (): void => {
435
+ volunteerOpMessageId = this.nextPendingMessageId;
396
436
  this.submitVolunteerOp(taskId);
397
437
  };
398
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
+
399
453
  const disconnectHandler = (): void => {
400
454
  // Wait to be connected again and then re-submit volunteer op
401
455
  this.connectionWatcher.once("connect", submitVolunteerOp);
402
456
  };
403
457
 
404
- const checkIfAbandoned = (eventTaskId: string): void => {
458
+ const checkIfAbandoned = (eventTaskId: string, messageId: number | undefined): void => {
405
459
  if (eventTaskId !== taskId) {
406
460
  return;
407
461
  }
408
-
409
- this.abandonWatcher.off("abandon", checkIfAbandoned);
410
- this.connectionWatcher.off("disconnect", disconnectHandler);
411
- this.connectionWatcher.off("connect", submitVolunteerOp);
412
- this.completedWatcher.off("completed", checkIfCompleted);
413
-
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();
414
477
  this.subscribedTasks.delete(taskId);
415
478
  };
416
479
 
417
- const checkIfCompleted = (eventTaskId: string): void => {
480
+ const checkIfCompleted = (eventTaskId: string, messageId: number | undefined): void => {
418
481
  if (eventTaskId !== taskId) {
419
482
  return;
420
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
+ };
421
495
 
422
- this.abandonWatcher.off("abandon", checkIfAbandoned);
423
- this.connectionWatcher.off("disconnect", disconnectHandler);
424
- this.connectionWatcher.off("connect", submitVolunteerOp);
425
- this.completedWatcher.off("completed", checkIfCompleted);
496
+ const checkIfRolledBack = (eventTaskId: string): void => {
497
+ if (eventTaskId !== taskId) {
498
+ return;
499
+ }
426
500
 
501
+ removeListeners();
427
502
  this.subscribedTasks.delete(taskId);
428
503
  };
429
504
 
430
- this.abandonWatcher.on("abandon", checkIfAbandoned);
431
- this.connectionWatcher.on("disconnect", disconnectHandler);
432
- this.completedWatcher.on("completed", checkIfCompleted);
505
+ setupListeners();
433
506
 
434
- if (!this.isAttached()) {
507
+ if (this.isDetached()) {
435
508
  // Simulate auto-ack in detached scenario
436
509
  assert(this.clientId !== undefined, 0x473 /* clientId should not be undefined */);
437
510
  this.addClientToQueue(taskId, this.clientId);
@@ -439,22 +512,19 @@ export class TaskManagerClass
439
512
  // a real clientId. At that point we should re-enter the queue with a real volunteer op (assuming we are
440
513
  // connected).
441
514
  this.runtime.once("attached", () => {
442
- if (this.queued(taskId)) {
443
- // If we are already queued, then we were able to replace the placeholderClientId with our real
444
- // clientId and no action is required.
445
- return;
446
- } else if (this.connected) {
515
+ // We call scrubClientsNotInQuorum() in case our clientId changed during the attach process.
516
+ this.scrubClientsNotInQuorum();
517
+ if (this.connected) {
447
518
  submitVolunteerOp();
448
519
  } else {
449
- this.connectionWatcher.once("connect", () => {
450
- submitVolunteerOp();
451
- });
520
+ this.connectionWatcher.once("connect", submitVolunteerOp);
452
521
  }
453
522
  });
454
523
  } else if (!this.connected) {
455
524
  // If we are disconnected (and attached), wait to be connected and submit volunteer op
456
525
  disconnectHandler();
457
- } 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.
458
528
  submitVolunteerOp();
459
529
  }
460
530
  this.subscribedTasks.add(taskId);
@@ -465,14 +535,13 @@ export class TaskManagerClass
465
535
  */
466
536
  public abandon(taskId: string): void {
467
537
  // Always allow abandon if the client is subscribed to allow clients to unsubscribe while disconnected.
468
- // Otherwise, we should check to make sure the client is both connected queued for the task before sending an
469
- // abandon op.
470
- 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)) {
471
540
  // Nothing to do
472
541
  return;
473
542
  }
474
543
 
475
- if (!this.isAttached()) {
544
+ if (this.isDetached()) {
476
545
  // Simulate auto-ack in detached scenario
477
546
  assert(this.clientId !== undefined, 0x474 /* clientId is undefined */);
478
547
  this.removeClientFromQueue(taskId, this.clientId);
@@ -480,10 +549,7 @@ export class TaskManagerClass
480
549
  return;
481
550
  }
482
551
 
483
- // If we're subscribed but not queued, we don't need to submit an abandon op (probably offline)
484
- if (this.queued(taskId)) {
485
- this.submitAbandonOp(taskId);
486
- }
552
+ this.submitAbandonOp(taskId);
487
553
  this.abandonWatcher.emit("abandon", taskId);
488
554
  }
489
555
 
@@ -496,11 +562,7 @@ export class TaskManagerClass
496
562
  }
497
563
 
498
564
  const currentAssignee = this.taskQueues.get(taskId)?.[0];
499
- return (
500
- currentAssignee !== undefined &&
501
- currentAssignee === this.clientId &&
502
- !this.latestPendingOps.has(taskId)
503
- );
565
+ return currentAssignee !== undefined && currentAssignee === this.clientId;
504
566
  }
505
567
 
506
568
  /**
@@ -512,14 +574,7 @@ export class TaskManagerClass
512
574
  }
513
575
 
514
576
  assert(this.clientId !== undefined, 0x07f /* "clientId undefined" */);
515
-
516
- const clientQueue = this.taskQueues.get(taskId);
517
- // If we have no queue for the taskId, then no one has signed up for it.
518
- return (
519
- ((clientQueue?.includes(this.clientId) ?? false) &&
520
- !this.latestPendingOps.has(taskId)) ||
521
- this.latestPendingOps.get(taskId)?.type === "volunteer"
522
- );
577
+ return this.taskQueues.get(taskId)?.includes(this.clientId) ?? false;
523
578
  }
524
579
 
525
580
  /**
@@ -539,16 +594,17 @@ export class TaskManagerClass
539
594
 
540
595
  // If we are detached we will simulate auto-ack for the complete op. Therefore we only need to send the op if
541
596
  // we are attached. Additionally, we don't need to check if we are connected while detached.
542
- if (this.isAttached()) {
543
- if (!this.connected) {
544
- throw new Error("Attempted to complete task in disconnected state");
545
- }
546
- 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;
547
602
  }
548
603
 
549
- this.taskQueues.delete(taskId);
550
- this.completedWatcher.emit("completed", taskId);
551
- this.emit("completed", taskId);
604
+ if (!this.connected) {
605
+ throw new Error("Attempted to complete task in disconnected state");
606
+ }
607
+ this.submitCompleteOp(taskId);
552
608
  }
553
609
 
554
610
  /**
@@ -618,12 +674,24 @@ export class TaskManagerClass
618
674
  this.connectionWatcher.emit("connect");
619
675
  }
620
676
 
621
- //
622
677
  /**
623
678
  * Override resubmit core to avoid resubmission on reconnect. On disconnect we accept our removal from the
624
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.
625
681
  */
626
- 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
+ }
627
695
 
628
696
  /**
629
697
  * Process a task manager operation
@@ -636,12 +704,12 @@ export class TaskManagerClass
636
704
  protected processCore(
637
705
  message: ISequencedDocumentMessage,
638
706
  local: boolean,
639
- localOpMetadata: unknown,
707
+ localOpMetadata: number | undefined,
640
708
  ): void {
641
709
  // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
642
710
  if (message.type === MessageType.Operation) {
643
711
  const op = message.contents as ITaskManagerOperation;
644
- const messageId = localOpMetadata as number;
712
+ const messageId = localOpMetadata;
645
713
 
646
714
  switch (op.type) {
647
715
  case "volunteer": {
@@ -667,12 +735,6 @@ export class TaskManagerClass
667
735
  }
668
736
 
669
737
  private addClientToQueue(taskId: string, clientId: string): void {
670
- const pendingIds = this.pendingCompletedTasks.get(taskId);
671
- if (pendingIds !== undefined && pendingIds.length > 0) {
672
- // Ignore the volunteer op if we know this task is about to be completed
673
- return;
674
- }
675
-
676
738
  // Ensure that the clientId exists in the quorum, or it is placeholderClientId (detached scenario)
677
739
  if (
678
740
  this.runtime.getQuorum().getMembers().has(clientId) ||
@@ -685,6 +747,13 @@ export class TaskManagerClass
685
747
  this.taskQueues.set(taskId, clientQueue);
686
748
  }
687
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
+
688
757
  const oldLockHolder = clientQueue[0];
689
758
  clientQueue.push(clientId);
690
759
  const newLockHolder = clientQueue[0];
@@ -734,7 +803,12 @@ export class TaskManagerClass
734
803
  for (const clientQueue of this.taskQueues.values()) {
735
804
  const clientIdIndex = clientQueue.indexOf(placeholderClientId);
736
805
  if (clientIdIndex !== -1) {
737
- 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
+ }
738
812
  }
739
813
  }
740
814
  }
@@ -758,24 +832,65 @@ export class TaskManagerClass
758
832
  }
759
833
  }
760
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
+
761
871
  protected applyStashedOp(content: unknown): void {
762
- const taskOp: ITaskManagerOperation = content as ITaskManagerOperation;
763
- switch (taskOp.type) {
764
- case "abandon": {
765
- this.abandon(taskOp.taskId);
766
- break;
767
- }
768
- case "complete": {
769
- this.complete(taskOp.taskId);
770
- break;
771
- }
772
- case "volunteer": {
773
- this.subscribeToTask(taskOp.taskId);
774
- break;
775
- }
776
- default: {
777
- unreachableCase(taskOp);
778
- }
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);
779
893
  }
894
+ this.rollbackWatcher.emit("rollback", content.taskId);
780
895
  }
781
896
  }