Skip to content

Commit

Permalink
[dagit] Use a custom hook to manage query polling (#6805)
Browse files Browse the repository at this point in the history
* [dagit] Use a custom hook to manage query polling

* PR feedback
  • Loading branch information
bengotow committed Mar 2, 2022
1 parent e6fb91b commit 9b08239
Show file tree
Hide file tree
Showing 18 changed files with 233 additions and 87 deletions.
28 changes: 0 additions & 28 deletions js_modules/dagit/packages/core/src/app/QueryCountdown.tsx

This file was deleted.

125 changes: 125 additions & 0 deletions js_modules/dagit/packages/core/src/app/QueryRefresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {NetworkStatus, ObservableQuery, QueryResult} from '@apollo/client';
import {useCountdown, RefreshableCountdown} from '@dagster-io/ui';
import * as React from 'react';

import {useDocumentVisibility} from '../hooks/useDocumentVisibility';

export const FIFTEEN_SECONDS = 15 * 1000;
export const ONE_MONTH = 30 * 24 * 60 * 60 * 1000;

export interface QueryRefreshState {
nextFireMs: number | null | undefined;
nextFireDelay: number; // seconds
networkStatus: NetworkStatus;
refetch: ObservableQuery['refetch'];
}

/**
* The default pollInterval feature of Apollo's useQuery is fine, but we want to add two features:
*
* - If you switch tabs in Chrome and the document is no longer visible, don't refresh anything.
* Just refresh immediately when you click back to the tab.
* - If a request takes more than 1/4 of the requested poll interval (eg: an every-20s query takes 5s),
* poll more slowly.
*
* You can choose to use this hook alone (no UI) or pass the returned refreshState object to
* <QueryRefreshCountdown /> to display the refresh status.
*
*/
export function useQueryRefreshAtInterval(queryResult: QueryResult<any, any>, intervalMs: number) {
const timer = React.useRef<number>();
const loadingStartMs = React.useRef<number>();
const [nextFireMs, setNextFireMs] = React.useState<number | null>();

const queryResultRef = React.useRef(queryResult);
queryResultRef.current = queryResult;

// Sanity check - don't use this hook alongside a useQuery pollInterval
if (queryResult.networkStatus === NetworkStatus.poll) {
throw new Error(
'useQueryRefreshAtInterval is meant to replace useQuery({pollInterval}). Remove the pollInterval!',
);
}

// If the page is in the background when our refresh timer fires, we set
// documentVisiblityDidInterrupt = true. When the document becomes visible again,
// this effect triggers an immediate out-of-interval refresh.
const documentVisiblityDidInterrupt = React.useRef(false);
const documentVisible = useDocumentVisibility();

React.useEffect(() => {
if (documentVisible && documentVisiblityDidInterrupt.current) {
queryResultRef.current?.refetch();
documentVisiblityDidInterrupt.current = false;
}
}, [documentVisible]);

React.useEffect(() => {
clearTimeout(timer.current);

// If the query has just transitioned to a `loading` state, capture the current
// time so we can compute the elapsed time when the query completes, and exit.
if (queryResult.loading) {
loadingStartMs.current = loadingStartMs.current || Date.now();
return;
}

// If the query is no longer `loading`, determine elapsed time and decide
// when to refresh. If the query took > 1/4 the desired interval, delay
// the next tick to give the server some slack.
const queryDurationMs = loadingStartMs.current ? Date.now() - loadingStartMs.current : 0;
const adjustedIntervalMs = Math.max(intervalMs, queryDurationMs * 4);

// To test that the UI reflects the next fire date correctly, try this:
// const adjustedIntervalMs = Math.max(3, Math.random() * 30) * 1000;

setNextFireMs(Date.now() + adjustedIntervalMs);
loadingStartMs.current = undefined;

// Schedule the next refretch
timer.current = window.setTimeout(() => {
if (document.hidden) {
// If the document is no longer visible, mark that we have skipped an update rather
// then updating in the background. We'll refresh when we return to the foreground.
documentVisiblityDidInterrupt.current = true;
return;
}
queryResultRef.current?.refetch();
}, adjustedIntervalMs);

return () => {
clearTimeout(timer.current);
};
}, [queryResult.loading, intervalMs]);

// Expose the next fire time both as a unix timstamp and as a "seconds" interval
// so the <QueryRefreshCountdown> can display the number easily.
const nextFireDelay = React.useMemo(() => (nextFireMs ? nextFireMs - Date.now() : -1), [
nextFireMs,
]);

// Memoize the returned object so components passed the entire QueryRefreshState
// can be memoized / pure components.
return React.useMemo<QueryRefreshState>(
() => ({
nextFireMs,
nextFireDelay,
networkStatus: queryResult.networkStatus,
refetch: queryResult.refetch,
}),
[nextFireMs, nextFireDelay, queryResult.networkStatus, queryResult.refetch],
);
}

export const QueryRefreshCountdown = ({refreshState}: {refreshState: QueryRefreshState}) => {
const status = refreshState.networkStatus === NetworkStatus.ready ? 'counting' : 'idle';
const timeRemaining = useCountdown({duration: refreshState.nextFireDelay, status});

return (
<RefreshableCountdown
refreshing={status === 'idle' || timeRemaining === 0}
seconds={Math.floor(timeRemaining / 1000)}
onRefresh={() => refreshState.refetch()}
/>
);
};
13 changes: 9 additions & 4 deletions js_modules/dagit/packages/core/src/assets/AssetView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import {gql, useQuery} from '@apollo/client';
import {Alert, Box, ButtonLink, ColorsWIP, NonIdealState, Spinner, Tab, Tabs} from '@dagster-io/ui';
import * as React from 'react';

import {QueryCountdown} from '../app/QueryCountdown';
import {
FIFTEEN_SECONDS,
QueryRefreshCountdown,
useQueryRefreshAtInterval,
} from '../app/QueryRefresh';
import {displayNameForAssetKey} from '../app/Util';
import {Timestamp} from '../app/time/Timestamp';
import {useDocumentTitle} from '../hooks/useDocumentTitle';
Expand Down Expand Up @@ -46,7 +50,6 @@ export const AssetView: React.FC<Props> = ({assetKey}) => {
const queryResult = useQuery<AssetQuery, AssetQueryVariables>(ASSET_QUERY, {
variables: {assetKey: {path: assetKey.path}},
notifyOnNetworkStatusChange: true,
pollInterval: 5 * 1000,
});

// Refresh immediately when a run is launched from this page
Expand All @@ -73,9 +76,11 @@ export const AssetView: React.FC<Props> = ({assetKey}) => {
},
},
notifyOnNetworkStatusChange: true,
pollInterval: 15 * 1000,
});

const refreshState = useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS);
useQueryRefreshAtInterval(inProgressRunsQuery, FIFTEEN_SECONDS);

let liveDataByNode: LiveData = {};

if (definition) {
Expand Down Expand Up @@ -123,7 +128,7 @@ export const AssetView: React.FC<Props> = ({assetKey}) => {
right={
<Box style={{margin: '-4px 0'}} flex={{gap: 8, alignItems: 'baseline'}}>
<Box margin={{top: 4}}>
<QueryCountdown pollInterval={5 * 1000} queryResult={queryResult} />
<QueryRefreshCountdown refreshState={refreshState} />
</Box>
{definition && definition.jobNames.length > 0 && repoAddress && (
<LaunchAssetExecutionButton
Expand Down
11 changes: 8 additions & 3 deletions js_modules/dagit/packages/core/src/assets/AssetsCatalogTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import styled from 'styled-components/macro';

import {Box, CursorPaginationControls, CursorPaginationProps, TextInput} from '../../../ui/src';
import {PythonErrorInfo, PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorInfo';
import {QueryCountdown} from '../app/QueryCountdown';
import {
FIFTEEN_SECONDS,
QueryRefreshCountdown,
useQueryRefreshAtInterval,
} from '../app/QueryRefresh';
import {tokenForAssetKey} from '../app/Util';
import {useDocumentTitle} from '../hooks/useDocumentTitle';
import {useQueryPersistedState} from '../hooks/useQueryPersistedState';
import {RepoFilterButton} from '../instance/RepoFilterButton';
import {POLL_INTERVAL} from '../runs/useCursorPaginatedQuery';
import {Loading} from '../ui/Loading';
import {DagsterRepoOption, WorkspaceContext} from '../workspace/WorkspaceContext';
import {buildRepoPath} from '../workspace/buildRepoAddress';
Expand Down Expand Up @@ -59,6 +62,8 @@ export const AssetsCatalogTable: React.FC<{prefixPath?: string[]}> = ({prefixPat
skip: view === 'graph',
});

const refreshState = useQueryRefreshAtInterval(assetsQuery, FIFTEEN_SECONDS);

if (view === 'graph') {
return <Redirect to="/instance/asset-graph" />;
}
Expand Down Expand Up @@ -123,7 +128,7 @@ export const AssetsCatalogTable: React.FC<{prefixPath?: string[]}> = ({prefixPat
placeholder="Search all asset_keys..."
onChange={(e: React.ChangeEvent<any>) => setSearch(e.target.value)}
/>
<QueryCountdown pollInterval={POLL_INTERVAL} queryResult={assetsQuery} />
<QueryRefreshCountdown refreshState={refreshState} />
</>
}
prefixPath={prefixPath || []}
Expand Down
16 changes: 16 additions & 0 deletions js_modules/dagit/packages/core/src/hooks/useDocumentVisibility.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';

export function useDocumentVisibility() {
const [documentVisible, setDocumentVisible] = React.useState(true);
React.useEffect(() => {
const handler = () => {
setDocumentVisible(!document.hidden);
};
document.addEventListener('visibilitychange', handler);
return () => {
document.removeEventListener('visibilitychange', handler);
};
});

return documentVisible;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {showCustomAlert} from '../app/CustomAlertProvider';
import {SharedToaster} from '../app/DomUtils';
import {usePermissions} from '../app/Permissions';
import {PythonErrorInfo, PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorInfo';
import {FIFTEEN_SECONDS, useQueryRefreshAtInterval} from '../app/QueryRefresh';
import {useDocumentTitle} from '../hooks/useDocumentTitle';
import {PipelineReference} from '../pipelines/PipelineReference';
import {
Expand Down Expand Up @@ -85,13 +86,14 @@ export const InstanceBackfills = () => {
? result.partitionBackfillsOrError.results
: [],
});
const refreshState = useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS);
useDocumentTitle('Backfills');

return (
<>
<PageHeader
title={<Heading>Instance status</Heading>}
tabs={<InstanceTabs tab="backfills" queryData={queryData} />}
tabs={<InstanceTabs tab="backfills" refreshState={refreshState} />}
/>
<Loading queryResult={queryResult} allowStaleData={true}>
{({partitionBackfillsOrError}) => {
Expand Down
11 changes: 9 additions & 2 deletions js_modules/dagit/packages/core/src/instance/InstanceConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import * as React from 'react';
import {Link, useHistory} from 'react-router-dom';
import styled, {createGlobalStyle, css} from 'styled-components/macro';

import {useQueryRefreshAtInterval, FIFTEEN_SECONDS} from '../app/QueryRefresh';

import {InstanceTabs} from './InstanceTabs';
import {InstanceConfigQuery} from './types/InstanceConfigQuery';

Expand All @@ -40,10 +42,12 @@ const YamlShimStyle = createGlobalStyle`

export const InstanceConfig = React.memo(() => {
const history = useHistory();
const {data} = useQuery<InstanceConfigQuery>(INSTANCE_CONFIG_QUERY, {
const queryResult = useQuery<InstanceConfigQuery>(INSTANCE_CONFIG_QUERY, {
fetchPolicy: 'cache-and-network',
});
const [hash, setHash] = React.useState(() => document.location.hash);
const refreshState = useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS);
const {data} = queryResult;

React.useEffect(() => {
// Once data has finished loading and rendering, scroll to hash
Expand Down Expand Up @@ -80,7 +84,10 @@ export const InstanceConfig = React.memo(() => {

return (
<>
<PageHeader title={<Heading>Instance status</Heading>} tabs={<InstanceTabs tab="config" />} />
<PageHeader
title={<Heading>Instance status</Heading>}
tabs={<InstanceTabs tab="config" refreshState={refreshState} />}
/>
<Box
padding={{vertical: 16, horizontal: 24}}
border={{side: 'bottom', width: 1, color: ColorsWIP.KeylineGray}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {gql, useQuery} from '@apollo/client';
import {Box, ColorsWIP, PageHeader, Heading, Subheading} from '@dagster-io/ui';
import * as React from 'react';

import {POLL_INTERVAL} from '../runs/useCursorPaginatedQuery';
import {FIFTEEN_SECONDS, useQueryRefreshAtInterval} from '../app/QueryRefresh';

import {DaemonList} from './DaemonList';
import {INSTANCE_HEALTH_FRAGMENT} from './InstanceHealthFragment';
Expand All @@ -12,10 +12,9 @@ import {InstanceHealthQuery} from './types/InstanceHealthQuery';
export const InstanceHealthPage = () => {
const queryData = useQuery<InstanceHealthQuery>(INSTANCE_HEALTH_QUERY, {
fetchPolicy: 'cache-and-network',
pollInterval: POLL_INTERVAL,
notifyOnNetworkStatusChange: true,
});

const refreshState = useQueryRefreshAtInterval(queryData, FIFTEEN_SECONDS);
const {loading, data} = queryData;

const daemonContent = () => {
Expand All @@ -35,7 +34,7 @@ export const InstanceHealthPage = () => {
<>
<PageHeader
title={<Heading>Instance status</Heading>}
tabs={<InstanceTabs tab="health" queryData={queryData} />}
tabs={<InstanceTabs tab="health" refreshState={refreshState} />}
/>
<Box padding={{vertical: 16, horizontal: 24}}>
<Subheading>Daemon statuses</Subheading>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import * as React from 'react';

import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorInfo';
import {QueryCountdown} from '../app/QueryCountdown';
import {FIFTEEN_SECONDS, useQueryRefreshAtInterval} from '../app/QueryRefresh';
import {ScheduleOrSensorTag} from '../nav/JobMetadata';
import {LegacyPipelineTag} from '../pipelines/LegacyPipelineTag';
import {PipelineReference} from '../pipelines/PipelineReference';
Expand Down Expand Up @@ -100,22 +100,20 @@ const initialState: State = {
searchValue: '',
};

const POLL_INTERVAL = 15 * 1000;

export const InstanceOverviewPage = () => {
const [state, dispatch] = React.useReducer(reducer, initialState);
const {allRepos, visibleRepos} = React.useContext(WorkspaceContext);

const queryResult = useQuery<InstanceOverviewInitialQuery>(INSTANCE_OVERVIEW_INITIAL_QUERY, {
fetchPolicy: 'network-only',
pollInterval: POLL_INTERVAL,
notifyOnNetworkStatusChange: true,
});
const refreshState = useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS);
const {data, loading} = queryResult;

const [retrieveLastTenRuns, {data: lastTenRunsData}] = useLazyQuery<LastTenRunsPerJobQuery>(
LAST_TEN_RUNS_PER_JOB_QUERY,
{fetchPolicy: 'network-only', pollInterval: POLL_INTERVAL},
{fetchPolicy: 'network-only', pollInterval: FIFTEEN_SECONDS},
);

const {searchValue} = state;
Expand Down Expand Up @@ -284,8 +282,7 @@ export const InstanceOverviewPage = () => {
<>
<PageHeader
title={<Heading>Instance status</Heading>}
tabs={<InstanceTabs tab="overview" />}
right={<QueryCountdown pollInterval={POLL_INTERVAL} queryResult={queryResult} />}
tabs={<InstanceTabs tab="overview" refreshState={refreshState} />}
/>
<Box
padding={{horizontal: 24, top: 16}}
Expand Down

0 comments on commit 9b08239

Please sign in to comment.