From 94158504a860e9f2b6d6d6f1b52ad2cc3db36a01 Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Thu, 19 Oct 2023 13:21:39 -0400 Subject: [PATCH] Cancel query timeout (#1651) * cancel query option * query timeout * run prettier * removed comments * fixed npm run compile errors * fixed tests * fixed unit test errors * fixed unit test errors * fixed unit test errors * fixed unit test errors * fixed unit test errors * increased min timeout * added automatican cancel query option * added react string format * npm run format * added unless automatic cancellation has been enabled --------- Co-authored-by: Asier Isayas --- package-lock.json | 6 + package.json | 3 +- .../Panes/SettingsPane/SettingsPane.tsx | 126 +++++++++++++++++- .../__snapshots__/SettingsPane.test.tsx.snap | 40 ++++++ src/Explorer/Tabs/DocumentsTab.html | 9 +- src/Explorer/Tabs/DocumentsTab.ts | 58 +++++++- .../Tabs/QueryTab/QueryTabComponent.tsx | 46 ++++++- src/Shared/Constants.ts | 6 + src/Shared/StorageUtility.ts | 3 + 9 files changed, 278 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index b96bab946..51368e807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22133,6 +22133,12 @@ "resolved": "https://registry.npmjs.org/react-splitter-layout/-/react-splitter-layout-4.0.0.tgz", "integrity": "sha512-SLqOjBOxRuizWUa83w6q5/u9cDWa9/yj9Iko9V9JFN8x+cqIXiDlUFWSx+icz3IIgvsN/oRIw3za5/32RjIwrA==" }, + "react-string-format": { + "version": "1.0.1", + "resolved": "https://msazure.pkgs.visualstudio.com/_packaging/AzurePortal/npm/registry/react-string-format/-/react-string-format-1.0.1.tgz", + "integrity": "sha1-JyQaRZHqURInBBx64HC3FJBh3AA=", + "license": "MIT" + }, "react-syntax-highlighter": { "version": "12.2.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz", diff --git a/package.json b/package.json index 83c8acdec..302248f9c 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "react-notification-system": "0.2.17", "react-redux": "7.1.3", "react-splitter-layout": "4.0.0", + "react-string-format": "1.0.1", "react-youtube": "9.0.1", "redux": "4.0.4", "reflect-metadata": "0.1.13", @@ -234,4 +235,4 @@ "printWidth": 120, "endOfLine": "auto" } -} \ No newline at end of file +} diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index fc31c7f7d..14a9fd5d7 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,4 +1,13 @@ -import { Checkbox, ChoiceGroup, IChoiceGroupOption, SpinButton } from "@fluentui/react"; +import { + Checkbox, + ChoiceGroup, + IChoiceGroupOption, + ISpinButtonStyles, + IToggleStyles, + Position, + SpinButton, + Toggle, +} from "@fluentui/react"; import * as Constants from "Common/Constants"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { configContext } from "ConfigContext"; @@ -6,10 +15,10 @@ import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import * as StringUtility from "Shared/StringUtility"; import { userContext } from "UserContext"; import { logConsoleInfo } from "Utils/NotificationConsoleUtils"; -import { useSidePanel } from "hooks/useSidePanel"; -import React, { FunctionComponent, MouseEvent, useState } from "react"; -import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; +import { useSidePanel } from "hooks/useSidePanel"; +import React, { FunctionComponent, useState } from "react"; +import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; export const SettingsPane: FunctionComponent = () => { const closeSidePanel = useSidePanel((state) => state.closeSidePanel); @@ -19,6 +28,13 @@ export const SettingsPane: FunctionComponent = () => { ? Constants.Queries.UnlimitedPageOption : Constants.Queries.CustomPageOption, ); + const [queryTimeoutEnabled, setQueryTimeoutEnabled] = useState( + LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled), + ); + const [queryTimeout, setQueryTimeout] = useState(LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout)); + const [automaticallyCancelQueryAfterTimeout, setAutomaticallyCancelQueryAfterTimeout] = useState( + LocalStorageUtility.getEntryBoolean(StorageKey.AutomaticallyCancelQueryAfterTimeout), + ); const [customItemPerPage, setCustomItemPerPage] = useState( LocalStorageUtility.getEntryNumber(StorageKey.CustomItemPerPage) || 0, ); @@ -53,7 +69,7 @@ export const SettingsPane: FunctionComponent = () => { const shouldShowCrossPartitionOption = userContext.apiType !== "Gremlin"; const shouldShowParallelismOption = userContext.apiType !== "Gremlin"; const shouldShowPriorityLevelOption = PriorityBasedExecutionUtils.isFeatureEnabled(); - const handlerOnSubmit = (e: MouseEvent) => { + const handlerOnSubmit = () => { setIsExecuting(true); LocalStorageUtility.setEntryNumber( @@ -61,6 +77,7 @@ export const SettingsPane: FunctionComponent = () => { isCustomPageOptionSelected() ? customItemPerPage : Constants.Queries.unlimitedItemsPerPage, ); LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); + LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled); LocalStorageUtility.setEntryString(StorageKey.ContainerPaginationEnabled, containerPaginationEnabled.toString()); LocalStorageUtility.setEntryString(StorageKey.IsCrossPartitionQueryEnabled, crossPartitionQueryEnabled.toString()); LocalStorageUtility.setEntryNumber(StorageKey.MaxDegreeOfParellism, maxDegreeOfParallelism); @@ -73,6 +90,14 @@ export const SettingsPane: FunctionComponent = () => { ); } + if (queryTimeoutEnabled) { + LocalStorageUtility.setEntryNumber(StorageKey.QueryTimeout, queryTimeout); + LocalStorageUtility.setEntryBoolean( + StorageKey.AutomaticallyCancelQueryAfterTimeout, + automaticallyCancelQueryAfterTimeout, + ); + } + setIsExecuting(false); logConsoleInfo( `Updated items per page setting to ${LocalStorageUtility.getEntryNumber(StorageKey.ActualItemPerPage)}`, @@ -97,7 +122,6 @@ export const SettingsPane: FunctionComponent = () => { `Updated query setting to ${LocalStorageUtility.getEntryString(StorageKey.SetPartitionKeyUndefined)}`, ); closeSidePanel(); - e.preventDefault(); }; const isCustomPageOptionSelected = () => { @@ -112,7 +136,7 @@ export const SettingsPane: FunctionComponent = () => { formError: "", isExecuting, submitButtonText: "Apply", - onSubmit: () => handlerOnSubmit(undefined), + onSubmit: () => handlerOnSubmit(), }; const pageOptionList: IChoiceGroupOption[] = [ { key: Constants.Queries.CustomPageOption, text: "Custom" }, @@ -140,6 +164,21 @@ export const SettingsPane: FunctionComponent = () => { setPageOption(option.key); }; + const handleOnQueryTimeoutToggleChange = (ev: React.MouseEvent, checked?: boolean): void => { + setQueryTimeoutEnabled(checked); + }; + + const handleOnAutomaticallyCancelQueryToggleChange = (ev: React.MouseEvent, checked?: boolean): void => { + setAutomaticallyCancelQueryAfterTimeout(checked); + }; + + const handleOnQueryTimeoutSpinButtonChange = (ev: React.MouseEvent, newValue?: string): void => { + const queryTimeout = Number(newValue); + if (!isNaN(queryTimeout)) { + setQueryTimeout(queryTimeout); + } + }; + const choiceButtonStyles = { root: { clear: "both", @@ -161,6 +200,35 @@ export const SettingsPane: FunctionComponent = () => { }, ], }; + + const queryTimeoutToggleStyles: IToggleStyles = { + label: { + fontSize: 12, + fontWeight: 400, + display: "block", + }, + root: {}, + container: {}, + pill: {}, + thumb: {}, + text: {}, + }; + + const queryTimeoutSpinButtonStyles: ISpinButtonStyles = { + label: { + fontSize: 12, + fontWeight: 400, + }, + root: { + paddingBottom: 10, + }, + labelWrapper: {}, + icon: {}, + spinButtonWrapper: {}, + input: {}, + arrowButtonsContainer: {}, + }; + return (
@@ -211,6 +279,50 @@ export const SettingsPane: FunctionComponent = () => {
)} + {userContext.apiType === "SQL" && ( +
+
+
+ + Query Timeout + + + When a query reaches a specified time limit, a popup with an option to cancel the query will show + unless automatic cancellation has been enabled + +
+
+ +
+ {queryTimeoutEnabled && ( +
+ + +
+ )} +
+
+ )}
diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 14837845b..372e7e179 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -97,6 +97,46 @@ exports[`Settings Pane should render Default properly 1`] = `
+
+
+
+ + Query Timeout + + + When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled + +
+
+ +
+
+
diff --git a/src/Explorer/Tabs/DocumentsTab.html b/src/Explorer/Tabs/DocumentsTab.html index 094c18e10..4283a661c 100644 --- a/src/Explorer/Tabs/DocumentsTab.html +++ b/src/Explorer/Tabs/DocumentsTab.html @@ -98,7 +98,7 @@
diff --git a/src/Explorer/Tabs/DocumentsTab.ts b/src/Explorer/Tabs/DocumentsTab.ts index a62c1f416..064220511 100644 --- a/src/Explorer/Tabs/DocumentsTab.ts +++ b/src/Explorer/Tabs/DocumentsTab.ts @@ -2,6 +2,9 @@ import { extractPartitionKey, ItemDefinition, PartitionKeyDefinition, QueryItera import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; import * as ko from "knockout"; import Q from "q"; +import { format } from "react-string-format"; +import { QueryConstants } from "Shared/Constants"; +import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import DeleteDocumentIcon from "../../../images/DeleteDocument.svg"; import DiscardIcon from "../../../images/discard.svg"; import NewDocumentIcon from "../../../images/NewDocument.svg"; @@ -79,6 +82,7 @@ export default class DocumentsTab extends TabsBase { private _resourceTokenPartitionKey: string; private _isQueryCopilotSampleContainer: boolean; private queryAbortController: AbortController; + private cancelQueryTimeoutID: NodeJS.Timeout; constructor(options: ViewModels.DocumentsTabOptions) { super(options); @@ -350,11 +354,11 @@ export default class DocumentsTab extends TabsBase { * Query first page of documents * Select and query first document and display content */ - private async autoPopulateContent() { + private async autoPopulateContent(applyFilterButtonPressed?: boolean) { // reset iterator this._documentsIterator = this.createIterator(); // load documents - await this.loadNextPage(); + await this.loadNextPage(applyFilterButtonPressed); // Select first document and load content if (this.documentIds().length > 0) { @@ -391,12 +395,14 @@ export default class DocumentsTab extends TabsBase { return true; }; - public async refreshDocumentsGrid(): Promise { + public async refreshDocumentsGrid(applyFilterButtonPressed?: boolean): Promise { // clear documents grid this.documentIds([]); - try { - await this.autoPopulateContent(); + // reset iterator + this._documentsIterator = this.createIterator(); + // load documents + await this.autoPopulateContent(applyFilterButtonPressed); // collapse filter this.appliedFilter(this.filterContent()); this.isFilterExpanded(false); @@ -733,9 +739,35 @@ export default class DocumentsTab extends TabsBase { this.initDocumentEditor(documentId, content); } - public loadNextPage(): Q.Promise { + public loadNextPage(applyFilterButtonClicked?: boolean): Q.Promise { this.isExecuting(true); this.isExecutionError(false); + let automaticallyCancelQueryAfterTimeout: boolean; + if (applyFilterButtonClicked && this.queryTimeoutEnabled()) { + const queryTimeout: number = LocalStorageUtility.getEntryNumber(StorageKey.QueryTimeout); + automaticallyCancelQueryAfterTimeout = LocalStorageUtility.getEntryBoolean( + StorageKey.AutomaticallyCancelQueryAfterTimeout, + ); + const cancelQueryTimeoutID: NodeJS.Timeout = setTimeout(() => { + if (this.isExecuting()) { + if (automaticallyCancelQueryAfterTimeout) { + this.queryAbortController.abort(); + } else { + useDialog + .getState() + .showOkCancelModalDialog( + QueryConstants.CancelQueryTitle, + format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached), + "Yes", + () => this.queryAbortController.abort(), + "No", + undefined, + ); + } + } + }, queryTimeout); + this.cancelQueryTimeoutID = cancelQueryTimeoutID; + } return this._loadNextPageInternal() .then( (documentsIdsResponse = []) => { @@ -791,7 +823,15 @@ export default class DocumentsTab extends TabsBase { } }, ) - .finally(() => this.isExecuting(false)); + .finally(() => { + this.isExecuting(false); + if (applyFilterButtonClicked && this.queryTimeoutEnabled()) { + clearTimeout(this.cancelQueryTimeoutID); + if (!automaticallyCancelQueryAfterTimeout) { + useDialog.getState().closeDialog(); + } + } + }); } public onLoadMoreKeyInput = (source: any, event: KeyboardEvent): void => { @@ -969,4 +1009,8 @@ export default class DocumentsTab extends TabsBase { useSelectedNode.getState().isQueryCopilotCollectionSelected(), }; } + + private queryTimeoutEnabled(): boolean { + return !this.isPreferredApiMongoDB && LocalStorageUtility.getEntryBoolean(StorageKey.QueryTimeoutEnabled); + } } diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index 98384d92e..d4deda184 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -1,12 +1,16 @@ import { FeedOptions } from "@azure/cosmos"; +import { useDialog } from "Explorer/Controls/Dialog"; import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient"; import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults"; import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar"; import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection"; +import { QueryConstants } from "Shared/Constants"; +import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot"; import React, { Fragment } from "react"; import SplitterLayout from "react-splitter-layout"; import "react-splitter-layout/lib/index.css"; +import { format } from "react-string-format"; import LaunchCopilot from "../../../../images/CopilotTabIcon.svg"; import CancelQueryIcon from "../../../../images/Entity_cancel.svg"; import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg"; @@ -80,6 +84,7 @@ interface IQueryTabStates { isExecuting: boolean; showCopilotSidebar: boolean; queryCopilotGeneratedQuery: string; + cancelQueryTimeoutID: NodeJS.Timeout; } export default class QueryTabComponent extends React.Component { @@ -107,13 +112,13 @@ export default class QueryTabComponent extends React.Component 0, visible: true, @@ -250,6 +255,34 @@ export default class QueryTabComponent extends React.Component { + if (this.state.isExecuting) { + if (automaticallyCancelQueryAfterTimeout) { + this.queryAbortController.abort(); + } else { + useDialog + .getState() + .showOkCancelModalDialog( + QueryConstants.CancelQueryTitle, + format(QueryConstants.CancelQuerySubTextTemplate, QueryConstants.CancelQueryTimeoutThresholdReached), + "Yes", + () => this.queryAbortController.abort(), + "No", + undefined, + ); + } + } + }, queryTimeout); + this.setState({ + cancelQueryTimeoutID, + }); + } useCommandBar.getState().setContextButtons(this.getTabsButtons()); try { @@ -273,7 +306,14 @@ export default class QueryTabComponent extends React.Component void; componentDidMount(): void { diff --git a/src/Shared/Constants.ts b/src/Shared/Constants.ts index 8594ccc25..1c2e91cfb 100644 --- a/src/Shared/Constants.ts +++ b/src/Shared/Constants.ts @@ -208,3 +208,9 @@ export class FreeTierLimits { public static RU: number = 1000; public static Storage: number = 25; } + +export class QueryConstants { + public static readonly CancelQueryTitle: string = "Cancel query"; + public static readonly CancelQuerySubTextTemplate: string = "{0} Do you want to cancel this query?"; + public static readonly CancelQueryTimeoutThresholdReached: string = "The query timeout threshold has been reached."; +} diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts index d142c07e4..dd142c8f3 100644 --- a/src/Shared/StorageUtility.ts +++ b/src/Shared/StorageUtility.ts @@ -4,6 +4,9 @@ import * as SessionStorageUtility from "./SessionStorageUtility"; export { LocalStorageUtility, SessionStorageUtility }; export enum StorageKey { ActualItemPerPage, + QueryTimeoutEnabled, + QueryTimeout, + AutomaticallyCancelQueryAfterTimeout, ContainerPaginationEnabled, CustomItemPerPage, DatabaseAccountId,