@webex/plugin-meetings 3.12.0-next.21 → 3.12.0-next.23

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.
@@ -71,6 +71,10 @@ declare class HashTreeParser {
71
71
  heartbeatIntervalMs?: number;
72
72
  private excludedDataSets;
73
73
  state: 'active' | 'stopped';
74
+ private syncQueue;
75
+ private isSyncInProgress;
76
+ private isSyncAllInProgress;
77
+ private syncQueueProcessingPromise;
74
78
  /**
75
79
  * Constructor for HashTreeParser
76
80
  * @param {Object} options
@@ -124,14 +128,6 @@ declare class HashTreeParser {
124
128
  * @returns {Promise}
125
129
  */
126
130
  private initializeNewVisibleDataSet;
127
- /**
128
- * Sends a special sync request to Locus with all leaves empty - this is a way to get all the data for a given dataset.
129
- *
130
- * @param {string} datasetName - name of the dataset for which to send the request
131
- * @param {string} debugText - text to include in logs
132
- * @returns {Promise}
133
- */
134
- private sendInitializationSyncRequestToLocus;
135
131
  /**
136
132
  * Queries Locus for all up-to-date information about all visible data sets
137
133
  *
@@ -302,11 +298,35 @@ declare class HashTreeParser {
302
298
  * Performs a sync for the given data set.
303
299
  *
304
300
  * @param {InternalDataSet} dataSet - The data set to sync
305
- * @param {string} rootHash - Our current root hash for this data set
306
301
  * @param {string} reason - The reason for the sync (used for logging)
302
+ * @param {boolean} [isInitialization] - Whether this is an initialization sync (sends empty leaves data instead of comparing hashes)
307
303
  * @returns {Promise<void>}
308
304
  */
309
305
  private performSync;
306
+ /**
307
+ * Enqueues a sync for the given data set. If the data set is already in the queue, the request is ignored.
308
+ * This ensures that all syncs are executed sequentially and no more than 1 sync runs at a time.
309
+ *
310
+ * @param {string} dataSetName - The name of the data set to sync
311
+ * @param {string} reason - The reason for the sync (used for logging)
312
+ * @param {boolean} [isInitialization=false] - Whether this is an initialization sync (uses empty leaves data instead of hash comparison)
313
+ * @returns {void}
314
+ */
315
+ private enqueueSyncForDataset;
316
+ /**
317
+ * Processes the sync queue sequentially. Only one instance of this method runs at a time.
318
+ *
319
+ * @returns {Promise<void>}
320
+ */
321
+ private processSyncQueue;
322
+ /**
323
+ * Syncs all data sets that have hash trees, one by one in sequence, using the priority order
324
+ * provided by sortByInitPriority(). Does nothing if the parser is stopped or if a syncAllDatasets
325
+ * call is already in progress.
326
+ *
327
+ * @returns {Promise<void>}
328
+ */
329
+ syncAllDatasets(): Promise<void>;
310
330
  /**
311
331
  * Runs the sync algorithm for the given data set.
312
332
  *
@@ -343,17 +363,25 @@ declare class HashTreeParser {
343
363
  */
344
364
  cleanUp(): void;
345
365
  /**
346
- * Resumes the HashTreeParser that was previously stopped.
366
+ * Resumes the HashTreeParser that was previously stopped, using a hash tree message.
347
367
  * @param {HashTreeMessage} message - The message to resume with, it must contain metadata with visible data sets info
348
368
  * @returns {void}
349
369
  */
350
- resume(message: HashTreeMessage): void;
370
+ resumeFromMessage(message: HashTreeMessage): void;
371
+ /**
372
+ * Resumes the HashTreeParser that was previously stopped, using a Locus API response.
373
+ * Unlike resumeFromMessage(), this does not require metadata/dataSets in the input,
374
+ * as it fetches all necessary information from Locus via initializeFromGetLociResponse.
375
+ * @param {LocusDTO} locus - locus object from an API response
376
+ * @returns {Promise}
377
+ */
378
+ resumeFromApiResponse(locus: LocusDTO): Promise<void>;
351
379
  private checkForSentinelHttpResponse;
352
380
  /**
353
381
  * Gets the current hashes from the locus for a specific data set.
354
382
  * @param {string} dataSetName
355
383
  * @param {string} currentRootHash
356
- * @returns {string[]}
384
+ * @returns {Object|null} An object containing the hashes and leaf count, or null if the hashes match and no sync is needed
357
385
  */
358
386
  private getHashesFromLocus;
359
387
  /**
@@ -11,11 +11,11 @@ export default class LocusRetryStatusInterceptor extends Interceptor {
11
11
  */
12
12
  static create(): LocusRetryStatusInterceptor;
13
13
  /**
14
- * Handle response errors
15
- * @param {Object} options
16
- * @param {WebexHttpError} reason
17
- * @returns {Promise<WebexHttpError>}
14
+ * Check whether a URI is a Locus /hashtree or /sync endpoint.
15
+ * @param {string} uri
16
+ * @returns {boolean}
18
17
  */
18
+ private static isLocusHashtreeOrSync;
19
19
  onResponseError(options: any, reason: any): Promise<unknown>;
20
20
  /**
21
21
  * Handle retries for locus service unavailable errors
@@ -166,6 +166,28 @@ export default class LocusInfo extends EventsScope {
166
166
  * @returns {void}
167
167
  */
168
168
  sendClassicVsHashTreeMismatchMetric(meeting: any, message: string): void;
169
+ /**
170
+ * Helper that handles the common logic for reactivating a stopped HashTreeParser when
171
+ * a newer "replaces" is detected. Used by both the message and API response parser switch methods.
172
+ *
173
+ * @param {string} callerName - name of the calling method, used in log messages
174
+ * @param {string} locusUrl - the locus URL of the stopped parser
175
+ * @param {HashTreeParserEntry} stoppedEntry - the stopped parser entry
176
+ * @param {ReplacesInfo} replaces - replacement info extracted from self
177
+ * @param {Function} resumeCallback - callback to invoke after reactivation to resume the parser
178
+ * @returns {void}
179
+ */
180
+ private resumeStoppedParser;
181
+ /**
182
+ * Handles an API response whose locusUrl doesn't match any active HashTreeParser
183
+ * (either no entry exists, or the existing entry is stopped).
184
+ * Creates a new parser or reactivates a stopped one using initializeFromGetLociResponse.
185
+ *
186
+ * @param {string} locusUrl - the locus URL from the API response
187
+ * @param {LocusDTO} locus - the locus DTO from the API response
188
+ * @returns {void}
189
+ */
190
+ private handleHashTreeParserSwitchForAPIResponse;
169
191
  /**
170
192
  * Checks if the hash tree message should trigger a switch to a different HashTreeParser
171
193
  *
@@ -182,6 +204,13 @@ export default class LocusInfo extends EventsScope {
182
204
  * @returns {void}
183
205
  */
184
206
  private handleHashTreeMessage;
207
+ /**
208
+ * Triggers a sync of all hash tree datasets for all hash tree parsers associated with this meeting.
209
+ * The syncs are executed sequentially within each parser.
210
+ *
211
+ * @returns {Promise<void>}
212
+ */
213
+ syncAllHashTreeDatasets(): Promise<void>;
185
214
  /**
186
215
  * Callback registered with HashTreeParser to receive locus info updates.
187
216
  * Updates our locus info based on the data parsed by the hash tree parser.
@@ -723,7 +723,7 @@ var Webinar = _webexCore.WebexPlugin.extend({
723
723
  }, _callee1);
724
724
  }))();
725
725
  },
726
- version: "3.12.0-next.21"
726
+ version: "3.12.0-next.23"
727
727
  });
728
728
  var _default = exports.default = Webinar;
729
729
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -94,5 +94,5 @@
94
94
  "//": [
95
95
  "TODO: upgrade jwt-decode when moving to node 18"
96
96
  ],
97
- "version": "3.12.0-next.21"
97
+ "version": "3.12.0-next.23"
98
98
  }
@@ -104,6 +104,10 @@ class HashTreeParser {
104
104
  heartbeatIntervalMs?: number;
105
105
  private excludedDataSets: string[];
106
106
  state: 'active' | 'stopped';
107
+ private syncQueue: Array<{dataSetName: string; reason: string; isInitialization?: boolean}> = [];
108
+ private isSyncInProgress = false;
109
+ private isSyncAllInProgress = false;
110
+ private syncQueueProcessingPromise: Promise<void> = Promise.resolve();
107
111
 
108
112
  /**
109
113
  * Constructor for HashTreeParser
@@ -229,16 +233,16 @@ class HashTreeParser {
229
233
  * @param {DataSet} dataSetInfo The new data set to be added
230
234
  * @returns {Promise}
231
235
  */
232
- private initializeNewVisibleDataSet(
236
+ private async initializeNewVisibleDataSet(
233
237
  visibleDataSetInfo: VisibleDataSetInfo,
234
238
  dataSetInfo: DataSet
235
- ): Promise<LocusInfoUpdate> {
239
+ ): Promise<void> {
236
240
  if (this.isVisibleDataSet(dataSetInfo.name)) {
237
241
  LoggerProxy.logger.info(
238
242
  `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Data set "${dataSetInfo.name}" already exists, skipping init`
239
243
  );
240
244
 
241
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
245
+ return;
242
246
  }
243
247
 
244
248
  LoggerProxy.logger.info(
@@ -246,7 +250,7 @@ class HashTreeParser {
246
250
  );
247
251
 
248
252
  if (!this.addToVisibleDataSetsList(visibleDataSetInfo)) {
249
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
253
+ return;
250
254
  }
251
255
 
252
256
  const hashTree = new HashTree([], dataSetInfo.leafCount);
@@ -256,51 +260,8 @@ class HashTreeParser {
256
260
  hashTree,
257
261
  };
258
262
 
259
- return this.sendInitializationSyncRequestToLocus(dataSetInfo.name, 'new visible data set');
260
- }
261
-
262
- /**
263
- * Sends a special sync request to Locus with all leaves empty - this is a way to get all the data for a given dataset.
264
- *
265
- * @param {string} datasetName - name of the dataset for which to send the request
266
- * @param {string} debugText - text to include in logs
267
- * @returns {Promise}
268
- */
269
- private sendInitializationSyncRequestToLocus(
270
- datasetName: string,
271
- debugText: string
272
- ): Promise<LocusInfoUpdate> {
273
- const dataset = this.dataSets[datasetName];
274
-
275
- if (!dataset) {
276
- LoggerProxy.logger.warn(
277
- `HashTreeParser#sendInitializationSyncRequestToLocus --> ${this.debugId} No data set found for ${datasetName}, cannot send the request for leaf data`
278
- );
279
-
280
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
281
- }
282
-
283
- const emptyLeavesData = new Array(dataset.leafCount).fill([]);
284
-
285
- LoggerProxy.logger.info(
286
- `HashTreeParser#sendInitializationSyncRequestToLocus --> ${this.debugId} Sending initial sync request to Locus for data set "${datasetName}" with empty leaf data`
287
- );
288
-
289
- return this.sendSyncRequestToLocus(this.dataSets[datasetName], emptyLeavesData).then(
290
- (syncResponse) => {
291
- if (syncResponse) {
292
- return {
293
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
294
- updatedObjects: this.parseMessage(
295
- syncResponse,
296
- `via empty leaves /sync API call for ${debugText}`
297
- ),
298
- };
299
- }
300
-
301
- return {updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []};
302
- }
303
- );
263
+ this.enqueueSyncForDataset(dataSetInfo.name, 'new visible data set initialization', true);
264
+ await this.syncQueueProcessingPromise;
304
265
  }
305
266
 
306
267
  /**
@@ -388,8 +349,6 @@ class HashTreeParser {
388
349
  return;
389
350
  }
390
351
 
391
- const updatedObjects: HashTreeObject[] = [];
392
-
393
352
  for (const dataSet of sortByInitPriority(visibleDataSets, DATA_SET_INIT_PRIORITY)) {
394
353
  const {name, leafCount, url} = dataSet;
395
354
 
@@ -426,19 +385,12 @@ class HashTreeParser {
426
385
  );
427
386
  this.dataSets[name].hashTree = new HashTree([], leafCount);
428
387
 
429
- // eslint-disable-next-line no-await-in-loop
430
- const data = await this.sendInitializationSyncRequestToLocus(name, debugText);
431
-
432
- if (data.updateType === LocusInfoUpdateType.OBJECTS_UPDATED) {
433
- updatedObjects.push(...(data.updatedObjects || []));
434
- }
388
+ this.enqueueSyncForDataset(name, `initialization sync for ${debugText}`, true);
435
389
  }
436
390
  }
437
391
 
438
- this.callLocusInfoUpdateCallback({
439
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
440
- updatedObjects,
441
- });
392
+ // wait for all enqueued initialization syncs to complete
393
+ await this.syncQueueProcessingPromise;
442
394
  }
443
395
 
444
396
  /**
@@ -697,6 +649,7 @@ class HashTreeParser {
697
649
  const {dataSets, locus, metadata} = update;
698
650
 
699
651
  if (!dataSets) {
652
+ // this happens for example when we handle GET /loci response
700
653
  LoggerProxy.logger.info(
701
654
  `HashTreeParser#handleLocusUpdate --> ${this.debugId} received hash tree update without dataSets`
702
655
  );
@@ -979,9 +932,7 @@ class HashTreeParser {
979
932
  );
980
933
  } else {
981
934
  // eslint-disable-next-line no-await-in-loop
982
- const updates = await this.initializeNewVisibleDataSet(ds, dataSetInfo);
983
-
984
- this.callLocusInfoUpdateCallback(updates);
935
+ await this.initializeNewVisibleDataSet(ds, dataSetInfo);
985
936
  }
986
937
  }
987
938
  }
@@ -1224,41 +1175,53 @@ class HashTreeParser {
1224
1175
  * Performs a sync for the given data set.
1225
1176
  *
1226
1177
  * @param {InternalDataSet} dataSet - The data set to sync
1227
- * @param {string} rootHash - Our current root hash for this data set
1228
1178
  * @param {string} reason - The reason for the sync (used for logging)
1179
+ * @param {boolean} [isInitialization] - Whether this is an initialization sync (sends empty leaves data instead of comparing hashes)
1229
1180
  * @returns {Promise<void>}
1230
1181
  */
1231
1182
  private async performSync(
1232
1183
  dataSet: InternalDataSet,
1233
- rootHash: string,
1234
- reason: string
1184
+ reason: string,
1185
+ isInitialization?: boolean
1235
1186
  ): Promise<void> {
1236
1187
  if (!dataSet.hashTree) {
1237
1188
  return;
1238
1189
  }
1239
1190
 
1191
+ const {hashTree} = dataSet;
1192
+ const rootHash = hashTree.getRootHash();
1193
+
1240
1194
  try {
1241
1195
  LoggerProxy.logger.info(
1242
1196
  `HashTreeParser#performSync --> ${this.debugId} ${reason}, syncing data set "${dataSet.name}"`
1243
1197
  );
1244
1198
 
1245
- const mismatchedLeavesData: Record<number, LeafDataItem[]> = {};
1199
+ let leavesData: Record<number, LeafDataItem[]>;
1246
1200
 
1247
- if (dataSet.leafCount !== 1) {
1201
+ if (isInitialization) {
1202
+ // initialization sync: send all leaves as empty to get all data from Locus
1203
+ leavesData = {};
1204
+ for (let i = 0; i < dataSet.leafCount; i += 1) {
1205
+ leavesData[i] = [];
1206
+ }
1207
+ } else if (dataSet.leafCount !== 1) {
1208
+ leavesData = {};
1248
1209
  let receivedHashes;
1249
1210
 
1250
1211
  try {
1251
1212
  // request hashes from sender
1252
- const {hashes, dataSet: latestDataSetInfo} = await this.getHashesFromLocus(
1253
- dataSet.name,
1254
- rootHash
1255
- );
1213
+ const hashesResult = await this.getHashesFromLocus(dataSet.name, rootHash);
1214
+
1215
+ if (!hashesResult) {
1216
+ // hashes match, no sync needed
1217
+ return;
1218
+ }
1256
1219
 
1257
- receivedHashes = hashes;
1220
+ receivedHashes = hashesResult.hashes;
1258
1221
 
1259
- dataSet.hashTree.resize(latestDataSetInfo.leafCount);
1260
- } catch (error) {
1261
- if (error.statusCode === 409) {
1222
+ hashTree.resize(hashesResult.dataSet.leafCount);
1223
+ } catch (error: any) {
1224
+ if (error?.statusCode === 409) {
1262
1225
  // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1263
1226
  LoggerProxy.logger.info(
1264
1227
  `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
@@ -1270,17 +1233,17 @@ class HashTreeParser {
1270
1233
  }
1271
1234
 
1272
1235
  // identify mismatched leaves
1273
- const mismatchedLeaveIndexes = dataSet.hashTree.diffHashes(receivedHashes);
1236
+ const mismatchedLeaveIndexes = hashTree.diffHashes(receivedHashes);
1274
1237
 
1275
1238
  mismatchedLeaveIndexes.forEach((index) => {
1276
- mismatchedLeavesData[index] = dataSet.hashTree.getLeafData(index);
1239
+ leavesData[index] = hashTree.getLeafData(index);
1277
1240
  });
1278
1241
  } else {
1279
- mismatchedLeavesData[0] = dataSet.hashTree.getLeafData(0);
1242
+ leavesData = {0: hashTree.getLeafData(0)};
1280
1243
  }
1281
1244
  // request sync for mismatched leaves
1282
- if (Object.keys(mismatchedLeavesData).length > 0) {
1283
- const syncResponse = await this.sendSyncRequestToLocus(dataSet, mismatchedLeavesData);
1245
+ if (Object.keys(leavesData).length > 0) {
1246
+ const syncResponse = await this.sendSyncRequestToLocus(dataSet, leavesData);
1284
1247
 
1285
1248
  // sync API may return nothing (in that case data will arrive via messages)
1286
1249
  // or it may return a response in the same format as messages
@@ -1302,6 +1265,105 @@ class HashTreeParser {
1302
1265
  }
1303
1266
  }
1304
1267
 
1268
+ /**
1269
+ * Enqueues a sync for the given data set. If the data set is already in the queue, the request is ignored.
1270
+ * This ensures that all syncs are executed sequentially and no more than 1 sync runs at a time.
1271
+ *
1272
+ * @param {string} dataSetName - The name of the data set to sync
1273
+ * @param {string} reason - The reason for the sync (used for logging)
1274
+ * @param {boolean} [isInitialization=false] - Whether this is an initialization sync (uses empty leaves data instead of hash comparison)
1275
+ * @returns {void}
1276
+ */
1277
+ private enqueueSyncForDataset(
1278
+ dataSetName: string,
1279
+ reason: string,
1280
+ isInitialization = false
1281
+ ): void {
1282
+ if (this.state === 'stopped') return;
1283
+
1284
+ const existingEntry = this.syncQueue.find((entry) => entry.dataSetName === dataSetName);
1285
+
1286
+ if (existingEntry) {
1287
+ if (isInitialization) {
1288
+ existingEntry.isInitialization = true;
1289
+ }
1290
+ LoggerProxy.logger.info(
1291
+ `HashTreeParser#enqueueSyncForDataset --> ${this.debugId} data set "${dataSetName}" already in sync queue, skipping`
1292
+ );
1293
+
1294
+ return;
1295
+ }
1296
+
1297
+ this.syncQueue.push({dataSetName, reason, isInitialization});
1298
+
1299
+ if (!this.isSyncInProgress) {
1300
+ this.syncQueueProcessingPromise = this.processSyncQueue();
1301
+ }
1302
+ }
1303
+
1304
+ /**
1305
+ * Processes the sync queue sequentially. Only one instance of this method runs at a time.
1306
+ *
1307
+ * @returns {Promise<void>}
1308
+ */
1309
+ private async processSyncQueue(): Promise<void> {
1310
+ if (this.isSyncInProgress) return;
1311
+
1312
+ this.isSyncInProgress = true;
1313
+ try {
1314
+ while (this.syncQueue.length > 0 && this.state !== 'stopped') {
1315
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1316
+ const {dataSetName, reason, isInitialization} = this.syncQueue.shift()!;
1317
+ const dataSet = this.dataSets[dataSetName];
1318
+
1319
+ if (!dataSet?.hashTree) {
1320
+ // eslint-disable-next-line no-continue
1321
+ continue;
1322
+ }
1323
+
1324
+ // eslint-disable-next-line no-await-in-loop
1325
+ await this.performSync(dataSet, reason, isInitialization);
1326
+ }
1327
+ } finally {
1328
+ this.isSyncInProgress = false;
1329
+ }
1330
+ }
1331
+
1332
+ /**
1333
+ * Syncs all data sets that have hash trees, one by one in sequence, using the priority order
1334
+ * provided by sortByInitPriority(). Does nothing if the parser is stopped or if a syncAllDatasets
1335
+ * call is already in progress.
1336
+ *
1337
+ * @returns {Promise<void>}
1338
+ */
1339
+ public async syncAllDatasets(): Promise<void> {
1340
+ if (this.state === 'stopped') return;
1341
+ if (this.isSyncAllInProgress) return;
1342
+
1343
+ this.isSyncAllInProgress = true;
1344
+ try {
1345
+ const dataSetsWithHashTrees = Object.values(this.dataSets)
1346
+ .filter((dataSet) => dataSet?.hashTree)
1347
+ .map((dataSet) => ({name: dataSet.name}));
1348
+
1349
+ const sorted = sortByInitPriority(dataSetsWithHashTrees, DATA_SET_INIT_PRIORITY);
1350
+
1351
+ LoggerProxy.logger.info(
1352
+ `HashTreeParser#syncAllDatasets --> ${this.debugId} syncing datasets: ${sorted
1353
+ .map((ds) => ds.name)
1354
+ .join(', ')}`
1355
+ );
1356
+
1357
+ for (const ds of sorted) {
1358
+ this.enqueueSyncForDataset(ds.name, 'syncAllDatasets');
1359
+ }
1360
+
1361
+ await this.syncQueueProcessingPromise;
1362
+ } finally {
1363
+ this.isSyncAllInProgress = false;
1364
+ }
1365
+ }
1366
+
1305
1367
  /**
1306
1368
  * Runs the sync algorithm for the given data set.
1307
1369
  *
@@ -1346,7 +1408,7 @@ class HashTreeParser {
1346
1408
  `HashTreeParser#runSyncAlgorithm --> ${this.debugId} setting "${dataSet.name}" sync timer for ${delay}`
1347
1409
  );
1348
1410
 
1349
- dataSet.timer = setTimeout(async () => {
1411
+ dataSet.timer = setTimeout(() => {
1350
1412
  dataSet.timer = undefined;
1351
1413
 
1352
1414
  if (!dataSet.hashTree) {
@@ -1360,9 +1422,8 @@ class HashTreeParser {
1360
1422
  const rootHash = dataSet.hashTree.getRootHash();
1361
1423
 
1362
1424
  if (dataSet.root !== rootHash) {
1363
- await this.performSync(
1364
- dataSet,
1365
- rootHash,
1425
+ this.enqueueSyncForDataset(
1426
+ dataSet.name,
1366
1427
  `Root hash mismatch: received=${dataSet.root}, ours=${rootHash}`
1367
1428
  );
1368
1429
  } else {
@@ -1408,18 +1469,14 @@ class HashTreeParser {
1408
1469
  const backoffTime = this.getWeightedBackoffTime(dataSet.backoff);
1409
1470
  const delay = this.heartbeatIntervalMs + backoffTime;
1410
1471
 
1411
- dataSet.heartbeatWatchdogTimer = setTimeout(async () => {
1472
+ dataSet.heartbeatWatchdogTimer = setTimeout(() => {
1412
1473
  dataSet.heartbeatWatchdogTimer = undefined;
1413
1474
 
1414
1475
  LoggerProxy.logger.warn(
1415
1476
  `HashTreeParser#resetHeartbeatWatchdogs --> ${this.debugId} Heartbeat watchdog fired for data set "${dataSet.name}" - no heartbeat received within expected interval, initiating sync`
1416
1477
  );
1417
1478
 
1418
- await this.performSync(
1419
- dataSet,
1420
- dataSet.hashTree.getRootHash(),
1421
- `heartbeat watchdog expired`
1422
- );
1479
+ this.enqueueSyncForDataset(dataSet.name, `heartbeat watchdog expired`);
1423
1480
  }, delay);
1424
1481
  }
1425
1482
  }
@@ -1452,6 +1509,7 @@ class HashTreeParser {
1452
1509
  `HashTreeParser#stop --> ${this.debugId} Stopping HashTreeParser, clearing timers and hash trees`
1453
1510
  );
1454
1511
  this.stopAllTimers();
1512
+ this.syncQueue = [];
1455
1513
  Object.values(this.dataSets).forEach((dataSet) => {
1456
1514
  dataSet.hashTree = undefined;
1457
1515
  });
@@ -1470,17 +1528,17 @@ class HashTreeParser {
1470
1528
  }
1471
1529
 
1472
1530
  /**
1473
- * Resumes the HashTreeParser that was previously stopped.
1531
+ * Resumes the HashTreeParser that was previously stopped, using a hash tree message.
1474
1532
  * @param {HashTreeMessage} message - The message to resume with, it must contain metadata with visible data sets info
1475
1533
  * @returns {void}
1476
1534
  */
1477
- public resume(message: HashTreeMessage) {
1535
+ public resumeFromMessage(message: HashTreeMessage) {
1478
1536
  // check that message contains metadata with visible data sets - this is essential to be able to resume
1479
1537
  const metadataObject = message.locusStateElements?.find((el) => isMetadata(el));
1480
1538
 
1481
1539
  if (!metadataObject?.data?.visibleDataSets) {
1482
1540
  LoggerProxy.logger.warn(
1483
- `HashTreeParser#resume --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1541
+ `HashTreeParser#resumeFromMessage --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1484
1542
  );
1485
1543
 
1486
1544
  return;
@@ -1501,7 +1559,7 @@ class HashTreeParser {
1501
1559
  };
1502
1560
  }
1503
1561
  LoggerProxy.logger.info(
1504
- `HashTreeParser#resume --> ${
1562
+ `HashTreeParser#resumeFromMessage --> ${
1505
1563
  this.debugId
1506
1564
  } Resuming HashTreeParser with data sets: ${Object.keys(this.dataSets).join(
1507
1565
  ', '
@@ -1512,6 +1570,24 @@ class HashTreeParser {
1512
1570
  this.handleMessage(message, 'on resume');
1513
1571
  }
1514
1572
 
1573
+ /**
1574
+ * Resumes the HashTreeParser that was previously stopped, using a Locus API response.
1575
+ * Unlike resumeFromMessage(), this does not require metadata/dataSets in the input,
1576
+ * as it fetches all necessary information from Locus via initializeFromGetLociResponse.
1577
+ * @param {LocusDTO} locus - locus object from an API response
1578
+ * @returns {Promise}
1579
+ */
1580
+ public async resumeFromApiResponse(locus: LocusDTO) {
1581
+ this.state = 'active';
1582
+ this.dataSets = {};
1583
+
1584
+ LoggerProxy.logger.info(
1585
+ `HashTreeParser#resumeFromApiResponse --> ${this.debugId} Resuming HashTreeParser from API response`
1586
+ );
1587
+
1588
+ await this.initializeFromGetLociResponse(locus);
1589
+ }
1590
+
1515
1591
  private checkForSentinelHttpResponse(error: any, dataSetName?: string) {
1516
1592
  const isValidDataSetForSentinel =
1517
1593
  dataSetName === undefined ||
@@ -1535,7 +1611,7 @@ class HashTreeParser {
1535
1611
  * Gets the current hashes from the locus for a specific data set.
1536
1612
  * @param {string} dataSetName
1537
1613
  * @param {string} currentRootHash
1538
- * @returns {string[]}
1614
+ * @returns {Object|null} An object containing the hashes and leaf count, or null if the hashes match and no sync is needed
1539
1615
  */
1540
1616
  private getHashesFromLocus(dataSetName: string, currentRootHash: string) {
1541
1617
  LoggerProxy.logger.info(
@@ -1554,6 +1630,15 @@ class HashTreeParser {
1554
1630
  },
1555
1631
  })
1556
1632
  .then((response) => {
1633
+ if (!response.body || isEmpty(response.body)) {
1634
+ // 204 with empty body means our hashes match Locus, no sync needed
1635
+ LoggerProxy.logger.info(
1636
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got ${response.statusCode} with empty body for data set "${dataSetName}", hashes match - no sync needed`
1637
+ );
1638
+
1639
+ return null;
1640
+ }
1641
+
1557
1642
  const hashes = response.body?.hashes as string[] | undefined;
1558
1643
  const dataSetFromResponse = response.body?.dataSet;
1559
1644
 
@@ -18,12 +18,33 @@ export default class LocusRetryStatusInterceptor extends Interceptor {
18
18
  }
19
19
 
20
20
  /**
21
- * Handle response errors
22
- * @param {Object} options
23
- * @param {WebexHttpError} reason
24
- * @returns {Promise<WebexHttpError>}
21
+ * Check whether a URI is a Locus /hashtree or /sync endpoint.
22
+ * @param {string} uri
23
+ * @returns {boolean}
25
24
  */
25
+ private static isLocusHashtreeOrSync(uri: string): boolean {
26
+ try {
27
+ const {pathname} = new URL(uri);
28
+
29
+ return (
30
+ pathname.includes('/locus/') &&
31
+ (pathname.endsWith('/hashtree') || pathname.endsWith('/sync'))
32
+ );
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
26
38
  onResponseError(options, reason) {
39
+ // Don't retry /hashtree or /sync calls for 429 or any 5xx — during a sync storm retries
40
+ // make things worse. The normal sync timers will handle recovery for these endpoints.
41
+ if (
42
+ (reason.statusCode === 429 || reason.statusCode >= 500) &&
43
+ LocusRetryStatusInterceptor.isLocusHashtreeOrSync(options.uri)
44
+ ) {
45
+ return Promise.reject(reason);
46
+ }
47
+
27
48
  if ((reason.statusCode === 503 || reason.statusCode === 429) && options.uri.includes('locus')) {
28
49
  const hasRetriedLocusRequest = rateLimitExpiryTime.get(this);
29
50
  const retryAfterTime = options.headers['retry-after'] || 2000;