@fluidframework/task-manager 2.60.0 → 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.
@@ -6,10 +6,19 @@
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.TaskManagerClass = void 0;
8
8
  const client_utils_1 = require("@fluid-internal/client-utils");
9
- const internal_1 = require("@fluidframework/core-utils/internal");
10
- const internal_2 = require("@fluidframework/driver-definitions/internal");
11
- const internal_3 = require("@fluidframework/driver-utils/internal");
12
- const internal_4 = require("@fluidframework/shared-object-base/internal");
9
+ const internal_1 = require("@fluidframework/container-definitions/internal");
10
+ const internal_2 = require("@fluidframework/core-utils/internal");
11
+ const internal_3 = require("@fluidframework/driver-definitions/internal");
12
+ const internal_4 = require("@fluidframework/driver-utils/internal");
13
+ const internal_5 = require("@fluidframework/shared-object-base/internal");
14
+ function assertIsTaskManagerOperation(op) {
15
+ (0, internal_2.assert)(typeof op === "object" &&
16
+ op !== null &&
17
+ "taskId" in op &&
18
+ typeof op.taskId === "string" &&
19
+ "type" in op &&
20
+ (op.type === "volunteer" || op.type === "abandon" || op.type === "complete"), "Not a TaskManager operation");
21
+ }
13
22
  const snapshotFileName = "header";
14
23
  /**
15
24
  * Placeholder clientId for detached scenarios.
@@ -21,7 +30,7 @@ const placeholderClientId = "placeholder";
21
30
  * @sealed
22
31
  * @legacy @beta
23
32
  */
24
- class TaskManagerClass extends internal_4.SharedObject {
33
+ class TaskManagerClass extends internal_5.SharedObject {
25
34
  /**
26
35
  * Returns the clientId. Will return a placeholder if the runtime is detached and not yet assigned a clientId.
27
36
  */
@@ -58,7 +67,9 @@ class TaskManagerClass extends internal_4.SharedObject {
58
67
  this.connectionWatcher = new client_utils_1.EventEmitter();
59
68
  // completedWatcher emits an event whenever the local client receives a completed op.
60
69
  this.completedWatcher = new client_utils_1.EventEmitter();
61
- this.messageId = -1;
70
+ // rollbackWatcher emits an event whenever a pending op is rolled back.
71
+ this.rollbackWatcher = new client_utils_1.EventEmitter();
72
+ this.nextPendingMessageId = 0;
62
73
  /**
63
74
  * Tracks the most recent pending op for a given task
64
75
  */
@@ -67,60 +78,47 @@ class TaskManagerClass extends internal_4.SharedObject {
67
78
  * Tracks tasks that are this client is currently subscribed to.
68
79
  */
69
80
  this.subscribedTasks = new Set();
70
- /**
71
- * Map to track tasks that have pending complete ops.
72
- */
73
- this.pendingCompletedTasks = new Map();
74
81
  this.opWatcher.on("volunteer", (taskId, clientId, local, messageId) => {
75
- // We're tracking local ops from this connection. Filter out local ops during "connecting"
76
- // state since these were sent on the prior connection and were already cleared from the latestPendingOps.
77
- if (runtime.connected && local) {
78
- const pendingOp = this.latestPendingOps.get(taskId);
79
- (0, internal_1.assert)(pendingOp !== undefined, 0x07b /* "Unexpected op" */);
80
- // Need to check the id, since it's possible to volunteer and abandon multiple times before the acks
81
- if (messageId === pendingOp.messageId) {
82
- (0, internal_1.assert)(pendingOp.type === "volunteer", 0x07c /* "Unexpected op type" */);
83
- // Delete the pending, because we no longer have an outstanding op
82
+ if (local) {
83
+ const latestPendingOps = this.latestPendingOps.get(taskId);
84
+ (0, internal_2.assert)(latestPendingOps !== undefined, "No pending ops for task");
85
+ const pendingOp = latestPendingOps.shift();
86
+ (0, internal_2.assert)(pendingOp !== undefined && pendingOp.messageId === messageId, "Unexpected op");
87
+ (0, internal_2.assert)(pendingOp.type === "volunteer", 0x07c /* "Unexpected op type" */);
88
+ if (latestPendingOps.length === 0) {
84
89
  this.latestPendingOps.delete(taskId);
85
90
  }
86
91
  }
87
92
  this.addClientToQueue(taskId, clientId);
88
93
  });
89
94
  this.opWatcher.on("abandon", (taskId, clientId, local, messageId) => {
90
- if (runtime.connected && local) {
91
- const pendingOp = this.latestPendingOps.get(taskId);
92
- (0, internal_1.assert)(pendingOp !== undefined, 0x07d /* "Unexpected op" */);
93
- // Need to check the id, since it's possible to abandon and volunteer multiple times before the acks
94
- if (messageId === pendingOp.messageId) {
95
- (0, internal_1.assert)(pendingOp.type === "abandon", 0x07e /* "Unexpected op type" */);
96
- // Delete the pending, because we no longer have an outstanding op
95
+ if (local) {
96
+ const latestPendingOps = this.latestPendingOps.get(taskId);
97
+ (0, internal_2.assert)(latestPendingOps !== undefined, "No pending ops for task");
98
+ const pendingOp = latestPendingOps.shift();
99
+ (0, internal_2.assert)(pendingOp !== undefined && pendingOp.messageId === messageId, "Unexpected op");
100
+ (0, internal_2.assert)(pendingOp.type === "abandon", 0x07e /* "Unexpected op type" */);
101
+ if (latestPendingOps.length === 0) {
97
102
  this.latestPendingOps.delete(taskId);
98
103
  }
104
+ this.abandonWatcher.emit("abandon", taskId, messageId);
99
105
  }
100
106
  this.removeClientFromQueue(taskId, clientId);
101
107
  });
102
108
  this.opWatcher.on("complete", (taskId, clientId, local, messageId) => {
103
- if (runtime.connected && local) {
104
- const pendingOp = this.latestPendingOps.get(taskId);
105
- (0, internal_1.assert)(pendingOp !== undefined, 0x400 /* Unexpected op */);
106
- // Need to check the id, since it's possible to complete multiple times before the acks
107
- if (messageId === pendingOp.messageId) {
108
- (0, internal_1.assert)(pendingOp.type === "complete", 0x401 /* Unexpected op type */);
109
- // Delete the pending, because we no longer have an outstanding op
109
+ if (local) {
110
+ const latestPendingOps = this.latestPendingOps.get(taskId);
111
+ (0, internal_2.assert)(latestPendingOps !== undefined, "No pending ops for task");
112
+ const pendingOp = latestPendingOps.shift();
113
+ (0, internal_2.assert)(pendingOp !== undefined && pendingOp.messageId === messageId, "Unexpected op");
114
+ (0, internal_2.assert)(pendingOp.type === "complete", 0x401 /* Unexpected op type */);
115
+ if (latestPendingOps.length === 0) {
110
116
  this.latestPendingOps.delete(taskId);
111
117
  }
112
- // Remove complete op from this.pendingCompletedTasks
113
- const pendingIds = this.pendingCompletedTasks.get(taskId);
114
- (0, internal_1.assert)(pendingIds !== undefined && pendingIds.length > 0, 0x402 /* pendingIds is empty */);
115
- const removed = pendingIds.shift();
116
- (0, internal_1.assert)(removed === messageId, 0x403 /* Removed complete op id does not match */);
117
- }
118
- // For clients in queue, we need to remove them from the queue and raise the proper events.
119
- if (!local) {
120
- this.taskQueues.delete(taskId);
121
- this.completedWatcher.emit("completed", taskId);
122
- this.emit("completed", taskId);
123
118
  }
119
+ this.taskQueues.delete(taskId);
120
+ this.completedWatcher.emit("completed", taskId, messageId);
121
+ this.emit("completed", taskId);
124
122
  });
125
123
  runtime.getQuorum().on("removeMember", (clientId) => {
126
124
  this.removeClientFromAllQueues(clientId);
@@ -143,18 +141,16 @@ class TaskManagerClass extends internal_4.SharedObject {
143
141
  }
144
142
  });
145
143
  this.connectionWatcher.on("disconnect", () => {
146
- (0, internal_1.assert)(this.clientId !== undefined, 0x1d3 /* "Missing client id on disconnect" */);
147
- // We don't modify the taskQueues on disconnect (they still reflect the latest known consensus state).
148
- // After reconnect these will get cleaned up by observing the clientLeaves.
149
- // However we do need to recognize that we lost the lock if we had it. Calls to .queued() and
150
- // .assigned() are also connection-state-aware to be consistent.
144
+ (0, internal_2.assert)(this.clientId !== undefined, 0x1d3 /* "Missing client id on disconnect" */);
145
+ // Emit "lost" for any tasks we were assigned to.
151
146
  for (const [taskId, clientQueue] of this.taskQueues.entries()) {
152
147
  if (this.isAttached() && clientQueue[0] === this.clientId) {
153
148
  this.emit("lost", taskId);
154
149
  }
155
150
  }
156
- // All of our outstanding ops will be for the old clientId even if they get ack'd
157
- this.latestPendingOps.clear();
151
+ // Remove this client from all queues to reflect the new state, since being disconnected automatically removes
152
+ // this client from all queues.
153
+ this.removeClientFromAllQueues(this.clientId);
158
154
  });
159
155
  }
160
156
  submitVolunteerOp(taskId) {
@@ -164,10 +160,16 @@ class TaskManagerClass extends internal_4.SharedObject {
164
160
  };
165
161
  const pendingOp = {
166
162
  type: "volunteer",
167
- messageId: ++this.messageId,
163
+ messageId: this.nextPendingMessageId++,
168
164
  };
169
165
  this.submitLocalMessage(op, pendingOp.messageId);
170
- this.latestPendingOps.set(taskId, pendingOp);
166
+ const latestPendingOps = this.latestPendingOps.get(taskId);
167
+ if (latestPendingOps === undefined) {
168
+ this.latestPendingOps.set(taskId, [pendingOp]);
169
+ }
170
+ else {
171
+ latestPendingOps.push(pendingOp);
172
+ }
171
173
  }
172
174
  submitAbandonOp(taskId) {
173
175
  const op = {
@@ -176,10 +178,16 @@ class TaskManagerClass extends internal_4.SharedObject {
176
178
  };
177
179
  const pendingOp = {
178
180
  type: "abandon",
179
- messageId: ++this.messageId,
181
+ messageId: this.nextPendingMessageId++,
180
182
  };
181
183
  this.submitLocalMessage(op, pendingOp.messageId);
182
- this.latestPendingOps.set(taskId, pendingOp);
184
+ const latestPendingOps = this.latestPendingOps.get(taskId);
185
+ if (latestPendingOps === undefined) {
186
+ this.latestPendingOps.set(taskId, [pendingOp]);
187
+ }
188
+ else {
189
+ latestPendingOps.push(pendingOp);
190
+ }
183
191
  }
184
192
  submitCompleteOp(taskId) {
185
193
  const op = {
@@ -188,23 +196,25 @@ class TaskManagerClass extends internal_4.SharedObject {
188
196
  };
189
197
  const pendingOp = {
190
198
  type: "complete",
191
- messageId: ++this.messageId,
199
+ messageId: this.nextPendingMessageId++,
192
200
  };
193
- if (this.pendingCompletedTasks.has(taskId)) {
194
- this.pendingCompletedTasks.get(taskId)?.push(pendingOp.messageId);
201
+ this.submitLocalMessage(op, pendingOp.messageId);
202
+ const latestPendingOps = this.latestPendingOps.get(taskId);
203
+ if (latestPendingOps === undefined) {
204
+ this.latestPendingOps.set(taskId, [pendingOp]);
195
205
  }
196
206
  else {
197
- this.pendingCompletedTasks.set(taskId, [pendingOp.messageId]);
207
+ latestPendingOps.push(pendingOp);
198
208
  }
199
- this.submitLocalMessage(op, pendingOp.messageId);
200
- this.latestPendingOps.set(taskId, pendingOp);
201
209
  }
202
210
  /**
203
211
  * {@inheritDoc ITaskManager.volunteerForTask}
204
212
  */
205
213
  async volunteerForTask(taskId) {
206
- // If we have the lock, resolve immediately
207
- if (this.assigned(taskId)) {
214
+ // If we are both queued and assigned, then we have the lock and do not
215
+ // have any pending abandon/complete ops. In this case we can resolve
216
+ // true immediately.
217
+ if (this.queuedOptimistically(taskId) && this.assigned(taskId)) {
208
218
  return true;
209
219
  }
210
220
  if (this.readOnlyInfo.readonly === true) {
@@ -213,9 +223,9 @@ class TaskManagerClass extends internal_4.SharedObject {
213
223
  : new Error("Attempted to volunteer in read-only state");
214
224
  throw error;
215
225
  }
216
- if (!this.isAttached()) {
226
+ if (this.isDetached()) {
217
227
  // Simulate auto-ack in detached scenario
218
- (0, internal_1.assert)(this.clientId !== undefined, 0x472 /* clientId should not be undefined */);
228
+ (0, internal_2.assert)(this.clientId !== undefined, 0x472 /* clientId should not be undefined */);
219
229
  this.addClientToQueue(taskId, this.clientId);
220
230
  return true;
221
231
  }
@@ -224,6 +234,25 @@ class TaskManagerClass extends internal_4.SharedObject {
224
234
  }
225
235
  // This promise works even if we already have an outstanding volunteer op.
226
236
  const lockAcquireP = new Promise((resolve, reject) => {
237
+ // If we don't send an op (meaning the latest pending op is "volunteer"), nextPendingMessageId
238
+ // will be greater than that prior "volunteer" op's messageId. This is OK because
239
+ // we only use it to filter stale abandon/complete, and not when determining if we
240
+ // acquired the lock.
241
+ const nextPendingMessageId = this.nextPendingMessageId;
242
+ const setupListeners = () => {
243
+ this.queueWatcher.on("queueChange", checkIfAcquiredLock);
244
+ this.abandonWatcher.on("abandon", checkIfAbandoned);
245
+ this.connectionWatcher.on("disconnect", rejectOnDisconnect);
246
+ this.completedWatcher.on("completed", checkIfCompleted);
247
+ this.rollbackWatcher.on("rollback", checkIfRolledBack);
248
+ };
249
+ const removeListeners = () => {
250
+ this.queueWatcher.off("queueChange", checkIfAcquiredLock);
251
+ this.abandonWatcher.off("abandon", checkIfAbandoned);
252
+ this.connectionWatcher.off("disconnect", rejectOnDisconnect);
253
+ this.completedWatcher.off("completed", checkIfCompleted);
254
+ this.rollbackWatcher.off("rollback", checkIfRolledBack);
255
+ };
227
256
  const checkIfAcquiredLock = (eventTaskId) => {
228
257
  if (eventTaskId !== taskId) {
229
258
  return;
@@ -231,47 +260,48 @@ class TaskManagerClass extends internal_4.SharedObject {
231
260
  // Also check pending ops here because it's possible we are currently in the queue from a previous
232
261
  // lock attempt, but have an outstanding abandon AND the outstanding volunteer for this lock attempt.
233
262
  // If we reach the head of the queue based on the previous lock attempt, we don't want to resolve.
234
- if (this.assigned(taskId) && !this.latestPendingOps.has(taskId)) {
235
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
236
- this.abandonWatcher.off("abandon", checkIfAbandoned);
237
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
238
- this.completedWatcher.off("completed", checkIfCompleted);
263
+ if (this.assigned(taskId)) {
264
+ removeListeners();
239
265
  resolve(true);
240
266
  }
241
267
  };
242
- const checkIfAbandoned = (eventTaskId) => {
268
+ const checkIfAbandoned = (eventTaskId, messageId) => {
243
269
  if (eventTaskId !== taskId) {
244
270
  return;
245
271
  }
246
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
247
- this.abandonWatcher.off("abandon", checkIfAbandoned);
248
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
249
- this.completedWatcher.off("completed", checkIfCompleted);
272
+ if (messageId !== undefined && messageId <= nextPendingMessageId) {
273
+ // Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
274
+ return;
275
+ }
276
+ removeListeners();
250
277
  reject(new Error("Abandoned before acquiring task assignment"));
251
278
  };
252
279
  const rejectOnDisconnect = () => {
253
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
254
- this.abandonWatcher.off("abandon", checkIfAbandoned);
255
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
256
- this.completedWatcher.off("completed", checkIfCompleted);
280
+ removeListeners();
257
281
  reject(new Error("Disconnected before acquiring task assignment"));
258
282
  };
259
- const checkIfCompleted = (eventTaskId) => {
283
+ const checkIfCompleted = (eventTaskId, messageId) => {
260
284
  if (eventTaskId !== taskId) {
261
285
  return;
262
286
  }
263
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
264
- this.abandonWatcher.off("abandon", checkIfAbandoned);
265
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
266
- this.completedWatcher.off("completed", checkIfCompleted);
287
+ if (messageId !== undefined && messageId <= nextPendingMessageId) {
288
+ // Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
289
+ return;
290
+ }
291
+ removeListeners();
267
292
  resolve(false);
268
293
  };
269
- this.queueWatcher.on("queueChange", checkIfAcquiredLock);
270
- this.abandonWatcher.on("abandon", checkIfAbandoned);
271
- this.connectionWatcher.on("disconnect", rejectOnDisconnect);
272
- this.completedWatcher.on("completed", checkIfCompleted);
294
+ const checkIfRolledBack = (eventTaskId) => {
295
+ if (eventTaskId !== taskId) {
296
+ return;
297
+ }
298
+ removeListeners();
299
+ resolve(false);
300
+ };
301
+ setupListeners();
273
302
  });
274
- if (!this.queued(taskId)) {
303
+ if (!this.queuedOptimistically(taskId)) {
304
+ // Only send the volunteer op if we are not already queued.
275
305
  this.submitVolunteerOp(taskId);
276
306
  }
277
307
  return lockAcquireP;
@@ -286,56 +316,83 @@ class TaskManagerClass extends internal_4.SharedObject {
286
316
  if (this.readOnlyInfo.readonly === true && this.readOnlyInfo.permissions === true) {
287
317
  throw new Error("Attempted to subscribe with read-only permissions");
288
318
  }
319
+ let volunteerOpMessageId;
289
320
  const submitVolunteerOp = () => {
321
+ volunteerOpMessageId = this.nextPendingMessageId;
290
322
  this.submitVolunteerOp(taskId);
291
323
  };
324
+ const setupListeners = () => {
325
+ this.abandonWatcher.on("abandon", checkIfAbandoned);
326
+ this.connectionWatcher.on("disconnect", disconnectHandler);
327
+ this.completedWatcher.on("completed", checkIfCompleted);
328
+ this.rollbackWatcher.on("rollback", checkIfRolledBack);
329
+ };
330
+ const removeListeners = () => {
331
+ this.abandonWatcher.off("abandon", checkIfAbandoned);
332
+ this.connectionWatcher.off("disconnect", disconnectHandler);
333
+ this.connectionWatcher.off("connect", submitVolunteerOp);
334
+ this.completedWatcher.off("completed", checkIfCompleted);
335
+ this.rollbackWatcher.off("rollback", checkIfRolledBack);
336
+ };
292
337
  const disconnectHandler = () => {
293
338
  // Wait to be connected again and then re-submit volunteer op
294
339
  this.connectionWatcher.once("connect", submitVolunteerOp);
295
340
  };
296
- const checkIfAbandoned = (eventTaskId) => {
341
+ const checkIfAbandoned = (eventTaskId, messageId) => {
297
342
  if (eventTaskId !== taskId) {
298
343
  return;
299
344
  }
300
- this.abandonWatcher.off("abandon", checkIfAbandoned);
301
- this.connectionWatcher.off("disconnect", disconnectHandler);
302
- this.connectionWatcher.off("connect", submitVolunteerOp);
303
- this.completedWatcher.off("completed", checkIfCompleted);
345
+ // abandonWatcher emits twice for a local abandon() call. When initially called it
346
+ // will emit with undefined messageId. It will emit a second time when the op is
347
+ // ack'd and processed, this time with the messageId for the ack.
348
+ // This condition accounts ensures we don't ignore the initial abandon() emit and
349
+ // only ignore emits associated with ack'd abandon ops that were sent prior to the
350
+ // current volunteer attempt.
351
+ if (messageId !== undefined &&
352
+ volunteerOpMessageId !== undefined &&
353
+ messageId <= volunteerOpMessageId) {
354
+ // Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
355
+ return;
356
+ }
357
+ removeListeners();
304
358
  this.subscribedTasks.delete(taskId);
305
359
  };
306
- const checkIfCompleted = (eventTaskId) => {
360
+ const checkIfCompleted = (eventTaskId, messageId) => {
307
361
  if (eventTaskId !== taskId) {
308
362
  return;
309
363
  }
310
- this.abandonWatcher.off("abandon", checkIfAbandoned);
311
- this.connectionWatcher.off("disconnect", disconnectHandler);
312
- this.connectionWatcher.off("connect", submitVolunteerOp);
313
- this.completedWatcher.off("completed", checkIfCompleted);
364
+ if (messageId !== undefined &&
365
+ volunteerOpMessageId !== undefined &&
366
+ messageId <= volunteerOpMessageId) {
367
+ // Ignore abandon events that were for abandon ops that were sent prior to our current volunteer attempt.
368
+ return;
369
+ }
370
+ removeListeners();
314
371
  this.subscribedTasks.delete(taskId);
315
372
  };
316
- this.abandonWatcher.on("abandon", checkIfAbandoned);
317
- this.connectionWatcher.on("disconnect", disconnectHandler);
318
- this.completedWatcher.on("completed", checkIfCompleted);
319
- if (!this.isAttached()) {
373
+ const checkIfRolledBack = (eventTaskId) => {
374
+ if (eventTaskId !== taskId) {
375
+ return;
376
+ }
377
+ removeListeners();
378
+ this.subscribedTasks.delete(taskId);
379
+ };
380
+ setupListeners();
381
+ if (this.isDetached()) {
320
382
  // Simulate auto-ack in detached scenario
321
- (0, internal_1.assert)(this.clientId !== undefined, 0x473 /* clientId should not be undefined */);
383
+ (0, internal_2.assert)(this.clientId !== undefined, 0x473 /* clientId should not be undefined */);
322
384
  this.addClientToQueue(taskId, this.clientId);
323
385
  // Because we volunteered with placeholderClientId, we need to wait for when we attach and are assigned
324
386
  // a real clientId. At that point we should re-enter the queue with a real volunteer op (assuming we are
325
387
  // connected).
326
388
  this.runtime.once("attached", () => {
327
- if (this.queued(taskId)) {
328
- // If we are already queued, then we were able to replace the placeholderClientId with our real
329
- // clientId and no action is required.
330
- return;
331
- }
332
- else if (this.connected) {
389
+ // We call scrubClientsNotInQuorum() in case our clientId changed during the attach process.
390
+ this.scrubClientsNotInQuorum();
391
+ if (this.connected) {
333
392
  submitVolunteerOp();
334
393
  }
335
394
  else {
336
- this.connectionWatcher.once("connect", () => {
337
- submitVolunteerOp();
338
- });
395
+ this.connectionWatcher.once("connect", submitVolunteerOp);
339
396
  }
340
397
  });
341
398
  }
@@ -343,7 +400,8 @@ class TaskManagerClass extends internal_4.SharedObject {
343
400
  // If we are disconnected (and attached), wait to be connected and submit volunteer op
344
401
  disconnectHandler();
345
402
  }
346
- else if (!this.assigned(taskId) && !this.queued(taskId)) {
403
+ else if (!this.queuedOptimistically(taskId)) {
404
+ // We don't need to send a second volunteer op if we just sent one.
347
405
  submitVolunteerOp();
348
406
  }
349
407
  this.subscribedTasks.add(taskId);
@@ -353,23 +411,19 @@ class TaskManagerClass extends internal_4.SharedObject {
353
411
  */
354
412
  abandon(taskId) {
355
413
  // Always allow abandon if the client is subscribed to allow clients to unsubscribe while disconnected.
356
- // Otherwise, we should check to make sure the client is both connected queued for the task before sending an
357
- // abandon op.
358
- if (!this.subscribed(taskId) && !this.queued(taskId)) {
414
+ // Otherwise, we should check to make sure the client is optimistically queued for the task before trying to abandon.
415
+ if (!this.queuedOptimistically(taskId) && !this.subscribed(taskId)) {
359
416
  // Nothing to do
360
417
  return;
361
418
  }
362
- if (!this.isAttached()) {
419
+ if (this.isDetached()) {
363
420
  // Simulate auto-ack in detached scenario
364
- (0, internal_1.assert)(this.clientId !== undefined, 0x474 /* clientId is undefined */);
421
+ (0, internal_2.assert)(this.clientId !== undefined, 0x474 /* clientId is undefined */);
365
422
  this.removeClientFromQueue(taskId, this.clientId);
366
423
  this.abandonWatcher.emit("abandon", taskId);
367
424
  return;
368
425
  }
369
- // If we're subscribed but not queued, we don't need to submit an abandon op (probably offline)
370
- if (this.queued(taskId)) {
371
- this.submitAbandonOp(taskId);
372
- }
426
+ this.submitAbandonOp(taskId);
373
427
  this.abandonWatcher.emit("abandon", taskId);
374
428
  }
375
429
  /**
@@ -380,9 +434,7 @@ class TaskManagerClass extends internal_4.SharedObject {
380
434
  return false;
381
435
  }
382
436
  const currentAssignee = this.taskQueues.get(taskId)?.[0];
383
- return (currentAssignee !== undefined &&
384
- currentAssignee === this.clientId &&
385
- !this.latestPendingOps.has(taskId));
437
+ return currentAssignee !== undefined && currentAssignee === this.clientId;
386
438
  }
387
439
  /**
388
440
  * {@inheritDoc ITaskManager.queued}
@@ -391,12 +443,8 @@ class TaskManagerClass extends internal_4.SharedObject {
391
443
  if (this.isAttached() && !this.connected) {
392
444
  return false;
393
445
  }
394
- (0, internal_1.assert)(this.clientId !== undefined, 0x07f /* "clientId undefined" */);
395
- const clientQueue = this.taskQueues.get(taskId);
396
- // If we have no queue for the taskId, then no one has signed up for it.
397
- return (((clientQueue?.includes(this.clientId) ?? false) &&
398
- !this.latestPendingOps.has(taskId)) ||
399
- this.latestPendingOps.get(taskId)?.type === "volunteer");
446
+ (0, internal_2.assert)(this.clientId !== undefined, 0x07f /* "clientId undefined" */);
447
+ return this.taskQueues.get(taskId)?.includes(this.clientId) ?? false;
400
448
  }
401
449
  /**
402
450
  * {@inheritDoc ITaskManager.subscribed}
@@ -413,15 +461,16 @@ class TaskManagerClass extends internal_4.SharedObject {
413
461
  }
414
462
  // If we are detached we will simulate auto-ack for the complete op. Therefore we only need to send the op if
415
463
  // we are attached. Additionally, we don't need to check if we are connected while detached.
416
- if (this.isAttached()) {
417
- if (!this.connected) {
418
- throw new Error("Attempted to complete task in disconnected state");
419
- }
420
- this.submitCompleteOp(taskId);
464
+ if (this.isDetached()) {
465
+ this.taskQueues.delete(taskId);
466
+ this.completedWatcher.emit("completed", taskId);
467
+ this.emit("completed", taskId);
468
+ return;
421
469
  }
422
- this.taskQueues.delete(taskId);
423
- this.completedWatcher.emit("completed", taskId);
424
- this.emit("completed", taskId);
470
+ if (!this.connected) {
471
+ throw new Error("Attempted to complete task in disconnected state");
472
+ }
473
+ this.submitCompleteOp(taskId);
425
474
  }
426
475
  /**
427
476
  * {@inheritDoc ITaskManager.canVolunteer}
@@ -458,13 +507,13 @@ class TaskManagerClass extends internal_4.SharedObject {
458
507
  }
459
508
  }
460
509
  const content = [...filteredMap.entries()];
461
- return (0, internal_4.createSingleBlobSummary)(snapshotFileName, JSON.stringify(content));
510
+ return (0, internal_5.createSingleBlobSummary)(snapshotFileName, JSON.stringify(content));
462
511
  }
463
512
  /**
464
513
  * {@inheritDoc @fluidframework/shared-object-base#SharedObject.loadCore}
465
514
  */
466
515
  async loadCore(storage) {
467
- const content = await (0, internal_3.readAndParse)(storage, snapshotFileName);
516
+ const content = await (0, internal_4.readAndParse)(storage, snapshotFileName);
468
517
  for (const [taskId, clientIdQueue] of content) {
469
518
  this.taskQueues.set(taskId, clientIdQueue);
470
519
  }
@@ -484,12 +533,22 @@ class TaskManagerClass extends internal_4.SharedObject {
484
533
  onConnect() {
485
534
  this.connectionWatcher.emit("connect");
486
535
  }
487
- //
488
536
  /**
489
537
  * Override resubmit core to avoid resubmission on reconnect. On disconnect we accept our removal from the
490
538
  * queues, and leave it up to the user to decide whether they want to attempt to re-enter a queue on reconnect.
539
+ * However, we do need to update latestPendingOps to account for the ops we will no longer be processing.
491
540
  */
492
- reSubmitCore() { }
541
+ reSubmitCore(content, localOpMetadata) {
542
+ assertIsTaskManagerOperation(content);
543
+ const pendingOps = this.latestPendingOps.get(content.taskId);
544
+ (0, internal_2.assert)(pendingOps !== undefined, "No pending ops for task on resubmit attempt");
545
+ const pendingOpIndex = pendingOps.findIndex((op) => op.messageId === localOpMetadata && op.type === content.type);
546
+ (0, internal_2.assert)(pendingOpIndex !== -1, "Could not match pending op on resubmit attempt");
547
+ pendingOps.splice(pendingOpIndex, 1);
548
+ if (pendingOps.length === 0) {
549
+ this.latestPendingOps.delete(content.taskId);
550
+ }
551
+ }
493
552
  /**
494
553
  * Process a task manager operation
495
554
  *
@@ -500,7 +559,7 @@ class TaskManagerClass extends internal_4.SharedObject {
500
559
  */
501
560
  processCore(message, local, localOpMetadata) {
502
561
  // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
503
- if (message.type === internal_2.MessageType.Operation) {
562
+ if (message.type === internal_3.MessageType.Operation) {
504
563
  const op = message.contents;
505
564
  const messageId = localOpMetadata;
506
565
  switch (op.type) {
@@ -523,11 +582,6 @@ class TaskManagerClass extends internal_4.SharedObject {
523
582
  }
524
583
  }
525
584
  addClientToQueue(taskId, clientId) {
526
- const pendingIds = this.pendingCompletedTasks.get(taskId);
527
- if (pendingIds !== undefined && pendingIds.length > 0) {
528
- // Ignore the volunteer op if we know this task is about to be completed
529
- return;
530
- }
531
585
  // Ensure that the clientId exists in the quorum, or it is placeholderClientId (detached scenario)
532
586
  if (this.runtime.getQuorum().getMembers().has(clientId) ||
533
587
  this.clientId === placeholderClientId) {
@@ -537,6 +591,12 @@ class TaskManagerClass extends internal_4.SharedObject {
537
591
  clientQueue = [];
538
592
  this.taskQueues.set(taskId, clientQueue);
539
593
  }
594
+ if (clientQueue.includes(clientId)) {
595
+ // We shouldn't re-add the client if it's already in the queue.
596
+ // This may be possible in scenarios where a client was added in
597
+ // while detached.
598
+ return;
599
+ }
540
600
  const oldLockHolder = clientQueue[0];
541
601
  clientQueue.push(clientId);
542
602
  const newLockHolder = clientQueue[0];
@@ -574,11 +634,17 @@ class TaskManagerClass extends internal_4.SharedObject {
574
634
  * transitioning from detached to attached and this.runtime.clientId is defined.
575
635
  */
576
636
  replacePlaceholderInAllQueues() {
577
- (0, internal_1.assert)(this.runtime.clientId !== undefined, 0x475 /* this.runtime.clientId should be defined */);
637
+ (0, internal_2.assert)(this.runtime.clientId !== undefined, 0x475 /* this.runtime.clientId should be defined */);
578
638
  for (const clientQueue of this.taskQueues.values()) {
579
639
  const clientIdIndex = clientQueue.indexOf(placeholderClientId);
580
640
  if (clientIdIndex !== -1) {
581
- clientQueue[clientIdIndex] = this.runtime.clientId;
641
+ if (clientQueue.includes(this.runtime.clientId)) {
642
+ // If the real clientId is already in the queue, just remove the placeholder.
643
+ clientQueue.splice(clientIdIndex, 1);
644
+ }
645
+ else {
646
+ clientQueue[clientIdIndex] = this.runtime.clientId;
647
+ }
582
648
  }
583
649
  }
584
650
  }
@@ -599,25 +665,55 @@ class TaskManagerClass extends internal_4.SharedObject {
599
665
  }
600
666
  }
601
667
  }
602
- applyStashedOp(content) {
603
- const taskOp = content;
604
- switch (taskOp.type) {
605
- case "abandon": {
606
- this.abandon(taskOp.taskId);
607
- break;
608
- }
609
- case "complete": {
610
- this.complete(taskOp.taskId);
611
- break;
612
- }
613
- case "volunteer": {
614
- this.subscribeToTask(taskOp.taskId);
615
- break;
616
- }
617
- default: {
618
- (0, internal_1.unreachableCase)(taskOp);
619
- }
668
+ /**
669
+ * Checks whether this client is currently assigned or in queue to become assigned, while also accounting
670
+ * for the latest pending ops.
671
+ */
672
+ queuedOptimistically(taskId) {
673
+ if (this.isAttached() && !this.connected) {
674
+ return false;
620
675
  }
676
+ (0, internal_2.assert)(this.clientId !== undefined, 0x07f /* "clientId undefined" */);
677
+ const inQueue = this.taskQueues.get(taskId)?.includes(this.clientId) ?? false;
678
+ const latestPendingOps = this.latestPendingOps.get(taskId);
679
+ const latestPendingOp = latestPendingOps !== undefined && latestPendingOps.length > 0
680
+ ? latestPendingOps[latestPendingOps.length - 1]
681
+ : undefined;
682
+ const isPendingVolunteer = latestPendingOp?.type === "volunteer";
683
+ const isPendingAbandonOrComplete = latestPendingOp?.type === "abandon" || latestPendingOp?.type === "complete";
684
+ // We return true if the client is either in queue already or the latest pending op for this task is a volunteer op.
685
+ // But we should always return false if the latest pending op is an abandon or complete op.
686
+ return (inQueue && !isPendingAbandonOrComplete) || isPendingVolunteer;
687
+ }
688
+ /**
689
+ * Returns true if the client is detached.
690
+ * This is distinct from !this.isAttached() because `isAttached()` also checks if `this._isBoundToContext`
691
+ * is true. We use `isDetached()` to determine if we should simulate auto-ack behavior for ops, which is
692
+ * mainly concerned with if we have been assigned a real clientId yet.
693
+ */
694
+ isDetached() {
695
+ return this.runtime.attachState === internal_1.AttachState.Detached;
696
+ }
697
+ applyStashedOp(content) {
698
+ // We don't apply any stashed ops since during the rehydration process. Since we lose any assigned tasks
699
+ // during rehydration we cannot be assigned any tasks. Additionally, without the in-memory state of the
700
+ // previous dds, we also cannot re-volunteer based on a previous subscribeToTask() call. Since we are
701
+ // unable to be assigned to any tasks, there is no reason to process abandon/complete ops either.
702
+ }
703
+ /**
704
+ * {@inheritDoc @fluidframework/shared-object-base#SharedObject.rollback}
705
+ */
706
+ rollback(content, localOpMetadata) {
707
+ (0, internal_2.assert)(typeof localOpMetadata === "number", "Expect localOpMetadata to be a number");
708
+ assertIsTaskManagerOperation(content);
709
+ const latestPendingOps = this.latestPendingOps.get(content.taskId);
710
+ (0, internal_2.assert)(latestPendingOps !== undefined, "No pending ops when trying to rollback");
711
+ const pendingOpToRollback = latestPendingOps.pop();
712
+ (0, internal_2.assert)(pendingOpToRollback !== undefined && pendingOpToRollback.messageId === localOpMetadata, "pending op mismatch");
713
+ if (latestPendingOps.length === 0) {
714
+ this.latestPendingOps.delete(content.taskId);
715
+ }
716
+ this.rollbackWatcher.emit("rollback", content.taskId);
621
717
  }
622
718
  }
623
719
  exports.TaskManagerClass = TaskManagerClass;