perf(mysql): batch adjacent DML into multi-statement COM_QUERY for large scripts
This commit is contained in:
9 files changed
+683
-82
No files matched your search
@@ -9,6 +9,7 @@ import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, CheckSquare2, Columns3Cog, Copy, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -1412,14 +1413,14 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
|
||||
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
|
||||
|
||||
<div v-else-if="activeOutputView === 'summary'" class="flex-1 min-h-0 overflow-auto bg-background">
|
||||
<div v-else-if="activeOutputView === 'summary'" class="flex flex-1 min-h-0 flex-col bg-background">
|
||||
<div v-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 v-if="activeTab.isExecuting" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<template v-if="activeTab.isExecuting">{{ t("executionSummary.executing") }}</template>
|
||||
<template v-else>{{ t("executionSummary.empty") }}</template>
|
||||
</div>
|
||||
<div v-else class="min-w-[46rem]">
|
||||
<div v-if="batchExecutionProgress" class="sticky top-0 z-10 border-b bg-background/95 px-3 py-2 backdrop-blur">
|
||||
<div v-else class="flex h-full min-h-0 min-w-[46rem] flex-col">
|
||||
<div v-if="batchExecutionProgress" class="z-10 shrink-0 border-b bg-background/95 px-3 py-2 backdrop-blur">
|
||||
<div class="mb-1.5 flex items-center gap-3 text-xs">
|
||||
<span class="font-medium">{{ activeTab.isExecuting ? t("executionSummary.executing") : t("executionSummary.finished") }}</span>
|
||||
<span class="tabular-nums text-muted-foreground">{{ batchExecutionProgress.completed }} / {{ batchExecutionProgress.total }}</span>
|
||||
@@ -1429,59 +1430,61 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
<div class="h-full rounded-full bg-primary transition-[width] duration-200" :style="{ width: `${batchExecutionPercent}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden border-b">
|
||||
<div class="grid grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div>{{ t("executionSummary.statement") }}</div>
|
||||
<div>{{ t("executionSummary.sql") }}</div>
|
||||
<div>{{ t("executionSummary.status") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.rows") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.time") }}</div>
|
||||
</div>
|
||||
<div v-for="item in summaryItems" :key="item.statementIndex" class="relative grid w-full grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] items-center border-b px-3 py-2 text-left text-xs last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 z-0 cursor-pointer text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
:title="item.error || item.sql"
|
||||
:aria-label="item.error || item.sql || t('executionSummary.noSql')"
|
||||
@click="previewExecutionSummaryItem(item)"
|
||||
@dblclick="focusExecutionSummaryItem(item)"
|
||||
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
|
||||
/>
|
||||
<div class="pointer-events-none relative z-[1] font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
|
||||
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="focusExecutionSummaryItem(item)">
|
||||
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
|
||||
<div v-if="item.error" class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-destructive">
|
||||
<span data-native-clipboard class="min-w-0 flex-1 cursor-text select-text truncate" :title="item.error" @mousedown.stop @click.stop @dblclick.stop>{{ item.error }}</span>
|
||||
<LightTooltip :text="t('grid.copy')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<button type="button" class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive" :aria-label="t('grid.copy')" @mousedown.stop @click.stop="copyExecutionSummaryError(item.error)">
|
||||
<Copy class="h-3 w-3" />
|
||||
</button>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none relative z-[1]">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border px-2 text-[10px]"
|
||||
:class="{
|
||||
'border-primary/35 bg-primary/10 text-primary': item.status === 'running',
|
||||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300': item.status === 'success',
|
||||
'border-destructive/40 bg-destructive/10 text-destructive': item.status === 'error',
|
||||
'border-border bg-muted/40 text-muted-foreground': item.status === 'pending' || item.status === 'skipped',
|
||||
'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300': item.status === 'cancelled',
|
||||
}"
|
||||
>
|
||||
<Loader2 v-if="item.status === 'running'" class="h-3 w-3 animate-spin" />
|
||||
<Check v-else-if="item.status === 'success'" class="h-3 w-3" />
|
||||
<X v-else-if="item.status === 'error'" class="h-3 w-3" />
|
||||
<SquareDashed v-else class="h-3 w-3" />
|
||||
{{ t(`executionSummary.statuses.${item.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.rowCount.toLocaleString() }}</div>
|
||||
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}</div>
|
||||
</div>
|
||||
<div class="grid shrink-0 grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div>{{ t("executionSummary.statement") }}</div>
|
||||
<div>{{ t("executionSummary.sql") }}</div>
|
||||
<div>{{ t("executionSummary.status") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.rows") }}</div>
|
||||
<div class="text-right">{{ t("executionSummary.time") }}</div>
|
||||
</div>
|
||||
<div class="px-3 py-2 text-[11px] text-muted-foreground">{{ t("executionSummary.navigationHint") }}</div>
|
||||
<DynamicScroller v-slot="{ item, index, active }" class="min-h-0 flex-1 border-b" :items="summaryItems" :min-item-size="37" :buffer="600" :skip-hover="true" key-field="statementIndex">
|
||||
<DynamicScrollerItem :item="item" :active="active" :data-index="index" :size-dependencies="[item.error]">
|
||||
<div class="relative grid w-full grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] items-center border-b px-3 py-2 text-left text-xs last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 z-0 cursor-pointer text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
:title="item.error || item.sql"
|
||||
:aria-label="item.error || item.sql || t('executionSummary.noSql')"
|
||||
@click="previewExecutionSummaryItem(item)"
|
||||
@dblclick="focusExecutionSummaryItem(item)"
|
||||
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
|
||||
/>
|
||||
<div class="pointer-events-none relative z-[1] font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
|
||||
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="focusExecutionSummaryItem(item)">
|
||||
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
|
||||
<div v-if="item.error" class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-destructive">
|
||||
<span data-native-clipboard class="min-w-0 flex-1 cursor-text select-text truncate" :title="item.error" @mousedown.stop @click.stop @dblclick.stop>{{ item.error }}</span>
|
||||
<LightTooltip :text="t('grid.copy')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<button type="button" class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive" :aria-label="t('grid.copy')" @mousedown.stop @click.stop="copyExecutionSummaryError(item.error)">
|
||||
<Copy class="h-3 w-3" />
|
||||
</button>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none relative z-[1]">
|
||||
<span
|
||||
class="inline-flex h-5 items-center gap-1 rounded-full border px-2 text-[10px]"
|
||||
:class="{
|
||||
'border-primary/35 bg-primary/10 text-primary': item.status === 'running',
|
||||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300': item.status === 'success',
|
||||
'border-destructive/40 bg-destructive/10 text-destructive': item.status === 'error',
|
||||
'border-border bg-muted/40 text-muted-foreground': item.status === 'pending' || item.status === 'skipped',
|
||||
'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300': item.status === 'cancelled',
|
||||
}"
|
||||
>
|
||||
<Loader2 v-if="item.status === 'running'" class="h-3 w-3 animate-spin" />
|
||||
<Check v-else-if="item.status === 'success'" class="h-3 w-3" />
|
||||
<X v-else-if="item.status === 'error'" class="h-3 w-3" />
|
||||
<SquareDashed v-else class="h-3 w-3" />
|
||||
{{ t(`executionSummary.statuses.${item.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.rowCount.toLocaleString() }}</div>
|
||||
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}</div>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
</DynamicScroller>
|
||||
<div class="shrink-0 px-3 py-2 text-[11px] text-muted-foreground">{{ t("executionSummary.navigationHint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+7
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
|
||||
describe("queryTimeout", () => {
|
||||
it("lets PostgreSQL row queries use the backend inactivity timeout", () => {
|
||||
@@ -19,6 +19,12 @@ describe("queryTimeout", () => {
|
||||
expect(queryTimeoutSecsForConnection({ query_timeout_secs: undefined })).toBe(30);
|
||||
});
|
||||
|
||||
it("does not schedule frontend timeouts beyond the browser timer limit", () => {
|
||||
expect(frontendQueryTimeoutDelayMs(60)).toBe(60_000);
|
||||
expect(frontendQueryTimeoutDelayMs(11_401_200)).toBeUndefined();
|
||||
expect(frontendQueryTimeoutDelayMs(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the global timeout only for inheriting connections", () => {
|
||||
expect(queryTimeoutSecsForConnection({ query_timeout_secs: 30, query_timeout_inherit: true }, 12)).toBe(12);
|
||||
expect(queryTimeoutSecsForConnection({ query_timeout_secs: 30, query_timeout_inherit: false }, 12)).toBe(30);
|
||||
|
||||
@@ -234,6 +234,35 @@ describe("execution summary", () => {
|
||||
{ status: "error", isError: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps out-of-order results to their explicit statement indexes", () => {
|
||||
const items = executionSummaryItems({
|
||||
results: [
|
||||
{ columns: ["value"], rows: [["third"]], affected_rows: 0, execution_time_ms: 3, statement_index: 2 },
|
||||
{ columns: ["value"], rows: [["first"]], affected_rows: 0, execution_time_ms: 1, statement_index: 0 },
|
||||
],
|
||||
batchSqlExecution: {
|
||||
executionId: "run-out-of-order",
|
||||
submittedSql: "SELECT 'first'; SELECT 'second'; SELECT 'third'",
|
||||
editorFingerprint: "fingerprint",
|
||||
sourceOffset: 0,
|
||||
completed: 2,
|
||||
total: 3,
|
||||
startedAt: 1,
|
||||
items: [
|
||||
{ statementIndex: 0, sql: "SELECT 'first'", from: 0, to: 14, status: "success" },
|
||||
{ statementIndex: 1, sql: "SELECT 'second'", from: 16, to: 31, status: "skipped" },
|
||||
{ statementIndex: 2, sql: "SELECT 'third'", from: 33, to: 47, status: "success" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(items.map((item) => [item.statementIndex, item.result?.rows[0]?.[0]])).toEqual([
|
||||
[0, "first"],
|
||||
[1, undefined],
|
||||
[2, "third"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statement execution markers", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
|
||||
export const DEFAULT_QUERY_TIMEOUT_SECS = 30;
|
||||
const MAX_BROWSER_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
const POSTGRES_ROW_STATEMENT_KEYWORDS = new Set(["select", "show", "explain", "table", "with"]);
|
||||
const POSTGRES_RETURNING_STATEMENT_KEYWORDS = new Set(["insert", "update", "delete", "merge"]);
|
||||
@@ -30,6 +31,12 @@ export function frontendQueryTimeoutSecsForSql(sql: string, databaseType: Databa
|
||||
return baseTimeoutSecs * statementCount;
|
||||
}
|
||||
|
||||
export function frontendQueryTimeoutDelayMs(timeoutSecs: number): number | undefined {
|
||||
const timeoutMs = timeoutSecs * 1000;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_BROWSER_TIMER_DELAY_MS) return undefined;
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
function postgresQueryMayReturnRows(sql: string, databaseType: DatabaseType): boolean {
|
||||
return splitSqlStatementRanges(sql, databaseType).some(({ sql: statement }) => {
|
||||
const tokens = tokenizeSqlSemantic(statement, "postgres");
|
||||
|
||||
@@ -368,8 +368,12 @@ export interface ExecutionSummaryItem {
|
||||
export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" | "batchSqlExecution">): ExecutionSummaryItem[] {
|
||||
const results = tab.results?.length ? tab.results : tab.result ? [tab.result] : [];
|
||||
if (tab.batchSqlExecution?.items.length) {
|
||||
const resultsByStatementIndex = new Map<number, QueryResult>();
|
||||
results.forEach((result, resultIndex) => {
|
||||
resultsByStatementIndex.set(result.statement_index ?? resultIndex, result);
|
||||
});
|
||||
return tab.batchSqlExecution.items.map((item, index) => {
|
||||
const result = results.find((candidate, resultIndex) => (candidate.statement_index ?? resultIndex) === item.statementIndex);
|
||||
const result = resultsByStatementIndex.get(item.statementIndex);
|
||||
const returnedRows = result?.rows.length ?? 0;
|
||||
const affectedRows = item.affectedRows ?? result?.affected_rows ?? 0;
|
||||
const hasTabularResult = (result?.columns.length ?? 0) > 0;
|
||||
|
||||
@@ -197,6 +197,47 @@ describe("queryStore multi-statement errors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks every statement completed by a pipelined progress event", async () => {
|
||||
const pendingExecution = deferred<any[]>();
|
||||
let reportProgress!: (progress: any) => void;
|
||||
mocks.executeMultiWithProgress.mockImplementationOnce((_connectionId, _database, _sql, onProgress) => {
|
||||
reportProgress = onProgress;
|
||||
return pendingExecution.promise;
|
||||
});
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const sql = "SET @n = 0;\nINSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\nSELECT COUNT(*) FROM t";
|
||||
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
|
||||
|
||||
const execution = store.executeTabSql(tabId, sql);
|
||||
await vi.waitFor(() => expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items[0]?.status).toBe("running"));
|
||||
const executionId = store.tabs.find((item) => item.id === tabId)!.executionId!;
|
||||
|
||||
reportProgress({
|
||||
executionId,
|
||||
statementIndex: 2,
|
||||
completed: 3,
|
||||
total: 4,
|
||||
success: true,
|
||||
executionTimeMs: 8,
|
||||
affectedRows: 1,
|
||||
});
|
||||
|
||||
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution).toMatchObject({
|
||||
completed: 3,
|
||||
total: 4,
|
||||
items: [{ status: "success" }, { status: "success" }, { status: "success" }, { status: "running" }],
|
||||
});
|
||||
|
||||
pendingExecution.resolve([
|
||||
{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 2, statement_index: 0 },
|
||||
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 4, statement_index: 1 },
|
||||
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 8, statement_index: 2 },
|
||||
{ columns: ["COUNT(*)"], rows: [[2]], affected_rows: 0, execution_time_ms: 2, statement_index: 3 },
|
||||
]);
|
||||
await execution;
|
||||
});
|
||||
|
||||
it("records a top-level batch failure on the current statement", async () => {
|
||||
mocks.executeMultiWithProgress.mockRejectedValueOnce(new Error("transport failed"));
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
|
||||
@@ -43,7 +43,7 @@ import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||||
import { getCachedTableMetadata, loadTableIndexes, loadTableMetadata, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache";
|
||||
import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
|
||||
import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource";
|
||||
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
|
||||
import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
|
||||
@@ -295,8 +295,17 @@ function applyBatchSqlProgress(
|
||||
) {
|
||||
const batch = batchSqlExecutionFor(tab, progress.executionId);
|
||||
if (!batch) return;
|
||||
const previousCompleted = batch.completed;
|
||||
const item = batch.items[progress.statementIndex];
|
||||
if (!item) return;
|
||||
if (progress.success && progress.completed > previousCompleted + 1) {
|
||||
for (let index = previousCompleted; index < progress.completed - 1; index += 1) {
|
||||
const completedItem = batch.items[index];
|
||||
if (completedItem && (completedItem.status === "pending" || completedItem.status === "running")) {
|
||||
completedItem.status = "success";
|
||||
}
|
||||
}
|
||||
}
|
||||
item.status = progress.success ? "success" : "error";
|
||||
item.executionTimeMs = progress.executionTimeMs;
|
||||
item.affectedRows = progress.affectedRows;
|
||||
@@ -304,7 +313,7 @@ function applyBatchSqlProgress(
|
||||
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
|
||||
batch.completed = Math.max(batch.completed, progress.completed);
|
||||
if ((progress.success || continueOnError) && progress.completed < batch.total) {
|
||||
const next = batch.items[progress.statementIndex + 1];
|
||||
const next = batch.items[progress.completed];
|
||||
if (next?.status === "pending") next.status = "running";
|
||||
}
|
||||
}
|
||||
@@ -430,14 +439,15 @@ function displayedQueryMetadataSql(tab: QueryTab, fallbackSql: string): string {
|
||||
}
|
||||
|
||||
async function withFrontendQueryTimeout<T>(promise: Promise<T>, timeoutSecs: number, message: string): Promise<T> {
|
||||
if (timeoutSecs === 0) return promise;
|
||||
const timeoutMs = frontendQueryTimeoutDelayMs(timeoutSecs);
|
||||
if (timeoutMs === undefined) return promise;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutSecs * 1000);
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user