perf(execution): prewarm tab session pools and report perceived wait

This commit is contained in:
zipg authored and GitHub committed 2026-09-22 14:25:39 +08:00
1 parent 78315c377a
commit f468399fbd
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);
+1
View File
@@ -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");
+4
View File
@@ -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,
+4
View File
@@ -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,