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 }