Skip to content

Commit

Permalink
Merge pull request metabase#5993 from metabase/xray-async
Browse files Browse the repository at this point in the history
Async X-ray computation
  • Loading branch information
salsakran authored Oct 13, 2017
2 parents 4e8e5d5 + 380fc27 commit 0fab395
Show file tree
Hide file tree
Showing 24 changed files with 762 additions and 235 deletions.
179 changes: 179 additions & 0 deletions frontend/src/metabase/lib/request.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { AsyncApi } from "metabase/services";

export class RestfulRequest {
// API endpoint that is used for the request
endpoint = null

// Prefix for request Redux actions
actionPrefix = null

// Name of the request result property
// In general, using the default value `result` is good for consistency
// but using an existing prop name (like `xray` or `dashboard`) temporarily
// can make the migration process from old implementation to this request API a lot easier
resultPropName = 'result'

constructor({ endpoint, actionPrefix, resultPropName } = {}) {
this.endpoint = endpoint
this.actionPrefix = actionPrefix
this.resultPropName = resultPropName || this.resultPropName

this.actions = {
requestStarted: `${this.actionPrefix}/REQUEST_STARTED`,
requestSuccessful: `${this.actionPrefix}/REQUEST_SUCCESSFUL`,
requestFailed: `${this.actionPrefix}/REQUEST_FAILED`,
resetRequest: `${this.actionPrefix}/REQUEST_RESET`
}
}

// Triggers the request; modelled as a Redux thunk action so wrap this to `dispatch()` call
trigger = (params) =>
async (dispatch) => {
dispatch.action(this.actions.requestStarted)
try {
const result = await this.endpoint(params)
dispatch.action(this.actions.requestSuccessful, { result })
} catch(error) {
console.error(error)
dispatch.action(this.actions.requestFailed, { error })
}

}

reset = () => (dispatch) => dispatch(this.actions.reset)

getReducers = () => ({
[this.actions.requestStarted]: (state) => ({...state, loading: true}),
[this.actions.requestSuccessful]: (state, { payload: { result }}) => ({
...state,
[this.resultPropName]: result,
loading: false,
fetched: true
}),
[this.actions.requestFailed]: (state, { payload: { error } }) => ({
...state,
loading: false,
error: error
}),
[this.actions.resetRequest]: (state) => ({ ...state, ...this.getDefaultState() })
})

getDefaultState = () => ({
[this.resultPropName]: null,
loading: false,
fetched: false,
error: null
})
}

const POLLING_INTERVAL = 100

export class BackgroundJobRequest {
// API endpoint that creates a new background job
creationEndpoint = null

// Prefix for request Redux actions
actionPrefix = null

// Name of the request result property
// In general, using the default value `result` is good for consistency
// but using an existing prop name (like `xray` or `dashboard`) temporarily
// can make the migration process from old implementation to this request API a lot easier
resultPropName = 'result'

pollingTimeoutId = null

constructor({ creationEndpoint, actionPrefix, resultPropName } = {}) {
this.creationEndpoint = creationEndpoint
this.actionPrefix = actionPrefix
this.resultPropName = resultPropName || this.resultPropName

this.actions = {
requestStarted: `${this.actionPrefix}/REQUEST_STARTED`,
requestSuccessful: `${this.actionPrefix}/REQUEST_SUCCESSFUL`,
requestFailed: `${this.actionPrefix}/REQUEST_FAILED`,
resetRequest: `${this.actionPrefix}/REQUEST_RESET`
}
}

// Triggers the request; modelled as a Redux thunk action so wrap this to `dispatch()` call
trigger = (params) => {
return async (dispatch) => {
dispatch.action(this.actions.requestStarted)

try {
const newJobId = await this._createNewJob(params)
const result = await this._pollForResult(newJobId)
dispatch.action(this.actions.requestSuccessful, { result })
} catch(error) {
console.error(error)
dispatch.action(this.actions.requestFailed, { error })
}
}
}

_createNewJob = async (requestParams) => {
return (await this.creationEndpoint(requestParams))["job-id"]
}

_pollForResult = (jobId) => {
if (this.pollingTimeoutId) {
clearTimeout(this.pollingTimeoutId);
}

return new Promise((resolve, reject) => {
const poll = async () => {
try {
const response = await AsyncApi.status({ jobId })

if (response.status === 'done') {
resolve(response.result)
} else if (response.status === 'result-not-available') {
// The job result has been deleted; this is an unexpected state as we just
// created the job so simply throw a descriptive error
reject(new ResultNoAvailableError())
} else {
this.pollingTimeoutId = setTimeout(poll, POLLING_INTERVAL)
}
} catch (error) {
this.pollingTimeoutId = null
reject(error)
}
}

poll()
})
}

reset = () => (dispatch) => dispatch(this.actions.reset)

getReducers = () => ({
[this.actions.requestStarted]: (state) => ({...state, loading: true}),
[this.actions.requestSuccessful]: (state, { payload: { result }}) => ({
...state,
[this.resultPropName]: result,
loading: false,
fetched: true
}),
[this.actions.requestFailed]: (state, { payload: { error } }) => ({
...state,
loading: false,
error: error
}),
[this.actions.resetRequest]: (state) => ({ ...state, ...this.getDefaultState() })
})

getDefaultState = () => ({
[this.resultPropName]: null,
loading: false,
fetched: false,
error: null
})
}

class ResultNoAvailableError extends Error {
constructor() {
super()
this.message = "Background job result isn't available for an unknown reason"
}
}
17 changes: 12 additions & 5 deletions frontend/src/metabase/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,20 +155,27 @@ export const MetabaseApi = {
dataset_duration: POST("/api/dataset/duration")
};

export const AsyncApi = {
status: GET("/api/async/:jobId"),
// endpoints: GET("/api/async/running-jobs")
}

export const XRayApi = {
// X-Rays
// NOTE Atte Keinänen 9/28/17: All xrays endpoints are asynchronous.
// You should use BackgroundJobRequest in `metabase/lib/promise` for invoking them.
field_xray: GET("/api/x-ray/field/:fieldId"),
table_xray: GET("/api/x-ray/table/:tableId"),
segment_xray: GET("/api/x-ray/segment/:segmentId"),
card_xray: GET("/api/x-ray/card/:cardId"),

field_compare: GET("/api/x-ray/compare/fields/:fieldId1/:fieldId2"),
table_compare: GET("/api/x-ray/compare/tables/:tableId1/:tableId2"),
segment_compare: GET("/api/x-ray/compare/segments/:segmentId1/:segmentId2"),
field_compare: GET("/api/x-ray/compare/field/:fieldId1/field/:fieldId2"),
table_compare: GET("/api/x-ray/compare/table/:tableId1/table/:tableId2"),
segment_compare: GET("/api/x-ray/compare/segment/:segmentId1/segment/:segmentId2"),
segment_table_compare: GET("/api/x-ray/compare/segment/:segmentId/table/:tableId"),
segment_field_compare: GET("/api/x-ray/compare/segments/:segmentId1/:segmentId2/field/:fieldName"),
segment_field_compare: GET("/api/x-ray/compare/segment/:segmentId1/segment/:segmentId2/field/:fieldName"),
segment_table_field_compare: GET("/api/x-ray/compare/segment/:segmentId/table/:tableId/field/:fieldName"),
card_compare: GET("/api/x-ray/compare/cards/:cardId1/:cardId2")
card_compare: GET("/api/x-ray/compare/card/:cardId1/card/:cardId2")
};

export const PulseApi = {
Expand Down
20 changes: 11 additions & 9 deletions frontend/src/metabase/xray/containers/CardXRay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { connect } from 'react-redux'

import { saturated } from 'metabase/lib/colors'

import { fetchXray, initialize } from 'metabase/xray/xray'
import { fetchCardXray, initialize } from 'metabase/xray/xray'
import {
getLoadingStatus,
getError,
getXray
getXray,
getIsAlreadyFetched
} from 'metabase/xray/selectors'

import { hasXray, xrayLoadingMessages } from 'metabase/xray/utils'
import { xrayLoadingMessages } from 'metabase/xray/utils'

import Icon from 'metabase/components/Icon'
import Tooltip from 'metabase/components/Tooltip'
Expand All @@ -25,18 +26,19 @@ import LoadingAnimation from 'metabase/xray/components/LoadingAnimation'
const mapStateToProps = state => ({
xray: getXray(state),
isLoading: getLoadingStatus(state),
isAlreadyFetched: getIsAlreadyFetched(state),
error: getError(state)
})

const mapDispatchToProps = {
initialize,
fetchXray
fetchCardXray
}

type Props = {
initialize: () => void,
initialize: () => {},
fetchXray: () => void,
fetchCardXray: () => void,
isLoading: boolean,
xray: {}
}
Expand All @@ -63,13 +65,12 @@ const GrowthRateDisplay = ({ period }) =>
</div>

class CardXRay extends Component {

props: Props

componentWillMount () {
const { cardId, cost } = this.props.params
this.props.initialize()
this.props.fetchXray('card', cardId, cost)
this.props.fetchCardXray(cardId, cost)
}

componentWillUnmount() {
Expand All @@ -80,10 +81,11 @@ class CardXRay extends Component {
}

render () {
const { xray, isLoading, error } = this.props
const { xray, isLoading, isAlreadyFetched, error } = this.props

return (
<LoadingAndErrorWrapper
loading={isLoading || !hasXray(xray)}
loading={isLoading || !isAlreadyFetched}
error={error}
noBackground
loadingMessages={xrayLoadingMessages}
Expand Down
20 changes: 11 additions & 9 deletions frontend/src/metabase/xray/containers/FieldXray.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import title from 'metabase/hoc/Title'
import { Link } from 'react-router'

import { isDate } from 'metabase/lib/schema_metadata'
import { fetchXray, initialize } from 'metabase/xray/xray'
import { fetchFieldXray, initialize } from 'metabase/xray/xray'
import {
getLoadingStatus,
getError,
getFeatures
getFeatures, getIsAlreadyFetched
} from 'metabase/xray/selectors'

import {
Expand All @@ -26,7 +26,7 @@ import StatGroup from 'metabase/xray/components/StatGroup'
import Histogram from 'metabase/xray/Histogram'
import { Heading, XRayPageWrapper } from 'metabase/xray/components/XRayLayout'

import { hasXray, xrayLoadingMessages } from 'metabase/xray/utils'
import { xrayLoadingMessages } from 'metabase/xray/utils'

import Periodicity from 'metabase/xray/components/Periodicity'
import LoadingAnimation from 'metabase/xray/components/LoadingAnimation'
Expand All @@ -35,9 +35,10 @@ import type { Field } from 'metabase/meta/types/Field'
import type { Table } from 'metabase/meta/types/Table'

type Props = {
fetchXray: () => void,
fetchFieldXray: () => void,
initialize: () => {},
isLoading: boolean,
isAlreadyFetched: boolean,
xray: {
table: Table,
field: Field,
Expand All @@ -55,12 +56,13 @@ type Props = {
const mapStateToProps = state => ({
xray: getFeatures(state),
isLoading: getLoadingStatus(state),
isAlreadyFetched: getIsAlreadyFetched(state),
error: getError(state)
})

const mapDispatchToProps = {
initialize,
fetchXray
fetchFieldXray
}

@connect(mapStateToProps, mapDispatchToProps)
Expand All @@ -81,8 +83,8 @@ class FieldXRay extends Component {
}

fetch() {
const { params, fetchXray } = this.props
fetchXray('field', params.fieldId, params.cost)
const { params, fetchFieldXray } = this.props
fetchFieldXray(params.fieldId, params.cost)
}

componentDidUpdate (prevProps: Props) {
Expand All @@ -92,11 +94,11 @@ class FieldXRay extends Component {
}

render () {
const { xray, params, isLoading, error } = this.props
const { xray, params, isLoading, isAlreadyFetched, error } = this.props

return (
<LoadingAndErrorWrapper
loading={isLoading || !hasXray(xray)}
loading={isLoading || !isAlreadyFetched}
error={error}
noBackground
loadingMessages={xrayLoadingMessages}
Expand Down
Loading

0 comments on commit 0fab395

Please sign in to comment.