@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
@@ -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.
@@ -19,10 +28,9 @@ const placeholderClientId = "placeholder";
19
28
  * {@inheritDoc ITaskManager}
20
29
  *
21
30
  * @sealed
22
- * @legacy
23
- * @alpha
31
+ * @legacy @beta
24
32
  */
25
- class TaskManagerClass extends internal_4.SharedObject {
33
+ class TaskManagerClass extends internal_5.SharedObject {
26
34
  /**
27
35
  * Returns the clientId. Will return a placeholder if the runtime is detached and not yet assigned a clientId.
28
36
  */
@@ -59,7 +67,9 @@ class TaskManagerClass extends internal_4.SharedObject {
59
67
  this.connectionWatcher = new client_utils_1.EventEmitter();
60
68
  // completedWatcher emits an event whenever the local client receives a completed op.
61
69
  this.completedWatcher = new client_utils_1.EventEmitter();
62
- 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;
63
73
  /**
64
74
  * Tracks the most recent pending op for a given task
65
75
  */
@@ -68,60 +78,47 @@ class TaskManagerClass extends internal_4.SharedObject {
68
78
  * Tracks tasks that are this client is currently subscribed to.
69
79
  */
70
80
  this.subscribedTasks = new Set();
71
- /**
72
- * Map to track tasks that have pending complete ops.
73
- */
74
- this.pendingCompletedTasks = new Map();
75
81
  this.opWatcher.on("volunteer", (taskId, clientId, local, messageId) => {
76
- // We're tracking local ops from this connection. Filter out local ops during "connecting"
77
- // state since these were sent on the prior connection and were already cleared from the latestPendingOps.
78
- if (runtime.connected && local) {
79
- const pendingOp = this.latestPendingOps.get(taskId);
80
- (0, internal_1.assert)(pendingOp !== undefined, 0x07b /* "Unexpected op" */);
81
- // Need to check the id, since it's possible to volunteer and abandon multiple times before the acks
82
- if (messageId === pendingOp.messageId) {
83
- (0, internal_1.assert)(pendingOp.type === "volunteer", 0x07c /* "Unexpected op type" */);
84
- // 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) {
85
89
  this.latestPendingOps.delete(taskId);
86
90
  }
87
91
  }
88
92
  this.addClientToQueue(taskId, clientId);
89
93
  });
90
94
  this.opWatcher.on("abandon", (taskId, clientId, local, messageId) => {
91
- if (runtime.connected && local) {
92
- const pendingOp = this.latestPendingOps.get(taskId);
93
- (0, internal_1.assert)(pendingOp !== undefined, 0x07d /* "Unexpected op" */);
94
- // Need to check the id, since it's possible to abandon and volunteer multiple times before the acks
95
- if (messageId === pendingOp.messageId) {
96
- (0, internal_1.assert)(pendingOp.type === "abandon", 0x07e /* "Unexpected op type" */);
97
- // 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) {
98
102
  this.latestPendingOps.delete(taskId);
99
103
  }
104
+ this.abandonWatcher.emit("abandon", taskId, messageId);
100
105
  }
101
106
  this.removeClientFromQueue(taskId, clientId);
102
107
  });
103
108
  this.opWatcher.on("complete", (taskId, clientId, local, messageId) => {
104
- if (runtime.connected && local) {
105
- const pendingOp = this.latestPendingOps.get(taskId);
106
- (0, internal_1.assert)(pendingOp !== undefined, 0x400 /* Unexpected op */);
107
- // Need to check the id, since it's possible to complete multiple times before the acks
108
- if (messageId === pendingOp.messageId) {
109
- (0, internal_1.assert)(pendingOp.type === "complete", 0x401 /* Unexpected op type */);
110
- // 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) {
111
116
  this.latestPendingOps.delete(taskId);
112
117
  }
113
- // Remove complete op from this.pendingCompletedTasks
114
- const pendingIds = this.pendingCompletedTasks.get(taskId);
115
- (0, internal_1.assert)(pendingIds !== undefined && pendingIds.length > 0, 0x402 /* pendingIds is empty */);
116
- const removed = pendingIds.shift();
117
- (0, internal_1.assert)(removed === messageId, 0x403 /* Removed complete op id does not match */);
118
- }
119
- // For clients in queue, we need to remove them from the queue and raise the proper events.
120
- if (!local) {
121
- this.taskQueues.delete(taskId);
122
- this.completedWatcher.emit("completed", taskId);
123
- this.emit("completed", taskId);
124
118
  }
119
+ this.taskQueues.delete(taskId);
120
+ this.completedWatcher.emit("completed", taskId, messageId);
121
+ this.emit("completed", taskId);
125
122
  });
126
123
  runtime.getQuorum().on("removeMember", (clientId) => {
127
124
  this.removeClientFromAllQueues(clientId);
@@ -144,18 +141,16 @@ class TaskManagerClass extends internal_4.SharedObject {
144
141
  }
145
142
  });
146
143
  this.connectionWatcher.on("disconnect", () => {
147
- (0, internal_1.assert)(this.clientId !== undefined, 0x1d3 /* "Missing client id on disconnect" */);
148
- // We don't modify the taskQueues on disconnect (they still reflect the latest known consensus state).
149
- // After reconnect these will get cleaned up by observing the clientLeaves.
150
- // However we do need to recognize that we lost the lock if we had it. Calls to .queued() and
151
- // .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.
152
146
  for (const [taskId, clientQueue] of this.taskQueues.entries()) {
153
147
  if (this.isAttached() && clientQueue[0] === this.clientId) {
154
148
  this.emit("lost", taskId);
155
149
  }
156
150
  }
157
- // All of our outstanding ops will be for the old clientId even if they get ack'd
158
- 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);
159
154
  });
160
155
  }
161
156
  submitVolunteerOp(taskId) {
@@ -165,10 +160,16 @@ class TaskManagerClass extends internal_4.SharedObject {
165
160
  };
166
161
  const pendingOp = {
167
162
  type: "volunteer",
168
- messageId: ++this.messageId,
163
+ messageId: this.nextPendingMessageId++,
169
164
  };
170
165
  this.submitLocalMessage(op, pendingOp.messageId);
171
- 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
+ }
172
173
  }
173
174
  submitAbandonOp(taskId) {
174
175
  const op = {
@@ -177,10 +178,16 @@ class TaskManagerClass extends internal_4.SharedObject {
177
178
  };
178
179
  const pendingOp = {
179
180
  type: "abandon",
180
- messageId: ++this.messageId,
181
+ messageId: this.nextPendingMessageId++,
181
182
  };
182
183
  this.submitLocalMessage(op, pendingOp.messageId);
183
- 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
+ }
184
191
  }
185
192
  submitCompleteOp(taskId) {
186
193
  const op = {
@@ -189,23 +196,25 @@ class TaskManagerClass extends internal_4.SharedObject {
189
196
  };
190
197
  const pendingOp = {
191
198
  type: "complete",
192
- messageId: ++this.messageId,
199
+ messageId: this.nextPendingMessageId++,
193
200
  };
194
- if (this.pendingCompletedTasks.has(taskId)) {
195
- 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]);
196
205
  }
197
206
  else {
198
- this.pendingCompletedTasks.set(taskId, [pendingOp.messageId]);
207
+ latestPendingOps.push(pendingOp);
199
208
  }
200
- this.submitLocalMessage(op, pendingOp.messageId);
201
- this.latestPendingOps.set(taskId, pendingOp);
202
209
  }
203
210
  /**
204
211
  * {@inheritDoc ITaskManager.volunteerForTask}
205
212
  */
206
213
  async volunteerForTask(taskId) {
207
- // If we have the lock, resolve immediately
208
- 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)) {
209
218
  return true;
210
219
  }
211
220
  if (this.readOnlyInfo.readonly === true) {
@@ -214,9 +223,9 @@ class TaskManagerClass extends internal_4.SharedObject {
214
223
  : new Error("Attempted to volunteer in read-only state");
215
224
  throw error;
216
225
  }
217
- if (!this.isAttached()) {
226
+ if (this.isDetached()) {
218
227
  // Simulate auto-ack in detached scenario
219
- (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 */);
220
229
  this.addClientToQueue(taskId, this.clientId);
221
230
  return true;
222
231
  }
@@ -225,6 +234,25 @@ class TaskManagerClass extends internal_4.SharedObject {
225
234
  }
226
235
  // This promise works even if we already have an outstanding volunteer op.
227
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
+ };
228
256
  const checkIfAcquiredLock = (eventTaskId) => {
229
257
  if (eventTaskId !== taskId) {
230
258
  return;
@@ -232,47 +260,48 @@ class TaskManagerClass extends internal_4.SharedObject {
232
260
  // Also check pending ops here because it's possible we are currently in the queue from a previous
233
261
  // lock attempt, but have an outstanding abandon AND the outstanding volunteer for this lock attempt.
234
262
  // If we reach the head of the queue based on the previous lock attempt, we don't want to resolve.
235
- if (this.assigned(taskId) && !this.latestPendingOps.has(taskId)) {
236
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
237
- this.abandonWatcher.off("abandon", checkIfAbandoned);
238
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
239
- this.completedWatcher.off("completed", checkIfCompleted);
263
+ if (this.assigned(taskId)) {
264
+ removeListeners();
240
265
  resolve(true);
241
266
  }
242
267
  };
243
- const checkIfAbandoned = (eventTaskId) => {
268
+ const checkIfAbandoned = (eventTaskId, messageId) => {
244
269
  if (eventTaskId !== taskId) {
245
270
  return;
246
271
  }
247
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
248
- this.abandonWatcher.off("abandon", checkIfAbandoned);
249
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
250
- 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();
251
277
  reject(new Error("Abandoned before acquiring task assignment"));
252
278
  };
253
279
  const rejectOnDisconnect = () => {
254
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
255
- this.abandonWatcher.off("abandon", checkIfAbandoned);
256
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
257
- this.completedWatcher.off("completed", checkIfCompleted);
280
+ removeListeners();
258
281
  reject(new Error("Disconnected before acquiring task assignment"));
259
282
  };
260
- const checkIfCompleted = (eventTaskId) => {
283
+ const checkIfCompleted = (eventTaskId, messageId) => {
261
284
  if (eventTaskId !== taskId) {
262
285
  return;
263
286
  }
264
- this.queueWatcher.off("queueChange", checkIfAcquiredLock);
265
- this.abandonWatcher.off("abandon", checkIfAbandoned);
266
- this.connectionWatcher.off("disconnect", rejectOnDisconnect);
267
- 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();
268
292
  resolve(false);
269
293
  };
270
- this.queueWatcher.on("queueChange", checkIfAcquiredLock);
271
- this.abandonWatcher.on("abandon", checkIfAbandoned);
272
- this.connectionWatcher.on("disconnect", rejectOnDisconnect);
273
- this.completedWatcher.on("completed", checkIfCompleted);
294
+ const checkIfRolledBack = (eventTaskId) => {
295
+ if (eventTaskId !== taskId) {
296
+ return;
297
+ }
298
+ removeListeners();
299
+ resolve(false);
300
+ };
301
+ setupListeners();
274
302
  });
275
- if (!this.queued(taskId)) {
303
+ if (!this.queuedOptimistically(taskId)) {
304
+ // Only send the volunteer op if we are not already queued.
276
305
  this.submitVolunteerOp(taskId);
277
306
  }
278
307
  return lockAcquireP;
@@ -287,56 +316,83 @@ class TaskManagerClass extends internal_4.SharedObject {
287
316
  if (this.readOnlyInfo.readonly === true && this.readOnlyInfo.permissions === true) {
288
317
  throw new Error("Attempted to subscribe with read-only permissions");
289
318
  }
319
+ let volunteerOpMessageId;
290
320
  const submitVolunteerOp = () => {
321
+ volunteerOpMessageId = this.nextPendingMessageId;
291
322
  this.submitVolunteerOp(taskId);
292
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
+ };
293
337
  const disconnectHandler = () => {
294
338
  // Wait to be connected again and then re-submit volunteer op
295
339
  this.connectionWatcher.once("connect", submitVolunteerOp);
296
340
  };
297
- const checkIfAbandoned = (eventTaskId) => {
341
+ const checkIfAbandoned = (eventTaskId, messageId) => {
298
342
  if (eventTaskId !== taskId) {
299
343
  return;
300
344
  }
301
- this.abandonWatcher.off("abandon", checkIfAbandoned);
302
- this.connectionWatcher.off("disconnect", disconnectHandler);
303
- this.connectionWatcher.off("connect", submitVolunteerOp);
304
- 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();
305
358
  this.subscribedTasks.delete(taskId);
306
359
  };
307
- const checkIfCompleted = (eventTaskId) => {
360
+ const checkIfCompleted = (eventTaskId, messageId) => {
308
361
  if (eventTaskId !== taskId) {
309
362
  return;
310
363
  }
311
- this.abandonWatcher.off("abandon", checkIfAbandoned);
312
- this.connectionWatcher.off("disconnect", disconnectHandler);
313
- this.connectionWatcher.off("connect", submitVolunteerOp);
314
- 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();
315
371
  this.subscribedTasks.delete(taskId);
316
372
  };
317
- this.abandonWatcher.on("abandon", checkIfAbandoned);
318
- this.connectionWatcher.on("disconnect", disconnectHandler);
319
- this.completedWatcher.on("completed", checkIfCompleted);
320
- 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()) {
321
382
  // Simulate auto-ack in detached scenario
322
- (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 */);
323
384
  this.addClientToQueue(taskId, this.clientId);
324
385
  // Because we volunteered with placeholderClientId, we need to wait for when we attach and are assigned
325
386
  // a real clientId. At that point we should re-enter the queue with a real volunteer op (assuming we are
326
387
  // connected).
327
388
  this.runtime.once("attached", () => {
328
- if (this.queued(taskId)) {
329
- // If we are already queued, then we were able to replace the placeholderClientId with our real
330
- // clientId and no action is required.
331
- return;
332
- }
333
- else if (this.connected) {
389
+ // We call scrubClientsNotInQuorum() in case our clientId changed during the attach process.
390
+ this.scrubClientsNotInQuorum();
391
+ if (this.connected) {
334
392
  submitVolunteerOp();
335
393
  }
336
394
  else {
337
- this.connectionWatcher.once("connect", () => {
338
- submitVolunteerOp();
339
- });
395
+ this.connectionWatcher.once("connect", submitVolunteerOp);
340
396
  }
341
397
  });
342
398
  }
@@ -344,7 +400,8 @@ class TaskManagerClass extends internal_4.SharedObject {
344
400
  // If we are disconnected (and attached), wait to be connected and submit volunteer op
345
401
  disconnectHandler();
346
402
  }
347
- 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.
348
405
  submitVolunteerOp();
349
406
  }
350
407
  this.subscribedTasks.add(taskId);
@@ -354,23 +411,19 @@ class TaskManagerClass extends internal_4.SharedObject {
354
411
  */
355
412
  abandon(taskId) {
356
413
  // Always allow abandon if the client is subscribed to allow clients to unsubscribe while disconnected.
357
- // Otherwise, we should check to make sure the client is both connected queued for the task before sending an
358
- // abandon op.
359
- 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)) {
360
416
  // Nothing to do
361
417
  return;
362
418
  }
363
- if (!this.isAttached()) {
419
+ if (this.isDetached()) {
364
420
  // Simulate auto-ack in detached scenario
365
- (0, internal_1.assert)(this.clientId !== undefined, 0x474 /* clientId is undefined */);
421
+ (0, internal_2.assert)(this.clientId !== undefined, 0x474 /* clientId is undefined */);
366
422
  this.removeClientFromQueue(taskId, this.clientId);
367
423
  this.abandonWatcher.emit("abandon", taskId);
368
424
  return;
369
425
  }
370
- // If we're subscribed but not queued, we don't need to submit an abandon op (probably offline)
371
- if (this.queued(taskId)) {
372
- this.submitAbandonOp(taskId);
373
- }
426
+ this.submitAbandonOp(taskId);
374
427
  this.abandonWatcher.emit("abandon", taskId);
375
428
  }
376
429
  /**
@@ -381,9 +434,7 @@ class TaskManagerClass extends internal_4.SharedObject {
381
434
  return false;
382
435
  }
383
436
  const currentAssignee = this.taskQueues.get(taskId)?.[0];
384
- return (currentAssignee !== undefined &&
385
- currentAssignee === this.clientId &&
386
- !this.latestPendingOps.has(taskId));
437
+ return currentAssignee !== undefined && currentAssignee === this.clientId;
387
438
  }
388
439
  /**
389
440
  * {@inheritDoc ITaskManager.queued}
@@ -392,12 +443,8 @@ class TaskManagerClass extends internal_4.SharedObject {
392
443
  if (this.isAttached() && !this.connected) {
393
444
  return false;
394
445
  }
395
- (0, internal_1.assert)(this.clientId !== undefined, 0x07f /* "clientId undefined" */);
396
- const clientQueue = this.taskQueues.get(taskId);
397
- // If we have no queue for the taskId, then no one has signed up for it.
398
- return (((clientQueue?.includes(this.clientId) ?? false) &&
399
- !this.latestPendingOps.has(taskId)) ||
400
- 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;
401
448
  }
402
449
  /**
403
450
  * {@inheritDoc ITaskManager.subscribed}
@@ -414,15 +461,16 @@ class TaskManagerClass extends internal_4.SharedObject {
414
461
  }
415
462
  // If we are detached we will simulate auto-ack for the complete op. Therefore we only need to send the op if
416
463
  // we are attached. Additionally, we don't need to check if we are connected while detached.
417
- if (this.isAttached()) {
418
- if (!this.connected) {
419
- throw new Error("Attempted to complete task in disconnected state");
420
- }
421
- 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;
422
469
  }
423
- this.taskQueues.delete(taskId);
424
- this.completedWatcher.emit("completed", taskId);
425
- this.emit("completed", taskId);
470
+ if (!this.connected) {
471
+ throw new Error("Attempted to complete task in disconnected state");
472
+ }
473
+ this.submitCompleteOp(taskId);
426
474
  }
427
475
  /**
428
476
  * {@inheritDoc ITaskManager.canVolunteer}
@@ -459,13 +507,13 @@ class TaskManagerClass extends internal_4.SharedObject {
459
507
  }
460
508
  }
461
509
  const content = [...filteredMap.entries()];
462
- return (0, internal_4.createSingleBlobSummary)(snapshotFileName, JSON.stringify(content));
510
+ return (0, internal_5.createSingleBlobSummary)(snapshotFileName, JSON.stringify(content));
463
511
  }
464
512
  /**
465
513
  * {@inheritDoc @fluidframework/shared-object-base#SharedObject.loadCore}
466
514
  */
467
515
  async loadCore(storage) {
468
- const content = await (0, internal_3.readAndParse)(storage, snapshotFileName);
516
+ const content = await (0, internal_4.readAndParse)(storage, snapshotFileName);
469
517
  for (const [taskId, clientIdQueue] of content) {
470
518
  this.taskQueues.set(taskId, clientIdQueue);
471
519
  }
@@ -485,12 +533,22 @@ class TaskManagerClass extends internal_4.SharedObject {
485
533
  onConnect() {
486
534
  this.connectionWatcher.emit("connect");
487
535
  }
488
- //
489
536
  /**
490
537
  * Override resubmit core to avoid resubmission on reconnect. On disconnect we accept our removal from the
491
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.
492
540
  */
493
- 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
+ }
494
552
  /**
495
553
  * Process a task manager operation
496
554
  *
@@ -501,7 +559,7 @@ class TaskManagerClass extends internal_4.SharedObject {
501
559
  */
502
560
  processCore(message, local, localOpMetadata) {
503
561
  // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
504
- if (message.type === internal_2.MessageType.Operation) {
562
+ if (message.type === internal_3.MessageType.Operation) {
505
563
  const op = message.contents;
506
564
  const messageId = localOpMetadata;
507
565
  switch (op.type) {
@@ -524,11 +582,6 @@ class TaskManagerClass extends internal_4.SharedObject {
524
582
  }
525
583
  }
526
584
  addClientToQueue(taskId, clientId) {
527
- const pendingIds = this.pendingCompletedTasks.get(taskId);
528
- if (pendingIds !== undefined && pendingIds.length > 0) {
529
- // Ignore the volunteer op if we know this task is about to be completed
530
- return;
531
- }
532
585
  // Ensure that the clientId exists in the quorum, or it is placeholderClientId (detached scenario)
533
586
  if (this.runtime.getQuorum().getMembers().has(clientId) ||
534
587
  this.clientId === placeholderClientId) {
@@ -538,6 +591,12 @@ class TaskManagerClass extends internal_4.SharedObject {
538
591
  clientQueue = [];
539
592
  this.taskQueues.set(taskId, clientQueue);
540
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
+ }
541
600
  const oldLockHolder = clientQueue[0];
542
601
  clientQueue.push(clientId);
543
602
  const newLockHolder = clientQueue[0];
@@ -575,11 +634,17 @@ class TaskManagerClass extends internal_4.SharedObject {
575
634
  * transitioning from detached to attached and this.runtime.clientId is defined.
576
635
  */
577
636
  replacePlaceholderInAllQueues() {
578
- (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 */);
579
638
  for (const clientQueue of this.taskQueues.values()) {
580
639
  const clientIdIndex = clientQueue.indexOf(placeholderClientId);
581
640
  if (clientIdIndex !== -1) {
582
- 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
+ }
583
648
  }
584
649
  }
585
650
  }
@@ -600,25 +665,55 @@ class TaskManagerClass extends internal_4.SharedObject {
600
665
  }
601
666
  }
602
667
  }
603
- applyStashedOp(content) {
604
- const taskOp = content;
605
- switch (taskOp.type) {
606
- case "abandon": {
607
- this.abandon(taskOp.taskId);
608
- break;
609
- }
610
- case "complete": {
611
- this.complete(taskOp.taskId);
612
- break;
613
- }
614
- case "volunteer": {
615
- this.subscribeToTask(taskOp.taskId);
616
- break;
617
- }
618
- default: {
619
- (0, internal_1.unreachableCase)(taskOp);
620
- }
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;
621
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);
622
717
  }
623
718
  }
624
719
  exports.TaskManagerClass = TaskManagerClass;