@salesforce/lds-runtime-mobile 1.453.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/main.js CHANGED
@@ -1973,16 +1973,18 @@ class LdsAbortController {
1973
1973
  }
1974
1974
 
1975
1975
  class AsyncWorkerPool {
1976
- constructor(concurrency) {
1976
+ constructor(concurrency, onWorkTiming) {
1977
1977
  this.queue = [];
1978
1978
  this.activeWork = [];
1979
1979
  this.concurrency = concurrency;
1980
+ this.onWorkTiming = onWorkTiming;
1980
1981
  }
1981
1982
  push(work) {
1982
1983
  return new Promise((resolve, reject) => {
1983
1984
  this.queue.push({
1984
1985
  ...work,
1985
1986
  workFn: (abortController) => work.workFn(abortController).then(resolve).catch(reject),
1987
+ enqueuedAt: Date.now(),
1986
1988
  });
1987
1989
  this.doWork();
1988
1990
  });
@@ -2025,10 +2027,30 @@ class AsyncWorkerPool {
2025
2027
  const abortController = new LdsAbortController();
2026
2028
  const newWork = { ...work, abortController };
2027
2029
  this.activeWork.push(newWork);
2028
- const { workFn } = work;
2030
+ const { workFn, enqueuedAt } = work;
2031
+ const onWorkTiming = this.onWorkTiming;
2032
+ let startedAt = 0;
2033
+ let waitMs = 0;
2034
+ let queueDepthAtStart = 0;
2035
+ if (onWorkTiming !== undefined) {
2036
+ startedAt = Date.now();
2037
+ waitMs = startedAt - enqueuedAt;
2038
+ // depth AFTER this unit was shifted off — units still waiting behind it.
2039
+ queueDepthAtStart = this.queue.length;
2040
+ }
2029
2041
  workFn(abortController).finally(() => {
2030
- this.activeWork = this.activeWork.filter((w) => w !== newWork);
2031
- this.doWork();
2042
+ try {
2043
+ if (onWorkTiming !== undefined) {
2044
+ onWorkTiming(waitMs, Date.now() - startedAt, queueDepthAtStart);
2045
+ }
2046
+ }
2047
+ catch {
2048
+ // Instrumentation must not prevent the pool from releasing the worker slot.
2049
+ }
2050
+ finally {
2051
+ this.activeWork = this.activeWork.filter((w) => w !== newWork);
2052
+ this.doWork();
2053
+ }
2032
2054
  });
2033
2055
  }
2034
2056
  }
@@ -2358,6 +2380,25 @@ const DRAFT_SEGMENT = 'DRAFT';
2358
2380
  */
2359
2381
  const DRAFT_ACTION_RETRY_COUNT_METADATA_KEY = 'retryCount';
2360
2382
  class DurableDraftQueue {
2383
+ // Instrumentation is strictly measure-only: a broken sink must never change queue behavior.
2384
+ // Pass the sink into the callback so methods are invoked through their owning object and retain
2385
+ // their receiver (some sinks use `this` to reach their metric registry).
2386
+ emitInstrumentation(emit) {
2387
+ const { instrumentation } = this;
2388
+ if (instrumentation === undefined) {
2389
+ return;
2390
+ }
2391
+ try {
2392
+ emit(instrumentation);
2393
+ }
2394
+ catch {
2395
+ // Telemetry failures must not interrupt draft processing.
2396
+ }
2397
+ }
2398
+ resetDrainCycle() {
2399
+ this.drainCycleStart = undefined;
2400
+ this.drainCycleBatchCount = 0;
2401
+ }
2361
2402
  getHandler(id) {
2362
2403
  const handler = this.handlers[id];
2363
2404
  if (handler === undefined) {
@@ -2380,9 +2421,19 @@ class DurableDraftQueue {
2380
2421
  ? __nimbus.plugins.JSLoggerPlugin
2381
2422
  : undefined;
2382
2423
  this.handlers = {};
2424
+ // Drain-wait attribution state. uploadStartTimes stamps when each action first began
2425
+ // uploading (keyed by action id) so actionCompleted/failed can emit its end-to-end drain time,
2426
+ // including automatic retries/backoff. actionQueueWaitEmittedIds ensures those retries do not
2427
+ // report the original enqueue wait more than once. drainCycleStart/BatchCount track one full
2428
+ // idle->empty sweep. All measure-only; when no instrumentation is injected these stay untouched.
2429
+ this.uploadStartTimes = new Map();
2430
+ this.actionQueueWaitEmittedIds = new Set();
2431
+ this.drainCycleBatchCount = 0;
2383
2432
  this.draftStore = draftStore;
2384
- this.workerPool = new AsyncWorkerPool(1);
2385
2433
  this.instrumentation = instrumentation;
2434
+ this.workerPool = new AsyncWorkerPool(1, instrumentation?.onWorkerTiming !== undefined
2435
+ ? (waitMs, holdMs, queueDepth) => this.emitInstrumentation((sink) => sink.onWorkerTiming?.(waitMs, holdMs, queueDepth))
2436
+ : undefined);
2386
2437
  }
2387
2438
  addHandler(handler) {
2388
2439
  const id = handler.handlerId;
@@ -2470,6 +2521,9 @@ class DurableDraftQueue {
2470
2521
  clearTimeout(this.timeoutHandler);
2471
2522
  this.timeoutHandler = undefined;
2472
2523
  }
2524
+ // A stop/user edit breaks the current sweep. A later restart starts a fresh cycle rather
2525
+ // than attributing stopped time to an offline drain.
2526
+ this.resetDrainCycle();
2473
2527
  this.state = DraftQueueState.Stopped;
2474
2528
  }
2475
2529
  async getQueueActions() {
@@ -2500,7 +2554,7 @@ class DurableDraftQueue {
2500
2554
  }
2501
2555
  return aTime - bTime;
2502
2556
  });
2503
- instrumentation?.onGetQueueActions?.(Date.now() - start, sorted.length);
2557
+ this.emitInstrumentation((sink) => sink.onGetQueueActions?.(Date.now() - start, sorted.length));
2504
2558
  return sorted;
2505
2559
  }
2506
2560
  async enqueue(handlerId, data, observabilityContext) {
@@ -2544,6 +2598,13 @@ class DurableDraftQueue {
2544
2598
  await handler.handleActionCompleted(action, queueOperations, values$2(this.handlers));
2545
2599
  this.retryIntervalMilliseconds = 0;
2546
2600
  this.uploadingActionId = undefined;
2601
+ // The action finished draining — emit its end-to-end drain time and
2602
+ // count it toward the current drain-cycle batch. Measure-only.
2603
+ this.emitActionDrainDuration(action.id, action.handler);
2604
+ this.actionQueueWaitEmittedIds.delete(action.id);
2605
+ if (this.drainCycleStart !== undefined) {
2606
+ this.drainCycleBatchCount += 1;
2607
+ }
2547
2608
  await this.notifyChangedListeners({
2548
2609
  type: DraftQueueEventType.ActionCompleted,
2549
2610
  action,
@@ -2555,6 +2616,16 @@ class DurableDraftQueue {
2555
2616
  },
2556
2617
  });
2557
2618
  }
2619
+ // Read+clear the action's upload-start stamp and emit its drain duration. Safe to
2620
+ // call when instrumentation is absent or the id was never stamped (no-op). Never emits the id.
2621
+ emitActionDrainDuration(id, handlerId) {
2622
+ const start = this.uploadStartTimes.get(id);
2623
+ if (start === undefined) {
2624
+ return;
2625
+ }
2626
+ this.uploadStartTimes.delete(id);
2627
+ this.emitInstrumentation((sink) => sink.onActionDrainDuration?.(handlerId, Date.now() - start));
2628
+ }
2558
2629
  async actionFailed(action, retry, retryDelayInMs, actionDataChanged) {
2559
2630
  if (actionDataChanged === true) {
2560
2631
  await this.draftStore.writeAction({
@@ -2564,14 +2635,24 @@ class DurableDraftQueue {
2564
2635
  }
2565
2636
  this.uploadingActionId = undefined;
2566
2637
  if (retry && this.state !== DraftQueueState.Stopped) {
2638
+ // A retry means this action is NOT done draining. Keep its first-upload stamp and the
2639
+ // open cycle so the eventual outcome includes network time and retry/backoff.
2567
2640
  this.state = DraftQueueState.Waiting;
2568
2641
  return retryDelayInMs !== undefined
2569
2642
  ? this.scheduleRetryWithSpecifiedDelay(retryDelayInMs)
2570
2643
  : this.scheduleRetry();
2571
2644
  }
2572
2645
  else if (isDraftError(action)) {
2646
+ // Terminal failure — this action is done draining. Emit its drain time.
2647
+ this.emitActionDrainDuration(action.id, action.handler);
2648
+ // This was not a clean drain-to-empty. Discard the sweep before user intervention so a
2649
+ // later manual retry/removal cannot inherit its elapsed time or completed batch count.
2650
+ this.resetDrainCycle();
2573
2651
  return this.handleServerError(action, action.error);
2574
2652
  }
2653
+ // Any other terminal path — ensure no stale start stamp lingers.
2654
+ this.emitActionDrainDuration(action.id, action.handler);
2655
+ this.resetDrainCycle();
2575
2656
  }
2576
2657
  handle(action) {
2577
2658
  const handler = this.getHandler(action.handler);
@@ -2591,12 +2672,22 @@ class DurableDraftQueue {
2591
2672
  this.state = DraftQueueState.Started;
2592
2673
  }
2593
2674
  this.processingAction = undefined;
2675
+ // The queue drained empty — close the drain-cycle window if one was open.
2676
+ if (this.drainCycleStart !== undefined) {
2677
+ const durationMs = Date.now() - this.drainCycleStart;
2678
+ const batchSize = this.drainCycleBatchCount;
2679
+ this.resetDrainCycle();
2680
+ this.emitInstrumentation((sink) => sink.onDrainCycle?.(durationMs, batchSize));
2681
+ }
2594
2682
  return ProcessActionResult.NO_ACTION_TO_PROCESS;
2595
2683
  }
2596
2684
  const { status, id } = action;
2597
2685
  if (status === DraftActionStatus.Error) {
2598
2686
  this.state = DraftQueueState.Error;
2599
2687
  this.processingAction = undefined;
2688
+ // The sweep is blocked on an errored action, not a clean drain-to-empty —
2689
+ // discard the open drain-cycle window so it doesn't inflate a later cycle's duration.
2690
+ this.resetDrainCycle();
2600
2691
  await this.notifyChangedListeners({
2601
2692
  type: DraftQueueEventType.ActionFailed,
2602
2693
  action: action,
@@ -2614,6 +2705,31 @@ class DurableDraftQueue {
2614
2705
  if (this.state === DraftQueueState.Waiting) {
2615
2706
  this.state = DraftQueueState.Started;
2616
2707
  }
2708
+ // The action is now leaving the queue to upload. Emit head-of-line FIFO wait
2709
+ // (enqueue -> upload start), stamp its drain-start for end-to-end timing on completion, and
2710
+ // open a drain-cycle window if the queue was previously idle. All measure-only.
2711
+ const { instrumentation } = this;
2712
+ if (instrumentation !== undefined) {
2713
+ const now = Date.now();
2714
+ const { timestamp } = action;
2715
+ if (instrumentation.onActionQueueWait !== undefined &&
2716
+ !this.actionQueueWaitEmittedIds.has(id) &&
2717
+ typeof timestamp === 'number' &&
2718
+ timestamp > 0) {
2719
+ // Mark before invoking the sink: even a throwing callback must not make a retry
2720
+ // report the same action's enqueue wait again.
2721
+ this.actionQueueWaitEmittedIds.add(id);
2722
+ this.emitInstrumentation((sink) => sink.onActionQueueWait?.(action.handler, now - timestamp));
2723
+ }
2724
+ if (instrumentation.onActionDrainDuration !== undefined &&
2725
+ !this.uploadStartTimes.has(id)) {
2726
+ this.uploadStartTimes.set(id, now);
2727
+ }
2728
+ if (instrumentation.onDrainCycle !== undefined && this.drainCycleStart === undefined) {
2729
+ this.drainCycleStart = now;
2730
+ this.drainCycleBatchCount = 0;
2731
+ }
2732
+ }
2617
2733
  await this.notifyChangedListeners({
2618
2734
  type: DraftQueueEventType.ActionUploading,
2619
2735
  action: { ...action, status: DraftActionStatus.Uploading },
@@ -2651,7 +2767,7 @@ class DurableDraftQueue {
2651
2767
  results.push(listener(event));
2652
2768
  }
2653
2769
  // W-23515682: listener fan-out per event type. Measure-only.
2654
- this.instrumentation?.onNotifyChangedListeners?.(event.type, draftQueueLen);
2770
+ this.emitInstrumentation((sink) => sink.onNotifyChangedListeners?.(event.type, draftQueueLen));
2655
2771
  await Promise.all(results);
2656
2772
  }
2657
2773
  /**
@@ -2680,9 +2796,17 @@ class DurableDraftQueue {
2680
2796
  const shouldDeleteRelated = handler.shouldDeleteActionByTagOnRemoval(action);
2681
2797
  if (shouldDeleteRelated) {
2682
2798
  await this.draftStore.deleteByTag(action.tag);
2799
+ queue.forEach((queuedAction) => {
2800
+ if (queuedAction.tag === action.tag) {
2801
+ this.uploadStartTimes.delete(queuedAction.id);
2802
+ this.actionQueueWaitEmittedIds.delete(queuedAction.id);
2803
+ }
2804
+ });
2683
2805
  }
2684
2806
  else {
2685
2807
  await this.draftStore.deleteDraft(action.id);
2808
+ this.uploadStartTimes.delete(action.id);
2809
+ this.actionQueueWaitEmittedIds.delete(action.id);
2686
2810
  }
2687
2811
  await handler.handleActionRemoved(action, queue.filter((x) => x.id !== actionId));
2688
2812
  await this.notifyChangedListeners({
@@ -42336,6 +42460,20 @@ const DRAFT_QUEUE_GET_QUEUE_ACTIONS_DURATION = 'draft-queue-get-queue-actions-du
42336
42460
  const DRAFT_QUEUE_NOTIFY_CHANGED_LISTENERS_COUNT = 'draft-queue-notify-changed-listeners-count';
42337
42461
  const DRAFT_QUEUE_DURABLE_OP_COUNT = 'draft-queue-durable-op-count';
42338
42462
  const DRAFT_QUEUE_DURABLE_OP_DURATION = 'draft-queue-durable-op-duration';
42463
+ // Drain-wait attribution telemetry: the wall-clock spine of where an offline drain actually spends
42464
+ // WAITING. Operational counts/durations/depths only — no draft content, field values, or server
42465
+ // record ids (matching the existing draft-queue privacy stance).
42466
+ const DRAFT_QUEUE_WORKER_WAIT_DURATION = 'draft-queue-worker-wait-duration';
42467
+ const DRAFT_QUEUE_WORKER_HOLD_DURATION = 'draft-queue-worker-hold-duration';
42468
+ const DRAFT_QUEUE_ACTION_QUEUE_WAIT_DURATION = 'draft-queue-action-queue-wait-duration';
42469
+ const DRAFT_QUEUE_ACTION_DRAIN_DURATION = 'draft-queue-action-drain-duration';
42470
+ const DRAFT_QUEUE_DRAIN_CYCLE_DURATION = 'draft-queue-drain-cycle-duration';
42471
+ const DRAFT_QUEUE_DRAIN_CYCLE_BATCH_SIZE = 'draft-queue-drain-cycle-batch-size';
42472
+ const DRAFT_QUEUE_ACTION_NETWORK_DURATION = 'draft-queue-action-network-duration';
42473
+ const DRAFT_QUEUE_ACTION_RETRY_ATTEMPT = 'draft-queue-action-retry-attempt';
42474
+ const DRAFT_QUEUE_SIDEEFFECT_OP_DURATION = 'draft-queue-sideeffect-op-duration';
42475
+ const DRAFT_QUEUE_SIDEEFFECT_MAP_SIZE = 'draft-queue-sideeffect-map-size';
42476
+ const DRAFT_QUEUE_SIDEEFFECT_REGION_DURATION = 'draft-queue-sideeffect-region-duration';
42339
42477
  /** Content Document */
42340
42478
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_TOTAL_SYNTHESIZE_CALLS = 'content-document-version-total-synthesize-calls';
42341
42479
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_DRAFT_SYNTHESIZE_ERROR = 'create-content-document-version-draft-synthesize-error';
@@ -42455,6 +42593,134 @@ function reportDraftQueueDurableOperation(op, segment, durationMs) {
42455
42593
  });
42456
42594
  ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DURABLE_OP_DURATION, durationMs);
42457
42595
  }
42596
+ const DRAIN_DURATION_BUCKETS = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
42597
+ const WORKER_DEPTH_BUCKETS = [1, 2, 5, 10, 20, 50, 100, 250, 500];
42598
+ // Handler ids are extensible through DraftManager, so only retain the fixed mobile handlers in
42599
+ // metric tags. Everything else shares one bounded bucket rather than creating an unbounded series.
42600
+ function draftQueueHandlerTagOf(handlerId) {
42601
+ switch (handlerId) {
42602
+ case 'LDS_ACTION_HANDLER':
42603
+ case 'ContentDocumentCompositeRepresentationActionHandler':
42604
+ case 'QUICK_ACTION_HANDLER':
42605
+ case 'UPDATE_RECORD_QUICK_ACTION_HANDLER':
42606
+ return handlerId;
42607
+ default:
42608
+ return 'custom';
42609
+ }
42610
+ }
42611
+ // Coarse, bounded-cardinality label for the pool depth a unit saw when it began, so the
42612
+ // wait/hold durations can be sliced BY backlog size without emitting depth as a free integer tag.
42613
+ function workerDepthBucketOf(queueDepth) {
42614
+ if (queueDepth <= 0) {
42615
+ return '0';
42616
+ }
42617
+ if (queueDepth <= 2) {
42618
+ return '1-2';
42619
+ }
42620
+ if (queueDepth <= 10) {
42621
+ return '3-10';
42622
+ }
42623
+ if (queueDepth <= 50) {
42624
+ return '11-50';
42625
+ }
42626
+ if (queueDepth <= 250) {
42627
+ return '51-250';
42628
+ }
42629
+ return '250+';
42630
+ }
42631
+ /**
42632
+ * Per-unit lifecycle split for the AsyncWorkerPool(1) serial queue, emitted once when a unit settles
42633
+ * so the two halves are a JOINED event (rather than shipping wait and depth as unrelated aggregates).
42634
+ * Isolates WHERE the time goes:
42635
+ * waitMs — enqueue -> work began (waiting on the serial queue before any logic runs).
42636
+ * holdMs — work began -> work settled (the critical section's own O(N) service time).
42637
+ * Both are additionally tagged by a depthBucket so "does per-unit cost scale with backlog depth"
42638
+ * (the O(N^2) signature) survives aggregation. queueDepth itself is also tracked. Operational only.
42639
+ */
42640
+ function reportDraftQueueWorkerTiming(waitMs, holdMs, queueDepth) {
42641
+ const depthBucket = workerDepthBucketOf(queueDepth);
42642
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_WORKER_WAIT_DURATION, waitMs, undefined, {
42643
+ depthBucket,
42644
+ });
42645
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_WORKER_HOLD_DURATION, holdMs, undefined, {
42646
+ depthBucket,
42647
+ });
42648
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_WORKER_WAIT_DURATION + '-depth', queueDepth, WORKER_DEPTH_BUCKETS);
42649
+ }
42650
+ /**
42651
+ * Head-of-line FIFO wait for one draft (enqueue -> upload start), bucketed so the
42652
+ * "does wait grow with backlog depth" (O(N^2)) signal is visible per handler type. Custom handler
42653
+ * ids are normalized to one bounded tag so caller-defined values never become metric dimensions.
42654
+ */
42655
+ function reportDraftQueueActionQueueWait(handlerId, waitMs) {
42656
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_ACTION_QUEUE_WAIT_DURATION, waitMs, DRAIN_DURATION_BUCKETS, undefined, { handlerId: draftQueueHandlerTagOf(handlerId) });
42657
+ }
42658
+ /**
42659
+ * End-to-end drain time for one action (first upload attempt -> completed/failed), including
42660
+ * retry/backoff. Compare it with network-duration and retry-attempt signals to understand the
42661
+ * non-request portion of the drain interval.
42662
+ */
42663
+ function reportDraftQueueActionDrainDuration(handlerId, durationMs) {
42664
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_ACTION_DRAIN_DURATION, durationMs, DRAIN_DURATION_BUCKETS, undefined, { handlerId: draftQueueHandlerTagOf(handlerId) });
42665
+ }
42666
+ /**
42667
+ * One full drain sweep — first action starts uploading -> queue empties. Both raw values share a
42668
+ * bounded batch-size cohort so aggregate duration remains sliceable by the number of actions drained.
42669
+ */
42670
+ function reportDraftQueueDrainCycle(durationMs, batchSize) {
42671
+ const batchSizeBucket = workerDepthBucketOf(batchSize);
42672
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DRAIN_CYCLE_DURATION, durationMs, undefined, {
42673
+ batchSizeBucket,
42674
+ });
42675
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DRAIN_CYCLE_BATCH_SIZE, batchSize, undefined, {
42676
+ batchSizeBucket,
42677
+ });
42678
+ }
42679
+ /**
42680
+ * Server round-trip duration for one upload. Isolates slow-network from CPU-bound
42681
+ * quadratic work — today only a THROWN error is captured, so a slow-but-successful (or slow 4xx/5xx)
42682
+ * response is invisible. Tags: handler type, HTTP method, and a bucketed status class ('2xx'/'4xx'/
42683
+ * '5xx'/'0'). No URL, body, or record id.
42684
+ */
42685
+ function reportDraftQueueActionNetworkDuration(handlerId, method, statusBucket, durationMs) {
42686
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_ACTION_NETWORK_DURATION, durationMs, undefined, {
42687
+ handlerId: draftQueueHandlerTagOf(handlerId),
42688
+ method,
42689
+ statusBucket,
42690
+ });
42691
+ }
42692
+ /**
42693
+ * Retry event count. Repeated events identify actions cycling through the drain even when the retry
42694
+ * cap is disabled and an exact attempt number is not persisted. cappedOut marks cap exhaustion.
42695
+ */
42696
+ function reportDraftQueueActionRetryAttempt(handlerId, cappedOut) {
42697
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_ACTION_RETRY_ATTEMPT, 1, undefined, {
42698
+ handlerId: draftQueueHandlerTagOf(handlerId),
42699
+ cappedOut: String(cappedOut),
42700
+ });
42701
+ }
42702
+ /**
42703
+ * SideEffectStore operation duration + map size. The incremental map sped up the by-KEY lookup but
42704
+ * getEffectsByTag still linearly scans all effects — this measures whether that residual scan is
42705
+ * silently quadratic during churn. op is 'add'|'remove'|'getByTag'; incremental flags the fast path;
42706
+ * mapSize is an integer effect count. No record keys, field names, or field values.
42707
+ */
42708
+ function reportDraftQueueSideEffectOp(op, incremental, durationMs, mapSize) {
42709
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_SIDEEFFECT_OP_DURATION, durationMs, undefined, {
42710
+ op,
42711
+ incremental: String(incremental),
42712
+ });
42713
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_SIDEEFFECT_MAP_SIZE, mapSize, undefined, {
42714
+ op,
42715
+ });
42716
+ }
42717
+ /**
42718
+ * Broad side-effect lifecycle duration. The fixed region and lifecycle tags identify which part of
42719
+ * serialized draft processing is slow without emitting record keys, action ids, or field data.
42720
+ */
42721
+ function reportDraftQueueSideEffectRegionDuration(region, lifecycle, durationMs) {
42722
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_SIDEEFFECT_REGION_DURATION, durationMs, undefined, { region, lifecycle });
42723
+ }
42458
42724
  /**
42459
42725
  * Reports an exception thrown while uploading a draft action. The thrown error is
42460
42726
  * otherwise swallowed by the upload handler's retry path, leaving the failure
@@ -42662,8 +42928,16 @@ class AbstractResourceRequestActionHandler {
42662
42928
  const dispatchOptions = action.observabilityContext !== undefined
42663
42929
  ? { requestCorrelator: { observabilityContext: action.observabilityContext } }
42664
42930
  : {};
42931
+ const method = request?.method?.toUpperCase() ?? 'UNKNOWN';
42932
+ const networkStart = Date.now();
42933
+ let responseReceived = false;
42665
42934
  try {
42666
42935
  const response = await this.networkAdapter(request, dispatchOptions);
42936
+ responseReceived = true;
42937
+ // Time the round-trip regardless of outcome (ok, retryable HTTP error, or non-ok
42938
+ // terminal error) — today only a THROWN error is visible; a slow-but-successful or
42939
+ // slow 4xx/5xx response is not.
42940
+ this.reportNetworkDuration(method, statusBucketOf(response.status), networkStart);
42667
42941
  if (response.ok) {
42668
42942
  await actionCompleted({
42669
42943
  ...action,
@@ -42749,19 +43023,32 @@ class AbstractResourceRequestActionHandler {
42749
43023
  actionDataChanged = true;
42750
43024
  }
42751
43025
  else if (!this.isServerGeneratedError(response.body)) {
42752
- // Killswitch CLOSED (default) — only an edge/gateway-injected 400 (no
43026
+ // Killswitch CLOSED (default) — an edge/gateway-injected 400 (no
42753
43027
  // recognizable server error body) is retried with the SAME key, so the
42754
43028
  // server can dedupe a write that may already have committed at the origin.
42755
- // A real, server-generated 400 (UiApiError[] or an enhancedErrorType body,
42756
- // e.g. a field-validation failure) falls through to the error path below:
42757
- // retrying it unchanged would only be rejected again, so the draft is
42758
- // errored for the user to fix or discard — the same terminal outcome as
42759
- // before this fix, minus the duplicating key rotation. The key is only ever
42760
- // rotated by the recognized idempotency error codes above, by a backdating
42761
- // collision (new body), or when the user edits an errored draft
42762
- // (mergeActions/handleReplaceAction).
43029
+ // Rotating here is what duplicated the WOLI records (@W-23445154), so the
43030
+ // key is preserved.
42763
43031
  shouldRetry = true;
42764
43032
  }
43033
+ else {
43034
+ // Killswitch CLOSED — a REAL, server-generated 400 (UiApiError[] or an
43035
+ // enhancedErrorType body, e.g. a field-validation failure). The origin
43036
+ // understood the request and rejected it, so it definitively did NOT
43037
+ // commit — there is no already-written record to duplicate, which is what
43038
+ // makes rotation safe here (unlike the edge-injected case above).
43039
+ //
43040
+ // We still do NOT retry (shouldRetry stays false): the request as-is would
43041
+ // just be rejected again and wedge the queue. The draft errors for the user
43042
+ // to fix or discard. But we DO rotate the key: the server's idempotency
43043
+ // layer caches the 400 against the key, so if the same request is later
43044
+ // resubmitted unchanged after the server-side validation rule changes to
43045
+ // allow it, reusing the key would replay that cached 400 and the now-valid
43046
+ // write would never be applied. Rotating gives the retried request a fresh
43047
+ // key so the origin re-evaluates it. @W-23445154
43048
+ updatedAction.data.headers[HTTP_HEADER_IDEMPOTENCY_KEY] = uuidv4();
43049
+ reportIdempotencyKeyRotated(updatedAction.targetId, 'server-validation-400');
43050
+ actionDataChanged = true;
43051
+ }
42765
43052
  }
42766
43053
  if (response.status === HttpStatusCode$1.BadRequest &&
42767
43054
  this.isBackdatingError(response.body, action)) {
@@ -42806,6 +43093,11 @@ class AbstractResourceRequestActionHandler {
42806
43093
  const error = normalizeError$1(e);
42807
43094
  const attempt = this.getUploadRetryCount(action) + 1;
42808
43095
  reportDraftActionUploadError(error, this.handlerId, attempt);
43096
+ // A thrown network call still has a round-trip duration worth surfacing — a slow
43097
+ // FAILING call is otherwise as invisible as a slow successful one.
43098
+ if (!responseReceived) {
43099
+ this.reportNetworkDuration(method, 'error', networkStart);
43100
+ }
42809
43101
  await this.retryOrCap(action, error, actionErrored);
42810
43102
  return ProcessActionResult.NETWORK_ERROR;
42811
43103
  }
@@ -42841,12 +43133,17 @@ class AbstractResourceRequestActionHandler {
42841
43133
  */
42842
43134
  async retryOrCap(action, lastFailure, actionErrored, retryDelayInMs, actionDataChanged) {
42843
43135
  if (!draftQueueMaxRetryAttemptsGate.isOpen({ fallback: false })) {
42844
- // Gate closed: preserve the pre-existing unbounded-retry behavior exactly.
43136
+ // Gate closed: preserve the pre-existing unbounded-retry behavior exactly. The
43137
+ // metric is still emitted (measure-only) so retry attempts are visible even with
43138
+ // the cap gate closed — cappedOut is always false here since nothing ever caps
43139
+ // out on this path.
43140
+ this.reportRetryAttempt(false);
42845
43141
  await actionErrored(action, true, retryDelayInMs, actionDataChanged);
42846
43142
  return;
42847
43143
  }
42848
43144
  const attempt = this.getUploadRetryCount(action) + 1;
42849
43145
  if (attempt >= MAX_RETRY_ATTEMPTS) {
43146
+ this.reportRetryAttempt(true);
42850
43147
  await actionErrored({
42851
43148
  ...action,
42852
43149
  error: buildMaxRetryError(lastFailure),
@@ -42854,6 +43151,7 @@ class AbstractResourceRequestActionHandler {
42854
43151
  }, false);
42855
43152
  return;
42856
43153
  }
43154
+ this.reportRetryAttempt(false);
42857
43155
  // Persist the incremented attempt count so it survives restarts. Forcing
42858
43156
  // actionDataChanged ensures the queue writes the updated metadata before the
42859
43157
  // action is rescheduled for retry.
@@ -42867,12 +43165,12 @@ class AbstractResourceRequestActionHandler {
42867
43165
  await actionErrored(retryingAction, true, retryDelayInMs, true);
42868
43166
  }
42869
43167
  async handleActionEnqueued(action) {
42870
- const impactedKeys = await this.recordService.setSideEffectsForActions([action]);
42871
- await this.recordService.reapplyRecordSideEffects(impactedKeys);
43168
+ const impactedKeys = await this.recordService.setSideEffectsForActions([action], 'enqueued');
43169
+ await this.recordService.reapplyRecordSideEffects(impactedKeys, 'enqueued');
42872
43170
  }
42873
43171
  async handleActionRemoved(action) {
42874
43172
  const impactedKeys = await this.recordService.removeSideEffectsForAction(action);
42875
- await this.recordService.reapplyRecordSideEffects(impactedKeys);
43173
+ await this.recordService.reapplyRecordSideEffects(impactedKeys, 'removed');
42876
43174
  }
42877
43175
  async handleActionReplaced(target, source) {
42878
43176
  // remove both the siden effects from the actions being merged before we
@@ -42880,10 +43178,10 @@ class AbstractResourceRequestActionHandler {
42880
43178
  const impactedKeysFromRemove = await this.recordService.removeSideEffectsForAction(source);
42881
43179
  const removedSideEffectFromSet = await this.recordService.removeSideEffectsForAction(target);
42882
43180
  // revert the values in the DEFAULT store back to what it was before the removed side effects
42883
- await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromRemove, ...removedSideEffectFromSet]));
43181
+ await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromRemove, ...removedSideEffectFromSet]), 'replaced-remove');
42884
43182
  // set the new side effect values for the merged action
42885
- const impactedKeysFromSet = await this.recordService.setSideEffectsForActions([target]);
42886
- await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromSet]));
43183
+ const impactedKeysFromSet = await this.recordService.setSideEffectsForActions([target], 'replaced-set');
43184
+ await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromSet]), 'replaced-set');
42887
43185
  }
42888
43186
  async handleActionCompleted(action, queueOperations) {
42889
43187
  const { data: request, tag } = action;
@@ -42917,8 +43215,8 @@ class AbstractResourceRequestActionHandler {
42917
43215
  actionsNeedingReplay.push(qo.action);
42918
43216
  }
42919
43217
  });
42920
- const impactedKeys = await this.recordService.setSideEffectsForActions(actionsNeedingReplay);
42921
- await this.recordService.reapplyRecordSideEffects(impactedKeys);
43218
+ const impactedKeys = await this.recordService.setSideEffectsForActions(actionsNeedingReplay, 'completed-replay');
43219
+ await this.recordService.reapplyRecordSideEffects(impactedKeys, 'completed-replay');
42922
43220
  }
42923
43221
  getQueueOperationsForCompletingDrafts(queue, action) {
42924
43222
  const queueOperations = [];
@@ -43244,6 +43542,39 @@ class AbstractResourceRequestActionHandler {
43244
43542
  return new StoreKeyMap();
43245
43543
  });
43246
43544
  }
43545
+ reportNetworkDuration(method, statusBucket, startedAt) {
43546
+ try {
43547
+ reportDraftQueueActionNetworkDuration(this.handlerId, method, statusBucket, Date.now() - startedAt);
43548
+ }
43549
+ catch {
43550
+ // Telemetry must not change upload processing behavior.
43551
+ }
43552
+ }
43553
+ reportRetryAttempt(cappedOut) {
43554
+ try {
43555
+ reportDraftQueueActionRetryAttempt(this.handlerId, cappedOut);
43556
+ }
43557
+ catch {
43558
+ // Telemetry must not change retry or cap behavior.
43559
+ }
43560
+ }
43561
+ }
43562
+ // Classifies an HTTP status into a small, greppable tag set for the network-duration metric —
43563
+ // never the raw status, to keep the metric's tag cardinality bounded.
43564
+ function statusBucketOf(status) {
43565
+ if (status === 0) {
43566
+ return '0';
43567
+ }
43568
+ if (status >= 200 && status <= 299) {
43569
+ return '2xx';
43570
+ }
43571
+ if (status >= 400 && status <= 499) {
43572
+ return '4xx';
43573
+ }
43574
+ if (status >= 500) {
43575
+ return '5xx';
43576
+ }
43577
+ return 'other';
43247
43578
  }
43248
43579
  function actionsForTag(tag, queue) {
43249
43580
  return queue.filter((action) => action.tag === tag);
@@ -43607,10 +43938,16 @@ class UiApiDraftRecordService {
43607
43938
  }
43608
43939
  // calculates the side effects for a given set of actions
43609
43940
  // and returns a set of impacted record keys
43610
- async setSideEffectsForActions(changedActions) {
43611
- const operations = await this.recalculateForChangedActions(changedActions);
43612
- await this.sideEffectStore.addEffects(operations);
43613
- return new Set(operations.map((o) => o.key));
43941
+ async setSideEffectsForActions(changedActions, lifecycle) {
43942
+ const startedAt = Date.now();
43943
+ try {
43944
+ const operations = await this.recalculateForChangedActions(changedActions);
43945
+ await this.sideEffectStore.addEffects(operations);
43946
+ return new Set(operations.map((o) => o.key));
43947
+ }
43948
+ finally {
43949
+ this.reportSideEffectRegionDuration('set', lifecycle, startedAt);
43950
+ }
43614
43951
  }
43615
43952
  // removes the associated side effects for a given action and returns
43616
43953
  // a set of impacted record keys
@@ -43621,31 +43958,45 @@ class UiApiDraftRecordService {
43621
43958
  }
43622
43959
  // reads a set of records out of the durable store, re-applies current side
43623
43960
  // effects and persists the changed record
43624
- async reapplyRecordSideEffects(recordKeys) {
43625
- const luvio = this.getLuvio();
43626
- const keys = Array.from(recordKeys);
43627
- const recordEntries = await this.durableRecordStore.getRecordsWithMetadata(keys);
43628
- keys.forEach((key) => {
43629
- let record;
43630
- const metadata = {
43631
- namespace: 'UiApi',
43632
- representationName: 'RecordRepresentation',
43633
- version: VERSION$1v,
43634
- ingestionTimestamp: Date.now(),
43635
- ttl: Number.MAX_SAFE_INTEGER,
43636
- };
43637
- const recordEntry = recordEntries.get(key);
43638
- if (recordEntry) {
43639
- record = recordEntry.record;
43640
- if (recordEntry.metadata) {
43641
- metadata.ingestionTimestamp = recordEntry.metadata.ingestionTimestamp;
43642
- metadata.ttl = RecordRepresentationTTL;
43961
+ async reapplyRecordSideEffects(recordKeys, lifecycle) {
43962
+ const startedAt = Date.now();
43963
+ try {
43964
+ const luvio = this.getLuvio();
43965
+ const keys = Array.from(recordKeys);
43966
+ const recordEntries = await this.durableRecordStore.getRecordsWithMetadata(keys);
43967
+ keys.forEach((key) => {
43968
+ let record;
43969
+ const metadata = {
43970
+ namespace: 'UiApi',
43971
+ representationName: 'RecordRepresentation',
43972
+ version: VERSION$1v,
43973
+ ingestionTimestamp: Date.now(),
43974
+ ttl: Number.MAX_SAFE_INTEGER,
43975
+ };
43976
+ const recordEntry = recordEntries.get(key);
43977
+ if (recordEntry) {
43978
+ record = recordEntry.record;
43979
+ if (recordEntry.metadata) {
43980
+ metadata.ingestionTimestamp = recordEntry.metadata.ingestionTimestamp;
43981
+ metadata.ttl = RecordRepresentationTTL;
43982
+ }
43643
43983
  }
43644
- }
43645
- luvio.storePublish(key, record);
43646
- luvio.publishStoreMetadata(key, metadata);
43647
- });
43648
- await luvio.storeBroadcast();
43984
+ luvio.storePublish(key, record);
43985
+ luvio.publishStoreMetadata(key, metadata);
43986
+ });
43987
+ await luvio.storeBroadcast();
43988
+ }
43989
+ finally {
43990
+ this.reportSideEffectRegionDuration('reapply', lifecycle, startedAt);
43991
+ }
43992
+ }
43993
+ reportSideEffectRegionDuration(region, lifecycle, startedAt) {
43994
+ try {
43995
+ reportDraftQueueSideEffectRegionDuration(region, lifecycle, Date.now() - startedAt);
43996
+ }
43997
+ catch {
43998
+ // Telemetry must not change draft processing behavior.
43999
+ }
43649
44000
  }
43650
44001
  async synthesizeId(apiName) {
43651
44002
  const objectInfo = await this.objectInfoService.getObjectInfo(apiName);
@@ -43881,7 +44232,7 @@ class UpdateRecordQuickActionExecutionRepresentationHandler extends AbstractQuic
43881
44232
  // since we don't ingest a record from the response, we need to manually trigger a re-apply here to ensure we
43882
44233
  // re-ingest the record without effects applied
43883
44234
  await this.recordService.removeSideEffectsForAction(action);
43884
- await this.recordService.reapplyRecordSideEffects(new Set([action.tag]));
44235
+ await this.recordService.reapplyRecordSideEffects(new Set([action.tag]), 'quick-action-completed');
43885
44236
  // notify luvio that record is now changed and needs to be refreshed
43886
44237
  await luvio.notifyStoreUpdateAvailable([action.tag]);
43887
44238
  }
@@ -52567,6 +52918,18 @@ const draftQueueInstrumentation = {
52567
52918
  onDurableOperation(op, segment, durationMs) {
52568
52919
  reportDraftQueueDurableOperation(op, segment, durationMs);
52569
52920
  },
52921
+ onWorkerTiming(waitMs, holdMs, queueDepth) {
52922
+ reportDraftQueueWorkerTiming(waitMs, holdMs, queueDepth);
52923
+ },
52924
+ onActionQueueWait(handlerId, waitMs) {
52925
+ reportDraftQueueActionQueueWait(handlerId, waitMs);
52926
+ },
52927
+ onActionDrainDuration(handlerId, durationMs) {
52928
+ reportDraftQueueActionDrainDuration(handlerId, durationMs);
52929
+ },
52930
+ onDrainCycle(durationMs, batchSize) {
52931
+ reportDraftQueueDrainCycle(durationMs, batchSize);
52932
+ },
52570
52933
  };
52571
52934
 
52572
52935
  // so eslint doesn't complain about nimbus
@@ -56057,6 +56420,9 @@ class SideEffectStore {
56057
56420
  this.initialize();
56058
56421
  }
56059
56422
  async addEffects(effects) {
56423
+ // Wall-clock across the whole method (including the durable write), matching
56424
+ // how the equivalent removeEffects/getEffectsByTag windows are measured.
56425
+ const startTime = Date.now();
56060
56426
  const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
56061
56427
  const durableEntries = {};
56062
56428
  effects.forEach((effect) => {
@@ -56071,8 +56437,11 @@ class SideEffectStore {
56071
56437
  this.regenerateKeyToEffectMap();
56072
56438
  }
56073
56439
  await this.durableStore.setEntries(durableEntries, SIDE_EFFECT_SEGMENT);
56440
+ this.reportOperation('add', incremental, startTime);
56074
56441
  }
56075
56442
  async removeEffects(effects) {
56443
+ // See addEffects — same wall-clock-across-the-method measurement.
56444
+ const startTime = Date.now();
56076
56445
  const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
56077
56446
  const effectKeys = effects.map((e) => e.uniqueId);
56078
56447
  effects.forEach((effect) => {
@@ -56086,6 +56455,7 @@ class SideEffectStore {
56086
56455
  this.regenerateKeyToEffectMap();
56087
56456
  }
56088
56457
  await this.durableStore.evictEntries(effectKeys, SIDE_EFFECT_SEGMENT);
56458
+ this.reportOperation('remove', incremental, startTime);
56089
56459
  }
56090
56460
  getEffects(key) {
56091
56461
  const effects = this.keyToEffectMap.get(key);
@@ -56095,6 +56465,10 @@ class SideEffectStore {
56095
56465
  return effects;
56096
56466
  }
56097
56467
  getEffectsByTag(tag) {
56468
+ // This scan is still O(N) over every effect regardless of the incremental-map
56469
+ // gate — timing it tells us whether that residual scan is the quadratic hotspot during churn.
56470
+ const startTime = Date.now();
56471
+ const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
56098
56472
  const filteredEffects = [];
56099
56473
  const { keyToEffectMap } = this;
56100
56474
  // Loop through every entry in the map
@@ -56104,7 +56478,17 @@ class SideEffectStore {
56104
56478
  // Add the matching effects to the filtered list
56105
56479
  filteredEffects.push(...matchingEffects);
56106
56480
  }
56107
- return filteredEffects.sort((a, b) => a.timestamp - b.timestamp);
56481
+ const result = filteredEffects.sort((a, b) => a.timestamp - b.timestamp);
56482
+ this.reportOperation('getByTag', incremental, startTime);
56483
+ return result;
56484
+ }
56485
+ reportOperation(op, incremental, startedAt) {
56486
+ try {
56487
+ reportDraftQueueSideEffectOp(op, incremental, Date.now() - startedAt, this.allEffects.size);
56488
+ }
56489
+ catch {
56490
+ // Telemetry must not change side-effect store behavior.
56491
+ }
56108
56492
  }
56109
56493
  async initialize() {
56110
56494
  const effects = await this.durableStore.getAllEntries(SIDE_EFFECT_SEGMENT);
@@ -59318,7 +59702,7 @@ function buildServiceDescriptor$b(luvio) {
59318
59702
  },
59319
59703
  };
59320
59704
  }
59321
- // version: 1.453.0-725e09cf25
59705
+ // version: 1.455.0-c53c97de2a
59322
59706
 
59323
59707
  /**
59324
59708
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -59344,7 +59728,7 @@ function buildServiceDescriptor$a(notifyRecordUpdateAvailable, getNormalizedLuvi
59344
59728
  },
59345
59729
  };
59346
59730
  }
59347
- // version: 1.453.0-725e09cf25
59731
+ // version: 1.455.0-c53c97de2a
59348
59732
 
59349
59733
  function findExecutableOperation(input) {
59350
59734
  const operations = input.query.definitions.filter(
@@ -62056,4 +62440,4 @@ register({
62056
62440
  });
62057
62441
 
62058
62442
  export { O11Y_NAMESPACE_LDS_MOBILE, getRuntime, ingest$1o as ingestDenormalizedRecordRepresentation, initializeOneStore, registerReportObserver, reportGraphqlQueryParseError };
62059
- // version: 1.453.0-725e09cf25
62443
+ // version: 1.455.0-c53c97de2a