Skip to content

Commit

Permalink
Add timeout for opening projects (#11909)
Browse files Browse the repository at this point in the history
  • Loading branch information
MrFlashAccount authored Dec 24, 2024
1 parent 5777fa6 commit 910d5a7
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 45 deletions.
4 changes: 2 additions & 2 deletions app/gui/src/dashboard/assets/cross.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions app/gui/src/dashboard/assets/cross2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/gui/src/dashboard/assets/error_filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/gui/src/dashboard/assets/error_outline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/gui/src/dashboard/assets/warning.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 58 additions & 16 deletions app/gui/src/dashboard/hooks/projectHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,51 @@ function useSetProjectAsset() {
)
}

export const OPENING_PROJECT_STATES = new Set([
backendModule.ProjectState.provisioned,
backendModule.ProjectState.scheduled,
backendModule.ProjectState.openInProgress,
backendModule.ProjectState.closing,
])
export const OPENED_PROJECT_STATES = new Set([backendModule.ProjectState.opened])
export const CLOSED_PROJECT_STATES = new Set([backendModule.ProjectState.closed])
export const STATIC_PROJECT_STATES = new Set([
backendModule.ProjectState.opened,
backendModule.ProjectState.closed,
])
export const CREATED_PROJECT_STATES = new Set([
backendModule.ProjectState.created,
backendModule.ProjectState.new,
])

/** Stale time for local projects, set to 10 seconds. */
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
export const LOCAL_PROJECT_OPEN_TIMEOUT_MS = 10 * 1_000
/** Stale time for cloud projects, set to 5 minutes. */
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
export const CLOUD_PROJECT_OPEN_TIMEOUT_MS = 5 * 60 * 1_000

/**
* Get the timeout based on the backend type.
* @param backendType - The backend type.
* @throws If the backend type is not supported.
* @returns The timeout in milliseconds.
*/
export function getTimeoutBasedOnTheBackendType(backendType: backendModule.BackendType) {
switch (backendType) {
case backendModule.BackendType.local: {
return LOCAL_PROJECT_OPEN_TIMEOUT_MS
}
case backendModule.BackendType.remote: {
return CLOUD_PROJECT_OPEN_TIMEOUT_MS
}

default: {
throw new Error('Unsupported backend type')
}
}
}

/** Project status query. */
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, backend } = options
Expand All @@ -90,22 +135,19 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
queryFn: () => backend.getProjectDetails(assetId, parentId),
meta: { persist: false },
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
networkMode: backend.type === backendModule.BackendType.remote ? 'online' : 'always',
refetchInterval: ({ state }): number | false => {
const staticStates = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
meta: { persist: false },
refetchInterval: (query): number | false => {
const { state } = query

const staticStates = STATIC_PROJECT_STATES

const openingStates = [
backendModule.ProjectState.provisioned,
backendModule.ProjectState.scheduled,
backendModule.ProjectState.openInProgress,
backendModule.ProjectState.closing,
]
const openingStates = OPENING_PROJECT_STATES

const createdStates = [backendModule.ProjectState.created, backendModule.ProjectState.new]
const createdStates = CREATED_PROJECT_STATES

if (state.status === 'error') {
return false
Expand All @@ -118,28 +160,28 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
const currentState = state.data.state.type

if (isLocal) {
if (createdStates.includes(currentState)) {
if (createdStates.has(currentState)) {
return LOCAL_OPENING_INTERVAL_MS
}

if (staticStates.includes(state.data.state.type)) {
if (staticStates.has(state.data.state.type)) {
return OPENED_INTERVAL_MS
}

if (openingStates.includes(state.data.state.type)) {
if (openingStates.has(state.data.state.type)) {
return LOCAL_OPENING_INTERVAL_MS
}
}

if (createdStates.includes(currentState)) {
if (createdStates.has(currentState)) {
return CLOUD_OPENING_INTERVAL_MS
}

// Cloud project
if (staticStates.includes(state.data.state.type)) {
if (staticStates.has(state.data.state.type)) {
return OPENED_INTERVAL_MS
}
if (openingStates.includes(state.data.state.type)) {
if (openingStates.has(state.data.state.type)) {
return CLOUD_OPENING_INTERVAL_MS
}

Expand Down
124 changes: 124 additions & 0 deletions app/gui/src/dashboard/hooks/timeoutHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* @file Timeout related hooks.
*/
import { useEffect, useRef, useState, type DependencyList } from 'react'
import { useEventCallback } from './eventCallbackHooks'
import { useUnmount } from './unmountHooks'

/**
* Options for {@link useTimeoutCallback}.
*/
export interface UseTimeoutCallbackOptions {
/**
* Callback to execute after the timeout.
*/
readonly callback: () => void
/**
* Timeout in milliseconds.
*/
readonly ms: number
/**
* Dependencies for {@link useEventCallback}.
* Reset the timeout when the dependencies change.
*/
readonly deps?: DependencyList
/**
* Whether the timeout is disabled.
*/
readonly isDisabled?: boolean
}

const STABLE_DEPS_ARRAY: DependencyList = []

/**
* Hook that executes a callback after a timeout.
*/
export function useTimeoutCallback(options: UseTimeoutCallbackOptions) {
const { callback, ms, deps = STABLE_DEPS_ARRAY, isDisabled = false } = options

const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stableCallback = useEventCallback(callback)

/**
* Restarts the timer.
*/
const restartTimer = useEventCallback(() => {
stopTimer()
startTimer()
})

/**
* Starts the timer.
*/
const startTimer = useEventCallback(() => {
stopTimer()
timeoutRef.current = setTimeout(stableCallback, ms)
})

/**
* Stops the timer.
*/
const stopTimer = useEventCallback(() => {
if (timeoutRef.current != null) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
})

useEffect(() => {
if (isDisabled) {
return
}

startTimer()

return () => {
stopTimer()
}
// There is no way to enable compiler, but it's not needed here
// as everything is stable.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ms, isDisabled, ...deps])

useUnmount(() => {
stopTimer()
})

return [restartTimer, stopTimer, startTimer] as const
}

/**
* Hook that returns a boolean indicating whether the timeout has expired.
*/
export function useTimeout(params: Pick<UseTimeoutCallbackOptions, 'deps' | 'ms'>) {
const { ms, deps = STABLE_DEPS_ARRAY } = params

/**
* Get the default value for the timeout.
*/
const getDefaultValue = useEventCallback(() => {
return ms === 0
})

const [isTimeout, setIsTimeout] = useState(getDefaultValue)

const [restartTimer] = useTimeoutCallback({
callback: () => {
setIsTimeout(true)
},
ms,
deps,
isDisabled: false,
})

/**
* Resets the timeout and restarts it.
*/
const restart = useEventCallback(() => {
setIsTimeout(getDefaultValue)
restartTimer()
})

return [isTimeout, restart] as const
}
53 changes: 38 additions & 15 deletions app/gui/src/dashboard/layouts/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as backendModule from '#/services/Backend'

import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as twMerge from '#/utilities/tailwindMerge'
import { useTimeoutCallback } from '../hooks/timeoutHooks'

// ====================
// === StringConfig ===
Expand Down Expand Up @@ -91,22 +92,49 @@ function Editor(props: EditorProps) {
backend,
})

const projectQuery = reactQuery.useQuery(projectStatusQuery)
const queryClient = reactQuery.useQueryClient()

const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
const projectQuery = reactQuery.useSuspenseQuery({
...projectStatusQuery,
select: (data) => {
const isOpeningProject = projectHooks.OPENING_PROJECT_STATES.has(data.state.type)
const isProjectClosed = projectHooks.CLOSED_PROJECT_STATES.has(data.state.type)

return { ...data, isOpeningProject, isProjectClosed }
},
})

const isProjectClosed = projectQuery.data.isProjectClosed
const isOpeningProject = projectQuery.data.isOpeningProject

React.useEffect(() => {
if (isProjectClosed) {
startProject(project)
}
}, [isProjectClosed, startProject, project])

useTimeoutCallback({
callback: () => {
const queryState = queryClient.getQueryCache().find({ queryKey: projectStatusQuery.queryKey })

queryState?.setState({
error: new Error('Timeout opening the project'),
status: 'error',
})
},
ms: projectHooks.getTimeoutBasedOnTheBackendType(backend.type),
deps: [],
isDisabled: !isOpeningProject || projectQuery.isError,
})

if (isOpeningFailed) {
return (
<errorBoundary.ErrorDisplay
error={openingError}
resetErrorBoundary={() => {
startProject(project)
if (isProjectClosed) {
startProject(project)
}
}}
/>
)
Expand All @@ -128,20 +156,17 @@ function Editor(props: EditorProps) {
/>
)

case projectQuery.isLoading ||
projectQuery.data?.state.type !== backendModule.ProjectState.opened:
case isOpeningProject:
return <suspense.Loader minHeight="full" />

default:
return (
<errorBoundary.ErrorBoundary>
<suspense.Suspense>
<EditorInternal
{...props}
openedProject={projectQuery.data}
backendType={project.type}
/>
</suspense.Suspense>
<EditorInternal
{...props}
openedProject={projectQuery.data}
backendType={project.type}
/>
</errorBoundary.ErrorBoundary>
)
}
Expand Down Expand Up @@ -178,9 +203,7 @@ function EditorInternal(props: EditorInternalProps) {
)

React.useEffect(() => {
if (hidden) {
return
} else {
if (!hidden) {
return gtagHooks.gtagOpenCloseCallback(gtagEvent, 'open_workflow', 'close_workflow')
}
}, [hidden, gtagEvent])
Expand Down
Loading

0 comments on commit 910d5a7

Please sign in to comment.