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 }