From 2d94c5d8854ba526f1bb5493e8e8ac428b1cb0b9 Mon Sep 17 00:00:00 2001 From: Xin Hao Zhang Date: Mon, 11 Jul 2022 16:32:11 -0400 Subject: [PATCH 1/3] cluster-ui: add sql api request wrapper and clusterLocks request This commit allows DB Console to use the SQL over HTTP API from `/api/v2/sql/`. A new fetch wrapper providing the custom header necessary for the API and using content type JSON has been added. The clusterLocksApi components added in this commit use the above SQL api functions to query from the `crdb_internal.cluster_locks` table. Release note: None --- .../cluster-ui/src/api/clusterLocksApi.ts | 118 ++++++++++++++++++ .../cluster-ui/src/api/fetchData.ts | 38 ++++++ pkg/ui/workspaces/cluster-ui/src/api/index.ts | 1 + .../workspaces/cluster-ui/src/api/sqlApi.ts | 78 ++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts diff --git a/pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts new file mode 100644 index 000000000000..f46dc5c714ae --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts @@ -0,0 +1,118 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import moment from "moment"; +import { executeSql, SqlExecutionRequest } from "./sqlApi"; + +export type ClusterLockState = { + databaseName?: string; + schemaName?: string; + tableName?: string; + indexName?: string; + lockHolderTxnID?: string; // Excecution ID of txn holding this lock. + holdTime: moment.Duration; + waiters?: LockWaiter[]; // List of waiting transaction execution IDs. +}; + +export type LockWaiter = { + id: string; // Txn execution ID. + waitTime: moment.Duration; +}; + +export type ClusterLocksResponse = ClusterLockState[]; + +type ClusterLockColumns = { + lock_key_pretty: string; + database_name: string; + schema_name: string; + table_name: string; + index_name: string; + txn_id: string; + duration: string; + granted: boolean; +}; + +/** + * getClusterLocksState returns information from crdb_internal.cluster_locks + * regarding the state of range locks in the cluster. + */ +export function getClusterLocksState(): Promise { + const request: SqlExecutionRequest = { + statements: [ + { + sql: ` +SELECT + lock_key_pretty, + database_name, + schema_name, + table_name, + index_name, + txn_id, + duration, + granted +FROM + crdb_internal.cluster_locks +WHERE + contended = true +`, + }, + ], + execute: true, + }; + return executeSql(request).then(result => { + if ( + result.execution.txn_results.length === 0 || + !result.execution.txn_results[0].rows + ) { + // No data. + return []; + } + + const locks: Record = {}; + + // If all the lock keys are blank, then the user has VIEWACTIVITYREDACTED + // role. We won't be able to group the resulting rows by lock key to get + // correlated transactions, but we can still surface wait time information. + // To do this, we treat each entry as a unique lock entry with a single + // txn. + const noLockKeys = result.execution.txn_results[0].rows.every( + row => !row.lock_key_pretty, + ); + + let counter = 0; + result.execution.txn_results[0].rows.forEach(row => { + const key = noLockKeys ? `entry_${counter++}` : row.lock_key_pretty; + + if (!locks[key]) { + locks[key] = { + databaseName: row.database_name, + schemaName: row.schema_name, + tableName: row.table_name, + indexName: row.index_name, + waiters: [], + holdTime: moment.duration(), + }; + } + + const duration = moment.duration(row.duration); + if (row.granted) { + locks[key].lockHolderTxnID = row.txn_id; + locks[key].holdTime = duration; + } else { + locks[key].waiters.push({ + id: row.txn_id, + waitTime: duration, + }); + } + }); + + return Object.values(locks); + }); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts b/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts index 9a28bac16dee..988f6652a186 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/fetchData.ts @@ -89,3 +89,41 @@ export const fetchData =

, T extends ProtoBuilder>( }) .then(buffer => RespBuilder.decode(new Uint8Array(buffer))); }; + +/** + * fetchDataJSON makes a request for /api/v2 which uses content type JSON. + * @param path relative path for requested resource. + * @param reqPayload request payload object. + */ +export function fetchDataJSON( + path: string, + reqPayload?: RequestType, +): Promise { + const params: RequestInit = { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Cockroach-API-Session": "cookie", + }, + credentials: "same-origin", + }; + + if (reqPayload) { + params.method = "POST"; + params.body = JSON.stringify(reqPayload); + } + + const basePath = getBasePath(); + + return fetch(`${basePath}${path}`, params).then(response => { + if (!response.ok) { + throw new RequestError( + response.statusText, + response.status, + response.statusText, + ); + } + + return response.json(); + }); +} diff --git a/pkg/ui/workspaces/cluster-ui/src/api/index.ts b/pkg/ui/workspaces/cluster-ui/src/api/index.ts index a15d4acc1bb1..56e294c79130 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/index.ts @@ -13,3 +13,4 @@ export * from "./statementDiagnosticsApi"; export * from "./statementsApi"; export * from "./basePath"; export * from "./nodesApi"; +export * from "./clusterLocksApi"; diff --git a/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts new file mode 100644 index 000000000000..e763f07a32e3 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts @@ -0,0 +1,78 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { fetchDataJSON } from "./fetchData"; + +export type SqlExecutionRequest = { + statements: SqlStatement[]; + execute?: boolean; + timeout?: string; // Default 5s + application_name?: string; // Defaults to '$ api-v2-sql' + database_name?: string; // Defaults to defaultDb + max_result_size?: number; // Default 10kib +}; + +export type SqlStatement = { + sql: string; + arguments?: unknown[]; +}; + +export type SqlExecutionResponse = { + num_statements?: number; + execution?: SqlExecutionExecResult; + error?: SqlExecutionErrorMessage; + request?: SqlExecutionRequest; +}; + +export interface SqlExecutionExecResult { + retries: number; + txn_results: SqlTxnResult[]; +} + +export type SqlTxnResult = { + statement: number; // Statement index from input array + tag: string; // Short stmt tag + start: string; // Start timestamp, encoded as RFC3339 + end: string; // End timestamp, encoded as RFC3339 + rows_affected: number; + columns?: SqlResultColumn[]; + rows?: RowType[]; + error?: Error; +}; + +export type SqlResultColumn = { + name: string; + type: string; + oid: number; +}; + +export type SqlExecutionErrorMessage = { + message: string; + code: string; + severity: string; + source: { file: string; line: number; function: "string" }; +}; + +export const SQL_API_PATH = "/api/v2/sql/"; + +/** + * executeSql executes the provided SQL statements in a single transaction + * over HTTP. + * + * @param req execution request details + */ +export function executeSql( + req: SqlExecutionRequest, +): Promise> { + return fetchDataJSON, SqlExecutionRequest>( + SQL_API_PATH, + req, + ); +} From 39645cab8ff0e5c4777feaf58ffc1d225ae78508 Mon Sep 17 00:00:00 2001 From: Xin Hao Zhang Date: Tue, 27 Sep 2022 16:08:19 -0400 Subject: [PATCH 2/3] cluster-ui: add helper to determine empty sql results This commit adds a helper function, `sqlResultsAreEmpty` to the sql api that determines whether there are execution results in the request response. Release note: None --- .../cluster-ui/src/api/clusterLocksApi.ts | 118 ------------------ .../cluster-ui/src/api/sqlApi.spec.ts | 86 +++++++++++++ .../workspaces/cluster-ui/src/api/sqlApi.ts | 39 +++++- 3 files changed, 124 insertions(+), 119 deletions(-) delete mode 100644 pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts create mode 100644 pkg/ui/workspaces/cluster-ui/src/api/sqlApi.spec.ts diff --git a/pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts deleted file mode 100644 index f46dc5c714ae..000000000000 --- a/pkg/ui/workspaces/cluster-ui/src/api/clusterLocksApi.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2022 The Cockroach Authors. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0, included in the file -// licenses/APL.txt. - -import moment from "moment"; -import { executeSql, SqlExecutionRequest } from "./sqlApi"; - -export type ClusterLockState = { - databaseName?: string; - schemaName?: string; - tableName?: string; - indexName?: string; - lockHolderTxnID?: string; // Excecution ID of txn holding this lock. - holdTime: moment.Duration; - waiters?: LockWaiter[]; // List of waiting transaction execution IDs. -}; - -export type LockWaiter = { - id: string; // Txn execution ID. - waitTime: moment.Duration; -}; - -export type ClusterLocksResponse = ClusterLockState[]; - -type ClusterLockColumns = { - lock_key_pretty: string; - database_name: string; - schema_name: string; - table_name: string; - index_name: string; - txn_id: string; - duration: string; - granted: boolean; -}; - -/** - * getClusterLocksState returns information from crdb_internal.cluster_locks - * regarding the state of range locks in the cluster. - */ -export function getClusterLocksState(): Promise { - const request: SqlExecutionRequest = { - statements: [ - { - sql: ` -SELECT - lock_key_pretty, - database_name, - schema_name, - table_name, - index_name, - txn_id, - duration, - granted -FROM - crdb_internal.cluster_locks -WHERE - contended = true -`, - }, - ], - execute: true, - }; - return executeSql(request).then(result => { - if ( - result.execution.txn_results.length === 0 || - !result.execution.txn_results[0].rows - ) { - // No data. - return []; - } - - const locks: Record = {}; - - // If all the lock keys are blank, then the user has VIEWACTIVITYREDACTED - // role. We won't be able to group the resulting rows by lock key to get - // correlated transactions, but we can still surface wait time information. - // To do this, we treat each entry as a unique lock entry with a single - // txn. - const noLockKeys = result.execution.txn_results[0].rows.every( - row => !row.lock_key_pretty, - ); - - let counter = 0; - result.execution.txn_results[0].rows.forEach(row => { - const key = noLockKeys ? `entry_${counter++}` : row.lock_key_pretty; - - if (!locks[key]) { - locks[key] = { - databaseName: row.database_name, - schemaName: row.schema_name, - tableName: row.table_name, - indexName: row.index_name, - waiters: [], - holdTime: moment.duration(), - }; - } - - const duration = moment.duration(row.duration); - if (row.granted) { - locks[key].lockHolderTxnID = row.txn_id; - locks[key].holdTime = duration; - } else { - locks[key].waiters.push({ - id: row.txn_id, - waitTime: duration, - }); - } - }); - - return Object.values(locks); - }); -} diff --git a/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.spec.ts b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.spec.ts new file mode 100644 index 000000000000..444d580dd204 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.spec.ts @@ -0,0 +1,86 @@ +// Copyright 2022 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +import { SqlExecutionResponse, sqlResultsAreEmpty } from "./sqlApi"; + +describe("sqlApi", () => { + test("sqlResultsAreEmpty should return true when there are no rows in the response", () => { + const testCases: { + response: SqlExecutionResponse; + expected: boolean; + }[] = [ + { + response: { + num_statements: 1, + execution: { + retries: 0, + txn_results: [ + { + statement: 1, + tag: "SELECT", + start: "start-date", + end: "end-date", + rows_affected: 0, + rows: [{ hello: "world" }], + }, + ], + }, + }, + expected: false, + }, + { + response: { + num_statements: 1, + execution: { + retries: 0, + txn_results: [ + { + statement: 1, + tag: "SELECT", + start: "start-date", + end: "end-date", + rows_affected: 0, + rows: [], + }, + ], + }, + }, + expected: true, + }, + { + response: { + num_statements: 1, + execution: { + retries: 0, + txn_results: [ + { + statement: 1, + tag: "SELECT", + start: "start-date", + end: "end-date", + rows_affected: 0, + columns: [], + }, + ], + }, + }, + expected: true, + }, + { + response: {}, + expected: true, + }, + ]; + + testCases.forEach(tc => { + expect(sqlResultsAreEmpty(tc.response)).toEqual(tc.expected); + }); + }); +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts index e763f07a32e3..5a6d5afe413d 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts @@ -15,7 +15,7 @@ export type SqlExecutionRequest = { execute?: boolean; timeout?: string; // Default 5s application_name?: string; // Defaults to '$ api-v2-sql' - database_name?: string; // Defaults to defaultDb + database?: string; // Defaults to defaultDb max_result_size?: number; // Default 10kib }; @@ -76,3 +76,40 @@ export function executeSql( req, ); } + +export const INTERNAL_SQL_API_APP = "$ internal-console"; +export const LONG_TIMEOUT = "300s"; +export const LARGE_RESULT_SIZE = 50000; // 50 kib + +/** + * executeInternalSql executes the provided SQL statements with + * the app name set to the internal sql api app name above. + * Note that technically all SQL executed over this API are + * executed as internal, but we make this distinction using the + * function name for when we want to execute user queries in the + * future, where such queries should not have an internal app name. + * + * @param req execution request details + */ +export function executeInternalSql( + req: SqlExecutionRequest, +): Promise> { + if (!req.application_name) { + req.application_name = INTERNAL_SQL_API_APP; + } else { + req.application_name = `$ internal-${req.application_name}`; + } + + return executeSql(req); +} + +export function sqlResultsAreEmpty( + result: SqlExecutionResponse, +): boolean { + return ( + !result.execution?.txn_results?.length || + result.execution.txn_results.every( + txn => !txn.rows || txn.rows.length === 0, + ) + ); +} From f1094cbf07fbddd5ffa4597bf0067e0a33cb0807 Mon Sep 17 00:00:00 2001 From: Xin Hao Zhang Date: Tue, 27 Sep 2022 16:24:13 -0400 Subject: [PATCH 3/3] cluster-ui: export sql api exec functions with pkg Release note: None --- pkg/ui/workspaces/cluster-ui/src/api/index.ts | 2 +- pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/ui/workspaces/cluster-ui/src/api/index.ts b/pkg/ui/workspaces/cluster-ui/src/api/index.ts index 56e294c79130..d80c80ac60a5 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/index.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/index.ts @@ -13,4 +13,4 @@ export * from "./statementDiagnosticsApi"; export * from "./statementsApi"; export * from "./basePath"; export * from "./nodesApi"; -export * from "./clusterLocksApi"; +export * from "./sqlApi"; diff --git a/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts index 5a6d5afe413d..ba6167c44fd5 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/sqlApi.ts @@ -103,6 +103,12 @@ export function executeInternalSql( return executeSql(req); } +/** + * sqlResultsAreEmpty returns true if the provided result + * does not contain any rows. + * @param result the sql execution result returned by the server + * @returns + */ export function sqlResultsAreEmpty( result: SqlExecutionResponse, ): boolean {