@salesforce/lds-drafts 1.454.0 → 1.455.0

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.
package/dist/ldsDrafts.js CHANGED
@@ -315,6 +315,25 @@ const DRAFT_SEGMENT = 'DRAFT';
315
315
  */
316
316
  const DRAFT_ACTION_RETRY_COUNT_METADATA_KEY = 'retryCount';
317
317
  class DurableDraftQueue {
318
+ // Instrumentation is strictly measure-only: a broken sink must never change queue behavior.
319
+ // Pass the sink into the callback so methods are invoked through their owning object and retain
320
+ // their receiver (some sinks use `this` to reach their metric registry).
321
+ emitInstrumentation(emit) {
322
+ const { instrumentation } = this;
323
+ if (instrumentation === undefined) {
324
+ return;
325
+ }
326
+ try {
327
+ emit(instrumentation);
328
+ }
329
+ catch {
330
+ // Telemetry failures must not interrupt draft processing.
331
+ }
332
+ }
333
+ resetDrainCycle() {
334
+ this.drainCycleStart = undefined;
335
+ this.drainCycleBatchCount = 0;
336
+ }
318
337
  getHandler(id) {
319
338
  const handler = this.handlers[id];
320
339
  if (handler === undefined) {
@@ -337,9 +356,19 @@ class DurableDraftQueue {
337
356
  ? __nimbus.plugins.JSLoggerPlugin
338
357
  : undefined;
339
358
  this.handlers = {};
359
+ // Drain-wait attribution state. uploadStartTimes stamps when each action first began
360
+ // uploading (keyed by action id) so actionCompleted/failed can emit its end-to-end drain time,
361
+ // including automatic retries/backoff. actionQueueWaitEmittedIds ensures those retries do not
362
+ // report the original enqueue wait more than once. drainCycleStart/BatchCount track one full
363
+ // idle->empty sweep. All measure-only; when no instrumentation is injected these stay untouched.
364
+ this.uploadStartTimes = new Map();
365
+ this.actionQueueWaitEmittedIds = new Set();
366
+ this.drainCycleBatchCount = 0;
340
367
  this.draftStore = draftStore;
341
- this.workerPool = new AsyncWorkerPool(1);
342
368
  this.instrumentation = instrumentation;
369
+ this.workerPool = new AsyncWorkerPool(1, instrumentation?.onWorkerTiming !== undefined
370
+ ? (waitMs, holdMs, queueDepth) => this.emitInstrumentation((sink) => sink.onWorkerTiming?.(waitMs, holdMs, queueDepth))
371
+ : undefined);
343
372
  }
344
373
  addHandler(handler) {
345
374
  const id = handler.handlerId;
@@ -427,6 +456,9 @@ class DurableDraftQueue {
427
456
  clearTimeout(this.timeoutHandler);
428
457
  this.timeoutHandler = undefined;
429
458
  }
459
+ // A stop/user edit breaks the current sweep. A later restart starts a fresh cycle rather
460
+ // than attributing stopped time to an offline drain.
461
+ this.resetDrainCycle();
430
462
  this.state = DraftQueueState.Stopped;
431
463
  }
432
464
  async getQueueActions() {
@@ -457,7 +489,7 @@ class DurableDraftQueue {
457
489
  }
458
490
  return aTime - bTime;
459
491
  });
460
- instrumentation?.onGetQueueActions?.(Date.now() - start, sorted.length);
492
+ this.emitInstrumentation((sink) => sink.onGetQueueActions?.(Date.now() - start, sorted.length));
461
493
  return sorted;
462
494
  }
463
495
  async enqueue(handlerId, data, observabilityContext) {
@@ -501,6 +533,13 @@ class DurableDraftQueue {
501
533
  await handler.handleActionCompleted(action, queueOperations, values(this.handlers));
502
534
  this.retryIntervalMilliseconds = 0;
503
535
  this.uploadingActionId = undefined;
536
+ // The action finished draining — emit its end-to-end drain time and
537
+ // count it toward the current drain-cycle batch. Measure-only.
538
+ this.emitActionDrainDuration(action.id, action.handler);
539
+ this.actionQueueWaitEmittedIds.delete(action.id);
540
+ if (this.drainCycleStart !== undefined) {
541
+ this.drainCycleBatchCount += 1;
542
+ }
504
543
  await this.notifyChangedListeners({
505
544
  type: DraftQueueEventType.ActionCompleted,
506
545
  action,
@@ -512,6 +551,16 @@ class DurableDraftQueue {
512
551
  },
513
552
  });
514
553
  }
554
+ // Read+clear the action's upload-start stamp and emit its drain duration. Safe to
555
+ // call when instrumentation is absent or the id was never stamped (no-op). Never emits the id.
556
+ emitActionDrainDuration(id, handlerId) {
557
+ const start = this.uploadStartTimes.get(id);
558
+ if (start === undefined) {
559
+ return;
560
+ }
561
+ this.uploadStartTimes.delete(id);
562
+ this.emitInstrumentation((sink) => sink.onActionDrainDuration?.(handlerId, Date.now() - start));
563
+ }
515
564
  async actionFailed(action, retry, retryDelayInMs, actionDataChanged) {
516
565
  if (actionDataChanged === true) {
517
566
  await this.draftStore.writeAction({
@@ -521,14 +570,24 @@ class DurableDraftQueue {
521
570
  }
522
571
  this.uploadingActionId = undefined;
523
572
  if (retry && this.state !== DraftQueueState.Stopped) {
573
+ // A retry means this action is NOT done draining. Keep its first-upload stamp and the
574
+ // open cycle so the eventual outcome includes network time and retry/backoff.
524
575
  this.state = DraftQueueState.Waiting;
525
576
  return retryDelayInMs !== undefined
526
577
  ? this.scheduleRetryWithSpecifiedDelay(retryDelayInMs)
527
578
  : this.scheduleRetry();
528
579
  }
529
580
  else if (isDraftError(action)) {
581
+ // Terminal failure — this action is done draining. Emit its drain time.
582
+ this.emitActionDrainDuration(action.id, action.handler);
583
+ // This was not a clean drain-to-empty. Discard the sweep before user intervention so a
584
+ // later manual retry/removal cannot inherit its elapsed time or completed batch count.
585
+ this.resetDrainCycle();
530
586
  return this.handleServerError(action, action.error);
531
587
  }
588
+ // Any other terminal path — ensure no stale start stamp lingers.
589
+ this.emitActionDrainDuration(action.id, action.handler);
590
+ this.resetDrainCycle();
532
591
  }
533
592
  handle(action) {
534
593
  const handler = this.getHandler(action.handler);
@@ -548,12 +607,22 @@ class DurableDraftQueue {
548
607
  this.state = DraftQueueState.Started;
549
608
  }
550
609
  this.processingAction = undefined;
610
+ // The queue drained empty — close the drain-cycle window if one was open.
611
+ if (this.drainCycleStart !== undefined) {
612
+ const durationMs = Date.now() - this.drainCycleStart;
613
+ const batchSize = this.drainCycleBatchCount;
614
+ this.resetDrainCycle();
615
+ this.emitInstrumentation((sink) => sink.onDrainCycle?.(durationMs, batchSize));
616
+ }
551
617
  return ProcessActionResult.NO_ACTION_TO_PROCESS;
552
618
  }
553
619
  const { status, id } = action;
554
620
  if (status === DraftActionStatus.Error) {
555
621
  this.state = DraftQueueState.Error;
556
622
  this.processingAction = undefined;
623
+ // The sweep is blocked on an errored action, not a clean drain-to-empty —
624
+ // discard the open drain-cycle window so it doesn't inflate a later cycle's duration.
625
+ this.resetDrainCycle();
557
626
  await this.notifyChangedListeners({
558
627
  type: DraftQueueEventType.ActionFailed,
559
628
  action: action,
@@ -571,6 +640,31 @@ class DurableDraftQueue {
571
640
  if (this.state === DraftQueueState.Waiting) {
572
641
  this.state = DraftQueueState.Started;
573
642
  }
643
+ // The action is now leaving the queue to upload. Emit head-of-line FIFO wait
644
+ // (enqueue -> upload start), stamp its drain-start for end-to-end timing on completion, and
645
+ // open a drain-cycle window if the queue was previously idle. All measure-only.
646
+ const { instrumentation } = this;
647
+ if (instrumentation !== undefined) {
648
+ const now = Date.now();
649
+ const { timestamp } = action;
650
+ if (instrumentation.onActionQueueWait !== undefined &&
651
+ !this.actionQueueWaitEmittedIds.has(id) &&
652
+ typeof timestamp === 'number' &&
653
+ timestamp > 0) {
654
+ // Mark before invoking the sink: even a throwing callback must not make a retry
655
+ // report the same action's enqueue wait again.
656
+ this.actionQueueWaitEmittedIds.add(id);
657
+ this.emitInstrumentation((sink) => sink.onActionQueueWait?.(action.handler, now - timestamp));
658
+ }
659
+ if (instrumentation.onActionDrainDuration !== undefined &&
660
+ !this.uploadStartTimes.has(id)) {
661
+ this.uploadStartTimes.set(id, now);
662
+ }
663
+ if (instrumentation.onDrainCycle !== undefined && this.drainCycleStart === undefined) {
664
+ this.drainCycleStart = now;
665
+ this.drainCycleBatchCount = 0;
666
+ }
667
+ }
574
668
  await this.notifyChangedListeners({
575
669
  type: DraftQueueEventType.ActionUploading,
576
670
  action: { ...action, status: DraftActionStatus.Uploading },
@@ -608,7 +702,7 @@ class DurableDraftQueue {
608
702
  results.push(listener(event));
609
703
  }
610
704
  // W-23515682: listener fan-out per event type. Measure-only.
611
- this.instrumentation?.onNotifyChangedListeners?.(event.type, draftQueueLen);
705
+ this.emitInstrumentation((sink) => sink.onNotifyChangedListeners?.(event.type, draftQueueLen));
612
706
  await Promise.all(results);
613
707
  }
614
708
  /**
@@ -637,9 +731,17 @@ class DurableDraftQueue {
637
731
  const shouldDeleteRelated = handler.shouldDeleteActionByTagOnRemoval(action);
638
732
  if (shouldDeleteRelated) {
639
733
  await this.draftStore.deleteByTag(action.tag);
734
+ queue.forEach((queuedAction) => {
735
+ if (queuedAction.tag === action.tag) {
736
+ this.uploadStartTimes.delete(queuedAction.id);
737
+ this.actionQueueWaitEmittedIds.delete(queuedAction.id);
738
+ }
739
+ });
640
740
  }
641
741
  else {
642
742
  await this.draftStore.deleteDraft(action.id);
743
+ this.uploadStartTimes.delete(action.id);
744
+ this.actionQueueWaitEmittedIds.delete(action.id);
643
745
  }
644
746
  await handler.handleActionRemoved(action, queue.filter((x) => x.id !== actionId));
645
747
  await this.notifyChangedListeners({
@@ -2,4 +2,8 @@ export interface DraftQueueInstrumentation {
2
2
  onGetQueueActions?(durationMs: number, actionCount: number): void;
3
3
  onNotifyChangedListeners?(eventType: string, listenerCount: number): void;
4
4
  onDurableOperation?(op: string, segment: string, durationMs: number): void;
5
+ onWorkerTiming?(waitMs: number, holdMs: number, queueDepth: number): void;
6
+ onActionQueueWait?(handlerId: string, waitMs: number): void;
7
+ onActionDrainDuration?(handlerId: string, durationMs: number): void;
8
+ onDrainCycle?(durationMs: number, batchSize: number): void;
5
9
  }
@@ -29,6 +29,12 @@ export declare class DurableDraftQueue implements DraftQueue {
29
29
  private workerPool;
30
30
  private handlers;
31
31
  private instrumentation?;
32
+ private uploadStartTimes;
33
+ private actionQueueWaitEmittedIds;
34
+ private drainCycleStart?;
35
+ private drainCycleBatchCount;
36
+ private emitInstrumentation;
37
+ private resetDrainCycle;
32
38
  private getHandler;
33
39
  constructor(draftStore: DraftStore, instrumentation?: DraftQueueInstrumentation);
34
40
  addHandler<Data>(handler: ActionHandler<Data, unknown>): Promise<void>;
@@ -46,6 +52,7 @@ export declare class DurableDraftQueue implements DraftQueue {
46
52
  enqueue<Data>(handlerId: string, data: unknown, observabilityContext?: ObservabilityContext): Promise<PendingDraftAction<Data>>;
47
53
  registerOnChangedListener(listener: DraftQueueChangeListener): () => Promise<void>;
48
54
  actionCompleted(action: CompletedDraftAction<unknown, unknown>): Promise<void>;
55
+ private emitActionDrainDuration;
49
56
  actionFailed(action: DraftAction<unknown, unknown>, retry: boolean, retryDelayInMs?: number, actionDataChanged?: boolean): Promise<void>;
50
57
  handle(action: DraftAction<unknown, unknown>): Promise<ProcessActionResult>;
51
58
  processNextAction(): Promise<ProcessActionResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/lds-drafts",
3
- "version": "1.454.0",
3
+ "version": "1.455.0",
4
4
  "license": "SEE LICENSE IN LICENSE.txt",
5
5
  "description": "LDS Drafts",
6
6
  "main": "dist/ldsDrafts.js",
@@ -26,10 +26,10 @@
26
26
  "dependencies": {
27
27
  "@luvio/engine": "0.161.2",
28
28
  "@luvio/environments": "0.161.2",
29
- "@salesforce/lds-utils-adapters": "^1.454.0"
29
+ "@salesforce/lds-utils-adapters": "^1.455.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@salesforce/nimbus-plugin-lds": "^1.454.0"
32
+ "@salesforce/nimbus-plugin-lds": "^1.455.0"
33
33
  },
34
34
  "volta": {
35
35
  "extends": "../../package.json"