Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically update metric plots for in-progress runs #2099 #5017

Merged
merged 21 commits into from Dec 2, 2021
Merged
@@ -1,9 +1,9 @@
import React from 'react';
import _ from 'lodash';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { Radio, Switch, TreeSelect, Tooltip } from 'antd';
import { Radio, Switch, TreeSelect, Tooltip, Progress } from 'antd';
import PropTypes from 'prop-types';
import { CHART_TYPE_LINE } from './MetricsPlotPanel';
import { CHART_TYPE_LINE, METRICS_PLOT_POLLING_INTERVAL_MS } from './MetricsPlotPanel';
import { LineSmoothSlider } from './LineSmoothSlider';

import { FormattedMessage, injectIntl } from 'react-intl';
Expand Down Expand Up @@ -31,6 +31,8 @@ export class MetricsPlotControlsImpl extends React.Component {
yAxisLogScale: PropTypes.bool.isRequired,
showPoint: PropTypes.bool.isRequired,
intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired }).isRequired,
numRuns: PropTypes.number.isRequired,
numCompletedRuns: PropTypes.number.isRequired,
};

handleMetricsSelectFilterChange = (text, option) =>
Expand All @@ -46,7 +48,14 @@ export class MetricsPlotControlsImpl extends React.Component {
};

render() {
const { chartType, yAxisLogScale, initialLineSmoothness, showPoint } = this.props;
const {
chartType,
yAxisLogScale,
initialLineSmoothness,
showPoint,
numRuns,
numCompletedRuns,
} = this.props;
const wrapperStyle = chartType === CHART_TYPE_LINE ? styles.linechartControlsWrapper : {};
const lineSmoothnessTooltipText = (
<FormattedMessage
Expand All @@ -55,10 +64,35 @@ export class MetricsPlotControlsImpl extends React.Component {
description='Helpful tooltip message to help with line smoothness for the metrics plot'
/>
);
const completedRunsTooltipText = (
<FormattedMessage
// eslint-disable-next-line max-len
defaultMessage='MLflow UI automatically fetches metric histories for active runs and updates the metrics plot with a {interval} second interval.'
description='Helpful tooltip message to explain the automatic metrics plot update'
values={{ interval: Math.round(METRICS_PLOT_POLLING_INTERVAL_MS / 1000) }}
/>
);
return (
<div className='plot-controls' style={wrapperStyle}>
{chartType === CHART_TYPE_LINE ? (
<div>
<div className='inline-control'>
<div className='control-label'>
<FormattedMessage
defaultMessage='Completed Runs'
// eslint-disable-next-line max-len
description='Label for the progress bar to show the number of completed runs'
/>{' '}
<Tooltip title={completedRunsTooltipText}>
<QuestionCircleOutlined />
</Tooltip>
<Progress
percent={Math.round((100 * numCompletedRuns) / numRuns)}
format={() => `${numCompletedRuns}/${numRuns}`}
status='normal'
/>
</div>
</div>
<div className='inline-control'>
<div className='control-label'>
<FormattedMessage
Expand Down
Expand Up @@ -2,11 +2,11 @@ import React from 'react';
import { connect } from 'react-redux';
import Utils from '../../common/utils/Utils';
import RequestStateWrapper from '../../common/components/RequestStateWrapper';
import { getMetricHistoryApi } from '../actions';
import { getMetricHistoryApi, getRunApi } from '../actions';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { MetricsPlotView } from './MetricsPlotView';
import { getRunTags } from '../reducers/Reducers';
import { getRunTags, getRunInfo } from '../reducers/Reducers';
import {
MetricsPlotControls,
X_AXIS_WALL,
Expand All @@ -22,10 +22,16 @@ import { getUUID } from '../../common/utils/ActionUtils';
export const CHART_TYPE_LINE = 'line';
export const CHART_TYPE_BAR = 'bar';

export const METRICS_PLOT_POLLING_INTERVAL_MS = 10 * 1000; // 10 seconds
// A run is considered as 'hanging' if its status is 'RUNNING' but its latest metric was logged
// prior to this threshold. The metrics plot doesn't automatically update hanging runs.
export const METRICS_PLOT_HANGING_RUN_THRESHOLD_MS = 3600 * 24 * 7 * 1000; // 1 week
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does "hanging" make sense?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep!


export class MetricsPlotPanel extends React.Component {
static propTypes = {
experimentId: PropTypes.string.isRequired,
runUuids: PropTypes.arrayOf(PropTypes.string).isRequired,
completedRunUuids: PropTypes.arrayOf(PropTypes.string).isRequired,
metricKey: PropTypes.string.isRequired,
// A map of { runUuid : { metricKey: value } }
latestMetricsByRunUuid: PropTypes.object.isRequired,
Expand All @@ -34,6 +40,7 @@ export class MetricsPlotPanel extends React.Component {
// An array of { metricKey, history, runUuid, runDisplayName }
metricsWithRunInfoAndHistory: PropTypes.arrayOf(PropTypes.object).isRequired,
getMetricHistoryApi: PropTypes.func.isRequired,
getRunApi: PropTypes.func.isRequired,
location: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
runDisplayNames: PropTypes.arrayOf(PropTypes.string).isRequired,
Expand Down Expand Up @@ -71,11 +78,85 @@ export class MetricsPlotPanel extends React.Component {
popoverX: 0,
popoverY: 0,
popoverRunItems: [],
focused: true,
};
this.displayPopover = false;
this.intervalId = null;
this.loadMetricHistory(this.props.runUuids, this.getUrlState().selectedMetricKeys);
}

onFocus = () => {
this.setState({ focused: true });
};

onBlur = () => {
this.setState({ focused: false });
};

clearEventListeners = () => {
// `window.removeEventListener` does nothing when called with an unregistered event listener:
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
window.removeEventListener('focus', this.onFocus);
window.removeEventListener('blur', this.onBlur);
};

clearInterval = () => {
// `clearInterval` does nothing when called with `null` or `undefine`:
// https://www.w3.org/TR/2011/WD-html5-20110525/timers.html#dom-windowtimers-cleartimeout
clearInterval(this.intervalId);
};

allRunsCompleted = () => {
return this.props.completedRunUuids.length === this.props.runUuids.length;
};

isHangingRunUuid = (activeRunUuid) => {
const metrics = this.props.latestMetricsByRunUuid[activeRunUuid];
if (!metrics) {
return false;
}
const timestamps = Object.values(metrics).map(({ timestamp }) => timestamp);
const latestTimestamp = Math.max(...timestamps);
return new Date().getTime() - latestTimestamp > METRICS_PLOT_HANGING_RUN_THRESHOLD_MS;
};

getActiveRunUuids = () => {
const { completedRunUuids, runUuids } = this.props;
const activeRunUuids = _.difference(runUuids, completedRunUuids);
return activeRunUuids.filter(_.negate(this.isHangingRunUuid)); // Exclude hanging runs
};

shouldPoll = () => {
return !(this.allRunsCompleted() || this.getActiveRunUuids().length === 0);
};

componentDidMount() {
if (this.shouldPoll()) {
// Set event listeners to detect when this component gains/loses focus,
// e.g., a user switches to a different browser tab or app.
window.addEventListener('blur', this.onBlur);
window.addEventListener('focus', this.onFocus);
this.intervalId = setInterval(() => {
// Skip polling if this component is out of focus.
if (this.state.focused) {
const activeRunUuids = this.getActiveRunUuids();
this.loadMetricHistory(activeRunUuids, this.getUrlState().selectedMetricKeys);
this.loadRuns(activeRunUuids);

if (!this.shouldPoll()) {
this.clearEventListeners();
this.clearInterval();
}
}
}, METRICS_PLOT_POLLING_INTERVAL_MS);
}
}

componentWillUnmount() {
this.clearEventListeners();
this.clearInterval();
}

getUrlState() {
return Utils.getMetricPlotStateFromUrl(this.props.location.search);
}
Expand Down Expand Up @@ -149,6 +230,16 @@ export class MetricsPlotPanel extends React.Component {
return requestIds;
};

loadRuns = (runUuids) => {
const requestIds = [];
runUuids.forEach((runUuid) => {
const id = getUUID();
this.props.getRunApi(runUuid);
requestIds.push(id);
});
return requestIds;
};

getMetrics = () => {
/* eslint-disable no-param-reassign */
const state = this.getUrlState();
Expand Down Expand Up @@ -471,6 +562,8 @@ export class MetricsPlotPanel extends React.Component {
return (
<div className='metrics-plot-container'>
<MetricsPlotControls
numRuns={this.props.runUuids.length}
numCompletedRuns={this.props.completedRunUuids.length}
distinctMetricKeys={distinctMetricKeys}
selectedXAxis={selectedXAxis}
selectedMetricKeys={selectedMetricKeys}
Expand Down Expand Up @@ -526,6 +619,9 @@ export class MetricsPlotPanel extends React.Component {

const mapStateToProps = (state, ownProps) => {
const { runUuids } = ownProps;
const completedRunUuids = runUuids.filter(
(runUuid) => getRunInfo(runUuid, state).status !== 'RUNNING',
);
const { latestMetricsByRunUuid, metricsByRunUuid } = state.entities;

// All metric keys from all runUuids, non-distinct
Expand All @@ -534,7 +630,6 @@ const mapStateToProps = (state, ownProps) => {
return latestMetrics ? Object.keys(latestMetrics) : [];
});
const distinctMetricKeys = [...new Set(metricKeys)].sort();

const runDisplayNames = [];

// Flat array of all metrics, with history and information of the run it belongs to
Expand All @@ -561,9 +656,10 @@ const mapStateToProps = (state, ownProps) => {
latestMetricsByRunUuid,
distinctMetricKeys,
metricsWithRunInfoAndHistory,
completedRunUuids,
};
};

const mapDispatchToProps = { getMetricHistoryApi };
const mapDispatchToProps = { getMetricHistoryApi, getRunApi };

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(MetricsPlotPanel));