From d0497e0ca6dc4046e62818095285b575713ca620 Mon Sep 17 00:00:00 2001 From: efeire Date: Thu, 24 Sep 2026 19:17:27 +0800 Subject: [PATCH] feat(query): cross-database timing details, drop OceanBase audit query --- CONTRIBUTING.zh-CN.md | 24 ++-- .../java/com/dbx/agent/AbstractJdbcAgent.java | 7 ++ .../java/com/dbx/agent/DatabaseAgent.java | 3 + .../main/java/com/dbx/agent/JdbcExecutor.java | 22 ++++ .../java/com/dbx/agent/JsonRpcServer.java | 48 ++++++-- .../java/com/dbx/agent/QueryPageResult.java | 5 + .../main/java/com/dbx/agent/QueryResult.java | 5 + .../main/java/com/dbx/agent/QueryTiming.java | 38 ++++++ .../dbx/agent/JdbcConnectionPoolingTest.java | 6 + .../oceanbaseoracle/OceanBaseOracleAgent.java | 97 +++------------ .../OceanBaseOracleAgentTest.java | 33 ++---- apps/desktop/src/components/grid/DataGrid.vue | 10 +- .../components/grid/QueryTimingDetails.vue | 88 ++++++++++++++ .../grid/__tests__/QueryTimingDetails.spec.ts | 82 +++++++++++++ apps/desktop/src/i18n/locales/en.ts | 24 +++- apps/desktop/src/i18n/locales/es.ts | 22 ++++ apps/desktop/src/i18n/locales/it.ts | 22 ++++ apps/desktop/src/i18n/locales/ja.ts | 22 ++++ apps/desktop/src/i18n/locales/ko.ts | 22 ++++ apps/desktop/src/i18n/locales/pt-BR.ts | 22 ++++ apps/desktop/src/i18n/locales/ru.ts | 22 ++++ apps/desktop/src/i18n/locales/zh-CN.ts | 24 +++- apps/desktop/src/i18n/locales/zh-TW.ts | 24 +++- .../lib/__tests__/queryRequestTiming.spec.ts | 30 +++++ .../lib/__tests__/tabs/tabResultCache.spec.ts | 12 ++ apps/desktop/src/lib/queryRequestTiming.ts | 28 +++++ apps/desktop/src/lib/tabs/tabResultCache.ts | 16 +++ .../queryStore.mongoExecutionSummary.spec.ts | 3 + .../queryStore.multiStatementError.spec.ts | 22 +++- apps/desktop/src/stores/queryStore.ts | 111 ++++++++++++------ apps/desktop/src/types/database.ts | 7 +- crates/dbx-cli/src/main.rs | 2 + crates/dbx-core/src/ai/agent_tools.rs | 1 + crates/dbx-core/src/connection/mod.rs | 1 + crates/dbx-core/src/data/table_export.rs | 1 + crates/dbx-core/src/data/transfer.rs | 1 + crates/dbx-core/src/query/mod.rs | 48 +++++++- crates/dbx-core/src/query/mongo_ops.rs | 1 + crates/dbx-core/src/schema/kingbase.rs | 1 + crates/dbx-core/src/schema/mod.rs | 13 ++ .../table_structure_sql/sqlite_rebuild.rs | 1 + .../dbx-drivers/src/db/clickhouse_driver.rs | 2 + .../dbx-drivers/src/db/cloudflare_d1/mod.rs | 1 + crates/dbx-drivers/src/db/dynamodb_driver.rs | 2 + .../src/db/elasticsearch_driver.rs | 5 + crates/dbx-drivers/src/db/influxdb3_driver.rs | 1 + crates/dbx-drivers/src/db/influxdb_driver.rs | 3 + .../dbx-drivers/src/db/meilisearch_driver.rs | 1 + crates/dbx-drivers/src/db/mysql.rs | 11 ++ crates/dbx-drivers/src/db/postgres.rs | 5 + crates/dbx-drivers/src/db/rqlite_driver.rs | 2 + crates/dbx-drivers/src/db/solr_driver.rs | 2 + crates/dbx-drivers/src/db/sqlite.rs | 2 + crates/dbx-drivers/src/db/sqlite_worker.rs | 1 + crates/dbx-drivers/src/db/sqlserver.rs | 12 ++ crates/dbx-drivers/src/db/turso_driver.rs | 4 + crates/dbx-drivers/src/db/vector_driver.rs | 2 + .../src/db/victoriametrics_driver.rs | 2 + crates/dbx-mcp/src/backend.rs | 1 + crates/dbx-mcp/src/http.rs | 1 + crates/dbx-mcp/src/server.rs | 6 + crates/dbx-mcp/src/transaction.rs | 1 + .../examples/query_timing_roundtrip.rs | 18 +++ crates/dbx-types/src/types.rs | 8 ++ crates/dbx-web/src/routes/query.rs | 1 + docs/testing/pr-preflight.md | 51 ++++++++ docs/testing/query-timing-common.png | Bin 0 -> 27223 bytes docs/testing/query-timing-details.md | 37 ++++++ docs/testing/query-timing-details.png | Bin 0 -> 42207 bytes 69 files changed, 973 insertions(+), 180 deletions(-) create mode 100644 agents/common/src/main/java/com/dbx/agent/QueryTiming.java create mode 100644 apps/desktop/src/components/grid/QueryTimingDetails.vue create mode 100644 apps/desktop/src/components/grid/__tests__/QueryTimingDetails.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/queryRequestTiming.spec.ts create mode 100644 apps/desktop/src/lib/queryRequestTiming.ts create mode 100644 crates/dbx-types/examples/query_timing_roundtrip.rs create mode 100644 docs/testing/pr-preflight.md create mode 100644 docs/testing/query-timing-common.png create mode 100644 docs/testing/query-timing-details.md create mode 100644 docs/testing/query-timing-details.png diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 8aac8031cf..ba9924f848 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -57,18 +57,18 @@ cd agents ## 项目结构 -| 路径 | 说明 | -| --- | --- | -| `apps/desktop/src/` | Vue 前端 | -| `src-tauri/` | Tauri 桌面端壳层与命令层 | -| `crates/dbx-core/` | 共享 Rust 数据库逻辑 | -| `crates/dbx-web/` | Docker / Web HTTP 后端 | -| `packages/cli/` | `@dbx-app/cli` | -| `packages/mcp-server/` | `@dbx-app/mcp-server` | +| 路径 | 说明 | +| ----------------------- | --------------------------------- | +| `apps/desktop/src/` | Vue 前端 | +| `src-tauri/` | Tauri 桌面端壳层与命令层 | +| `crates/dbx-core/` | 共享 Rust 数据库逻辑 | +| `crates/dbx-web/` | Docker / Web HTTP 后端 | +| `packages/cli/` | `@dbx-app/cli` | +| `packages/mcp-server/` | `@dbx-app/mcp-server` | | `packages/mongo-shell/` | 桌面端内部 MongoDB 编辑器解析工具 | -| `docs/` | 官方文档站 | -| `examples/` | 配置与自动化示例 | -| `agents/` | JDBC Agent 驱动工程 | +| `docs/` | 官方文档站 | +| `examples/` | 配置与自动化示例 | +| `agents/` | JDBC Agent 驱动工程 | ## 开发约定 @@ -84,6 +84,8 @@ cd agents 一个 PR 只做一类事。文档 PR 不要夹带无关代码;修 Bug 时也不要顺手大重构,除非重构是修复所必需的。 +提交前和推送后请按 [PR 提交与 CI 核查](docs/testing/pr-preflight.md) 检查多语言完整性、相关工作流及远端状态。 + ### 提交说明 提交信息用自然语言写清楚即可: diff --git a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java index 4412834396..528eb48044 100644 --- a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java @@ -32,6 +32,9 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { private boolean sessionAffinity; private boolean pooledConnectionPoisoned; + @Override + public boolean supportsQueryTiming() { return true; } + @Override public final Connection getConnection() { return connection; @@ -185,8 +188,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { @Override public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { + long prepareStarted = System.nanoTime(); Connection conn = requireConnected(); uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs())); + QueryTiming.record("session_prepare", prepareStarted); return JdbcExecutor.current().execute( conn, sql, @@ -204,8 +209,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { @Override public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { + long prepareStarted = System.nanoTime(); Connection conn = requireConnected(); uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs())); + QueryTiming.record("session_prepare", prepareStarted); return JdbcExecutor.current().executePage( conn, sql, diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java index 1924d958ea..c12640d4ce 100644 --- a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -11,6 +11,9 @@ import java.util.Set; import java.util.stream.Collectors; public interface DatabaseAgent { + /** Opt in to request-boundary timing, including pooled connection lifecycle. */ + default boolean supportsQueryTiming() { return false; } + void connect(ConnectParams params); boolean testConnection(ConnectParams params); diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java b/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java index 8d87747727..078ffaad68 100644 --- a/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java +++ b/agents/common/src/main/java/com/dbx/agent/JdbcExecutor.java @@ -140,7 +140,10 @@ public final class JdbcExecutor { String trimmedSql = trimSql(sql); long start = System.currentTimeMillis(); + long phaseStarted = System.nanoTime(); applySchema(conn, schema, setSchemaSql, resetSchemaSql); + QueryTiming.record("schema", phaseStarted); + phaseStarted = System.nanoTime(); try (Statement stmt = conn.createStatement()) { activeStatements.add(stmt); @@ -153,7 +156,10 @@ public final class JdbcExecutor { } // SQL dumps often contain BEGIN/COMMIT/ROLLBACK as executable statements. // Do not translate them to Connection.commit(), which requires autoCommit=false. + QueryTiming.record("statement_prepare", phaseStarted); + phaseStarted = System.nanoTime(); boolean hasResultSet = stmt.execute(trimmedSql); + QueryTiming.record("jdbc_execute", phaseStarted); long elapsed = System.currentTimeMillis() - start; long affectedRows; if (advancePastUpdateCounts) { @@ -210,6 +216,7 @@ public final class JdbcExecutor { ResultValueReader valueReader ) { return unchecked(() -> { + long phaseStarted = System.nanoTime(); ResultSetMetaData meta = rs.getMetaData(); int colCount = meta.getColumnCount(); List columns = new ArrayList<>(colCount); @@ -226,6 +233,8 @@ public final class JdbcExecutor { typeNameByIndex[i - 1] = typeName; } + QueryTiming.record("metadata", phaseStarted); + phaseStarted = System.nanoTime(); List> rows = new ArrayList<>(initialRowCapacity(maxRows)); boolean truncated = false; while (rs.next()) { @@ -236,6 +245,7 @@ public final class JdbcExecutor { rows.add(rowValues(rs, valueReader, sqlTypeByIndex, typeNameByIndex)); } + QueryTiming.record("fetch", phaseStarted); return new QueryResult(columns, columnTypes, rows, 0L, executionTimeMs, truncated); }); } @@ -383,7 +393,10 @@ public final class JdbcExecutor { String trimmedSql = trimSql(sql); long start = System.currentTimeMillis(); + long phaseStarted = System.nanoTime(); applySchema(conn, schema, setSchemaSql, resetSchemaSql); + QueryTiming.record("schema", phaseStarted); + phaseStarted = System.nanoTime(); Statement stmt = conn.createStatement(); QuerySession createdSession = null; @@ -395,7 +408,10 @@ public final class JdbcExecutor { } // Keep script transaction-control statements in the SQL stream. // JDBC transaction APIs are reserved for executeTransaction. + QueryTiming.record("statement_prepare", phaseStarted); + phaseStarted = System.nanoTime(); boolean hasResultSet = stmt.execute(trimmedSql); + QueryTiming.record("jdbc_execute", phaseStarted); long elapsed = System.currentTimeMillis() - start; long affectedRows; if (advancePastUpdateCounts) { @@ -442,6 +458,7 @@ public final class JdbcExecutor { ); } + phaseStarted = System.nanoTime(); ResultSet rs = stmt.getResultSet(); ResultSetMetaData meta = rs.getMetaData(); String sessionId = UUID.randomUUID().toString(); @@ -471,7 +488,10 @@ public final class JdbcExecutor { ); createdSession = session; targetSessions.put(sessionId, session); + QueryTiming.record("metadata", phaseStarted); + phaseStarted = System.nanoTime(); QueryPageResult page = readSessionPage(targetSessions, session, options.getPageSize(), elapsed); + QueryTiming.record("fetch", phaseStarted); // Same reason as `execute`: pulling the first page's rows is // part of the duration the caller waited for. page.setExecution_time_ms(System.currentTimeMillis() - start); @@ -724,7 +744,9 @@ public final class JdbcExecutor { synchronized (session) { long fetchStart = System.currentTimeMillis(); try { + long phaseStarted = System.nanoTime(); QueryPageResult page = readSessionPage(targetSessions, session, pageSize, 0L); + QueryTiming.record("fetch", phaseStarted); // A later page reads its rows after `stmt.execute()` has long // returned, so it has to measure the fetch itself instead of // reporting 0ms while the caller waits. diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java index ee252726b6..dbfc2b646e 100644 --- a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -89,6 +89,26 @@ public final class JsonRpcServer { } Object dispatchForRuntime(String method, JsonObject params) throws Exception { + boolean timedQuery = agent.supportsQueryTiming() + && (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method) + || AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method) + || AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method)); + try (QueryTiming timing = timedQuery ? QueryTiming.begin() : null) { + Object result = dispatchWithConnection(method, params); + // Capture after connection return/reset, which runs in the finally + // block below. Driver-only snapshots omit that lifecycle work. + if (timing != null) { + if (result instanceof QueryResult) { + ((QueryResult) result).setQuery_timings_ms(timing.finish()); + } else if (result instanceof QueryPageResult) { + ((QueryPageResult) result).setQuery_timings_ms(timing.finish()); + } + } + return result; + } + } + + private Object dispatchWithConnection(String method, JsonObject params) throws Exception { return AgentExecutionContext.withJdbcExecutor(jdbcExecutor, () -> { AbstractJdbcAgent jdbcAgent = pooledJdbcAgent(); if (AgentProtocol.METHOD_VALIDATE_CONNECTION.equals(method) @@ -98,7 +118,12 @@ public final class JsonRpcServer { } boolean manageConnection = jdbcAgent != null && requiresConnectedConnection(method); if (manageConnection) { - jdbcAgent.beginPooledRequest(); + long acquireStarted = System.nanoTime(); + try { + jdbcAgent.beginPooledRequest(); + } finally { + QueryTiming.record("pool_acquire", acquireStarted); + } } boolean succeeded = false; try { @@ -107,14 +132,19 @@ public final class JsonRpcServer { return result; } finally { if (manageConnection) { - jdbcAgent.finishPooledRequest( - jdbcExecutor, - succeeded, - requiresSessionAffinity(method, params), - evictAfterRequest(method), - endsSessionAffinity(method), - preservesSchemaContext() - ); + long releaseStarted = System.nanoTime(); + try { + jdbcAgent.finishPooledRequest( + jdbcExecutor, + succeeded, + requiresSessionAffinity(method, params), + evictAfterRequest(method), + endsSessionAffinity(method), + preservesSchemaContext() + ); + } finally { + QueryTiming.record("pool_release", releaseStarted); + } } } }); diff --git a/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java b/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java index bfee05d6ea..6e37e34af3 100644 --- a/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java +++ b/agents/common/src/main/java/com/dbx/agent/QueryPageResult.java @@ -13,6 +13,7 @@ public final class QueryPageResult { private long affected_rows; private long execution_time_ms; private Long server_execute_time_us; + private java.util.Map query_timings_ms; /** Number of rows read by this JDBC cursor across all pages; never sent over JSON RPC. */ private transient long cursor_rows_read; private boolean truncated; @@ -100,6 +101,10 @@ public final class QueryPageResult { return execution_time_ms; } + public java.util.Map getQuery_timings_ms() { return query_timings_ms; } + + public void setQuery_timings_ms(java.util.Map value) { query_timings_ms = value; } + public Long getServer_execute_time_us() { return server_execute_time_us; } diff --git a/agents/common/src/main/java/com/dbx/agent/QueryResult.java b/agents/common/src/main/java/com/dbx/agent/QueryResult.java index e45fdb91b5..03c2c81aad 100644 --- a/agents/common/src/main/java/com/dbx/agent/QueryResult.java +++ b/agents/common/src/main/java/com/dbx/agent/QueryResult.java @@ -18,6 +18,7 @@ public final class QueryResult { private long affected_rows; private long execution_time_ms; private Long server_execute_time_us; + private java.util.Map query_timings_ms; private boolean truncated; public QueryResult() { @@ -85,6 +86,10 @@ public final class QueryResult { return execution_time_ms; } + public java.util.Map getQuery_timings_ms() { return query_timings_ms; } + + public void setQuery_timings_ms(java.util.Map value) { query_timings_ms = value; } + public Long getServer_execute_time_us() { return server_execute_time_us; } diff --git a/agents/common/src/main/java/com/dbx/agent/QueryTiming.java b/agents/common/src/main/java/com/dbx/agent/QueryTiming.java new file mode 100644 index 0000000000..a938784d50 --- /dev/null +++ b/agents/common/src/main/java/com/dbx/agent/QueryTiming.java @@ -0,0 +1,38 @@ +package com.dbx.agent; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Request-scoped, monotonic timings. Disabled unless a driver explicitly opens a scope. */ +public final class QueryTiming implements AutoCloseable { + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private final Map stages = new LinkedHashMap<>(); + private final long started = System.nanoTime(); + private final QueryTiming previous; + + private QueryTiming() { + previous = CURRENT.get(); + CURRENT.set(this); + } + + public static QueryTiming begin() { return new QueryTiming(); } + + public static void record(String stage, long started) { + QueryTiming timing = CURRENT.get(); + if (timing != null) timing.stages.merge(stage, (System.nanoTime() - started) / 1_000_000.0, Double::sum); + } + + public Map finish() { + Map result = new LinkedHashMap<>(stages); + result.put("agent_total", (System.nanoTime() - started) / 1_000_000.0); + return result; + } + + @Override public void close() { + // A driver scope also works for direct calls. When invoked through the + // RPC boundary, carry its non-overlapping stages into the outer request. + // agent_total is a snapshot only; never merge nested totals. + if (previous != null) stages.forEach((key, value) -> previous.stages.merge(key, value, Double::sum)); + if (previous == null) CURRENT.remove(); else CURRENT.set(previous); + } +} diff --git a/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java b/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java index 0cd554e285..7601c3287f 100644 --- a/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java +++ b/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java @@ -1112,6 +1112,12 @@ class JdbcConnectionPoolingTest { pageParams )); assertTrue(firstPage.get("has_more").getAsBoolean()); + JsonObject timings = firstPage.getAsJsonObject("query_timings_ms"); + assertNotNull(timings, "all JDBC agents expose timings through JSON RPC"); + assertTrue(timings.get("pool_acquire").getAsDouble() >= 0); + assertTrue(timings.get("jdbc_execute").getAsDouble() >= 0); + assertTrue(timings.get("fetch").getAsDouble() >= 0); + assertTrue(timings.get("pool_release").getAsDouble() >= 0); String querySessionId = firstPage.get("session_id").getAsString(); Future waiting = worker.submit( diff --git a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java index bbe7dbbce5..aa8d499c0c 100644 --- a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java +++ b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java @@ -27,6 +27,7 @@ import com.dbx.agent.PartitionInfo; import com.dbx.agent.QueryPageOptions; import com.dbx.agent.QueryPageResult; import com.dbx.agent.QueryResult; +import com.dbx.agent.QueryTiming; import com.dbx.agent.TableInfo; import com.dbx.agent.TriggerInfo; @@ -34,7 +35,6 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Statement; import java.sql.Types; import java.util.ArrayDeque; import java.util.ArrayList; @@ -66,9 +66,6 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { "GGSYS", "FLOWS_FILES", "APEX_PUBLIC_USER", "GSMROOTUSER", "SYSRAC" ); private boolean queryTimeoutChanged; - private String auditEligibleCursorId; - private static final String SQL_AUDIT_BY_TRACE = "SELECT EXECUTE_TIME, RETURN_ROWS FROM SYS.GV$OB_SQL_AUDIT " - + "WHERE TRACE_ID = ? AND IS_INNER_SQL = 0 AND IS_EXECUTOR_RPC = 0 AND ROWNUM <= 2"; public static final JdbcAgentProfile OCEANBASE_ORACLE_PROFILE = new JdbcAgentProfile( "com.oceanbase.jdbc.Driver", @@ -88,97 +85,33 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { super(OCEANBASE_ORACLE_PROFILE); } + @Override + public boolean supportsQueryTiming() { return true; } + @Override public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) { - auditEligibleCursorId = null; - QueryResult result = super.executeQuery(sql, schema, options); - if (!result.getTruncated() && !result.getColumns().isEmpty()) { - result.setServer_execute_time_us(availableServerExecuteTimeUs( - result.getRows().size(), JdbcExecutor.statementMaxRows(options.getMaxRows()) - )); + try (QueryTiming timing = QueryTiming.begin()) { + QueryResult result = super.executeQuery(sql, schema, options); + result.setQuery_timings_ms(timing.finish()); + return result; } - return result; } @Override public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) { - auditEligibleCursorId = null; - QueryPageResult result = super.executeQueryPage(sql, schema, options); - if (result.getHas_more()) { - auditEligibleCursorId = result.getSession_id(); + try (QueryTiming timing = QueryTiming.begin()) { + QueryPageResult result = super.executeQueryPage(sql, schema, options); + result.setQuery_timings_ms(timing.finish()); return result; } - return withCompletedCursorServerTiming(result); } @Override public QueryPageResult fetchQueryPage(String sessionId, int pageSize) { - boolean auditEligible = sessionId != null && sessionId.equals(auditEligibleCursorId); - QueryPageResult result = super.fetchQueryPage(sessionId, pageSize); - if (!result.getHas_more()) auditEligibleCursorId = null; - return auditEligible ? withCompletedCursorServerTiming(result) : result; - } - - @Override - protected void beforeAgentMethod(String method, String querySessionId) { - if (!AgentProtocol.METHOD_FETCH_QUERY_PAGE.equals(method) - || auditEligibleCursorId == null - || !auditEligibleCursorId.equals(querySessionId)) { - auditEligibleCursorId = null; - } - } - - private QueryPageResult withCompletedCursorServerTiming(QueryPageResult result) { - // JdbcExecutor closes the ResultSet and Statement before returning a terminal - // page. Never issue audit SQL while Connector/J still owns an open cursor. - // executePage limits rows while reading, without Statement.setMaxRows. - // Preserve its JDBC default of 0 here; QueryPageOptions.maxRows is a - // client-side cap, not the statement limit used by Connector/J. - if (!result.getHas_more() && !result.getTruncated() && !result.getColumns().isEmpty()) { - result.setServer_execute_time_us(availableServerExecuteTimeUs(result.getCursor_rows_read(), 0)); - } - return result; - } - - private Long availableServerExecuteTimeUs(long expectedRows, int statementMaxRows) { - try { - return serverExecuteTimeUs(requireConnected(), expectedRows, statementMaxRows); - } catch (RuntimeException ignored) { - return null; - } - } - - static Long serverExecuteTimeUs(Connection connection, long expectedRows, int statementMaxRows) { - try { - String traceId; - try (Statement statement = connection.createStatement()) { - // Connector/J resets the session's SQL_SELECT_LIMIT in execute prolog - // when this differs from the previous statement. That SET would - // replace the query's LAST_TRACE_ID before we can read it. - statement.setMaxRows(statementMaxRows); - statement.setQueryTimeout(1); - try (ResultSet traces = statement.executeQuery("SELECT LAST_TRACE_ID() FROM DUAL")) { - traceId = traces.next() ? traces.getString(1) : null; - } - } - if (traceId == null || traceId.isBlank()) return null; - try (PreparedStatement statement = connection.prepareStatement(SQL_AUDIT_BY_TRACE)) { - statement.setMaxRows(statementMaxRows); - statement.setQueryTimeout(1); - statement.setString(1, traceId); - try (ResultSet audit = statement.executeQuery()) { - if (!audit.next()) return null; - long executionUs = audit.getLong(1); - if (audit.wasNull() || executionUs < 0) return null; - long returnedRows = audit.getLong(2); - if (audit.wasNull() || returnedRows != expectedRows || audit.next()) return null; - return executionUs; - } - } - } catch (SQLException | RuntimeException ignored) { - // SQL Audit can be disabled, unavailable to this account, or evicted. - // The user's query result remains valid; no wall-clock substitute is used. - return null; + try (QueryTiming timing = QueryTiming.begin()) { + QueryPageResult result = super.fetchQueryPage(sessionId, pageSize); + result.setQuery_timings_ms(timing.finish()); + return result; } } diff --git a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java index 1e07fdfd76..a89e06077a 100644 --- a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java +++ b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java @@ -1,6 +1,5 @@ package com.dbx.agent.oceanbaseoracle; -import com.dbx.agent.AgentProtocol; import com.dbx.agent.ColumnInfo; import com.dbx.agent.CompletionAssistantMatchMode; import com.dbx.agent.CompletionAssistantObjectKind; @@ -141,28 +140,9 @@ class OceanBaseOracleAgentTest { Assertions.assertThrows(IllegalArgumentException.class, () -> OceanBaseOracleAgent.queryTimeoutSql(-1)); } - @Test - void readsServerExecutionOnlyFromOneMatchingAuditRow() { - List statements = new ArrayList<>(); - List parameters = new ArrayList<>(); - List maxRows = new ArrayList<>(); - Assertions.assertEquals(370L, OceanBaseOracleAgent.serverExecuteTimeUs( - auditTimingConnection(1, 500, false, statements, parameters, maxRows), 500, 1001 - )); - Assertions.assertEquals(List.of(1001, 1001), maxRows, - "trace and audit statements must preserve the target statement's session row limit"); - Assertions.assertEquals(List.of("trace-1"), parameters); - Assertions.assertTrue(statements.get(1).contains("IS_INNER_SQL = 0 AND IS_EXECUTOR_RPC = 0")); - Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(0, 500, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0)); - Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(2, 500, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0)); - Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(1, 1, false, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0)); - Assertions.assertEquals(null, OceanBaseOracleAgent.serverExecuteTimeUs(auditTimingConnection(1, 500, true, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()), 500, 0)); - } - - @ParameterizedTest @CsvSource({"10, 1000, false", "1, 1000, false", "1, 1, true", "2, 2, false"}) - void preservesTheCursorStatementLimitWhenSamplingItsTerminalPage(int pageSize, int maxRows, boolean truncated) { + void returnsCursorRowsWithoutAdditionalAuditQueries(int pageSize, int maxRows, boolean truncated) { List auditLimits = new ArrayList<>(); List auditSql = new ArrayList<>(); Connection auditConnection = auditTimingConnection(1, 2, false, auditSql, new ArrayList<>(), auditLimits); @@ -219,12 +199,13 @@ class OceanBaseOracleAgentTest { Assertions.assertEquals(truncated, result.getTruncated()); Assertions.assertEquals(truncated ? List.of(List.of(1)) : List.of(List.of(1), List.of(2)), rows); Assertions.assertEquals(0, queryLimit[0], "the paging cap must not change the JDBC statement limit"); - Assertions.assertEquals(truncated ? List.of() : List.of(0, 0), auditLimits); - Assertions.assertEquals(truncated ? null : Long.valueOf(370), result.getServer_execute_time_us()); + Assertions.assertTrue(auditSql.isEmpty(), "completed queries must not read trace or audit records"); + Assertions.assertTrue(auditLimits.isEmpty()); + Assertions.assertNull(result.getServer_execute_time_us()); } @Test - void doesNotAuditAnOpenCursorAfterAnotherSessionMethodCanReplaceItsTrace() { + void finishesCursorWithoutCreatingDiagnosticStatements() { int[] row = {-1}; ResultSetMetaData meta = proxy(ResultSetMetaData.class, (method, args) -> { if ("getColumnCount".equals(method.getName())) return 1; @@ -259,11 +240,11 @@ class OceanBaseOracleAgentTest { Assertions.assertTrue(first.getHas_more()); Assertions.assertEquals(2, statementsCreated[0]); - agent.beforeAgentMethod(AgentProtocol.METHOD_LIST_TABLES, null); + QueryPageResult last = agent.fetchQueryPage(first.getSession_id(), 1); Assertions.assertFalse(last.getHas_more()); Assertions.assertNull(last.getServer_execute_time_us()); - Assertions.assertEquals(2, statementsCreated[0], "no LAST_TRACE_ID query may run for an invalidated cursor"); + Assertions.assertEquals(2, statementsCreated[0], "no diagnostic query may run when the cursor finishes"); } @Test diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index aac03b8f26..79adae8f95 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1,4 +1,5 @@ + + diff --git a/apps/desktop/src/components/grid/__tests__/QueryTimingDetails.spec.ts b/apps/desktop/src/components/grid/__tests__/QueryTimingDetails.spec.ts new file mode 100644 index 0000000000..358f932432 --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/QueryTimingDetails.spec.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +import { createApp, nextTick } from "vue"; +import { createI18n } from "vue-i18n"; +import { describe, expect, it } from "vitest"; +import QueryTimingDetails from "../QueryTimingDetails.vue"; +import zh from "@/i18n/locales/zh-CN"; + +describe("query timing disclosure", () => { + it("shows only wait, then ordered details on keyboard focus", async () => { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp(QueryTimingDetails, { + result: { + columns: [], + rows: [], + affected_rows: 0, + execution_time_ms: 55, + client_request_wait_ms: 7030, + server_execute_time_us: 1019, + query_timings_ms: { pool_acquire: 350, pool_release: 5, session_prepare: 2, schema: 3, statement_prepare: 1, jdbc_execute: 40, metadata: 2, fetch: 7, trace: 1, audit: 6000, agent_total: 6411 }, + }, + renderMs: 4, + }); + app.use(createI18n({ legacy: false, locale: "zh-CN", messages: { "zh-CN": zh } })); + app.mount(host); + await nextTick(); + expect(host.textContent).toBe("7030ms"); + expect(host.textContent).not.toContain("数据库执行"); + host.querySelector("button")!.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await nextTick(); + const text = document.body.textContent!; + expect(text).toContain("1."); + expect(text).not.toContain("读取审计记录"); + expect(text).not.toContain("读取追踪 ID"); + expect(text).not.toContain("数据库计划"); + expect(text).toContain("后端报告耗时"); + expect(text).not.toContain("驱动报告耗时"); + expect(text).toContain("获取连接"); + expect(text).toContain("归还连接"); + const details = document.querySelector('[data-testid="query-timing-details"]')!; + const stages = [...details.querySelectorAll("tbody tr")].map((row) => row.textContent!); + // No Core lock sample means both the lock and dependent remainder are absent. + expect(stages).toHaveLength(12); + expect([...details.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual(["序号", "阶段", "耗时"]); + expect(stages.findIndex((s) => s.includes("获取连接"))).toBeLessThan(stages.findIndex((s) => s.includes("会话"))); + expect(stages.findIndex((s) => s.includes("归还连接"))).toBeGreaterThan(stages.findIndex((s) => s.includes("读取结果"))); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + app.unmount(); + host.remove(); + }); + it("shows common request stages without inventing JDBC steps for a native or HTTP result", async () => { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp(QueryTimingDetails, { result: { columns: [], rows: [], affected_rows: 0, execution_time_ms: 4, client_request_wait_ms: 12, client_prepare_ms: 2, client_result_ms: 1 }, renderMs: 3 }); + app.use(createI18n({ legacy: false, locale: "zh-CN", messages: { "zh-CN": zh } })); + app.mount(host); + await nextTick(); + host.querySelector("button")!.focus(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await nextTick(); + const details = document.querySelector('[data-testid="query-timing-details"]')!; + expect(details.querySelectorAll("tbody tr")).toHaveLength(4); + expect(details.textContent).toContain("请求等待"); + expect(details.textContent).not.toContain("JDBC"); + expect(details.textContent).not.toContain("获取连接"); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + app.unmount(); + host.remove(); + }); + it("does not substitute execution time when wait or phases are missing", async () => { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp(QueryTimingDetails, { result: { columns: [], rows: [], affected_rows: 0, execution_time_ms: 55 } }); + app.use(createI18n({ legacy: false, locale: "zh-CN", messages: { "zh-CN": zh } })); + app.mount(host); + await nextTick(); + expect(host.textContent).toBe("—"); + app.unmount(); + host.remove(); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 958898ba94..9a5aaddc77 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2094,8 +2094,30 @@ export default { serverExecuteTimeHint: "OceanBase SQL Audit EXECUTE_TIME measures server plan execution. A JDBC cursor shows its completed statement sample; independent page queries show the sum only when every page is sampled. This overlaps with execution and wait timings. Unavailable while a cursor remains open or when audit data cannot be read or matched.", agentExecuteTimeHint: "Agent time from schema setup through JDBC execute. Excludes row fetching; not database server execution time.", + timingNumber: "No.", + timingStage: "Stage", + timingDuration: "Time", + timingDetails: "Query timing details", + timingUnavailable: "Not measured", + timingPages: "Cumulative across {count} requests; rendering is for the latest update.", + timingPhase_request: "Request wait", + timingPhase_prepare: "Preparation (outside wait)", + timingPhase_core_lock: "Wait for execution lock", + timingPhase_pool_acquire: "Acquire connection", + timingPhase_session_prepare: "Prepare session", + timingPhase_pool_release: "Return connection", + timingPhase_schema: "Set schema", + timingPhase_statement_prepare: "Prepare statement", + timingPhase_jdbc_execute: "Execute SQL (JDBC)", + timingPhase_metadata: "Read column metadata", + timingPhase_fetch: "Read rows and close cursor", + timingPhase_agent_other: "Other Agent processing (remainder)", + timingPhase_remainder: "Other wait (remainder, not network time)", + timingPhase_result: "Process results (outside wait)", + timingBackend: "Backend-reported time (overlaps stages)", + timingPhase_render: "Update grid (outside wait)", clientRequestWait: "Wait: {ms} ms", - clientRequestWaitHint: "Desktop time from query request dispatch until the full result payload arrives. Includes fetching, the optional server audit lookup, and transport; excludes SQL preparation and grid rendering. Summed across loaded pages.", + clientRequestWaitHint: "Desktop time from query request dispatch until the full result payload arrives. Includes backend processing, fetching, and transport; excludes request preparation and grid rendering. Summed across loaded pages.", resultViewUpdate: "Grid update: {ms} ms", resultViewUpdateHint: "Time from the grid observing this result until its DOM update completes or a Canvas draw is submitted. Excludes GPU compositing and screen presentation.", querySuccess: "Query executed successfully", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 65f19b76a2..c097cb6e34 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1992,6 +1992,28 @@ export default withEnglishFallback({ serverExecuteTimeHint: "EXECUTE_TIME de OceanBase SQL Audit mide la ejecución del plan en el servidor. Los cursores JDBC muestran la muestra al finalizar la sentencia original; la paginación con consultas independientes solo muestra la suma si todas las páginas tienen muestras. Se solapa con los tiempos de ejecución y espera. No está disponible si el cursor sigue abierto o si los datos de auditoría no se pueden leer o asociar.", agentExecuteTimeHint: "Tiempo del Agent desde la configuración del esquema hasta que retorna JDBC execute. No incluye la lectura de filas ni equivale al tiempo de ejecución del servidor de base de datos.", + timingNumber: "N.º", + timingStage: "Etapa", + timingDuration: "Tiempo", + timingDetails: "Detalles del tiempo de consulta", + timingUnavailable: "Sin medir", + timingPages: "Acumulado de {count} solicitudes; el renderizado corresponde a la última actualización.", + timingPhase_request: "Espera de la solicitud", + timingPhase_prepare: "Preparación (fuera de la espera)", + timingPhase_core_lock: "Espera del bloqueo de ejecución", + timingPhase_pool_acquire: "Obtener conexión", + timingPhase_session_prepare: "Preparar sesión", + timingPhase_pool_release: "Devolver conexión", + timingPhase_schema: "Establecer esquema", + timingPhase_statement_prepare: "Preparar sentencia", + timingPhase_jdbc_execute: "Ejecutar SQL (JDBC)", + timingPhase_metadata: "Leer metadatos de columnas", + timingPhase_fetch: "Leer filas y cerrar cursor", + timingPhase_agent_other: "Otros procesos del Agent (diferencia)", + timingPhase_remainder: "Otras esperas (diferencia, no es tiempo de red)", + timingPhase_result: "Procesar resultados (fuera de la espera)", + timingBackend: "Tiempo informado por el backend (se solapa con las etapas)", + timingPhase_render: "Actualizar tabla (fuera de la espera)", clientRequestWait: "Espera: {ms} ms", clientRequestWaitHint: "Tiempo desde que la aplicación de escritorio envía la consulta hasta que recibe el resultado completo. Incluye la lectura de filas, la consulta de auditoría del servidor cuando está disponible y la transferencia; excluye la preparación del SQL y el renderizado de la tabla. Se suma entre las páginas cargadas.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7bf2ecfe56..29124d827f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1926,6 +1926,28 @@ export default withEnglishFallback({ serverExecuteTimeHint: "EXECUTE_TIME di OceanBase SQL Audit misura l'esecuzione del piano sul server. I cursori JDBC mostrano il campione al termine dell'istruzione originale; la paginazione con query indipendenti mostra la somma solo se tutte le pagine hanno un campione. Si sovrappone ai tempi di esecuzione e attesa. Non è disponibile se il cursore è ancora aperto o se i dati di audit non possono essere letti o associati.", agentExecuteTimeHint: "Tempo dell'Agent dall'impostazione dello schema al ritorno di JDBC execute. Esclude la lettura delle righe e non equivale al tempo di esecuzione del server di database.", + timingNumber: "N.", + timingStage: "Fase", + timingDuration: "Tempo", + timingDetails: "Dettagli dei tempi della query", + timingUnavailable: "Non misurato", + timingPages: "Totale di {count} richieste; il rendering si riferisce al più recente aggiornamento.", + timingPhase_request: "Attesa della richiesta", + timingPhase_prepare: "Preparazione (fuori dal tempo di attesa)", + timingPhase_core_lock: "Attesa del blocco di esecuzione", + timingPhase_pool_acquire: "Acquisizione connessione", + timingPhase_session_prepare: "Preparazione sessione", + timingPhase_pool_release: "Restituzione connessione", + timingPhase_schema: "Impostazione schema", + timingPhase_statement_prepare: "Preparazione istruzione", + timingPhase_jdbc_execute: "Esecuzione SQL (JDBC)", + timingPhase_metadata: "Lettura metadati delle colonne", + timingPhase_fetch: "Lettura righe e chiusura cursore", + timingPhase_agent_other: "Altre elaborazioni Agent (differenza)", + timingPhase_remainder: "Altre attese (differenza, non tempo di rete)", + timingPhase_result: "Elaborazione risultati (fuori dal tempo di attesa)", + timingBackend: "Tempo riportato dal backend (sovrapposto alle fasi)", + timingPhase_render: "Aggiornamento tabella (fuori dal tempo di attesa)", clientRequestWait: "Attesa: {ms} ms", clientRequestWaitHint: "Tempo dall'invio della query dall'applicazione desktop alla ricezione del risultato completo. Include la lettura delle righe, la query di audit del server quando disponibile e il trasferimento; esclude la preparazione SQL e il rendering della tabella. Viene sommato tra le pagine caricate.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 558bd4a42e..afe0ced55b 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1948,6 +1948,28 @@ export default withEnglishFallback({ serverExecuteTimeHint: "OceanBase SQL Audit の EXECUTE_TIME はサーバーでの実行計画の処理時間です。JDBC カーソルでは元の文の終了後に取得したサンプルを表示します。ページごとに独立した SQL を実行する場合は、すべてのページのサンプルがあるときだけ合計を表示します。実行時間や待機時間と範囲が重複します。カーソルが終了していない場合や、監査データの取得・関連付けができない場合は取得不可となります。", agentExecuteTimeHint: "スキーマの設定から JDBC execute が戻るまでの Agent の処理時間です。行の取得は含まず、データベースサーバーだけの実行時間ではありません。", + timingNumber: "番号", + timingStage: "段階", + timingDuration: "時間", + timingDetails: "クエリ時間の詳細", + timingUnavailable: "未計測", + timingPages: "{count} 件のリクエストの累計。描画時間は直近の更新分です。", + timingPhase_request: "リクエスト待機", + timingPhase_prepare: "準備(待機時間に含まない)", + timingPhase_core_lock: "実行ロック待機", + timingPhase_pool_acquire: "接続の取得", + timingPhase_session_prepare: "セッションの準備", + timingPhase_pool_release: "接続の返却", + timingPhase_schema: "スキーマの設定", + timingPhase_statement_prepare: "ステートメントの準備", + timingPhase_jdbc_execute: "SQL の実行(JDBC)", + timingPhase_metadata: "列メタデータの取得", + timingPhase_fetch: "行の読み取りとカーソルの終了", + timingPhase_agent_other: "その他の Agent 処理(差分)", + timingPhase_remainder: "その他の待機(差分、通信時間ではない)", + timingPhase_result: "結果の処理(待機時間に含まない)", + timingBackend: "バックエンド報告時間(各段階と重複)", + timingPhase_render: "表の更新(待機時間に含まない)", clientRequestWait: "待機時間:{ms} ms", clientRequestWaitHint: "デスクトップ側がクエリ要求を送信してから結果全体を受信するまでの時間です。行の取得、利用可能な場合のサーバー監査クエリ、転送を含み、SQL の準備とテーブル描画は含みません。複数ページを読み込んだ場合は各ページの待機時間を合計します。", resultViewUpdate: "テーブル更新:{ms} ms", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 2407dc196a..e04b32c05c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1877,6 +1877,28 @@ export default withEnglishFallback({ serverExecuteTimeHint: "OceanBase SQL Audit의 EXECUTE_TIME은 서버에서 실행 계획을 수행한 시간입니다. JDBC 커서는 원래 구문이 끝난 후의 샘플을 표시하며, 페이지별 독립 SQL은 모든 페이지에 샘플이 있을 때만 합계를 표시합니다. 실행 시간 및 대기 시간과 측정 범위가 겹칩니다. 커서가 끝나지 않았거나 감사 데이터를 읽거나 연결할 수 없으면 사용할 수 없습니다.", agentExecuteTimeHint: "스키마 설정부터 JDBC execute가 반환될 때까지의 Agent 처리 시간입니다. 행 가져오기는 포함하지 않으며 데이터베이스 서버만의 실행 시간은 아닙니다.", + timingNumber: "번호", + timingStage: "단계", + timingDuration: "시간", + timingDetails: "쿼리 시간 상세", + timingUnavailable: "측정되지 않음", + timingPages: "{count}개 요청의 누적 시간입니다. 렌더링은 최근 업데이트 기준입니다.", + timingPhase_request: "요청 대기", + timingPhase_prepare: "준비 (대기 시간 제외)", + timingPhase_core_lock: "실행 잠금 대기", + timingPhase_pool_acquire: "연결 획득", + timingPhase_session_prepare: "세션 준비", + timingPhase_pool_release: "연결 반환", + timingPhase_schema: "스키마 설정", + timingPhase_statement_prepare: "문 준비", + timingPhase_jdbc_execute: "SQL 실행 (JDBC)", + timingPhase_metadata: "열 메타데이터 읽기", + timingPhase_fetch: "행 읽기 및 커서 닫기", + timingPhase_agent_other: "기타 Agent 처리 (차이값)", + timingPhase_remainder: "기타 대기 (차이값, 네트워크 시간 아님)", + timingPhase_result: "결과 처리 (대기 시간 제외)", + timingBackend: "백엔드 보고 시간 (단계별 시간과 중복)", + timingPhase_render: "표 업데이트 (대기 시간 제외)", clientRequestWait: "대기 시간: {ms} ms", clientRequestWaitHint: "데스크톱에서 쿼리 요청을 보낸 시점부터 전체 결과를 받을 때까지의 시간입니다. 행 가져오기, 사용 가능한 경우의 서버 감사 조회 및 전송을 포함하며 SQL 준비와 테이블 렌더링은 제외합니다. 여러 페이지를 불러오면 각 페이지의 대기 시간을 합산합니다.", resultViewUpdate: "테이블 갱신: {ms} ms", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 00c3ec3181..36e2c55886 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1928,6 +1928,28 @@ export default withEnglishFallback({ serverExecuteTimeHint: "EXECUTE_TIME do OceanBase SQL Audit mede a execução do plano no servidor. Os cursores JDBC mostram a amostra após o término da instrução original; a paginação com consultas independentes só mostra a soma quando todas as páginas têm amostras. Há sobreposição com os tempos de execução e espera. Indisponível enquanto o cursor não terminar ou quando os dados de auditoria não puderem ser lidos ou associados.", agentExecuteTimeHint: "Tempo do Agent desde a configuração do esquema até o retorno de JDBC execute. Exclui a leitura de linhas e não equivale ao tempo de execução do servidor de banco de dados.", + timingNumber: "Nº", + timingStage: "Etapa", + timingDuration: "Tempo", + timingDetails: "Detalhes do tempo da consulta", + timingUnavailable: "Não medido", + timingPages: "Acumulado de {count} solicitações; a renderização corresponde à última atualização.", + timingPhase_request: "Espera da solicitação", + timingPhase_prepare: "Preparação (fora da espera)", + timingPhase_core_lock: "Espera pelo bloqueio de execução", + timingPhase_pool_acquire: "Obter conexão", + timingPhase_session_prepare: "Preparar sessão", + timingPhase_pool_release: "Devolver conexão", + timingPhase_schema: "Definir esquema", + timingPhase_statement_prepare: "Preparar instrução", + timingPhase_jdbc_execute: "Executar SQL (JDBC)", + timingPhase_metadata: "Ler metadados das colunas", + timingPhase_fetch: "Ler linhas e fechar cursor", + timingPhase_agent_other: "Outros processamentos do Agent (diferença)", + timingPhase_remainder: "Outras esperas (diferença, não é tempo de rede)", + timingPhase_result: "Processar resultados (fora da espera)", + timingBackend: "Tempo informado pelo backend (sobrepõe as etapas)", + timingPhase_render: "Atualizar tabela (fora da espera)", clientRequestWait: "Espera: {ms} ms", clientRequestWaitHint: "Tempo entre o envio da consulta pelo aplicativo desktop e o recebimento do resultado completo. Inclui a leitura de linhas, a consulta de auditoria do servidor quando disponível e a transferência; exclui a preparação do SQL e a renderização da tabela. Soma os tempos de espera das páginas carregadas.", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index e0809be458..27eeefd9c7 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -1976,6 +1976,28 @@ export default withEnglishFallback({ serverExecuteTimeHint: "OceanBase SQL Audit EXECUTE_TIME измеряет выполнение плана на сервере. Курсор JDBC показывает образец завершённого оператора; независимые запросы страниц показывают сумму только тогда, когда каждая страница попала в выборку. Эти данные пересекаются со временем выполнения и ожидания. Недоступно, пока курсор остаётся открытым или пока данные аудита нельзя прочитать или сопоставить.", agentExecuteTimeHint: "Время агента от настройки схемы до выполнения JDBC. Не включает выборку строк; это не время выполнения на сервере БД.", + timingNumber: "№", + timingStage: "Этап", + timingDuration: "Время", + timingDetails: "Подробности времени запроса", + timingUnavailable: "Не измерено", + timingPages: "Суммарно по {count} запросам; отрисовка относится к последнему обновлению.", + timingPhase_request: "Ожидание запроса", + timingPhase_prepare: "Подготовка (вне ожидания)", + timingPhase_core_lock: "Ожидание блокировки выполнения", + timingPhase_pool_acquire: "Получение соединения", + timingPhase_session_prepare: "Подготовка сеанса", + timingPhase_pool_release: "Возврат соединения", + timingPhase_schema: "Установка схемы", + timingPhase_statement_prepare: "Подготовка оператора", + timingPhase_jdbc_execute: "Выполнение SQL (JDBC)", + timingPhase_metadata: "Чтение метаданных столбцов", + timingPhase_fetch: "Чтение строк и закрытие курсора", + timingPhase_agent_other: "Прочая обработка Agent (разница)", + timingPhase_remainder: "Прочее ожидание (разница, не время сети)", + timingPhase_result: "Обработка результатов (вне ожидания)", + timingBackend: "Время от backend (перекрывается с этапами)", + timingPhase_render: "Обновление таблицы (вне ожидания)", clientRequestWait: "Ожидание: {ms} мс", clientRequestWaitHint: "Время настольного приложения от отправки запроса до получения полного ответа. Включает выборку, необязательный поиск в аудите сервера и передачу данных; не включает подготовку SQL и отрисовку таблицы. Суммируется по загруженным страницам.", resultViewUpdate: "Обновление таблицы: {ms} мс", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2b8a6c6f1e..9dbc3c5105 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2006,8 +2006,30 @@ export default withEnglishFallback({ serverExecuteTimeUnavailable: "数据库执行:不可用", serverExecuteTimeHint: "OceanBase SQL Audit EXECUTE_TIME 表示服务端执行计划的耗时。JDBC 游标显示原语句结束后的样本;独立分页 SQL 仅在各页均有样本时显示总和。与执行耗时和等待耗时范围重叠。游标未结束或审计数据无法读取、关联时不可用。", agentExecuteTimeHint: "从设置 schema 到 JDBC execute 返回的 Agent 耗时;不含取行,并非数据库服务端纯执行时间。", + timingNumber: "序号", + timingStage: "阶段", + timingDuration: "耗时", + timingDetails: "查询耗时详情", + timingUnavailable: "未采集", + timingPages: "累计 {count} 次请求;表格更新为最近一次。", + timingPhase_request: "请求等待", + timingPhase_prepare: "请求准备(不计入等待)", + timingPhase_core_lock: "等待执行锁", + timingPhase_pool_acquire: "获取连接", + timingPhase_session_prepare: "准备会话", + timingPhase_pool_release: "归还连接", + timingPhase_schema: "设置 schema", + timingPhase_statement_prepare: "准备语句", + timingPhase_jdbc_execute: "执行 SQL(JDBC)", + timingPhase_metadata: "读取列信息", + timingPhase_fetch: "读取结果并关闭游标", + timingPhase_agent_other: "Agent 其他处理(差额)", + timingPhase_remainder: "其他等待(差额,非网络耗时)", + timingPhase_result: "结果整理(不计入等待)", + timingBackend: "后端报告耗时(与步骤重叠)", + timingPhase_render: "表格更新(不计入等待)", clientRequestWait: "等待耗时:{ms} 毫秒", - clientRequestWaitHint: "桌面端发出查询请求至收到完整结果的耗时,包含取行、可用时的服务端审计查询和传输,不含 SQL 准备与表格渲染;加载多页时累加各页等待时间。", + clientRequestWaitHint: "桌面端发出查询请求至收到完整结果的耗时,包含后端处理、取行和传输,不含请求准备与表格渲染;加载多页时累加各页等待时间。", resultViewUpdate: "表格更新:{ms} 毫秒", resultViewUpdateHint: "从表格接收到本次结果,到 DOM 更新完成或 Canvas 绘制提交;不含 GPU 合成及屏幕实际呈现。", querySuccess: "查询执行成功", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 16f42f3965..6317862234 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1951,8 +1951,30 @@ export default withEnglishFallback({ serverExecuteTimeUnavailable: "資料庫執行:無法取得", serverExecuteTimeHint: "OceanBase SQL Audit EXECUTE_TIME 表示伺服端執行計畫的耗時。JDBC 游標顯示原語句結束後的樣本;獨立分頁 SQL 僅在各頁均有樣本時顯示總和。與執行耗時和等待耗時範圍重疊。游標尚未結束或稽核資料無法讀取、關聯時無法取得。", agentExecuteTimeHint: "從設定 schema 到 JDBC execute 返回的 Agent 耗時;不含讀取資料列,並非資料庫伺服端純執行時間。", + timingNumber: "序號", + timingStage: "階段", + timingDuration: "耗時", + timingDetails: "查詢耗時詳情", + timingUnavailable: "未採集", + timingPages: "累計 {count} 次請求;表格更新為最近一次。", + timingPhase_request: "請求等待", + timingPhase_prepare: "請求準備(不計入等待)", + timingPhase_core_lock: "等待執行鎖", + timingPhase_pool_acquire: "取得連線", + timingPhase_session_prepare: "準備工作階段", + timingPhase_pool_release: "歸還連線", + timingPhase_schema: "設定 schema", + timingPhase_statement_prepare: "準備語句", + timingPhase_jdbc_execute: "執行 SQL(JDBC)", + timingPhase_metadata: "讀取欄位資訊", + timingPhase_fetch: "讀取結果並關閉游標", + timingPhase_agent_other: "Agent 其他處理(差額)", + timingPhase_remainder: "其他等待(差額,非網路耗時)", + timingPhase_result: "結果整理(不計入等待)", + timingBackend: "後端回報耗時(與步驟重疊)", + timingPhase_render: "表格更新(不計入等待)", clientRequestWait: "等待耗時:{ms} 毫秒", - clientRequestWaitHint: "桌面端送出查詢請求至收到完整結果的耗時,包含讀取資料列、可用時的伺服端稽核查詢與傳輸,不含 SQL 準備與表格呈現;載入多頁時累加各頁等待時間。", + clientRequestWaitHint: "桌面端送出查詢請求至收到完整結果的耗時,包含後端處理、讀取資料列與傳輸,不含請求準備與表格呈現;載入多頁時累加各頁等待時間。", resultViewUpdate: "表格更新:{ms} 毫秒", resultViewUpdateHint: "從表格接收到本次結果,到 DOM 更新完成或 Canvas 繪製提交;不含 GPU 合成及螢幕實際呈現。", querySuccess: "查詢執行成功", diff --git a/apps/desktop/src/lib/__tests__/queryRequestTiming.spec.ts b/apps/desktop/src/lib/__tests__/queryRequestTiming.spec.ts new file mode 100644 index 0000000000..b4640e5c02 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/queryRequestTiming.spec.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createQueryRequestTiming } from "../queryRequestTiming"; + +afterEach(() => vi.restoreAllMocks()); + +it("separates preparation, backend waits and local conversion across multiple calls", async () => { + let now = 100; + vi.spyOn(performance, "now").mockImplementation(() => now); + const timing = createQueryRequestTiming(80); + const value = await timing.run(async () => { + now = 130; + return "result"; + }); + expect(value).toBe("result"); + now = 140; + await timing.run(async () => { + now = 160; + }); + now = 165; + const result = { columns: [], rows: [], affected_rows: 0, execution_time_ms: 99 }; + expect(timing.finish(result)).toMatchObject({ client_prepare_ms: 20, client_request_wait_ms: 50, client_result_ms: 15, timing_page_count: 2, execution_time_ms: 99 }); +}); + +it("does not invent request timings for a local command and propagates failures without replay", async () => { + const result = { columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 }; + expect(createQueryRequestTiming(0).finish(result)).not.toHaveProperty("client_request_wait_ms"); + const call = vi.fn().mockRejectedValue(new Error("query failed")); + await expect(createQueryRequestTiming(0).run(call)).rejects.toThrow("query failed"); + expect(call).toHaveBeenCalledTimes(1); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts index 6d1a0f648d..5be361ad4c 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts @@ -17,6 +17,18 @@ function backend(name: ResultCacheBackend["name"], overrides: Partial { + it("retains timing details for active, array and historical results through cache restore", async () => { + const { buildTabResultSnapshot } = await import("@/lib/tabs/tabResultCache"); + const timing = { query_timings_ms: { agent_total: 40, pool_acquire: 20, pool_release: 2 }, client_prepare_ms: 3, client_result_ms: 4, timing_page_count: 2 }; + const result = { columns: ["N"], rows: [[1]], affected_rows: 0, execution_time_ms: 50, ...timing }; + const tab = { result, results: [result], resultRuns: [{ id: "timed-run", title: "Run", sequence: 1, sql: "SELECT 1", createdAt: 1, result, results: [result] }] }; + const snapshot = buildTabResultSnapshot(tab as Parameters[0])!; + expect(snapshot.result?.query_timings_ms).not.toBe(result.query_timings_ms); + const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(snapshot)); + for (const value of [restored.result, restored.results?.[0], restored.resultRuns?.[0].result, restored.resultRuns?.[0].results?.[0]]) { + expect(value).toMatchObject({ rows: [[1]], ...timing }); + } + }); it("selects one authoritative backend before fallback", () => { expect(resultCacheBackendOrder(true)).toEqual(["runtime", "indexed-db"]); expect(resultCacheBackendOrder(false)).toEqual(["indexed-db", "runtime"]); diff --git a/apps/desktop/src/lib/queryRequestTiming.ts b/apps/desktop/src/lib/queryRequestTiming.ts new file mode 100644 index 0000000000..00205a4653 --- /dev/null +++ b/apps/desktop/src/lib/queryRequestTiming.ts @@ -0,0 +1,28 @@ +import type { QueryResult } from "@/types/database"; + +/** Local command timing; run wraps only backend calls, never result conversion. */ +export function createQueryRequestTiming(prepareStartedAt: number) { + let firstRequestAt: number | undefined; + let waitMs = 0; + let requests = 0; + return { + async run(request: (...args: A) => Promise, ...args: A): Promise { + const startedAt = performance.now(); + firstRequestAt ??= startedAt; + requests += 1; + try { + return await request(...args); + } finally { + waitMs += Math.max(0, performance.now() - startedAt); + } + }, + finish(result: T): T { + if (firstRequestAt === undefined || result.execution_error) return result; + result.client_prepare_ms = Math.max(0, firstRequestAt - prepareStartedAt); + result.client_request_wait_ms = waitMs; + result.client_result_ms = Math.max(0, performance.now() - firstRequestAt - waitMs); + result.timing_page_count = requests; + return result; + }, + }; +} diff --git a/apps/desktop/src/lib/tabs/tabResultCache.ts b/apps/desktop/src/lib/tabs/tabResultCache.ts index a5e66dc2c2..ff27cafdba 100644 --- a/apps/desktop/src/lib/tabs/tabResultCache.ts +++ b/apps/desktop/src/lib/tabs/tabResultCache.ts @@ -71,6 +71,10 @@ interface ColumnarQueryResult { execution_time_ms: number; server_execute_time_us?: number; client_request_wait_ms?: number; + query_timings_ms?: QueryResult["query_timings_ms"]; + client_prepare_ms?: number; + client_result_ms?: number; + timing_page_count?: number; truncated?: boolean; has_more?: boolean; sourceLabel?: string; @@ -361,6 +365,10 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin execution_time_ms: result.execution_time_ms, server_execute_time_us: result.server_execute_time_us, client_request_wait_ms: result.client_request_wait_ms, + query_timings_ms: result.query_timings_ms ? { ...result.query_timings_ms } : undefined, + client_prepare_ms: result.client_prepare_ms, + client_result_ms: result.client_result_ms, + timing_page_count: result.timing_page_count, truncated: result.truncated, session_id: undefined, has_more: result.has_more, @@ -420,6 +428,10 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult execution_time_ms: result.execution_time_ms, server_execute_time_us: result.server_execute_time_us, client_request_wait_ms: result.client_request_wait_ms, + query_timings_ms: result.query_timings_ms ? { ...result.query_timings_ms } : undefined, + client_prepare_ms: result.client_prepare_ms, + client_result_ms: result.client_result_ms, + timing_page_count: result.timing_page_count, truncated: result.truncated, has_more: result.has_more, sourceLabel: result.sourceLabel, @@ -451,6 +463,10 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul execution_time_ms: result.execution_time_ms, server_execute_time_us: result.server_execute_time_us, client_request_wait_ms: result.client_request_wait_ms, + query_timings_ms: result.query_timings_ms ? { ...result.query_timings_ms } : undefined, + client_prepare_ms: result.client_prepare_ms, + client_result_ms: result.client_result_ms, + timing_page_count: result.timing_page_count, truncated: result.truncated, session_id: undefined, has_more: result.has_more, diff --git a/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts index c309a81c5c..05b5e8dad3 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts @@ -80,6 +80,9 @@ describe("queryStore MongoDB execution summary", () => { const tab = store.tabs.find((item) => item.id === tabId)!; expect(tab.result?.rows).toHaveLength(3); + expect(tab.result?.client_request_wait_ms).toEqual(expect.any(Number)); + expect(tab.result?.client_result_ms).toEqual(expect.any(Number)); + expect(tab.result?.timing_page_count).toBe(1); expect(tab.batchSqlExecution).toMatchObject({ total: 1, completed: 1, diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index 627e3fc891..3acb62a453 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -113,6 +113,24 @@ function structuredSqlError(detail = "duplicate key") { } describe("queryStore multi-statement errors", () => { + it.each(["oracle", "oceanbase-oracle"])("preserves the intended offset timing scope for %s", async (dbType) => { + mocks.getConnectionConfig.mockReturnValue({ id: "timing-offset", name: "Timing", db_type: dbType, database: "APP", query_timeout_secs: 30 }); + mocks.analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "complex-query" }); + mocks.prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({ sqlToExecute: options.sql, pageSql: options.sql, pageLimit: options.pagination.limit, pageOffset: options.pagination.offset, countSql: undefined, useAgentResultSession: true })); + const timed = true; + mocks.executeMulti + .mockResolvedValueOnce([{ columns: ["VALUE"], rows: [[1], [2]], affected_rows: 0, execution_time_ms: 12, session_id: "offset-page", has_more: true, ...(timed ? { query_timings_ms: { agent_total: 10 } } : {}) }]) + .mockResolvedValueOnce([{ columns: ["VALUE"], rows: [[3], [4]], affected_rows: 0, execution_time_ms: 34, has_more: false, ...(timed ? { query_timings_ms: { agent_total: 30 } } : {}) }]); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("timing-offset", "APP", "Query", "query", "APP"); + await store.executeTabSql(tabId, "SELECT VALUE FROM T", { pagination: { limit: 2, offset: 2 } }); + const result = store.tabs.find((item) => item.id === tabId)!.result!; + expect(result.rows).toEqual([[3], [4]]); + expect(result.execution_time_ms).toBe(timed ? 46 : 34); + expect(result.query_timings_ms).toEqual(timed ? { agent_total: 40 } : undefined); + expect(result.timing_page_count).toBe(timed ? 2 : undefined); + }); beforeEach(() => { vi.clearAllMocks(); mocks.defaultAutoKeepResults = false; @@ -160,7 +178,7 @@ describe("queryStore multi-statement errors", () => { }); }); - it.each(["oceanbase-oracle", "mysql"] as const)("measures complete result wait only for a single %s query result", async (databaseType) => { + it.each(["oceanbase-oracle", "oracle", "mysql", "postgres", "sqlite", "sqlserver", "db2"] as const)("measures complete result wait only for a single %s query result", async (databaseType) => { mocks.getConnectionConfig.mockReturnValue({ id: "timing-1", name: "Timing", @@ -183,7 +201,7 @@ describe("queryStore multi-statement errors", () => { await execution; const result = store.tabs.find((item) => item.id === tabId)?.result; expect(result?.execution_time_ms).toBe(12); - expect(result?.client_request_wait_ms).toBe(databaseType === "oceanbase-oracle" ? 45 : undefined); + expect(result?.client_request_wait_ms).toBe(45); } finally { vi.restoreAllMocks(); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index e6ccc71158..5ac729d12c 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1,3 +1,4 @@ +import { createQueryRequestTiming } from "@/lib/queryRequestTiming"; import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation"; import { defineStore } from "pinia"; import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonitor"; @@ -346,6 +347,11 @@ export function appendQueryResultSegment(previous: QueryResult, segment: QueryRe // Independent page SQLs need every page sampled before a total is shown. server_execute_time_us: previous.session_id ? segment.server_execute_time_us : previous.server_execute_time_us !== undefined && segment.server_execute_time_us !== undefined ? previous.server_execute_time_us + segment.server_execute_time_us : undefined, client_request_wait_ms: previous.client_request_wait_ms !== undefined && segment.client_request_wait_ms !== undefined ? previous.client_request_wait_ms + segment.client_request_wait_ms : undefined, + query_timings_ms: + previous.query_timings_ms && segment.query_timings_ms ? Object.fromEntries([...new Set([...Object.keys(previous.query_timings_ms), ...Object.keys(segment.query_timings_ms)])].map((key) => [key, (previous.query_timings_ms![key] ?? 0) + (segment.query_timings_ms![key] ?? 0)])) : undefined, + client_prepare_ms: previous.client_prepare_ms !== undefined && segment.client_prepare_ms !== undefined ? previous.client_prepare_ms + segment.client_prepare_ms : undefined, + client_result_ms: previous.client_result_ms !== undefined && segment.client_result_ms !== undefined ? previous.client_result_ms + segment.client_result_ms : undefined, + timing_page_count: (previous.timing_page_count ?? 1) + (segment.timing_page_count ?? 1), has_more: previous.rows.length + appendedRowCount >= maxRows ? false : segment.has_more, }); } @@ -6906,11 +6912,12 @@ export const useQueryStore = defineStore("query", () => { const skipSafety = options?.skipRedisSafetyCheck; let hadMutatingCommand = false; for (const [commandIndex, command] of commands.entries()) { + const commandTiming = createQueryRequestTiming(commandIndex === 0 ? startedAt : performance.now()); const commandRange = commandRanges[commandIndex]; const sourceRange = commandRange && options?.sourceOffset !== undefined ? { from: options.sourceOffset + commandRange.from, to: options.sourceOffset + commandRange.to } : undefined; try { - const result = await api.redisExecuteCommand(executionConnectionId, currentDb, command, skipSafety); - allResults.push(markQueryResultRowsRaw(annotateQueryResultSource(redisCommandResultToQueryResult(result.value, performance.now() - startedAt, command), command, undefined, undefined, sourceRange))); + const result = await commandTiming.run(api.redisExecuteCommand, executionConnectionId, currentDb, command, skipSafety); + allResults.push(commandTiming.finish(markQueryResultRowsRaw(annotateQueryResultSource(redisCommandResultToQueryResult(result.value, performance.now() - startedAt, command), command, undefined, undefined, sourceRange)))); // Track db switches from SELECT N so later commands in the same batch run on the right db. currentDb = nextRedisCommandDb(currentDb, command, result.value); // Write commands (SET/DEL/...) mutate the key set — drop the cached key-name completion @@ -6986,7 +6993,7 @@ export const useQueryStore = defineStore("query", () => { let mongoEditTarget: QueryTab["mongoEditTarget"] | undefined; let mongoFindPageState: { pageLimit: number; pageOffset: number; total: number; totalIsExact: boolean } | undefined; - for (const parsedCommand of mongoCommands) { + for (const [commandIndex, parsedCommand] of mongoCommands.entries()) { let mongoCommand = parsedCommand.command; // db.getSiblingDB("x").: target that database for this command only. const sessionDatabase = currentDatabase; @@ -6998,6 +7005,7 @@ export const useQueryStore = defineStore("query", () => { const sourceStatement = parsedCommand.text; const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + parsedCommand.from, to: options.sourceOffset + parsedCommand.to }; const commandStartedAt = performance.now(); + const commandTiming = createQueryRequestTiming(commandIndex === 0 ? startedAt : commandStartedAt); const annotateMongoResult = (result: QueryResult): QueryResult => { const annotated = annotateQueryResultSource(result, sourceStatement, undefined, undefined, sourceRange); if ("collection" in mongoCommand) { @@ -7005,7 +7013,7 @@ export const useQueryStore = defineStore("query", () => { annotated.sourceName = mongoCommand.collection; annotated.sourceLabel = currentDatabase ? `${currentDatabase}.${mongoCommand.collection}` : mongoCommand.collection; } - return annotated; + return commandTiming.finish(annotated); }; try { // The frontend parser remains responsible for editor ranges, while @@ -7029,7 +7037,19 @@ export const useQueryStore = defineStore("query", () => { if (!pagePlan) throw new Error(describeMongoCommandParseFailure(sourceStatement)); // A stale request can point past an explicit .limit() bound. Keep // the backend call bounded so limit(0) cannot become unbounded. - const result = await api.mongoFindDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, pagePlan.requestSkip, Math.max(1, pagePlan.requestLimit), mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, mongoCommand.collation, executionId); + const result = await commandTiming.run( + api.mongoFindDocuments, + executionConnectionId, + currentDatabase, + mongoCommand.collection, + pagePlan.requestSkip, + Math.max(1, pagePlan.requestLimit), + mongoCommand.filter, + mongoCommand.projection, + mongoCommand.sort, + mongoCommand.collation, + executionId, + ); const documents = pagePlan.requestLimit === 0 ? [] : result.documents; const extendedDocuments = pagePlan.requestLimit === 0 ? [] : result.extended_documents; const totalIsExact = result.total_is_exact !== false; @@ -7057,7 +7077,7 @@ export const useQueryStore = defineStore("query", () => { } case "findOne": { queryExecutionLog("info", "mongo-find-one:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); - const result = await api.mongoFindOne(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId); + const result = await commandTiming.run(api.mongoFindOne, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId); const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false))); allResults.push(queryResult); mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; @@ -7072,7 +7092,7 @@ export const useQueryStore = defineStore("query", () => { } case "version": { queryExecutionLog("info", "mongo-version:start", { traceId, database: currentDatabase }); - const version = await api.mongoServerVersion(executionConnectionId, currentDatabase, executionId); + const version = await commandTiming.run(api.mongoServerVersion, executionConnectionId, currentDatabase, executionId); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoVersionToQueryResult(version, performance.now() - commandStartedAt)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-version:done", { @@ -7085,7 +7105,7 @@ export const useQueryStore = defineStore("query", () => { } case "showDatabases": { queryExecutionLog("info", "mongo-show-databases:start", { traceId }); - const result = await api.mongoRunCommand(executionConnectionId, "admin", '{"listDatabases":1}', executionId); + const result = await commandTiming.run(api.mongoRunCommand, executionConnectionId, "admin", '{"listDatabases":1}', executionId); const queryResult = mongoDatabasesToQueryResult(result.documents, performance.now() - commandStartedAt, agentProtocolQueryResultMaxRows(mongoResultMaxRows)); allResults.push(markQueryResultRowsRaw(annotateMongoResult(queryResult))); mongoEditTarget = undefined; @@ -7098,7 +7118,7 @@ export const useQueryStore = defineStore("query", () => { } case "countDocuments": { queryExecutionLog("info", "mongo-count:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); - const total = await api.mongoCountDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.mode, executionId); + const total = await commandTiming.run(api.mongoCountDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.mode, executionId); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCountToQueryResult(total, performance.now() - commandStartedAt)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-count:done", { @@ -7112,7 +7132,8 @@ export const useQueryStore = defineStore("query", () => { } case "findExplain": { queryExecutionLog("info", "mongo-explain:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); - const plan = await api.mongoExplainFind( + const plan = await commandTiming.run( + api.mongoExplainFind, executionConnectionId, currentDatabase, mongoCommand.collection, @@ -7139,7 +7160,7 @@ export const useQueryStore = defineStore("query", () => { } queryExecutionLog("info", "mongo-aggregate:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize); - const result = await api.mongoAggregateDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, mongoCommand.options, executionId); + const result = await commandTiming.run(api.mongoAggregateDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, mongoCommand.options, executionId); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-aggregate:done", { @@ -7154,7 +7175,7 @@ export const useQueryStore = defineStore("query", () => { } case "distinct": { queryExecutionLog("info", "mongo-distinct:start", { traceId, collection: mongoCommand.collection, database: currentDatabase, field: mongoCommand.field }); - const result = await api.mongoDistinct(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.field, mongoCommand.filter, executionId); + const result = await commandTiming.run(api.mongoDistinct, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.field, mongoCommand.filter, executionId); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDistinctToQueryResult(mongoCommand.field, result.documents, performance.now() - commandStartedAt)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-distinct:done", { @@ -7169,7 +7190,7 @@ export const useQueryStore = defineStore("query", () => { } case "getIndexes": { queryExecutionLog("info", "mongo-indexes:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); - const indexes = await api.listIndexes(executionConnectionId, currentDatabase, "", mongoCommand.collection); + const indexes = await commandTiming.run(api.listIndexes, executionConnectionId, currentDatabase, "", mongoCommand.collection); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoIndexesToQueryResult(indexes, performance.now() - commandStartedAt)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-indexes:done", { @@ -7188,7 +7209,7 @@ export const useQueryStore = defineStore("query", () => { metric: mongoCommand.metric, database: currentDatabase, }); - const stats = await api.mongoCollectionStats(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.scale, executionId); + const stats = await commandTiming.run(api.mongoCollectionStats, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.scale, executionId); // SAFETY: The backend returns collection statistics as a JSON object; the API type is broader than the converter's record-shaped input. allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCollectionStatsToQueryResult(mongoCommand.metric, stats as unknown as Record, performance.now() - commandStartedAt)))); mongoEditTarget = undefined; @@ -7216,10 +7237,10 @@ export const useQueryStore = defineStore("query", () => { }); const result = mongoCommand.kind === "findOneAndUpdate" - ? await api.mongoFindOneAndUpdate(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.options) + ? await commandTiming.run(api.mongoFindOneAndUpdate, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.options) : mongoCommand.kind === "findOneAndReplace" - ? await api.mongoFindOneAndReplace(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options) - : await api.mongoFindOneAndDelete(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options); + ? await commandTiming.run(api.mongoFindOneAndReplace, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options) + : await commandTiming.run(api.mongoFindOneAndDelete, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-find-and-modify:done", { @@ -7241,7 +7262,7 @@ export const useQueryStore = defineStore("query", () => { traceId, database: currentDatabase, }); - const result = await api.mongoRunCommand(executionConnectionId, currentDatabase, mongoCommand.commandJson, executionId); + const result = await commandTiming.run(api.mongoRunCommand, executionConnectionId, currentDatabase, mongoCommand.commandJson, executionId); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents, result.total_is_exact !== false)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-run-command:done", { @@ -7275,38 +7296,38 @@ export const useQueryStore = defineStore("query", () => { }); mongoEditTarget = undefined; if (mongoCommand.kind === "insert") { - const result = await api.mongoInsertDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.docsJson); + const result = await commandTiming.run(api.mongoInsertDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.docsJson); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "update") { - const result = await api.mongoUpdateDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.many, mongoCommand.options); + const result = await commandTiming.run(api.mongoUpdateDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.update, mongoCommand.many, mongoCommand.options); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "bulkWrite") { - const result = await api.mongoBulkWrite(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.operations, mongoCommand.options); + const result = await commandTiming.run(api.mongoBulkWrite, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.operations, mongoCommand.options); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoBulkWriteToQueryResult(result, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "replace") { - const result = await api.mongoReplaceDocument(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options); + const result = await commandTiming.run(api.mongoReplaceDocument, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "createIndex") { - const result = await api.mongoCreateIndex(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.keys, mongoCommand.options); + const result = await commandTiming.run(api.mongoCreateIndex, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.keys, mongoCommand.options); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoCreateIndexToQueryResult(result.name, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "createUser") { - const result = await api.mongoCreateUser(executionConnectionId, currentDatabase, mongoCommand.userJson, mongoCommand.writeConcernJson); + const result = await commandTiming.run(api.mongoCreateUser, executionConnectionId, currentDatabase, mongoCommand.userJson, mongoCommand.writeConcernJson); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "dropIndex" || mongoCommand.kind === "dropIndexes") { try { - const result = await api.mongoDropIndexes(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex"); + const result = await commandTiming.run(api.mongoDropIndexes, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.kind === "dropIndex" ? mongoCommand.index : mongoCommand.indexes, mongoCommand.kind === "dropIndex"); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDroppedIndexesToQueryResult(result.dropped_names, performance.now() - commandStartedAt, result.failures)))); } finally { await refreshLoadedMongoIndexesAfterMutation(executionConnectionId, currentDatabase, mongoCommand.collection, traceId); } } else if (mongoCommand.kind === "renameCollection") { - await api.mongoRenameCollection(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.newName); + await commandTiming.run(api.mongoRenameCollection, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.newName); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoScalarToQueryResult("renamed", `${mongoCommand.collection} -> ${mongoCommand.newName}`, performance.now() - commandStartedAt)))); } else if (mongoCommand.kind === "dropCollection") { - await api.mongoDropCollection(executionConnectionId, currentDatabase, mongoCommand.collection); + await commandTiming.run(api.mongoDropCollection, executionConnectionId, currentDatabase, mongoCommand.collection); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(1, performance.now() - commandStartedAt)))); } else { - const result = await api.mongoDeleteDocuments(executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.many); + const result = await commandTiming.run(api.mongoDeleteDocuments, executionConnectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.many); allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoWriteToQueryResult(result.affected_rows, performance.now() - commandStartedAt)))); } queryExecutionLog("info", "mongo-write:done", { @@ -7420,15 +7441,16 @@ export const useQueryStore = defineStore("query", () => { }); const allResults: QueryResult[] = []; const continueOnError = continueOnBatchError; - for (const request of elasticsearchRequests) { + for (const [requestIndex, request] of elasticsearchRequests.entries()) { + const commandTiming = createQueryRequestTiming(requestIndex === 0 ? startedAt : performance.now()); const current = findExecutionTab(id); if (current?.executionId !== executionId) break; const sourceRange = options?.sourceOffset === undefined ? undefined : { from: options.sourceOffset + request.from, to: options.sourceOffset + request.to }; try { - const result = await api.executeQuery(executionConnectionId, executionDatabase, request.sql, undefined, executionId, { + const result = await commandTiming.run(api.executeQuery, executionConnectionId, executionDatabase, request.sql, undefined, executionId, { timeoutSecs: queryTimeoutSecs, }); - allResults.push(markQueryResultRowsRaw(annotateQueryResultSource(result, request.sql, targetDatabase || conn?.database, effectiveDbType, sourceRange))); + allResults.push(commandTiming.finish(markQueryResultRowsRaw(annotateQueryResultSource(result, request.sql, targetDatabase || conn?.database, effectiveDbType, sourceRange)))); if (elasticsearchHttpErrorStatus(result) !== undefined && !continueOnError) break; } catch (error) { const latest = findExecutionTab(id); @@ -7613,11 +7635,15 @@ export const useQueryStore = defineStore("query", () => { clientSession: Boolean(executionClientSessionId), }); executionDispatched = true; - if (effectiveDbType === "oceanbase-oracle" && tab.mode === "query") clientRequestStartedAt = performance.now(); + if (tab.mode === "query") clientRequestStartedAt = performance.now(); if (useAgentResultSession && tab.mode === "query" && typeof pageOffset === "number" && pageOffset > 0 && !options?.pagination?.sessionId && !(tab.batchSqlExecution && tab.batchSqlExecution.total > 1)) { return (async () => { let sessionId: string | undefined; let skipped = 0; + let pageCount = 0; + let executionMs = 0; + let completeTimings = true; + const timings: Record = {}; while (true) { const pageResults = await api.executeMulti(executionConnectionId, executionDatabase, sqlToExecute, executionSchema, executionId, { ...executionOptions, @@ -7625,10 +7651,17 @@ export const useQueryStore = defineStore("query", () => { }); const page = pageResults[0]; if (!page) return pageResults; + pageCount += 1; + executionMs += page.execution_time_ms; + if (!page.query_timings_ms) completeTimings = false; + else for (const [key, value] of Object.entries(page.query_timings_ms)) timings[key] = (timings[key] ?? 0) + value; + // Offset jumps consume several cursor pages before publication. + // Keep their timings as well, without retaining skipped rows. + const timingSummary = { query_timings_ms: completeTimings ? { ...timings } : undefined, execution_time_ms: executionMs, timing_page_count: pageCount }; if (skipped + page.rows.length > pageOffset) { const start = pageOffset - skipped; const limit = typeof pageLimit === "number" ? pageLimit : page.rows.length - start; - return [{ ...page, rows: page.rows.slice(start, start + limit) }]; + return [{ ...page, ...timingSummary, rows: page.rows.slice(start, start + limit) }]; } skipped += page.rows.length; if (!page.has_more || !page.session_id) { @@ -7637,7 +7670,7 @@ export const useQueryStore = defineStore("query", () => { // returning the short page would show the wrong rows. throw new Error("Result session ended before the requested page offset"); } - return [{ ...page, rows: [] }]; + return [{ ...page, ...timingSummary, rows: [] }]; } sessionId = page.session_id; } @@ -7686,7 +7719,7 @@ export const useQueryStore = defineStore("query", () => { } else { queryExecutionLog("info", "execute-in-txn:invoke", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() }); executionDispatched = true; - if (effectiveDbType === "oceanbase-oracle" && tab.mode === "query") clientRequestStartedAt = performance.now(); + if (tab.mode === "query") clientRequestStartedAt = performance.now(); // Only an initial manual execution classifies the user SQL (sticky // proven-read-only dialects). A later cursor-page fetch must neither // set nor clear the sticky bit. @@ -7741,8 +7774,11 @@ export const useQueryStore = defineStore("query", () => { // A single result has an unambiguous request boundary. This includes fetch and // transport, but excludes SQL preparation and the grid's later render work. if (clientRequestStartedAt !== undefined && responseResults.length === 1 && !responseResults[0]?.execution_error) { - responseResults[0]!.client_request_wait_ms = Math.max(0, Math.round(performance.now() - clientRequestStartedAt)); + responseResults[0]!.client_request_wait_ms = Math.max(0, performance.now() - clientRequestStartedAt); + responseResults[0]!.client_prepare_ms = Math.max(0, clientRequestStartedAt - startedAt); + responseResults[0]!.timing_page_count ??= 1; } + const resultProcessingStartedAt = performance.now(); const annotatedResults = annotateQueryResultSources(markQueryResultsRowsRaw(responseResults), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset, sqlStatementParameterOptions, sqlToExecute, options?.sourceOffset === undefined ? undefined : tab.sql); const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0); if (paginationRowNumberColumn && results.length === 1) { @@ -7822,6 +7858,9 @@ export const useQueryStore = defineStore("query", () => { current.database = mysqlUseDatabase; current.schema = undefined; } + if (clientRequestStartedAt !== undefined && results.length === 1 && !results[0]?.execution_error) { + results[0]!.client_result_ms = Math.max(0, performance.now() - resultProcessingStartedAt); + } const activeGroupIndex = current.activeResultIndex; const activeGroupResults = current.results; const shouldAppendResult = !!options?.appendResult && !!current.result; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 97b0203acf..1cb8756f3b 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1272,8 +1272,13 @@ export interface QueryResult { execution_time_ms: number; /** OceanBase SQL Audit EXECUTE_TIME for a completed statement, in microseconds. */ server_execute_time_us?: number; - /** Desktop wait from query request dispatch to the complete result payload; summed across appended pages. OceanBase Oracle query tabs only. */ + /** Desktop wait from query request dispatch to the complete result payload; summed across appended pages. Completed query/command requests only. */ client_request_wait_ms?: number; + /** Measured phases; totals overlap. Missing phases were not measured. */ + query_timings_ms?: Record; + client_prepare_ms?: number; + client_result_ms?: number; + timing_page_count?: number; /** Whether a backend-reported result total is exact. */ total_is_exact?: boolean; truncated?: boolean; diff --git a/crates/dbx-cli/src/main.rs b/crates/dbx-cli/src/main.rs index 58a971de0c..7acd346154 100644 --- a/crates/dbx-cli/src/main.rs +++ b/crates/dbx-cli/src/main.rs @@ -1077,6 +1077,7 @@ mod tests { affected_rows: 2, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -1153,6 +1154,7 @@ mod tests { affected_rows: 1, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/ai/agent_tools.rs b/crates/dbx-core/src/ai/agent_tools.rs index 0661e32342..243a48f9ce 100644 --- a/crates/dbx-core/src/ai/agent_tools.rs +++ b/crates/dbx-core/src/ai/agent_tools.rs @@ -1922,6 +1922,7 @@ for line in sys.stdin: affected_rows, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/connection/mod.rs b/crates/dbx-core/src/connection/mod.rs index 150279ace1..ce027813d2 100644 --- a/crates/dbx-core/src/connection/mod.rs +++ b/crates/dbx-core/src/connection/mod.rs @@ -7410,6 +7410,7 @@ mod tests { affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/data/table_export.rs b/crates/dbx-core/src/data/table_export.rs index 8a4f21cae6..fd207e4b6f 100644 --- a/crates/dbx-core/src/data/table_export.rs +++ b/crates/dbx-core/src/data/table_export.rs @@ -716,6 +716,7 @@ async fn fetch_table_export_batch( affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/data/transfer.rs b/crates/dbx-core/src/data/transfer.rs index 3ebecdaf8d..333b482194 100644 --- a/crates/dbx-core/src/data/transfer.rs +++ b/crates/dbx-core/src/data/transfer.rs @@ -10797,6 +10797,7 @@ CREATE TABLE "Other"."prefix""Source"."NAME" ("ID" INT);"#; affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/query/mod.rs b/crates/dbx-core/src/query/mod.rs index 29423155cc..596423e39b 100644 --- a/crates/dbx-core/src/query/mod.rs +++ b/crates/dbx-core/src/query/mod.rs @@ -2175,6 +2175,7 @@ async fn do_execute_typed( } let cancel_for_agent = cancel_token.clone(); let result = async move { + let lock_started = std::time::Instant::now(); let mut client = match cancel_for_agent.as_ref() { Some(token) => { tokio::select! { @@ -2188,7 +2189,10 @@ async fn do_execute_typed( } None => client.lock().await, }; - if let Some(session_id) = options.result_session_id.as_deref() { + let lock_ms = lock_started.elapsed().as_secs_f64() * 1000.0; + let response: Result = if let Some(session_id) = + options.result_session_id.as_deref() + { let params = agent_fetch_query_page_params(session_id, options.page_size.unwrap_or(MAX_ROWS)); client .fetch_query_page_typed_with_timeout_and_cancel(params, rpc_timeout, cancel_for_agent.clone()) @@ -2203,7 +2207,14 @@ async fn do_execute_typed( client .execute_query_typed_with_timeout_and_cancel(params, rpc_timeout, cancel_for_agent.clone()) .await - } + }; + response.map(|mut result| { + // Older agents have no phase map. Do not imply complete telemetry. + if let Some(timings) = result.query_timings_ms.as_mut() { + timings.insert("core_lock".into(), lock_ms); + } + result + }) } .await .map(|result| truncate_result_with_max_rows(result, max_rows)); @@ -4208,6 +4219,7 @@ fn error_query_result(message: String) -> db::QueryResult { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4227,6 +4239,7 @@ fn empty_query_result(execution_time_ms: u128) -> db::QueryResult { affected_rows: 0, execution_time_ms, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4574,6 +4587,7 @@ async fn execute_statements_inner( affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5188,6 +5202,7 @@ async fn exec_tx_pg_inner( affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5282,6 +5297,7 @@ async fn exec_tx_mysql_inner( affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5478,6 +5494,7 @@ async fn exec_tx_sqlite_inner( affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5611,6 +5628,7 @@ async fn exec_tx_explicit_inner( affected_rows: total_affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6614,7 +6632,9 @@ async fn execute_manual_txn_agent_statement( execution_schema, options, ); + let lock_started = std::time::Instant::now(); let mut locked = client.lock().await; + let lock_ms = lock_started.elapsed().as_secs_f64() * 1000.0; let result = match request { ManualTxnAgentQueryRequest::Execute(params) => { locked.execute_query_typed_with_timeout::(params, None).await @@ -6627,7 +6647,12 @@ async fn execute_manual_txn_agent_statement( } }; result - .map(|result| truncate_result_with_max_rows(result, Some(row_limit.max(1)))) + .map(|mut result| { + if let Some(timings) = result.query_timings_ms.as_mut() { + timings.insert("core_lock".into(), lock_ms); + } + truncate_result_with_max_rows(result, Some(row_limit.max(1))) + }) .map_err(|error| error.into_legacy_string()) } @@ -6719,6 +6744,7 @@ async fn execute_manual_txn_postgres_statement( affected_rows: affected, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6754,6 +6780,7 @@ async fn execute_manual_txn_mysql_statement( affected_rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6786,6 +6813,7 @@ async fn execute_manual_txn_mysql_statement( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -6806,6 +6834,7 @@ async fn execute_manual_txn_mysql_statement( affected_rows, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6863,6 +6892,7 @@ pub async fn commit_manual_transaction(state: &AppState, txn_session_id: &str) - affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6893,6 +6923,7 @@ pub async fn rollback_manual_transaction(state: &AppState, txn_session_id: &str) affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -9109,6 +9140,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 4, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -9384,6 +9416,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -10444,6 +10477,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -10470,6 +10504,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -11766,6 +11801,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -11804,6 +11840,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -11872,6 +11909,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -11912,6 +11950,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -11944,6 +11983,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -11983,6 +12023,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -12016,6 +12057,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/query/mongo_ops.rs b/crates/dbx-core/src/query/mongo_ops.rs index 3745f420c6..80a0466ffc 100644 --- a/crates/dbx-core/src/query/mongo_ops.rs +++ b/crates/dbx-core/src/query/mongo_ops.rs @@ -1383,6 +1383,7 @@ fn query_result(columns: Vec, rows: Vec>, affecte affected_rows, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/schema/kingbase.rs b/crates/dbx-core/src/schema/kingbase.rs index 0ef460a31f..e6611d2427 100644 --- a/crates/dbx-core/src/schema/kingbase.rs +++ b/crates/dbx-core/src/schema/kingbase.rs @@ -206,6 +206,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/schema/mod.rs b/crates/dbx-core/src/schema/mod.rs index 85837e60f9..a84036484f 100644 --- a/crates/dbx-core/src/schema/mod.rs +++ b/crates/dbx-core/src/schema/mod.rs @@ -3225,6 +3225,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4096,6 +4097,7 @@ done affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4124,6 +4126,7 @@ done affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4149,6 +4152,7 @@ done affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4178,6 +4182,7 @@ done affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4206,6 +4211,7 @@ done affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5177,6 +5183,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5226,6 +5233,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5431,6 +5439,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5450,6 +5459,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5487,6 +5497,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5682,6 +5693,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5788,6 +5800,7 @@ for line in sys.stdin: affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-core/src/schema/table_structure_sql/sqlite_rebuild.rs b/crates/dbx-core/src/schema/table_structure_sql/sqlite_rebuild.rs index 326d932243..2f1e598dc1 100644 --- a/crates/dbx-core/src/schema/table_structure_sql/sqlite_rebuild.rs +++ b/crates/dbx-core/src/schema/table_structure_sql/sqlite_rebuild.rs @@ -685,6 +685,7 @@ fn execute_change_transaction( affected_rows: 0, execution_time_ms: started_at.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/clickhouse_driver.rs b/crates/dbx-drivers/src/db/clickhouse_driver.rs index d605c447b3..b0999121e1 100644 --- a/crates/dbx-drivers/src/db/clickhouse_driver.rs +++ b/crates/dbx-drivers/src/db/clickhouse_driver.rs @@ -590,6 +590,7 @@ fn limited_query_result(result: ChJsonResult, execution_time_ms: u128, max_rows: affected_rows: 0, execution_time_ms, server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -848,6 +849,7 @@ pub async fn execute_query_with_max_rows( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/cloudflare_d1/mod.rs b/crates/dbx-drivers/src/db/cloudflare_d1/mod.rs index 0faaacfcd7..994c31ee24 100644 --- a/crates/dbx-drivers/src/db/cloudflare_d1/mod.rs +++ b/crates/dbx-drivers/src/db/cloudflare_d1/mod.rs @@ -363,6 +363,7 @@ fn query_result( affected_rows, execution_time_ms, server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/dynamodb_driver.rs b/crates/dbx-drivers/src/db/dynamodb_driver.rs index 01d8bfa89d..ce8f911aed 100644 --- a/crates/dbx-drivers/src/db/dynamodb_driver.rs +++ b/crates/dbx-drivers/src/db/dynamodb_driver.rs @@ -572,6 +572,7 @@ fn document_query_result(result: DocumentQueryResult, started: Instant) -> Query affected_rows: 0, execution_time_ms: started.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -591,6 +592,7 @@ fn affected_query_result(affected_rows: u64, started: Instant) -> QueryResult { affected_rows, execution_time_ms: started.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/elasticsearch_driver.rs b/crates/dbx-drivers/src/db/elasticsearch_driver.rs index 0a95115537..daa2dd2397 100644 --- a/crates/dbx-drivers/src/db/elasticsearch_driver.rs +++ b/crates/dbx-drivers/src/db/elasticsearch_driver.rs @@ -2356,6 +2356,7 @@ fn parse_elasticsearch_response_with_sql_parser( affected_rows: row_count, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -2383,6 +2384,7 @@ fn parse_elasticsearch_response_with_sql_parser( affected_rows: row_count, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -2556,6 +2558,7 @@ fn raw_json_response_result( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -2624,6 +2627,7 @@ fn parse_elasticsearch_rest_response_with_sql_parser( affected_rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -3134,6 +3138,7 @@ pub fn parse_tabular_sql_response( .unwrap_or(rows.len() as u64), execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: body.get("cursor").and_then(|cursor| cursor.as_str()).map(str::to_string), has_more: body.get("cursor").and_then(|cursor| cursor.as_str()).is_some(), diff --git a/crates/dbx-drivers/src/db/influxdb3_driver.rs b/crates/dbx-drivers/src/db/influxdb3_driver.rs index c8667fae0f..259927682b 100644 --- a/crates/dbx-drivers/src/db/influxdb3_driver.rs +++ b/crates/dbx-drivers/src/db/influxdb3_driver.rs @@ -367,6 +367,7 @@ fn build_query_result(rows: Vec, start: Instant) -> QueryResu affected_rows: affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/influxdb_driver.rs b/crates/dbx-drivers/src/db/influxdb_driver.rs index b651ce721c..a23c4c6d24 100644 --- a/crates/dbx-drivers/src/db/influxdb_driver.rs +++ b/crates/dbx-drivers/src/db/influxdb_driver.rs @@ -515,6 +515,7 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) - rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -532,6 +533,7 @@ pub async fn execute_query(client: &InfluxdbClient, database: &str, sql: &str) - affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -732,6 +734,7 @@ fn parse_flux_csv(text: &str, start: Instant) -> Result { rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/meilisearch_driver.rs b/crates/dbx-drivers/src/db/meilisearch_driver.rs index ec43de760b..b3bbc9b2af 100644 --- a/crates/dbx-drivers/src/db/meilisearch_driver.rs +++ b/crates/dbx-drivers/src/db/meilisearch_driver.rs @@ -1910,6 +1910,7 @@ fn raw_response_result(status: u16, body: String, start: Instant, truncated: boo affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/mysql.rs b/crates/dbx-drivers/src/db/mysql.rs index 2e1fcba6d2..fc0e40247a 100644 --- a/crates/dbx-drivers/src/db/mysql.rs +++ b/crates/dbx-drivers/src/db/mysql.rs @@ -4872,6 +4872,7 @@ async fn execute_result_set_with_text_protocol_on_conn( affected_rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4916,6 +4917,7 @@ async fn execute_result_set_with_text_protocol_on_conn( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -5010,6 +5012,7 @@ async fn execute_result_set_with_text_protocol_on_conn( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -5140,6 +5143,7 @@ async fn execute_result_sets_with_text_protocol_on_conn( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -5167,6 +5171,7 @@ async fn execute_result_sets_with_text_protocol_on_conn( affected_rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5350,6 +5355,7 @@ async fn execute_result_set_with_prepared_protocol_on_conn( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -5673,6 +5679,7 @@ pub async fn execute_transaction_statement_on_conn( affected_rows, execution_time_ms: started_at.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5708,6 +5715,7 @@ pub async fn execute_transaction_statement_on_conn( affected_rows: 0, execution_time_ms: started_at.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -5829,6 +5837,7 @@ pub async fn execute_query_on_conn_with_limits_progress( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5867,6 +5876,7 @@ pub async fn execute_query_on_conn_with_limits_progress( affected_rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5996,6 +6006,7 @@ pub async fn execute_non_result_batch_on_conn( affected_rows: result.affected_rows(), execution_time_ms: elapsed_ms.saturating_sub(previous_elapsed_ms), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/postgres.rs b/crates/dbx-drivers/src/db/postgres.rs index a672c778d1..86986edf21 100644 --- a/crates/dbx-drivers/src/db/postgres.rs +++ b/crates/dbx-drivers/src/db/postgres.rs @@ -1961,6 +1961,7 @@ async fn execute_select_prepared( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -2057,6 +2058,7 @@ async fn execute_select_text( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -8001,6 +8003,7 @@ pub async fn execute_query_with_max_rows( affected_rows: affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -8828,6 +8831,7 @@ async fn execute_query_with_max_rows_inner( affected_rows: affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -14415,6 +14419,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/rqlite_driver.rs b/crates/dbx-drivers/src/db/rqlite_driver.rs index 8eb6f75795..cfe2fe917f 100644 --- a/crates/dbx-drivers/src/db/rqlite_driver.rs +++ b/crates/dbx-drivers/src/db/rqlite_driver.rs @@ -315,6 +315,7 @@ pub async fn execute_query_with_max_rows( affected_rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -369,6 +370,7 @@ fn query_result_from_rqlite_result( affected_rows: 0, execution_time_ms, server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/solr_driver.rs b/crates/dbx-drivers/src/db/solr_driver.rs index 7e953922b2..877af2425d 100644 --- a/crates/dbx-drivers/src/db/solr_driver.rs +++ b/crates/dbx-drivers/src/db/solr_driver.rs @@ -989,6 +989,7 @@ fn solr_table_result( affected_rows: row_count, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -1008,6 +1009,7 @@ fn solr_raw_json_response_result(status: u16, body_text: impl Into, star affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/sqlite.rs b/crates/dbx-drivers/src/db/sqlite.rs index 6500831637..2ba1fb1703 100644 --- a/crates/dbx-drivers/src/db/sqlite.rs +++ b/crates/dbx-drivers/src/db/sqlite.rs @@ -2950,6 +2950,7 @@ fn execute_query_blocking( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -2968,6 +2969,7 @@ fn execute_query_blocking( affected_rows: conn.changes(), execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/sqlite_worker.rs b/crates/dbx-drivers/src/db/sqlite_worker.rs index 9ab352033c..2a097815eb 100644 --- a/crates/dbx-drivers/src/db/sqlite_worker.rs +++ b/crates/dbx-drivers/src/db/sqlite_worker.rs @@ -122,6 +122,7 @@ impl SqliteWorkerSession<'_> { affected_rows: affected_rows.unwrap_or(0), execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: truncated.unwrap_or(false), session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/sqlserver.rs b/crates/dbx-drivers/src/db/sqlserver.rs index 9f6928f819..393e1e8f3a 100644 --- a/crates/dbx-drivers/src/db/sqlserver.rs +++ b/crates/dbx-drivers/src/db/sqlserver.rs @@ -757,6 +757,7 @@ fn server_messages_query_result(messages: Vec, start: Instant) -> Option affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -831,6 +832,7 @@ async fn collect_first_result_limited( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, @@ -1751,6 +1753,7 @@ fn push_sqlserver_result_set(results: &mut Vec, result: Option(), execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -3589,6 +3594,7 @@ pub async fn execute_batch_with_max_rows_metadata( affected_rows: result.rows_affected().iter().sum::(), execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -3684,6 +3690,7 @@ pub async fn execute_simple_batch_with_max_rows_metadata( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4111,6 +4118,7 @@ mod tests { affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4136,6 +4144,7 @@ mod tests { affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4156,6 +4165,7 @@ mod tests { affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5062,6 +5072,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -5622,6 +5633,7 @@ mod tests { affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/turso_driver.rs b/crates/dbx-drivers/src/db/turso_driver.rs index 912b749a0f..64cbd8f8ff 100644 --- a/crates/dbx-drivers/src/db/turso_driver.rs +++ b/crates/dbx-drivers/src/db/turso_driver.rs @@ -364,6 +364,7 @@ pub async fn execute_query_with_max_rows( affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -387,6 +388,7 @@ pub async fn execute_query_with_max_rows( affected_rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -407,6 +409,7 @@ pub async fn execute_query_with_max_rows( affected_rows: affected, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -589,6 +592,7 @@ fn query_result_from_turso_result( affected_rows: 0, execution_time_ms, server_execute_time_us: None, + query_timings_ms: None, truncated, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/vector_driver.rs b/crates/dbx-drivers/src/db/vector_driver.rs index c8bb1295ef..35f3efef55 100644 --- a/crates/dbx-drivers/src/db/vector_driver.rs +++ b/crates/dbx-drivers/src/db/vector_driver.rs @@ -923,6 +923,7 @@ fn json_to_query_result(status: u16, body: Value, start: Instant) -> QueryResult affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -958,6 +959,7 @@ fn values_to_query_result(items: Vec, start: Instant) -> QueryResult { rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-drivers/src/db/victoriametrics_driver.rs b/crates/dbx-drivers/src/db/victoriametrics_driver.rs index 1c09df7e26..aa479fa105 100644 --- a/crates/dbx-drivers/src/db/victoriametrics_driver.rs +++ b/crates/dbx-drivers/src/db/victoriametrics_driver.rs @@ -355,6 +355,7 @@ fn series_result_to_query_result(series: Vec, start: Instant) -> Q rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, spatial_columns: vec![], spatial_values: vec![], truncated: false, @@ -393,6 +394,7 @@ fn simple_result(rows: Vec>, value_type: &str, start: Instant) -> Que rows, execution_time_ms: start.elapsed().as_millis(), server_execute_time_us: None, + query_timings_ms: None, spatial_columns: vec![], spatial_values: vec![], truncated: false, diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs index 15993437d6..1dbf4b097b 100644 --- a/crates/dbx-mcp/src/backend.rs +++ b/crates/dbx-mcp/src/backend.rs @@ -2496,6 +2496,7 @@ fn query_result(columns: Vec, rows: Vec>, affected_rows: u64) affected_rows, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-mcp/src/http.rs b/crates/dbx-mcp/src/http.rs index 3fa8deacf1..8c3ec016e3 100644 --- a/crates/dbx-mcp/src/http.rs +++ b/crates/dbx-mcp/src/http.rs @@ -195,6 +195,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index 9030d78e09..f09d551658 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -3055,6 +3055,7 @@ fn empty_query_result() -> dbx_core::db::QueryResult { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4048,6 +4049,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -4374,6 +4376,7 @@ mod tests { affected_rows: 0, execution_time_ms: 1, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6847,6 +6850,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6879,6 +6883,7 @@ mod tests { affected_rows: 2, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, @@ -6912,6 +6917,7 @@ mod tests { affected_rows: 1, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-mcp/src/transaction.rs b/crates/dbx-mcp/src/transaction.rs index df67ed010e..d726953253 100644 --- a/crates/dbx-mcp/src/transaction.rs +++ b/crates/dbx-mcp/src/transaction.rs @@ -806,6 +806,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-types/examples/query_timing_roundtrip.rs b/crates/dbx-types/examples/query_timing_roundtrip.rs new file mode 100644 index 0000000000..f9af3aa268 --- /dev/null +++ b/crates/dbx-types/examples/query_timing_roundtrip.rs @@ -0,0 +1,18 @@ +//! Verify captured live Agent responses survive the desktop's typed JSON boundary. +//! Usage: cargo run -p dbx-types --example query_timing_roundtrip -- +use dbx_types::types::QueryResult; + +fn main() -> Result<(), Box> { + let file = std::env::args().nth(1).ok_or("expected live-results.json")?; + let records: Vec = serde_json::from_slice(&std::fs::read(file)?)?; + for record in &records { + let input = record["result"].clone(); + let result: QueryResult = serde_json::from_value(input.clone())?; + let output = serde_json::to_value(result)?; + assert_eq!(output["query_timings_ms"], input["query_timings_ms"]); + assert_eq!(output["server_execute_time_us"], input["server_execute_time_us"]); + assert_eq!(output["rows"], input["rows"]); + } + println!("PASS: {} live responses preserve timings and rows", records.len()); + Ok(()) +} diff --git a/crates/dbx-types/src/types.rs b/crates/dbx-types/src/types.rs index 5620d1242d..a2396875c7 100644 --- a/crates/dbx-types/src/types.rs +++ b/crates/dbx-types/src/types.rs @@ -482,6 +482,9 @@ pub struct QueryResult { /// completed statement cannot be correlated to one audit row. #[serde(default)] pub server_execute_time_us: Option, + /// Optional measured query phases in milliseconds; absent on older agents. + #[serde(default)] + pub query_timings_ms: Option>, #[serde(default)] pub truncated: bool, #[serde(default)] @@ -523,6 +526,7 @@ impl Serialize for QueryResult { + usize::from(!self.spatial_values.is_empty()) + usize::from(self.elasticsearch_raw_body.is_some()) + usize::from(self.server_execute_time_us.is_some()) + + usize::from(self.query_timings_ms.is_some()) + usize::from(!self.messages.is_empty()); let mut state = serializer.serialize_struct("QueryResult", field_count)?; state.serialize_field("columns", &self.columns)?; @@ -540,6 +544,9 @@ impl Serialize for QueryResult { if let Some(server_execute_time_us) = &self.server_execute_time_us { state.serialize_field("server_execute_time_us", server_execute_time_us)?; } + if let Some(timings) = &self.query_timings_ms { + state.serialize_field("query_timings_ms", timings)?; + } state.serialize_field("truncated", &self.truncated)?; state.serialize_field("session_id", &self.session_id)?; state.serialize_field("has_more", &self.has_more)?; @@ -1252,6 +1259,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index f1afd296b8..d1540074b0 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -1401,6 +1401,7 @@ mod tests { affected_rows: 0, execution_time_ms: 0, server_execute_time_us: None, + query_timings_ms: None, truncated: false, session_id: None, has_more: false, diff --git a/docs/testing/pr-preflight.md b/docs/testing/pr-preflight.md new file mode 100644 index 0000000000..6ce711e934 --- /dev/null +++ b/docs/testing/pr-preflight.md @@ -0,0 +1,51 @@ +# PR 提交与 CI 核查 + +## 提交前 + +1. 确认实际工作目录、分支和远端。执行 `git status --short`、`git remote -v`、`git fetch origin main`,用 `git diff origin/main...HEAD` 检查本 PR 范围;本地未提交改动还要单独检查。工作区有改动时不要直接拉取覆盖。 +2. 按 `.github/workflows/ci.yml` 和其他相关 workflow 选择检查。单元/组件测试、类型检查、格式、lint、生成文件、多语言完整性是不同检查,不能互相替代。重放到新基线后检查上游是否影响已验证路径。 +3. 前端至少核对 CI 使用的格式与 lint 命令,以及当前变更相关测试和类型检查。Rust、Agent、发布文件按实际影响范围执行对应检查,不为无关路径增加发布关卡。 +4. 新增简中词条时,先检查 `.github/scripts/i18n-autofill.mjs` 的 `TARGET_LOCALES`。补齐其要求的语言并保持 `{count}` 等占位符;不要把“英文可回退”当作自动翻译 CI 会成功的保证。 + +### 多语言的本地检查 + +在仓库根目录执行: + +```sh +node .github/scripts/i18n-autofill.mjs --dry-run --base-ref origin/main +``` + +该脚本只检查相对基线新增的简中键,不负责历史缺口或已有文案修改的同步。当前要求 en、es、it、ja、ko、pt-BR、ru、zh-TW;以脚本实际配置为准。 + +注意:`--dry-run` 只保证不写文件,有缺口且配置 API key 时仍会请求翻译服务。离线预检应在未配置 `DEEPSEEK_API_KEY` 且未开启 `I18N_MOCK_TRANSLATIONS` 的环境执行。PowerShell 可临时清空这两个进程环境变量,检查后恢复原值,不打印密钥。 + +通过标准是每种目标语言都输出 `all new keys already present`,最后输出 `No locale files needed changes`;没有新增键时则输出 `No new ... i18n keys`。缺失 API key 的错误说明仍有翻译缺口,应补齐后再提交。`--mock-translations` 仅用于复现/诊断,不能作为翻译完整性通过依据,更不能将 mock 文案写入提交。 + +## 推送与远端核查 + +```sh +git push -u fork +gh pr checks --repo t8y2/dbx +gh pr view --repo t8y2/dbx --json headRefOid,state,mergeable,statusCheckRollup +gh run view --repo t8y2/dbx --log-failed +``` + +- push 成功只代表远端分支收到提交。PR 已创建、无合并冲突、CI 通过、已合并和已发布是不同状态。 +- 创建或更新 PR 后查看对应提交 SHA 的检查结果。排队/运行中写明待完成,不能将本地测试通过写成远端全绿。 +- 失败时保存任务名、run/job 链接、提交 SHA 和首个实际错误。先判断是代码、格式、翻译缺口、权限、在线服务还是 runner 环境,不能只根据红叉推测。 +- 修复本分支可解决的问题,运行对应检查再正常提交推送。不要靠不断 rerun 掩盖确定性缺口,也不要为了绿灯跳过检查或改无关 workflow。 +- 后续机器人提交会改变 SHA,应先 fetch 核实再更新,避免覆盖机器人的改动。最终报告列出最新提交通过、失败和仍在运行的检查。 +- GitHub CLI 的 `repo`/`workflow` 授权问题与代码 CI 失败分开处理;只检查 scope,不输出 token。 + +## #10231 的实际遗漏 + +2026-09-24,提交 `8ab182140` 新增 22 个耗时词条,仅补齐简中、繁中和英文。I18n Autofill 尝试自动补齐其他语言,在日语阶段收到空响应: + +```text +DeepSeek API returned no translation content for ja +Process completed with exit code 1 +``` + +[失败任务](https://github.com/t8y2/dbx/actions/runs/35973804399/job/107549411291)。这是翻译服务失败叠加提交前遗漏多语言检查;不是从该日志得出的数据库逻辑失败。修复直接补齐 es、it、ja、ko、pt-BR、ru 的 132 条翻译,使本 PR 不再需要在线补译。本地 dry-run 已确认所有目标语言完整,无文件需要修改。 + +此前“234 项前端测试、405 项 Java 测试通过”只证明这些测试的覆盖范围。它们没有覆盖在线补译。此后交付必须同时报告远端 CI 状态,不以测试数量代替检查覆盖。 diff --git a/docs/testing/query-timing-common.png b/docs/testing/query-timing-common.png new file mode 100644 index 0000000000000000000000000000000000000000..f9025c8ca5cbe3bfba62f8c281053cd2bf884c86 GIT binary patch literal 27223 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/testing/query-timing-details.md b/docs/testing/query-timing-details.md new file mode 100644 index 0000000000..e2a4188415 --- /dev/null +++ b/docs/testing/query-timing-details.md @@ -0,0 +1,37 @@ +# Query timing details across databases + +The query result status bar displays request wait as a number, for example `45ms`. Hover or keyboard focus opens a numbered three-column table. Notes stay in parentheses beside the stage. Escape closes the tooltip. + +## Collected stages + +- All SQL query tabs, including manual transactions, measure request preparation, backend request wait, result processing and the latest grid update locally. This elapsed request wait includes backend/database processing and transport; it is not database CPU or execution-plan time. +- Java JDBC agents additionally report measured pool acquisition, session preparation, schema selection, statement preparation, execution, metadata, fetch and pool return stages. Only reported internal phases are displayed. Core lock and calculated remainders appear when their inputs exist. +- MongoDB, Redis and Elasticsearch command results use the same common stages. Sequential backend calls within one command accumulate their wait without counting result conversion twice. Local commands without a backend request and continuous Redis MONITOR do not invent a completed-request duration. +- A single SQL batch returning multiple results has no independent client request span per result; unavailable values remain unavailable instead of assigning the whole batch duration to each result. +- Pagination accumulates completed request waits and phase maps, including pages consumed during offset jumps. Grid update timing describes the latest update. Result cache serialization preserves the optional timing fields. +- The backend total, when available with detailed Agent telemetry, overlaps the listed stages. Other wait is a calculated remainder, not measured network time. Missing values are never replaced by zero. +- Normal OceanBase queries no longer run LAST_TRACE_ID or SQL audit lookups for timing. No additional diagnostic SQL is introduced for other databases. + +![JDBC stages from a captured Agent result](query-timing-details.png) + +![Common stages using an explicitly synthetic layout example](query-timing-common.png) + +## Verification on 2026-09-24 + +Environment: Windows, Node with repository dependencies, JDK 21. Tests and regression expectations were prepared before their corresponding implementation. Identified risks included missing/invalid telemetry, double counting, errors, pagination/cache loss, command conversion counted as wait, and regressions to query behavior. + +From the repository root: + +```sh +node node_modules/vitest/vitest.mjs run apps/desktop/src/components/grid/__tests__/QueryTimingDetails.spec.ts apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts apps/desktop/src/lib/__tests__/queryRequestTiming.spec.ts apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts apps/desktop/src/stores/__tests__/queryStore.mongoExecutionSummary.spec.ts apps/desktop/src/stores/__tests__/queryStore.spatialMetadata.spec.ts apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts apps/desktop/src/composables/__tests__/useResultViewUpdateTiming.spec.ts +node node_modules/vue-tsc/bin/vue-tsc.js --noEmit --project apps/desktop/tsconfig.json +agents/gradlew.bat -p agents :common:test :oceanbase-oracle:test :oceanbase-oracle:shadowJar :db2:test :dameng:test --console=plain +``` + +234 frontend tests passed. After the final phase-filter adjustment, the three component checks passed again. 405 Java checks passed: common 218, OceanBase 43, DB2 14, Dameng 130. The common pool/cursor test uses embedded H2 through actual multi-session JSON-RPC and confirms nonnegative acquisition, execution, fetch and release measurements while preserving cursor behavior. Driver tests are not evidence of connectivity to external DB2 or Dameng services. + +Browser verification mounted the production component, styles, locale and cache codec. The captured JDBC response displayed `26ms` and 12 applicable rows; the native/HTTP layout example displayed `45ms` and four common rows. Keyboard focus opened both tables, values were right-aligned, and Escape closed the popup. The native/HTTP example is synthetic and does not prove live MongoDB, Redis or Elasticsearch execution. + +Earlier OceanBase validation, before this cross-database extension, exercised seven read-only JSON-RPC requests against OceanBase Oracle 4.2.5.7 with Connector/J 2.4.18: two 27-table UNION counts, three cursor pages, a capped result and an empty query. It verified rows, phase bounds, cursor lifecycle and absence of trace/audit/server-plan telemetry. This extension does not repeat that external database run. + +The prior Rust core check passed for the timing protocol; this extension does not change Rust. Full native desktop Core/store/grid execution and real external services for every supported database remain unverified. No package has been rebuilt for this extension and the PR remains paused. diff --git a/docs/testing/query-timing-details.png b/docs/testing/query-timing-details.png new file mode 100644 index 0000000000000000000000000000000000000000..7e5c0eb508d3ed0da1893be76bb53a2221d414ce GIT binary patch literal 42207 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 RKO^v;5%~Xo1de=T{a=XI!m9uP literal 0 HcmV?d00001