github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/redux/metrics.spec.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  import { assert } from "chai";
    12  import _ from "lodash";
    13  import Long from "long";
    14  import { expectSaga, testSaga } from "redux-saga-test-plan";
    15  import * as matchers from "redux-saga-test-plan/matchers";
    16  
    17  import { call, put, delay } from "redux-saga/effects";
    18  import { queryTimeSeries, TimeSeriesQueryRequestMessage } from "src/util/api";
    19  import * as protos from "src/js/protos";
    20  
    21  import * as metrics from "./metrics";
    22  
    23  type TSRequest = protos.cockroach.ts.tspb.TimeSeriesQueryRequest;
    24  
    25  describe("metrics reducer", function() {
    26    describe("actions", function() {
    27      it("requestMetrics() creates the correct action type.", function() {
    28        assert.equal(metrics.requestMetrics("id", null).type, metrics.REQUEST);
    29      });
    30  
    31      it("receiveMetrics() creates the correct action type.", function() {
    32        assert.equal(metrics.receiveMetrics("id", null, null).type, metrics.RECEIVE);
    33      });
    34  
    35      it("errorMetrics() creates the correct action type.", function() {
    36        assert.equal(metrics.errorMetrics("id", null).type, metrics.ERROR);
    37      });
    38  
    39      it("fetchMetrics() creates the correct action type.", function() {
    40        assert.equal(metrics.fetchMetrics().type, metrics.FETCH);
    41      });
    42  
    43      it("fetchMetricsComplete() creates the correct action type.", function() {
    44        assert.equal(metrics.fetchMetricsComplete().type, metrics.FETCH_COMPLETE);
    45      });
    46    });
    47  
    48    describe("reducer", function() {
    49      const componentID = "test-component";
    50      let state: metrics.MetricsState;
    51  
    52      beforeEach(() => {
    53        state = metrics.metricsReducer(undefined, { type: "unknown" });
    54      });
    55  
    56      it("should have the correct default value.", function() {
    57        const expected = {
    58          inFlight: 0,
    59          queries: metrics.metricQuerySetReducer(undefined, { type: "unknown" }),
    60        };
    61        assert.deepEqual(state, expected);
    62      });
    63  
    64      it("should correctly dispatch requestMetrics", function() {
    65        const request = new protos.cockroach.ts.tspb.TimeSeriesQueryRequest({
    66          start_nanos: Long.fromNumber(0),
    67          end_nanos: Long.fromNumber(10),
    68          queries: [
    69            {
    70              name: "test.metric.1",
    71            },
    72            {
    73              name: "test.metric.2",
    74            },
    75          ],
    76        });
    77        state = metrics.metricsReducer(state, metrics.requestMetrics(componentID, request));
    78        assert.isDefined(state.queries);
    79        assert.isDefined(state.queries[componentID]);
    80        assert.lengthOf(_.keys(state.queries), 1);
    81        assert.equal(state.queries[componentID].nextRequest, request);
    82        assert.isUndefined(state.queries[componentID].data);
    83        assert.isUndefined(state.queries[componentID].error);
    84      });
    85  
    86      it("should correctly dispatch receiveMetrics with an unmatching nextRequest", function() {
    87        const response = new protos.cockroach.ts.tspb.TimeSeriesQueryResponse({
    88          results: [
    89            {
    90              datapoints: [],
    91            },
    92          ],
    93        });
    94        const request = new protos.cockroach.ts.tspb.TimeSeriesQueryRequest({
    95          start_nanos: Long.fromNumber(0),
    96          end_nanos: Long.fromNumber(10),
    97          queries: [
    98            {
    99              name: "test.metric.1",
   100            },
   101          ],
   102        });
   103        state = metrics.metricsReducer(state, metrics.receiveMetrics(componentID, request, response));
   104        assert.isDefined(state.queries);
   105        assert.isDefined(state.queries[componentID]);
   106        assert.lengthOf(_.keys(state.queries), 1);
   107        assert.equal(state.queries[componentID].data, null);
   108        assert.equal(state.queries[componentID].request, null);
   109        assert.isUndefined(state.queries[componentID].nextRequest);
   110        assert.isUndefined(state.queries[componentID].error);
   111      });
   112  
   113      it("should correctly dispatch receiveMetrics with a matching nextRequest", function() {
   114        const response = new protos.cockroach.ts.tspb.TimeSeriesQueryResponse({
   115          results: [
   116            {
   117              datapoints: [],
   118            },
   119          ],
   120        });
   121        const request = new protos.cockroach.ts.tspb.TimeSeriesQueryRequest({
   122          start_nanos: Long.fromNumber(0),
   123          end_nanos: Long.fromNumber(10),
   124          queries: [
   125            {
   126              name: "test.metric.1",
   127            },
   128          ],
   129        });
   130        // populate nextRequest
   131        state = metrics.metricsReducer(state, metrics.requestMetrics(componentID, request));
   132        state = metrics.metricsReducer(state, metrics.receiveMetrics(componentID, request, response));
   133        assert.isDefined(state.queries);
   134        assert.isDefined(state.queries[componentID]);
   135        assert.lengthOf(_.keys(state.queries), 1);
   136        assert.equal(state.queries[componentID].data, response);
   137        assert.equal(state.queries[componentID].request, request);
   138        assert.isUndefined(state.queries[componentID].error);
   139      });
   140  
   141      it("should correctly dispatch errorMetrics", function() {
   142        const error: Error = new Error("An error occurred");
   143        state = metrics.metricsReducer(state, metrics.errorMetrics(componentID, error));
   144        assert.isDefined(state.queries);
   145        assert.isDefined(state.queries[componentID]);
   146        assert.lengthOf(_.keys(state.queries), 1);
   147        assert.equal(state.queries[componentID].error, error);
   148        assert.isUndefined(state.queries[componentID].request);
   149        assert.isUndefined(state.queries[componentID].data);
   150      });
   151  
   152      it("should correctly dispatch fetchMetrics and fetchMetricsComplete", function() {
   153        state = metrics.metricsReducer(state, metrics.fetchMetrics());
   154        assert.equal(state.inFlight, 1);
   155        state = metrics.metricsReducer(state, metrics.fetchMetrics());
   156        assert.equal(state.inFlight, 2);
   157        state = metrics.metricsReducer(state, metrics.fetchMetricsComplete());
   158        assert.equal(state.inFlight, 1);
   159      });
   160    });
   161  
   162    describe("saga functions", function() {
   163      type timespan = [Long, Long];
   164      const shortTimespan: timespan = [Long.fromNumber(400), Long.fromNumber(500)];
   165      const longTimespan: timespan = [Long.fromNumber(0), Long.fromNumber(500)];
   166  
   167      // Helper function to generate metrics request.
   168      function createRequest(ts: timespan, ...names: string[]): TSRequest {
   169        return new protos.cockroach.ts.tspb.TimeSeriesQueryRequest({
   170          start_nanos: ts[0],
   171          end_nanos: ts[1],
   172          queries: _.map(names, (s) => {
   173            return {
   174              name: s,
   175            };
   176          }),
   177        });
   178      }
   179  
   180      function createResponse(
   181        queries: protos.cockroach.ts.tspb.IQuery[],
   182        datapoints: protos.cockroach.ts.tspb.TimeSeriesDatapoint[]  = [],
   183      ) {
   184        return new protos.cockroach.ts.tspb.TimeSeriesQueryResponse({
   185          results: queries.map(query => {
   186            return {
   187              query,
   188              datapoints,
   189            };
   190          }),
   191        });
   192      }
   193  
   194      function createDatapoints(val: number) {
   195        const result: protos.cockroach.ts.tspb.TimeSeriesDatapoint[] = [];
   196        for (let i = 0; i < val; i++) {
   197          result.push(new protos.cockroach.ts.tspb.TimeSeriesDatapoint({
   198            timestamp_nanos: new Long(val),
   199            value: val,
   200          }));
   201        }
   202        return result;
   203      }
   204  
   205      describe("queryMetricsSaga plan", function() {
   206        it("initially waits for incoming request objects", function () {
   207          testSaga(metrics.queryMetricsSaga)
   208            .next()
   209            .take(metrics.REQUEST);
   210        });
   211  
   212        it("correctly accumulates batches", function () {
   213          const requestAction = metrics.requestMetrics("id", createRequest(shortTimespan, "short.1"));
   214          const beginAction = metrics.beginMetrics(requestAction.payload.id, requestAction.payload.data);
   215  
   216          return expectSaga(metrics.queryMetricsSaga)
   217            // Stub out calls to batchAndSendRequests.
   218            .provide([
   219              [matchers.call.fn(metrics.batchAndSendRequests), null],
   220            ])
   221            // Dispatch six requests, with delays inserted in order to trigger
   222            // batch sends.
   223            .dispatch(requestAction)
   224            .dispatch(requestAction)
   225            .dispatch(requestAction)
   226            .delay(0)
   227            .dispatch(requestAction)
   228            .delay(0)
   229            .dispatch(requestAction)
   230            .dispatch(requestAction)
   231            .run()
   232            .then((result) => {
   233              const { effects } = result;
   234              // Verify the order of call dispatches.
   235              assert.deepEqual(
   236                effects.call,
   237                [
   238                  delay(0),
   239                  call(metrics.batchAndSendRequests, [requestAction.payload, requestAction.payload, requestAction.payload]),
   240                  delay(0),
   241                  call(metrics.batchAndSendRequests, [requestAction.payload]),
   242                  delay(0),
   243                  call(metrics.batchAndSendRequests, [requestAction.payload, requestAction.payload]),
   244                ],
   245              );
   246              // Verify that all beginAction puts were dispatched.
   247              assert.deepEqual(
   248                effects.put,
   249                [
   250                  put(beginAction),
   251                  put(beginAction),
   252                  put(beginAction),
   253                  put(beginAction),
   254                  put(beginAction),
   255                  put(beginAction),
   256                ],
   257              );
   258            });
   259        });
   260      });
   261  
   262      describe("batchAndSendRequests", function() {
   263        it("sendBatches correctly batches multiple requests", function () {
   264          const shortRequests = [
   265            metrics.requestMetrics("id", createRequest(shortTimespan, "short.1")).payload,
   266            metrics.requestMetrics("id", createRequest(shortTimespan, "short.2", "short.3")).payload,
   267            metrics.requestMetrics("id", createRequest(shortTimespan, "short.4")).payload,
   268          ];
   269          const longRequests = [
   270            metrics.requestMetrics("id", createRequest(longTimespan, "long.1")).payload,
   271            metrics.requestMetrics("id", createRequest(longTimespan, "long.2", "long.3")).payload,
   272            metrics.requestMetrics("id", createRequest(longTimespan, "long.4", "long.5")).payload,
   273          ];
   274  
   275          // Mix the requests together and send the combined request set.
   276          const mixedRequests = _.flatMap(shortRequests, (short, i) => [short, longRequests[i]]);
   277  
   278          testSaga(metrics.batchAndSendRequests, mixedRequests)
   279            // sendBatches next puts a "fetchMetrics" action into the store.
   280            .next()
   281            .put(metrics.fetchMetrics())
   282            .next()
   283            // Next, sendBatches dispatches a "all" effect with a "call" for each
   284            // batch; there should be two batches in total, one containing the
   285            // short requests and one containing the long requests. The order of
   286            // requests in each batch is maintained.
   287            .all([
   288              call(metrics.sendRequestBatch, shortRequests),
   289              call(metrics.sendRequestBatch, longRequests),
   290            ])
   291            // After completion, puts "fetchMetricsComplete" to store.
   292            .next()
   293            .put(metrics.fetchMetricsComplete())
   294            .next()
   295            .isDone();
   296        });
   297      });
   298  
   299      describe("sendRequestBatch", function() {
   300        const requests = [
   301          metrics.requestMetrics("id1", createRequest(shortTimespan, "short.1")).payload,
   302          metrics.requestMetrics("id2", createRequest(shortTimespan, "short.2", "short.3")).payload,
   303          metrics.requestMetrics("id3", createRequest(shortTimespan, "short.4")).payload,
   304        ];
   305  
   306        it("correctly sends batch as single request, correctly handles valid response", function() {
   307          // The expected request that will be generated by sendRequestBatch.
   308          const expectedRequest = createRequest(shortTimespan, "short.1", "short.2", "short.3", "short.4");
   309          // Return a valid response.
   310          const response = createResponse(expectedRequest.queries);
   311          // Generate the expected put effects to be generated after receiving the response.
   312          const expectedEffects = _.map(requests, req => metrics.receiveMetrics(
   313            req.id, req.data, createResponse(req.data.queries),
   314          ));
   315  
   316          testSaga(metrics.sendRequestBatch, requests)
   317            .next()
   318            .call(queryTimeSeries, expectedRequest)
   319            .next(response)
   320            .put(expectedEffects[0])
   321            .next()
   322            .put(expectedEffects[1])
   323            .next()
   324            .put(expectedEffects[2])
   325            .next()
   326            .isDone();
   327        });
   328  
   329        it("correctly handles error response", function() {
   330          // The expected request that will be generated by sendRequestBatch.
   331          const expectedRequest = createRequest(shortTimespan, "short.1", "short.2", "short.3", "short.4");
   332          // Return an error response.
   333          const err = new Error("network error");
   334          // Generate the expected put effects to be generated after receiving the response.
   335          const expectedEffects = _.map(requests, req => metrics.errorMetrics(
   336            req.id, err,
   337          ));
   338  
   339          testSaga(metrics.sendRequestBatch, requests)
   340            .next()
   341            .call(queryTimeSeries, expectedRequest)
   342            .throw(err)
   343            .put(expectedEffects[0])
   344            .next()
   345            .put(expectedEffects[1])
   346            .next()
   347            .put(expectedEffects[2])
   348            .next()
   349            .isDone();
   350        });
   351      });
   352  
   353      describe("integration test", function() {
   354        const shortRequests = [
   355          metrics.requestMetrics("id.0", createRequest(shortTimespan, "short.1")),
   356          metrics.requestMetrics("id.2", createRequest(shortTimespan, "short.2", "short.3")),
   357          metrics.requestMetrics("id.4", createRequest(shortTimespan, "short.4")),
   358        ];
   359        const longRequests = [
   360          metrics.requestMetrics("id.1", createRequest(longTimespan, "long.1")),
   361          metrics.requestMetrics("id.3", createRequest(longTimespan, "long.2", "long.3")),
   362          metrics.requestMetrics("id.5", createRequest(longTimespan, "long.4", "long.5")),
   363        ];
   364  
   365        const createMetricsState = (
   366          id: string, ts: timespan, metricNames: string[], datapointCount: number,
   367        ): metrics.MetricsQuery => {
   368          const request = createRequest(ts, ...metricNames);
   369          const state = new metrics.MetricsQuery(id);
   370          state.request = request;
   371          state.nextRequest = request;
   372          state.data = createResponse(request.queries, createDatapoints(datapointCount));
   373          state.error = undefined;
   374          return state;
   375        };
   376  
   377        const createMetricsErrorState = (
   378          id: string, ts: timespan, metricNames: string[], err: Error,
   379        ): metrics.MetricsQuery => {
   380          const request = createRequest(ts, ...metricNames);
   381          const state = new metrics.MetricsQuery(id);
   382          state.nextRequest = request;
   383          state.error = err;
   384          return state;
   385        };
   386  
   387        const createMetricsInFlightState = (
   388          id: string, ts: timespan, metricNames: string[],
   389        ): metrics.MetricsQuery => {
   390          const request = createRequest(ts, ...metricNames);
   391          const state = new metrics.MetricsQuery(id);
   392          state.nextRequest = request;
   393          return state;
   394        };
   395  
   396        it("handles success correctly", function() {
   397          const expectedState = new metrics.MetricsState();
   398          expectedState.inFlight = 0;
   399          expectedState.queries = {
   400            "id.0": createMetricsState("id.0", shortTimespan, ["short.1"], 3),
   401            "id.1": createMetricsState("id.1", longTimespan, ["long.1"], 3),
   402            "id.2": createMetricsState("id.2", shortTimespan, ["short.2", "short.3"], 3),
   403            "id.3": createMetricsState("id.3", longTimespan, ["long.2", "long.3"], 3),
   404            "id.4": createMetricsState("id.4", shortTimespan, ["short.4"], 3),
   405            "id.5": createMetricsState("id.5", longTimespan, ["long.4", "long.5"], 3),
   406          };
   407  
   408          return expectSaga(metrics.queryMetricsSaga)
   409            .withReducer(metrics.metricsReducer)
   410            .hasFinalState(expectedState)
   411            .provide({
   412              call(effect, next) {
   413                if (effect.fn === queryTimeSeries) {
   414                  return new Promise((resolve) => {
   415                    setTimeout(
   416                      () => resolve(createResponse((effect.args[0] as TimeSeriesQueryRequestMessage).queries, createDatapoints(3))),
   417                      10,
   418                    );
   419                  });
   420                }
   421                return next();
   422              },
   423            })
   424            .dispatch(shortRequests[0])
   425            .dispatch(longRequests[0])
   426            .dispatch(shortRequests[1])
   427            .delay(0)
   428            .dispatch(longRequests[1])
   429            .dispatch(shortRequests[2])
   430            .dispatch(longRequests[2])
   431            .run();
   432        });
   433  
   434        it("handles errors correctly", function() {
   435          const fakeError = new Error("connection error");
   436  
   437          const expectedState = new metrics.MetricsState();
   438          expectedState.inFlight = 0;
   439          expectedState.queries = {
   440            "id.0": createMetricsState("id.0", shortTimespan, ["short.1"], 3),
   441            "id.1": createMetricsState("id.1", longTimespan, ["long.1"], 3),
   442            "id.2": createMetricsState("id.2", shortTimespan, ["short.2", "short.3"], 3),
   443            "id.3": createMetricsErrorState("id.3", longTimespan, ["long.2", "long.3"], fakeError),
   444            "id.4": createMetricsErrorState("id.4", shortTimespan, ["short.4"], fakeError),
   445            "id.5": createMetricsErrorState("id.5", longTimespan, ["long.4", "long.5"], fakeError),
   446          };
   447  
   448          let callCounter = 0;
   449          return expectSaga(metrics.queryMetricsSaga)
   450            .withReducer(metrics.metricsReducer)
   451            .hasFinalState(expectedState)
   452            .provide({
   453              call(effect, next) {
   454                if (effect.fn === queryTimeSeries) {
   455                  callCounter++;
   456                  if (callCounter > 2) {
   457                    throw fakeError;
   458                  }
   459                  return createResponse((effect.args[0] as TimeSeriesQueryRequestMessage).queries, createDatapoints(3));
   460                }
   461                return next();
   462              },
   463            })
   464            .dispatch(shortRequests[0])
   465            .dispatch(longRequests[0])
   466            .dispatch(shortRequests[1])
   467            .delay(0)
   468            .dispatch(longRequests[1])
   469            .dispatch(shortRequests[2])
   470            .dispatch(longRequests[2])
   471            .run();
   472        });
   473  
   474        it("handles inflight counter correctly", function() {
   475          const expectedState = new metrics.MetricsState();
   476          expectedState.inFlight = 1;
   477          expectedState.queries = {
   478            "id.0": createMetricsState("id.0", shortTimespan, ["short.1"], 3),
   479            "id.1": createMetricsState("id.1", longTimespan, ["long.1"], 3),
   480            "id.2": createMetricsState("id.2", shortTimespan, ["short.2", "short.3"], 3),
   481            "id.3": createMetricsInFlightState("id.3", longTimespan, ["long.2", "long.3"]),
   482            "id.4": createMetricsInFlightState("id.4", shortTimespan, ["short.4"]),
   483            "id.5": createMetricsInFlightState("id.5", longTimespan, ["long.4", "long.5"]),
   484          };
   485  
   486          let callCounter = 0;
   487          return expectSaga(metrics.queryMetricsSaga)
   488            .withReducer(metrics.metricsReducer)
   489            .hasFinalState(expectedState)
   490            .provide({
   491              call(effect, next) {
   492                if (effect.fn === queryTimeSeries) {
   493                  callCounter++;
   494                  if (callCounter > 2) {
   495                    // return a promise that never resolves.
   496                    return new Promise((_resolve) => {});
   497                  }
   498                  return createResponse((effect.args[0] as TimeSeriesQueryRequestMessage).queries, createDatapoints(3));
   499                }
   500                return next();
   501              },
   502            })
   503            .dispatch(shortRequests[0])
   504            .dispatch(longRequests[0])
   505            .dispatch(shortRequests[1])
   506            .delay(0)
   507            .dispatch(longRequests[1])
   508            .dispatch(shortRequests[2])
   509            .dispatch(longRequests[2])
   510            .run();
   511        });
   512      });
   513    });
   514  });