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 });