@datum-cloud/activity-ui 0.6.0-dev.5b51180 → 0.6.0-dev.917556e

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/index.js CHANGED
@@ -9794,6 +9794,14 @@ function ActionToggle({ value, onChange, className = '', disabled = false, }) {
9794
9794
 
9795
9795
  // Debounce delay for filter changes (ms)
9796
9796
  const FILTER_DEBOUNCE_MS$1 = 300;
9797
+ // Backoff for watch stream reconnects. Without this, a persistently failing
9798
+ // upstream gets re-opened on every render with no delay — see
9799
+ // https://github.com/datum-cloud/infra/issues/5060, where a single browser
9800
+ // tab reconnected roughly 2.5 times a minute against a stream that kept
9801
+ // aborting, driving the proxy's memory up until the process was OOMKilled.
9802
+ const RECONNECT_BASE_DELAY_MS = 1000;
9803
+ const RECONNECT_MAX_DELAY_MS = 30000;
9804
+ const MAX_RECONNECT_ATTEMPTS = 8;
9797
9805
  /**
9798
9806
  * Escape a value for safe interpolation into a CEL string literal.
9799
9807
  * Prevents injection via values containing backslashes or double quotes.
@@ -9888,6 +9896,12 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
9888
9896
  const resourceVersionRef = React.useRef();
9889
9897
  // Track the watch stop function
9890
9898
  const watchStopRef = React.useRef(null);
9899
+ // Consecutive watch failures since the last successful event, used to back
9900
+ // off reconnect attempts. Reset on any real event (including BOOKMARK) and
9901
+ // on a manual startStreaming() call.
9902
+ const reconnectAttemptsRef = React.useRef(0);
9903
+ // Pending scheduled reconnect, if any.
9904
+ const reconnectTimerRef = React.useRef(null);
9891
9905
  // Track whether streaming should restart after filter change
9892
9906
  const shouldRestartStreamingRef = React.useRef(false);
9893
9907
  // Track if we've done the initial load
@@ -9962,6 +9976,9 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
9962
9976
  console.error('Watch error:', event.object);
9963
9977
  return;
9964
9978
  }
9979
+ // A real event (including a BOOKMARK keep-alive) means the connection is
9980
+ // healthy — reset backoff so a later failure starts from the base delay.
9981
+ reconnectAttemptsRef.current = 0;
9965
9982
  if (event.type === 'BOOKMARK') {
9966
9983
  // Update resource version for resume capability
9967
9984
  if (event.object.metadata?.resourceVersion) {
@@ -10027,15 +10044,13 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
10027
10044
  setActivities((prev) => prev.filter((a) => a.metadata?.name !== event.object.metadata?.name));
10028
10045
  }
10029
10046
  }, [filters]);
10030
- // Start watching for real-time updates
10031
- const startStreaming = React.useCallback(() => {
10047
+ // Open the watch connection. Does not touch userPaused/backoff state —
10048
+ // callers (startStreaming, scheduleReconnect) own that.
10049
+ const connectWatch = React.useCallback(() => {
10032
10050
  if (watchStopRef.current) {
10033
10051
  // Already watching
10034
10052
  return;
10035
10053
  }
10036
- // Explicit start clears the user-paused flag so the auto-restart
10037
- // effects can keep the stream alive after subsequent filter changes.
10038
- setUserPaused(false);
10039
10054
  // Clear any previous watch error when starting a new stream
10040
10055
  setWatchError(null);
10041
10056
  const params = buildWatchParams();
@@ -10049,6 +10064,7 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
10049
10064
  setWatchError(streamError);
10050
10065
  setIsStreaming(false);
10051
10066
  watchStopRef.current = null;
10067
+ reconnectAttemptsRef.current += 1;
10052
10068
  },
10053
10069
  onClose: () => {
10054
10070
  setIsStreaming(false);
@@ -10059,11 +10075,50 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
10059
10075
  setIsStreaming(true);
10060
10076
  setNewActivitiesCount(0);
10061
10077
  }, [client, buildWatchParams, handleWatchEvent]);
10078
+ // Reconnect with exponential backoff. The first attempt (no prior
10079
+ // failures) connects immediately; each subsequent failure doubles the
10080
+ // delay up to RECONNECT_MAX_DELAY_MS. After MAX_RECONNECT_ATTEMPTS
10081
+ // consecutive failures, this stops retrying automatically — the watch
10082
+ // stays stopped (with watchError set) until the user calls
10083
+ // startStreaming() again, which resets the attempt count.
10084
+ const scheduleReconnect = React.useCallback(() => {
10085
+ if (reconnectTimerRef.current) {
10086
+ // A reconnect is already scheduled.
10087
+ return;
10088
+ }
10089
+ if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
10090
+ return;
10091
+ }
10092
+ const attempt = reconnectAttemptsRef.current;
10093
+ const delay = attempt === 0 ? 0 : Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1));
10094
+ reconnectTimerRef.current = setTimeout(() => {
10095
+ reconnectTimerRef.current = null;
10096
+ connectWatch();
10097
+ }, delay);
10098
+ }, [connectWatch]);
10099
+ // Start watching for real-time updates. This is the user/effect-facing
10100
+ // entry point: it resets backoff and any pending scheduled reconnect
10101
+ // before connecting, so a manual retry always starts from a clean slate.
10102
+ const startStreaming = React.useCallback(() => {
10103
+ // Explicit start clears the user-paused flag so the auto-restart
10104
+ // effects can keep the stream alive after subsequent filter changes.
10105
+ setUserPaused(false);
10106
+ reconnectAttemptsRef.current = 0;
10107
+ if (reconnectTimerRef.current) {
10108
+ clearTimeout(reconnectTimerRef.current);
10109
+ reconnectTimerRef.current = null;
10110
+ }
10111
+ connectWatch();
10112
+ }, [connectWatch]);
10062
10113
  // Stop watching. Marks the user as paused so the auto-start effects
10063
10114
  // below don't immediately re-open the stream this call just closed.
10064
10115
  const stopStreaming = React.useCallback(() => {
10065
10116
  setUserPaused(true);
10066
10117
  setIsStreaming(false);
10118
+ if (reconnectTimerRef.current) {
10119
+ clearTimeout(reconnectTimerRef.current);
10120
+ reconnectTimerRef.current = null;
10121
+ }
10067
10122
  if (watchStopRef.current) {
10068
10123
  watchStopRef.current();
10069
10124
  watchStopRef.current = null;
@@ -10226,9 +10281,10 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
10226
10281
  }
10227
10282
  };
10228
10283
  }, [filters, timeRange]); // eslint-disable-line react-hooks/exhaustive-deps
10229
- // Auto-start streaming after initial load when enabled. Skipped when
10230
- // the user has explicitly paused — otherwise clicking Pause would be
10231
- // immediately undone by this effect on the next render.
10284
+ // Auto-start streaming after initial load when enabled, and reconnect
10285
+ // (with backoff — see scheduleReconnect) whenever a connection drops.
10286
+ // Skipped when the user has explicitly paused — otherwise clicking Pause
10287
+ // would be immediately undone by this effect on the next render.
10232
10288
  React.useEffect(() => {
10233
10289
  if (enableStreaming &&
10234
10290
  autoStartStreaming &&
@@ -10236,9 +10292,9 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
10236
10292
  activities.length > 0 &&
10237
10293
  !isStreaming &&
10238
10294
  !isLoading) {
10239
- startStreaming();
10295
+ scheduleReconnect();
10240
10296
  }
10241
- }, [enableStreaming, autoStartStreaming, userPaused, activities.length, isStreaming, isLoading, startStreaming]);
10297
+ }, [enableStreaming, autoStartStreaming, userPaused, activities.length, isStreaming, isLoading, scheduleReconnect]);
10242
10298
  // Restart streaming after filter change refresh completes. Also skipped
10243
10299
  // when the user has paused — a filter change shouldn't silently resume
10244
10300
  // a stream the user intentionally stopped.
@@ -10250,15 +10306,18 @@ function useActivityFeed({ client, initialFilters = {}, initialTimeRange = { sta
10250
10306
  !isStreaming &&
10251
10307
  !isLoading) {
10252
10308
  shouldRestartStreamingRef.current = false;
10253
- startStreaming();
10309
+ scheduleReconnect();
10254
10310
  }
10255
- }, [enableStreaming, userPaused, activities.length, isStreaming, isLoading, startStreaming]);
10311
+ }, [enableStreaming, userPaused, activities.length, isStreaming, isLoading, scheduleReconnect]);
10256
10312
  // Cleanup on unmount
10257
10313
  React.useEffect(() => {
10258
10314
  return () => {
10259
10315
  if (watchStopRef.current) {
10260
10316
  watchStopRef.current();
10261
10317
  }
10318
+ if (reconnectTimerRef.current) {
10319
+ clearTimeout(reconnectTimerRef.current);
10320
+ }
10262
10321
  if (filterDebounceRef.current) {
10263
10322
  clearTimeout(filterDebounceRef.current);
10264
10323
  }