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

     1  // Copyright 2018 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  /**
    12   * This module maintains the state of CockroachDB time series queries needed by
    13   * the web application. Cached query data is maintained separately for
    14   * individual components (e.g. different graphs); components are distinguished
    15   * in the reducer by a unique ID.
    16   */
    17  
    18  import _ from "lodash";
    19  import { Action } from "redux";
    20  import { delay } from "redux-saga/effects";
    21  import { take, fork, call, all, put } from "redux-saga/effects";
    22  
    23  import * as protos from  "src/js/protos";
    24  import { PayloadAction } from "src/interfaces/action";
    25  import { queryTimeSeries } from "src/util/api";
    26  
    27  type TSRequest = protos.cockroach.ts.tspb.TimeSeriesQueryRequest;
    28  type TSResponse = protos.cockroach.ts.tspb.TimeSeriesQueryResponse;
    29  
    30  export const REQUEST = "cockroachui/metrics/REQUEST";
    31  export const BEGIN = "cockroachui/metrics/BEGIN";
    32  export const RECEIVE = "cockroachui/metrics/RECEIVE";
    33  export const ERROR = "cockroachui/metrics/ERROR";
    34  export const FETCH = "cockroachui/metrics/FETCH";
    35  export const FETCH_COMPLETE = "cockroachui/metrics/FETCH_COMPLETE";
    36  
    37  /**
    38   * WithID is a convenient interface for associating arbitrary data structures
    39   * with a component ID.
    40   */
    41  interface WithID<T> {
    42    id: string;
    43    data: T;
    44  }
    45  
    46  /**
    47   * A request/response pair.
    48   */
    49  interface RequestWithResponse {
    50    request: TSRequest;
    51    response: TSResponse;
    52  }
    53  
    54  /**
    55   * MetricsQuery maintains the cached data for a single component.
    56   */
    57  export class MetricsQuery {
    58    // ID of the component which owns this data.
    59    id: string;
    60    // The currently cached response data for this component.
    61    data: TSResponse;
    62    // If the immediately previous request attempt returned an error, rather than
    63    // a response, it is maintained here. Null if the previous request was
    64    // successful.
    65    error: Error;
    66    // The previous request, which will have resulted in either "data" or "error"
    67    // being populated.
    68    request: TSRequest;
    69    // A possibly outstanding request used to retrieve data from the server for this
    70    // component. This may represent a currently in-flight query, and thus is not
    71    // necessarily the request used to retrieve the current value of "data".
    72    nextRequest: TSRequest;
    73  
    74    constructor(id: string) {
    75      this.id = id;
    76    }
    77  }
    78  
    79  /**
    80   * metricsQueryReducer is a reducer which modifies the state of a single
    81   * MetricsQuery object.
    82   */
    83  function metricsQueryReducer(state: MetricsQuery, action: Action) {
    84    switch (action.type) {
    85      // This component has requested a new set of metrics from the server.
    86      case REQUEST:
    87        const { payload: request } = action as PayloadAction<WithID<TSRequest>>;
    88        state = _.clone(state);
    89        state.nextRequest = request.data;
    90        return state;
    91  
    92      // Results for a previous request have been received from the server.
    93      case RECEIVE:
    94        const { payload: response } = action as PayloadAction<WithID<RequestWithResponse>>;
    95        if (response.data.request === state.nextRequest) {
    96          state = _.clone(state);
    97          state.data = response.data.response;
    98          state.request = response.data.request;
    99          state.error = undefined;
   100        }
   101        return state;
   102  
   103      // The previous query for metrics for this component encountered an error.
   104      case ERROR:
   105        const { payload: error } = action as PayloadAction<WithID<Error>>;
   106        state = _.clone(state);
   107        state.error = error.data;
   108        return state;
   109  
   110      default:
   111        return state;
   112    }
   113  }
   114  
   115  /**
   116   * MetricsQueries is a collection of individual MetricsQuery objects, indexed by
   117   * component id.
   118   */
   119  interface MetricQuerySet {
   120    [id: string]: MetricsQuery;
   121  }
   122  
   123  /**
   124   * metricsQueriesReducer dispatches actions to the correct MetricsQuery, based
   125   * on the ID of the actions.
   126   */
   127  export function metricQuerySetReducer(state: MetricQuerySet = {}, action: Action) {
   128    switch (action.type) {
   129      case REQUEST:
   130      case RECEIVE:
   131      case ERROR:
   132        // All of these requests should be dispatched to a MetricQuery in the
   133        // collection. If a MetricQuery with that ID does not yet exist, create it.
   134        const { id } = (action as PayloadAction<WithID<any>>).payload;
   135        state = _.clone(state);
   136        state[id] = metricsQueryReducer(state[id] || new MetricsQuery(id), action);
   137        return state;
   138  
   139      default:
   140        return state;
   141    }
   142  }
   143  
   144  /**
   145   * MetricsState maintains a MetricQuerySet collection, along with some
   146   * metadata relevant to server queries.
   147   */
   148  export class MetricsState {
   149    // A count of the number of in-flight fetch requests.
   150    inFlight = 0;
   151    // The collection of MetricQuery objects.
   152    queries: MetricQuerySet;
   153  }
   154  
   155  /**
   156   * The metrics reducer accepts events for individual MetricQuery objects,
   157   * dispatching them based on ID. It also accepts actions which indicate the
   158   * state of the connection to the server.
   159   */
   160  export function metricsReducer(state: MetricsState = new MetricsState(), action: Action): MetricsState {
   161    switch (action.type) {
   162      // A new fetch request to the server is now in flight.
   163      case FETCH:
   164        state = _.clone(state);
   165        state.inFlight += 1;
   166        return state;
   167  
   168      // A fetch request to the server has completed.
   169      case FETCH_COMPLETE:
   170        state = _.clone(state);
   171        state.inFlight -= 1;
   172        return state;
   173  
   174      // Other actions may be handled by the metricsQueryReducer.
   175      default:
   176        state = _.clone(state);
   177        state.queries = metricQuerySetReducer(state.queries, action);
   178        return state;
   179    }
   180  }
   181  
   182  /**
   183   * requestMetrics indicates that a component is requesting new data from the
   184   * server.
   185   */
   186  export function requestMetrics(id: string, request: TSRequest): PayloadAction<WithID<TSRequest>> {
   187    return {
   188      type: REQUEST,
   189      payload: {
   190        id: id,
   191        data: request,
   192      },
   193    };
   194  }
   195  
   196  /**
   197   * beginMetrics is dispatched by the processing saga to indicate that it has
   198   * begun the process of dispatching a request.
   199   */
   200  export function beginMetrics(id: string, request: TSRequest): PayloadAction<WithID<TSRequest>> {
   201    return {
   202      type: BEGIN,
   203      payload: {
   204        id: id,
   205        data: request,
   206      },
   207    };
   208  }
   209  
   210  /**
   211   * receiveMetrics indicates that a previous request from this component has been
   212   * fulfilled by the server.
   213   */
   214  export function receiveMetrics(
   215    id: string,
   216    request: TSRequest,
   217    response: TSResponse,
   218  ): PayloadAction<WithID<RequestWithResponse>> {
   219    return {
   220      type: RECEIVE,
   221      payload: {
   222        id: id,
   223        data: {
   224          request: request,
   225          response: response,
   226        },
   227      },
   228    };
   229  }
   230  
   231  /**
   232   * errorMetrics indicates that a previous request from this component could not
   233   * be fulfilled due to an error.
   234   */
   235  export function errorMetrics(id: string, error: Error): PayloadAction<WithID<Error>> {
   236    return {
   237      type: ERROR,
   238      payload: {
   239        id: id,
   240        data: error,
   241      },
   242    };
   243  }
   244  
   245  /**
   246   * fetchMetrics indicates that a new asynchronous request to the server is in-flight.
   247   */
   248  export function fetchMetrics(): Action {
   249    return {
   250      type: FETCH,
   251    };
   252  }
   253  
   254  /**
   255   * fetchMetricsComplete indicates that an in-flight request to the server has
   256   * completed.
   257   */
   258  export function fetchMetricsComplete(): Action {
   259    return {
   260      type: FETCH_COMPLETE,
   261    };
   262  }
   263  
   264  /**
   265   * queryMetricsSaga is a redux saga which listens for REQUEST actions and sends
   266   * those requests to the server asynchronously.
   267   *
   268   * Metric queries can be batched when sending to the to the server -
   269   * specifically, queries which have the same time span can be handled by the
   270   * server in a single call. This saga will attempt to batch any requests which
   271   * are dispatched as part of the same event (e.g. if a rendering page displays
   272   * several graphs which need data).
   273   */
   274  export function* queryMetricsSaga() {
   275    let requests: WithID<TSRequest>[] = [];
   276  
   277    while (true) {
   278      const requestAction: PayloadAction<WithID<TSRequest>> = yield take((REQUEST));
   279  
   280      // Dispatch action to underlying store.
   281      yield put(beginMetrics(requestAction.payload.id, requestAction.payload.data));
   282      requests.push(requestAction.payload);
   283  
   284      // If no other requests are queued, fork a process which will send the
   285      // request (and any other subsequent requests that are queued).
   286      if (requests.length === 1) {
   287        yield fork(sendRequestsAfterDelay);
   288      }
   289    }
   290  
   291    function* sendRequestsAfterDelay() {
   292      // Delay of zero will defer execution to the message queue, allowing the
   293      // currently executing event (e.g. rendering a new page or a timespan change)
   294      // to dispatch additional requests which can be batched.
   295      yield delay(0);
   296  
   297      const requestsToSend = requests;
   298      requests = [];
   299      yield call(batchAndSendRequests, requestsToSend);
   300    }
   301  }
   302  
   303  /**
   304   * batchAndSendRequests attempts to send the supplied requests in the
   305   * smallest number of batches possible.
   306   */
   307  export function* batchAndSendRequests(requests: WithID<TSRequest>[]) {
   308    // Construct queryable batches from the set of queued queries. Queries can
   309    // be dispatched in a batch if they are querying over the same timespan.
   310    const batches = _.groupBy(requests, (qr) => timespanKey(qr.data));
   311    requests = [];
   312  
   313    yield put(fetchMetrics());
   314    yield all(_.map(batches, batch => call(sendRequestBatch, batch)));
   315    yield put(fetchMetricsComplete());
   316  }
   317  
   318  /**
   319   * sendRequestBatch sends the supplied requests in a single batch.
   320   */
   321  export function* sendRequestBatch(requests: WithID<TSRequest>[]) {
   322    // Flatten the queries from the batch into a single request.
   323    const unifiedRequest = _.clone(requests[0].data);
   324    unifiedRequest.queries = _.flatMap(requests, req => req.data.queries);
   325  
   326    let response: protos.cockroach.ts.tspb.TimeSeriesQueryResponse;
   327    try {
   328      response = yield call(queryTimeSeries, unifiedRequest);
   329      // The number of results should match the queries exactly, and should
   330      // be in the exact order passed.
   331      if (response.results.length !== unifiedRequest.queries.length) {
   332        throw `mismatched count of results (${response.results.length}) and queries (${unifiedRequest.queries.length})`;
   333      }
   334    } catch (e) {
   335      // Dispatch the error to each individual MetricsQuery which was
   336      // requesting data.
   337      for (const request of requests) {
   338        yield put(errorMetrics(request.id, e));
   339      }
   340      return;
   341    }
   342  
   343    // Match each result in the unified response to its corresponding original
   344    // query. Each request may have sent multiple queries in the batch.
   345    const results = response.results;
   346    for (const request of requests) {
   347      yield put(receiveMetrics(
   348        request.id,
   349        request.data,
   350        new protos.cockroach.ts.tspb.TimeSeriesQueryResponse({
   351          results: results.splice(0, request.data.queries.length),
   352        }),
   353      ));
   354    }
   355  }
   356  
   357  interface SimpleTimespan {
   358    start_nanos?: Long;
   359    end_nanos?: Long;
   360  }
   361  
   362  function timespanKey(timewindow: SimpleTimespan): string {
   363    return (timewindow.start_nanos && timewindow.start_nanos.toString()) + ":" + (timewindow.end_nanos && timewindow.end_nanos.toString());
   364  }