perf(execution): prewarm tab session pools and report perceived wait
This commit is contained in:
17 files changed
+558
-29
No files matched your search
@@ -7783,6 +7783,19 @@ watch(
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// The editor component is reused across tabs, so a tab switch can change the
|
||||
// connection/database without remounting it.
|
||||
watch([() => props.connectionId, () => props.database, () => props.catalog, () => props.clientSessionId], () => warmActiveTabConnection());
|
||||
|
||||
// Restored tabs mount before their connection is established, so the warm-up
|
||||
// above is skipped and never retried when connecting finishes later.
|
||||
watch(
|
||||
() => (props.connectionId ? connectionStore.connectedIds.has(props.connectionId) : false),
|
||||
(connected) => {
|
||||
if (connected) warmActiveTabConnection();
|
||||
},
|
||||
);
|
||||
|
||||
// A content-search jump. Fires on id change (a new or reused tab, or a repeat
|
||||
// click on the same result), and on mount when the request is already pending.
|
||||
watch(
|
||||
@@ -8086,12 +8099,24 @@ function resumeQueryEditorBackgroundWork() {
|
||||
editorIsActive = true;
|
||||
registerTableReferenceDropListener();
|
||||
scheduleSemanticDiagnostics();
|
||||
// Warm the database driver/pool while the user is still reading or typing, so
|
||||
// the first Run does not pay pool creation or external-driver startup.
|
||||
warmActiveTabConnection();
|
||||
if (view.value) schedulePreviewContextRefresh(view.value);
|
||||
restoreEditorSelection(undefined, !props.initialViewport);
|
||||
restoreEditorFocus();
|
||||
restoreEditorViewport();
|
||||
}
|
||||
|
||||
function warmActiveTabConnection() {
|
||||
if (!props.connectionId) return;
|
||||
connectionStore.warmConnection(props.connectionId, {
|
||||
database: props.database,
|
||||
catalog: props.catalog,
|
||||
clientSessionId: props.clientSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
onActivated(resumeQueryEditorBackgroundWork);
|
||||
|
||||
onDeactivated(pauseQueryEditorBackgroundWork);
|
||||
@@ -8099,6 +8124,7 @@ onDeactivated(pauseQueryEditorBackgroundWork);
|
||||
onMounted(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener(BEFORE_TAB_SWITCH_EVENT, captureEditorStateBeforeTabSwitch);
|
||||
warmActiveTabConnection();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
activeConnectionId: "",
|
||||
getConfig: vi.fn((connectionId: string) => ({ id: connectionId, db_type: "postgres" })),
|
||||
ensureConnected: vi.fn(),
|
||||
warmConnection: vi.fn(),
|
||||
connectionIdentifierQuote: vi.fn(() => undefined),
|
||||
// 与真实 store 一致:setTableMeta 记录连接元数据代次
|
||||
metadataGenerationFor: vi.fn(() => 0),
|
||||
|
||||
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
openDataTabsNextToActive: false,
|
||||
metadataGeneration: 0,
|
||||
ensureConnected: vi.fn(),
|
||||
warmConnection: vi.fn(),
|
||||
executeTabSql: vi.fn(),
|
||||
loadTableMetadata: vi.fn(),
|
||||
buildTableSelectSql: vi.fn(),
|
||||
@@ -26,6 +27,7 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: () => ({ id: "connection-1", db_type: mocks.databaseType }),
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
warmConnection: mocks.warmConnection,
|
||||
connectionIdentifierQuote: () => undefined,
|
||||
metadataGenerationFor: () => mocks.metadataGeneration,
|
||||
}),
|
||||
@@ -237,6 +239,19 @@ describe("useSidebarDataOpenRuntime", () => {
|
||||
expect(mocks.tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prewarms the tab session pool before the first page is fetched", async () => {
|
||||
await useSidebarDataOpenRuntime().openData(tableNode);
|
||||
|
||||
const createdTab = mocks.tabs[0]!;
|
||||
// The tab executes on its own session pool; warming it up front keeps the
|
||||
// connection setup off the critical path of the visible loading spinner.
|
||||
expect(mocks.warmConnection).toHaveBeenCalledWith("connection-1", {
|
||||
database: "app",
|
||||
catalog: undefined,
|
||||
clientSessionId: createdTab.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps adjacent placement for every tab created in always-new mode", async () => {
|
||||
mocks.dataTabReuseMode = "always-new";
|
||||
mocks.openDataTabsNextToActive = true;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||||
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { canActivateExistingDataTableTab } from "@/lib/tabs/dataTabActivation";
|
||||
import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration";
|
||||
import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase";
|
||||
|
||||
const DATA_TAB_METADATA_TTL_MS = TABLE_METADATA_CACHE_TTL_MS;
|
||||
|
||||
@@ -60,6 +61,21 @@ export function useSidebarDataOpenRuntime() {
|
||||
});
|
||||
lastPhaseAt = now;
|
||||
};
|
||||
// Each data tab executes on its own session-scoped pool
|
||||
// (`<connection>:<database>[:catalog:..]:session:<tabId>`), so its first Run
|
||||
// pays a fresh database connection. On a remote link that setup costs
|
||||
// hundreds of milliseconds of round trips and lands on the critical path of
|
||||
// the page fetch the user is waiting for. Start it in the background as soon
|
||||
// as the tab exists (and again once the connection is up) so it overlaps the
|
||||
// table-metadata load instead of the visible loading spinner.
|
||||
const warmTabSessionPool = (targetTabId: string) => {
|
||||
if (!config) return;
|
||||
connectionStore.warmConnection(node.connectionId, {
|
||||
database: dataTabExecutionDatabase(config, node.database, node.catalog),
|
||||
catalog: node.catalog,
|
||||
clientSessionId: targetTabId,
|
||||
});
|
||||
};
|
||||
openDataLog("info", "start", {
|
||||
traceId,
|
||||
type: node.type,
|
||||
@@ -215,6 +231,7 @@ export function useSidebarDataOpenRuntime() {
|
||||
})();
|
||||
openDataLog("info", "tab-created", { traceId, tabId, elapsed: elapsed() });
|
||||
logPhase("tab-created", { tabId });
|
||||
warmTabSessionPool(tabId);
|
||||
// 接管该 tab:登记本次导航代次,作废在途的 openTableTarget/openData 旧代次
|
||||
// ——旧导航即使目标身份相同(同表不同 whereInput)也不得再落地
|
||||
const navigationToken = beginDataTabNavigation(tabId);
|
||||
@@ -312,6 +329,7 @@ export function useSidebarDataOpenRuntime() {
|
||||
}
|
||||
openDataLog("info", "ensure-connected:done", { traceId, elapsed: elapsed() });
|
||||
logPhase("ensure-connected", { tabId });
|
||||
warmTabSessionPool(tabId);
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
|
||||
const limit = tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
|
||||
|
||||
@@ -70,6 +70,7 @@ export const sessionCredentialStatus = forward("sessionCredentialStatus");
|
||||
export const forgetSessionCredential = forward("forgetSessionCredential");
|
||||
export const replaceNacosSessionCredential = forward("replaceNacosSessionCredential");
|
||||
export const checkConnectionHealth = forward("checkConnectionHealth");
|
||||
export const prewarmConnection = forward("prewarmConnection");
|
||||
export const connectionIdentifierQuote = forward("connectionIdentifierQuote");
|
||||
export const closeDatabaseConnection = forward("closeDatabaseConnection");
|
||||
export const refreshConnections = forward("refreshConnections");
|
||||
|
||||
@@ -488,6 +488,10 @@ export async function checkConnectionHealth(connectionId: string): Promise<void>
|
||||
return post("/api/connection/check-health", { connectionId });
|
||||
}
|
||||
|
||||
export async function prewarmConnection(connectionId: string, database?: string, catalog?: string, clientSessionId?: string): Promise<void> {
|
||||
return post("/api/connection/prewarm", { connectionId, database, catalog, clientSessionId });
|
||||
}
|
||||
|
||||
export async function connectionIdentifierQuote(connectionId: string, database?: string): Promise<string | undefined> {
|
||||
const quote = await post<string | null>("/api/connection/identifier-quote", {
|
||||
connectionId,
|
||||
|
||||
@@ -1335,6 +1335,10 @@ export async function checkConnectionHealth(connectionId: string): Promise<void>
|
||||
return invokeBackend("check_connection_health", { connectionId });
|
||||
}
|
||||
|
||||
export async function prewarmConnection(connectionId: string, database?: string, catalog?: string, clientSessionId?: string): Promise<void> {
|
||||
return invokeBackend("prewarm_connection", { connectionId, database, catalog, clientSessionId });
|
||||
}
|
||||
|
||||
export async function connectionIdentifierQuote(connectionId: string, database?: string): Promise<string | undefined> {
|
||||
const quote = await invoke<string | null>("connection_identifier_quote", {
|
||||
connectionId,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function postgresConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "pg-1",
|
||||
name: "Postgres",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "",
|
||||
database: "app",
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
function installApiMocks(prewarmConnection: ReturnType<typeof vi.fn>) {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
getColumns: vi.fn().mockResolvedValue([]),
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
listObjects: vi.fn().mockResolvedValue([]),
|
||||
listTables: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
prewarmConnection,
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createStore() {
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = postgresConnection();
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("connectionStore driver prewarm", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("warms the driver once per pool target and skips unconnected connections", async () => {
|
||||
const prewarmConnection = vi.fn().mockResolvedValue(undefined);
|
||||
installApiMocks(prewarmConnection);
|
||||
const store = await createStore();
|
||||
|
||||
store.warmConnection("pg-1", { database: "app" });
|
||||
store.warmConnection("pg-1", { database: "app" });
|
||||
store.warmConnection("pg-1", { database: "app", clientSessionId: "tab-1" });
|
||||
store.warmConnection("unknown", { database: "app" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prewarmConnection).toHaveBeenCalledTimes(2);
|
||||
expect(prewarmConnection).toHaveBeenCalledWith("pg-1", "app", undefined, undefined);
|
||||
expect(prewarmConnection).toHaveBeenCalledWith("pg-1", "app", undefined, "tab-1");
|
||||
});
|
||||
|
||||
it("does not surface warm-up failures and retries with a fresh attempt", async () => {
|
||||
const prewarmConnection = vi.fn().mockRejectedValueOnce(new Error("driver unavailable")).mockResolvedValue(undefined);
|
||||
installApiMocks(prewarmConnection);
|
||||
const store = await createStore();
|
||||
|
||||
expect(() => store.warmConnection("pg-1", { database: "app" })).not.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
store.warmConnection("pg-1", { database: "app" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(prewarmConnection).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("forgets warm-up bookkeeping when a connection is torn down", async () => {
|
||||
const prewarmConnection = vi.fn().mockResolvedValue(undefined);
|
||||
installApiMocks(prewarmConnection);
|
||||
const store = await createStore();
|
||||
const waitForCall = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
store.warmConnection("pg-1", { database: "app", clientSessionId: "tab-1" });
|
||||
await waitForCall();
|
||||
expect(prewarmConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Inside the TTL window the second request is deduplicated.
|
||||
store.warmConnection("pg-1", { database: "app", clientSessionId: "tab-1" });
|
||||
await waitForCall();
|
||||
expect(prewarmConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The pool is gone after a teardown, so the next warm-up must run again
|
||||
// instead of trusting the stale timestamp.
|
||||
store.markConnectionOffline("pg-1");
|
||||
store.connectedIds.add("pg-1");
|
||||
store.warmConnection("pg-1", { database: "app", clientSessionId: "tab-1" });
|
||||
await waitForCall();
|
||||
expect(prewarmConnection).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -209,6 +209,9 @@ const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
|
||||
const SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY = "dbx-sidebar-table-name-filters";
|
||||
const CONNECTION_HEALTH_CHECK_TTL_MS = 2000;
|
||||
const CONNECTION_HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
/** How long a successful driver/pool warm-up is trusted before the next tab
|
||||
* activation warms the same pool target again. */
|
||||
const CONNECTION_PREWARM_TTL_MS = 30_000;
|
||||
const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000;
|
||||
const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 60_000;
|
||||
const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000;
|
||||
@@ -495,6 +498,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const databaseCompatibilityModes = ref<Record<string, string>>({});
|
||||
const databaseCompatibilityRefreshes = new Map<string, Promise<void>>();
|
||||
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
|
||||
/** Per pool-target timestamp of the last successful driver/pool warm-up (see
|
||||
* `warmConnection`). Fire-and-forget: a skipped warm-up only means the pool is
|
||||
* created by the first real request instead. */
|
||||
const lastConnectionPrewarmAt = ref<Record<string, number>>({});
|
||||
const connectionPrewarmInFlight = new Map<string, Promise<void>>();
|
||||
const agentDrivers = ref<AgentDriverInstallState[]>([]);
|
||||
let agentDriversRefreshPromise: Promise<void> | null = null;
|
||||
let localAgentDriversRefreshPromise: Promise<void> | null = null;
|
||||
@@ -1318,6 +1326,58 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return typeof checkedAt === "number" && Date.now() - checkedAt < CONNECTION_HEALTH_CHECK_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets warm-up bookkeeping for a connection. The backend pool is gone once a
|
||||
* connection is torn down, so a remembered timestamp must not make
|
||||
* `warmConnection` skip a warm-up that the next execution would then pay for.
|
||||
*/
|
||||
function clearConnectionPrewarmState(connectionId: string) {
|
||||
const prefix = `${connectionId}\u0000`;
|
||||
for (const key of Object.keys(lastConnectionPrewarmAt.value)) {
|
||||
if (key.startsWith(prefix)) delete lastConnectionPrewarmAt.value[key];
|
||||
}
|
||||
for (const key of connectionPrewarmInFlight.keys()) {
|
||||
if (key.startsWith(prefix)) connectionPrewarmInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function connectionPrewarmKey(connectionId: string, target: { database?: string; catalog?: string; clientSessionId?: string }) {
|
||||
return [connectionId, target.database ?? "", target.catalog ?? "", target.clientSessionId ?? ""].join("\u0000");
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the driver and connection pool for a connection a tab is about to use.
|
||||
*
|
||||
* Opening a SQL editor tab used to do nothing until the user pressed Run, so
|
||||
* the first execution paid pool creation, tunnel setup, and — for externally
|
||||
* driven databases such as Oracle — JDBC driver/agent startup. Those seconds
|
||||
* were visible in the loading indicator but are outside the statement timer,
|
||||
* which is why the summary could read "23ms" after a multi-second wait.
|
||||
* Warming in the background moves that cost off the critical path. Failures
|
||||
* are ignored here because the real request reports them with full context.
|
||||
*/
|
||||
function warmConnection(connectionId: string, target: { database?: string; catalog?: string; clientSessionId?: string } = {}) {
|
||||
if (!connectionId || !connectedIds.value.has(connectionId)) return;
|
||||
const key = connectionPrewarmKey(connectionId, target);
|
||||
const warmedAt = lastConnectionPrewarmAt.value[key];
|
||||
if (typeof warmedAt === "number" && Date.now() - warmedAt < CONNECTION_PREWARM_TTL_MS) return;
|
||||
if (connectionPrewarmInFlight.has(key)) return;
|
||||
const promise = api
|
||||
.prewarmConnection(connectionId, target.database, target.catalog, target.clientSessionId)
|
||||
.then(() => {
|
||||
lastConnectionPrewarmAt.value[key] = Date.now();
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed warm-up is not a user-facing error: the next real request
|
||||
// rebuilds the pool and surfaces any genuine failure itself.
|
||||
})
|
||||
.finally(() => {
|
||||
connectionPrewarmInFlight.delete(key);
|
||||
});
|
||||
connectionPrewarmInFlight.set(key, promise);
|
||||
void promise;
|
||||
}
|
||||
|
||||
function clearConnectionNodeLoading(connectionId: string) {
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (node) node.isLoading = false;
|
||||
@@ -4382,6 +4442,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
clearConnectionHealthCheck(connectionId);
|
||||
clearConnectionPrewarmState(connectionId);
|
||||
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
|
||||
invalidateCompletionCache(connectionId);
|
||||
cancelObjectDdlLoadsForConnection(connectionId);
|
||||
@@ -4403,6 +4464,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
node.isLoading = false;
|
||||
}
|
||||
clearConnectionHealthCheck(connectionId);
|
||||
clearConnectionPrewarmState(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4426,6 +4488,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
forgetSuccessfulLocalConnectionAttempt(connectionId);
|
||||
clearConnectionHealthCheck(connectionId);
|
||||
clearConnectionPrewarmState(connectionId);
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (node) {
|
||||
node.isLoading = false;
|
||||
@@ -9714,6 +9777,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
hasSessionCredential,
|
||||
closeDatabaseConnection,
|
||||
ensureConnected,
|
||||
warmConnection,
|
||||
loadConnectedConnectionRootForSidebarSearch,
|
||||
isTreeNodeChildrenLoaded,
|
||||
canUseLoadedTreeNodeToggle,
|
||||
|
||||
Reference in new issue
Block a user