github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/redux/queryManager/saga.ts (about)

     1  // Copyright 2019 The Cockroach Authors.
     2  //
     3  // Use of this software is governed by the Business Source License
     4  // included in the file licenses/BSL.txt.
     5  //
     6  // As of the Change Date specified in that file, in accordance with
     7  // the Business Source License, use of this software will be governed
     8  // by the Apache License, Version 2.0, included in the file
     9  // licenses/APL.txt.
    10  
    11  import moment from "moment";
    12  import { Action } from "redux";
    13  import { channel, Task, Channel } from "redux-saga";
    14  import {
    15      call, cancel, fork, join, put, race, take, delay,
    16  } from "redux-saga/effects";
    17  
    18  import { queryBegin, queryComplete, queryError } from "./reducer";
    19  
    20  export const DEFAULT_REFRESH_INTERVAL = moment.duration(10, "s");
    21  export const DEFAULT_RETRY_DELAY = moment.duration(2, "s");
    22  
    23  /**
    24   * A ManagedQuery describes an asynchronous query that can have its execution
    25   * managed by the query management saga.
    26   *
    27   * Managed queries are executed by dispatching redux actions:
    28   * + refresh(query) can be used to immediately run the query.
    29   * + autoRefresh(query) will begin automatically refreshing the query
    30   *   automatically on a cadence.
    31   * + stopAutoRefresh(query) will stop automatically refreshing the query.
    32   *
    33   * Note that "autoRefresh" and "stopAutoRefresh" events are counted; the
    34   * query will refresh as long as there is at least one auto_refresh() action
    35   * that has not been canceled by a stop_auto_refresh().
    36   */
    37  interface ManagedQuery {
    38      // A string ID that distinguishes this query from all other queries.
    39      id: string;
    40      // The interval at which this query should be refreshed if it is being
    41      // auto-refreshed. Default is ten seconds.
    42      refreshInterval?: moment.Duration;
    43      // The delay after which an auto-refreshing query will be retried after
    44      // a failure. Default is two seconds.
    45      retryDelay?: moment.Duration;
    46      // A redux saga task that should execute the query and put its results into
    47      // the store. This method can yield any of the normal redux saga effects.
    48      querySaga: () => IterableIterator<any>;
    49  }
    50  
    51  export const QUERY_REFRESH = "cockroachui/query/QUERY_REFRESH";
    52  export const QUERY_AUTO_REFRESH = "cockroachui/query/QUERY_AUTO_REFRESH";
    53  export const QUERY_STOP_AUTO_REFRESH = "cockroachui/query/QUERY_STOP_AUTO_REFRESH";
    54  
    55  interface QueryRefreshAction extends Action {
    56      type: typeof QUERY_REFRESH;
    57      query: ManagedQuery;
    58  }
    59  
    60  interface QueryAutoRefreshAction extends Action {
    61      type: typeof QUERY_AUTO_REFRESH;
    62      query: ManagedQuery;
    63  }
    64  
    65  interface QueryStopRefreshAction extends Action {
    66      type: typeof QUERY_STOP_AUTO_REFRESH;
    67      query: ManagedQuery;
    68  }
    69  
    70  type QueryManagementAction =
    71      QueryRefreshAction | QueryAutoRefreshAction | QueryStopRefreshAction;
    72  
    73  /**
    74   * refresh indicates that a managed query should run immediately.
    75   */
    76  export function refresh(query: ManagedQuery): QueryRefreshAction {
    77      return {
    78          type: QUERY_REFRESH,
    79          query: query,
    80      };
    81  }
    82  
    83  /**
    84   * autoRefresh indicates that a managed query should start automatically
    85   * refreshing on a regular cadence.
    86   */
    87  export function autoRefresh(query: ManagedQuery): QueryAutoRefreshAction {
    88      return {
    89          type: QUERY_AUTO_REFRESH,
    90          query: query,
    91      };
    92  }
    93  
    94  /**
    95   * stopAutoRefresh indicates that a managed query no longer needs to automatically
    96   * refresh.
    97   */
    98  export function stopAutoRefresh(query: ManagedQuery): QueryStopRefreshAction {
    99      return {
   100          type: QUERY_STOP_AUTO_REFRESH,
   101          query: query,
   102      };
   103  }
   104  
   105  /**
   106   * Contains state information about a managed query which has been run by the
   107   * manager.
   108   */
   109  export class ManagedQuerySagaState {
   110      // The query being managed.
   111      query: ManagedQuery;
   112      // The saga task which is managing this query, either running it or
   113      // auto-refreshing it. If the auto-refresh count drops to zero, this saga
   114      // may complete (the manager will start a new saga if the query starts
   115      // again).
   116      sagaTask: Task;
   117      // A saga channel that the main query management saga will use to dispatch
   118      // events to the query-specific saga.
   119      channel: Channel<QueryManagementAction>;
   120      // The number of components currently requesting that this query be
   121      // auto-refreshed. This is the result of incrementing on autoRefresh()
   122      // actions and decrementing on stopAutoRefresh() actions.
   123      autoRefreshCount: number = 0;
   124      // If true, the query saga needs to run the underlying query immediately. If
   125      // this is false, the saga will delay until it needs to be refreshed (or
   126      // will exit if autoRefreshCount is zero,)
   127      shouldRefreshQuery: boolean;
   128      // Contains the time at which the query last completed, either successfully
   129      // or with an error.
   130      queryCompletedAt: moment.Moment;
   131      // True if the last attempt to run this query ended in an error.
   132      lastAttemptFailed: boolean;
   133  }
   134  
   135  /**
   136   * Contains state needed by a running query manager saga.
   137   */
   138  export class QueryManagerSagaState {
   139      private queryStates: {[queryId: string]: ManagedQuerySagaState} = {};
   140  
   141      // Retrieve the ManagedQuerySagaState for the query with the given id.
   142      // Creates a new state object if the given id has not yet been encountered.
   143      getQueryState(query: ManagedQuery) {
   144          const { id } = query;
   145          if (!this.queryStates.hasOwnProperty(id)) {
   146              this.queryStates[id] = new ManagedQuerySagaState();
   147              this.queryStates[id].query = query;
   148          }
   149          return this.queryStates[id];
   150      }
   151  }
   152  
   153  /**
   154   * The top-level saga responsible for dispatching events to the child sagas
   155   * which manage individual queries.
   156   */
   157  export function *queryManagerSaga() {
   158      const queryManagerState = new QueryManagerSagaState();
   159  
   160      while (true) {
   161          const qmAction: QueryManagementAction = yield take(
   162              [QUERY_REFRESH, QUERY_AUTO_REFRESH, QUERY_STOP_AUTO_REFRESH],
   163          );
   164  
   165          // Fork a saga to manage this query if it is not already running.
   166          const state = queryManagerState.getQueryState(qmAction.query);
   167          if (!taskIsRunning(state.sagaTask)) {
   168              state.channel = channel<QueryManagementAction>();
   169              state.sagaTask = yield fork(managedQuerySaga, state);
   170          }
   171          yield put(state.channel, qmAction);
   172      }
   173  }
   174  
   175  /**
   176   * Saga used to manages the execution of an individual query.
   177   */
   178  export function *managedQuerySaga(state: ManagedQuerySagaState) {
   179      // Process the initial action.
   180      yield call(processQueryManagementAction, state);
   181  
   182      // Run loop while we either need to run the query immediately, or if there
   183      // are any components requesting this query should auto refresh.
   184      while (state.shouldRefreshQuery || state.autoRefreshCount > 0) {
   185          if (state.shouldRefreshQuery) {
   186              yield call(refreshQuery, state);
   187          }
   188  
   189          if (state.autoRefreshCount > 0) {
   190              yield call(waitForNextRefresh, state);
   191          }
   192      }
   193  }
   194  
   195  /**
   196   * Processes the next QueryManagementAction dispatched to this query.
   197   */
   198  export function *processQueryManagementAction(state: ManagedQuerySagaState) {
   199      const { type } = (yield take(state.channel)) as QueryManagementAction;
   200      switch (type) {
   201          case QUERY_REFRESH:
   202              state.shouldRefreshQuery = true;
   203              break;
   204          case QUERY_AUTO_REFRESH:
   205              state.autoRefreshCount += 1;
   206              break;
   207          case QUERY_STOP_AUTO_REFRESH:
   208              state.autoRefreshCount -= 1;
   209              break;
   210          default:
   211              break;
   212      }
   213  }
   214  
   215  /**
   216   * refreshQuery is the execution state of the query management saga
   217   * when the query is being executed.
   218   */
   219  export function *refreshQuery(state: ManagedQuerySagaState) {
   220      const queryTask = yield fork(runQuery, state);
   221      while (queryTask.isRunning()) {
   222          // While the query is running, we still need to increment or
   223          // decrement the auto-refresh count.
   224          yield race({
   225              finished: join(queryTask),
   226              nextAction: call(processQueryManagementAction, state),
   227          });
   228      }
   229      state.shouldRefreshQuery = false;
   230  }
   231  
   232  /**
   233   * waitForNextRefresh is the execution state of the query management saga
   234   * when it is waiting to automatically refresh.
   235   */
   236  export function *waitForNextRefresh(state: ManagedQuerySagaState) {
   237      // If this query should be auto-refreshed, compute the time until
   238      // the query is out of date. If the request is already out of date,
   239      // refresh the query immediately.
   240      const delayTime = yield call(timeToNextRefresh, state);
   241      if (delayTime <= 0) {
   242          state.shouldRefreshQuery = true;
   243          return;
   244      }
   245  
   246      const delayTask = yield fork(delayGenerator, delayTime);
   247      while (delayTask.isRunning()) {
   248          yield race({
   249              finished: join(delayTask),
   250              nextAction: call(processQueryManagementAction, state),
   251          });
   252          // If a request comes in to run the query immediately, or if the
   253          // auto-refresh count drops to zero, cancel the delay task.
   254          if (state.shouldRefreshQuery || state.autoRefreshCount <= 0) {
   255              yield cancel(delayTask);
   256              return;
   257          }
   258      }
   259  
   260      state.shouldRefreshQuery = true;
   261  }
   262  
   263  /**
   264   * Calculates the number of milliseconds until the given query needs to be
   265   * refreshed.
   266   */
   267  export function *timeToNextRefresh(state: ManagedQuerySagaState) {
   268      if (!state.queryCompletedAt) {
   269          return 0;
   270      }
   271  
   272      let interval: moment.Duration;
   273      if (state.lastAttemptFailed) {
   274          interval = state.query.retryDelay || DEFAULT_RETRY_DELAY;
   275      } else {
   276          interval = state.query.refreshInterval || DEFAULT_REFRESH_INTERVAL;
   277      }
   278  
   279      // Yielding to moment lets us easily mock time in tests.
   280      const now: moment.Moment = yield call(getMoment);
   281      const dueAt = state.queryCompletedAt.clone().add(interval);
   282      return dueAt.diff(now);
   283  }
   284  
   285  /**
   286   * Runs the underlying query of the supplied managed query.
   287   *
   288   * This task will catch any errors thrown by the query.
   289   *
   290   * This task is also responsible for putting information about the query into
   291   * the query management reducer (the saga itself doesn't use the information in
   292   * the reducer; it is provided in order to give visibility to other components
   293   * in the system).
   294   */
   295  export function *runQuery(state: ManagedQuerySagaState) {
   296      const { id, querySaga } = state.query;
   297  
   298      let err: Error;
   299      try {
   300          yield put(queryBegin(id));
   301          yield call(querySaga);
   302      } catch (e) {
   303          err = e;
   304      }
   305  
   306      // Yielding to moment lets us easily mock time in tests.
   307      state.queryCompletedAt = yield call(getMoment);
   308      if (err) {
   309          state.lastAttemptFailed = true;
   310          yield put(queryError(id, err, state.queryCompletedAt));
   311      } else {
   312          state.lastAttemptFailed = false;
   313          yield put(queryComplete(id, state.queryCompletedAt));
   314      }
   315  }
   316  
   317  // getMoment is a function that can be dispatched to redux-saga's "call" effect.
   318  // Saga doesn't like using the bare moment object because moment is also an
   319  // object and Saga chooses the wrong overload.
   320  export function getMoment() {
   321      return moment();
   322  }
   323  
   324  // delayGenerator wraps the delay function so that it can be forked. Note that
   325  // redux saga itself does support forking arbitrary promise-returning functions,
   326  // but redux-saga-test-plan does not.
   327  // https://github.com/jfairbank/redux-saga-test-plan/issues/139
   328  function *delayGenerator(delayTime: number) {
   329    yield delay(delayTime);
   330  }
   331  
   332  /**
   333   * Utility that returns true if the provided task is running. Helpful for use
   334   * when a task-containing variable may be null.
   335   */
   336  function taskIsRunning(task: Task | null) {
   337      return task && task.isRunning();
   338  }