From 4106a8ce2275eff36210488f23f149209f41ab0a Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 12 Aug 2026 21:55:00 +0000 Subject: [PATCH] fix(process-list): cancel queries without closing sessions Closes #3967 --- .../src/components/admin/ProcessListPanel.vue | 54 +++++++-------- apps/desktop/src/i18n/locales/en.ts | 12 ++-- apps/desktop/src/i18n/locales/es.ts | 12 ++-- apps/desktop/src/i18n/locales/it.ts | 12 ++-- apps/desktop/src/i18n/locales/ja.ts | 12 ++-- apps/desktop/src/i18n/locales/ko.ts | 12 ++-- apps/desktop/src/i18n/locales/pt-BR.ts | 12 ++-- apps/desktop/src/i18n/locales/zh-CN.ts | 12 ++-- apps/desktop/src/i18n/locales/zh-TW.ts | 12 ++-- .../database/mysqlProcessList.spec.ts | 17 ++--- .../database/postgresProcessList.spec.ts | 66 ++++++++++--------- .../src/lib/database/mysqlProcessList.ts | 10 +-- .../src/lib/database/postgresProcessList.ts | 36 +++++----- .../src/lib/database/processListDrivers.ts | 56 ++++++++-------- crates/dbx-core/src/sql_risk.rs | 19 ++++++ 15 files changed, 189 insertions(+), 165 deletions(-) diff --git a/apps/desktop/src/components/admin/ProcessListPanel.vue b/apps/desktop/src/components/admin/ProcessListPanel.vue index c349e0de71..21612ae3b1 100644 --- a/apps/desktop/src/components/admin/ProcessListPanel.vue +++ b/apps/desktop/src/components/admin/ProcessListPanel.vue @@ -42,8 +42,8 @@ const autoRefresh = ref(false); const intervalSeconds = ref(DEFAULT_REFRESH_SECONDS); let timer: ReturnType | undefined; -const killTarget = ref(null); -const killing = ref(false); +const cancelTarget = ref(null); +const canceling = ref(false); const fallbackListSql = ref(null); // Full-text preview for long cells (SQL statement / info), opened by clicking them. @@ -137,20 +137,20 @@ function isOwnSession(row: ProcessRow): boolean { return ownSessionId.value !== null && row.id === ownSessionId.value; } -function requestKill(row: ProcessRow) { +function requestCancel(row: ProcessRow) { if (isOwnSession(row)) return; - killTarget.value = row; + cancelTarget.value = row; } -async function confirmKill() { - const target = killTarget.value; +async function confirmCancel() { + const target = cancelTarget.value; const activeDriver = driver.value; if (!target || !activeDriver) return; - killing.value = true; + canceling.value = true; try { - const killSql = activeDriver.buildKillSql(target.id); - let usedFallbackKillSql = false; - const executeKillSql = async (sql: string) => { + const cancelSql = activeDriver.buildCancelQuerySql(target.id); + let usedFallbackCancelSql = false; + const executeCancelSql = async (sql: string) => { const results = await api.executeMulti(props.connection.id, "", sql, undefined, undefined, { maxRows: 1 }); const executionError = processListExecutionError(results); if (executionError) throw new Error(executionError); @@ -159,28 +159,28 @@ async function confirmKill() { const result = await executeWithProductionSqlGuard({ connection: props.connection, database: "", - sql: killSql, + sql: cancelSql, source: t("production.sourceAdmin"), execute: async () => { try { - return await executeKillSql(killSql); + return await executeCancelSql(cancelSql); } catch (error) { - if (!activeDriver.buildFallbackKillSql || !activeDriver.shouldUseFallbackKillSql?.(error)) throw error; - usedFallbackKillSql = true; - return executeKillSql(activeDriver.buildFallbackKillSql(target.id)); + if (!activeDriver.buildFallbackCancelQuerySql || !activeDriver.shouldUseFallbackCancelQuerySql?.(error)) throw error; + usedFallbackCancelSql = true; + return executeCancelSql(activeDriver.buildFallbackCancelQuerySql(target.id)); } }, }); if (result === undefined) return; - const killResultError = usedFallbackKillSql ? activeDriver.fallbackKillResultError?.(result) : activeDriver.killResultError?.(result); - if (killResultError) throw new Error(killResultError); + const cancelResultError = usedFallbackCancelSql ? activeDriver.fallbackCancelQueryResultError?.(result) : activeDriver.cancelQueryResultError?.(result); + if (cancelResultError) throw new Error(cancelResultError); toast(t("processList.killSuccess", { id: target.id }), 2500); - killTarget.value = null; + cancelTarget.value = null; await load({ silent: true }); } catch (error: any) { toast(t("processList.killFailed", { message: error?.message || String(error) }), 5000); } finally { - killing.value = false; + canceling.value = false; } } @@ -299,7 +299,7 @@ onBeforeUnmount(stopTimer); class="h-6 gap-1 px-1.5 text-[11px] text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-40" :disabled="isOwnSession(row)" :title="isOwnSession(row) ? t('processList.cannotKillSelf') : t('processList.kill')" - @click="requestKill(row)" + @click="requestCancel(row)" > {{ t("processList.kill") }} @@ -316,10 +316,10 @@ onBeforeUnmount(stopTimer); @@ -330,13 +330,13 @@ onBeforeUnmount(stopTimer); {{ t("processList.killTitle") }} -

- {{ t("processList.killConfirm", { id: killTarget.id, user: killTarget.user }) }} +

+ {{ t("processList.killConfirm", { id: cancelTarget.id, user: cancelTarget.user }) }}

- - + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fb6c451a48..d30b88caa9 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2956,13 +2956,13 @@ export default { colQuery: "Query", colActions: "Actions", self: "you", - kill: "Kill", - cannotKillSelf: "You cannot kill your own session", + kill: "Cancel query", + cannotKillSelf: "You cannot cancel a query on your own session", empty: "No active sessions.", - killTitle: "Kill session", - killConfirm: "Kill session {id} ({user})? Its current statement will be aborted and the connection closed.", - killSuccess: "Session {id} killed", - killFailed: "Failed to kill session: {message}", + killTitle: "Cancel running query", + killConfirm: "Cancel the running query for session {id} ({user})? The session will remain connected.", + killSuccess: "Canceled the running query for session {id}", + killFailed: "Failed to cancel query: {message}", previewTitle: "Statement", copy: "Copy", copied: "Copied", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5f23600b6d..4ddc435ee5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7279,13 +7279,13 @@ export default withEnglishFallback({ colInfo: "SQL", colActions: "Acciones", self: "Actual", - kill: "Terminar", - cannotKillSelf: "No se puede terminar la sesión actual", + kill: "Cancelar consulta", + cannotKillSelf: "No se puede cancelar una consulta en la sesión actual", empty: "No hay sesiones activas.", - killTitle: "Terminar sesión", - killConfirm: "¿Está seguro de que desea terminar la sesión {id} ({user})? La sentencia actual se cancelará y la conexión se cerrará.", - killSuccess: "Sesión {id} terminada", - killFailed: "Error al terminar la sesión: {message}", + killTitle: "Cancelar consulta en ejecución", + killConfirm: "¿Cancelar la consulta en ejecución de la sesión {id} ({user})? La sesión permanecerá conectada.", + killSuccess: "Consulta en ejecución de la sesión {id} cancelada", + killFailed: "Error al cancelar la consulta: {message}", colPid: "PID", colClient: "Cliente", colApp: "Aplicación", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7470684c5f..676591e31c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7279,13 +7279,13 @@ export default withEnglishFallback({ colInfo: "SQL", colActions: "Azioni", self: "Corrente", - kill: "Termina", - cannotKillSelf: "Impossibile terminare la sessione corrente", + kill: "Annulla query", + cannotKillSelf: "Impossibile annullare una query nella sessione corrente", empty: "Nessuna sessione attiva.", - killTitle: "Termina sessione", - killConfirm: "Sei sicuro di voler terminare la sessione {id} ({user})? La dichiarazione corrente verrà interrotta e la connessione verrà chiusa.", - killSuccess: "Sessione {id} terminata", - killFailed: "Terminazione sessione non riuscita: {message}", + killTitle: "Annulla query in esecuzione", + killConfirm: "Annullare la query in esecuzione della sessione {id} ({user})? La sessione resterà connessa.", + killSuccess: "Query in esecuzione della sessione {id} annullata", + killFailed: "Impossibile annullare la query: {message}", colPid: "PID", colClient: "Client", colApp: "Applicazione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 340269508d..e388dd1609 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7332,13 +7332,13 @@ export default withEnglishFallback({ colInfo: "SQL", colActions: "操作", self: "現在", - kill: "強制終了", - cannotKillSelf: "現在のセッションは強制終了できません", + kill: "クエリをキャンセル", + cannotKillSelf: "現在のセッションのクエリはキャンセルできません", empty: "アクティブなセッションはありません。", - killTitle: "セッションの強制終了", - killConfirm: "セッション {id}({user})を強制終了しますか?現在のステートメントは中断され、接続が閉じられます。", - killSuccess: "セッション {id} を強制終了しました。", - killFailed: "セッションの強制終了に失敗しました:{message}", + killTitle: "実行中のクエリをキャンセル", + killConfirm: "セッション {id}({user})で実行中のクエリをキャンセルしますか?セッションの接続は維持されます。", + killSuccess: "セッション {id} で実行中のクエリをキャンセルしました。", + killFailed: "クエリのキャンセルに失敗しました:{message}", colPid: "PID", colClient: "クライアント", colApp: "アプリケーション", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f5e4c94468..b55557056b 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2806,13 +2806,13 @@ export default withEnglishFallback({ colQuery: "쿼리", colActions: "작업", self: "나", - kill: "종료", - cannotKillSelf: "자신의 세션은 종료할 수 없습니다", + kill: "쿼리 취소", + cannotKillSelf: "현재 세션의 쿼리는 취소할 수 없습니다", empty: "활성 세션이 없습니다.", - killTitle: "세션 종료", - killConfirm: "세션 {id} ({user})을(를) 종료하시겠습니까? 현재 구문이 중단되고 연결이 닫힙니다.", - killSuccess: "세션 {id}을(를) 종료했습니다", - killFailed: "세션 종료에 실패했습니다: {message}", + killTitle: "실행 중인 쿼리 취소", + killConfirm: "세션 {id} ({user})에서 실행 중인 쿼리를 취소하시겠습니까? 세션 연결은 유지됩니다.", + killSuccess: "세션 {id}에서 실행 중인 쿼리를 취소했습니다", + killFailed: "쿼리 취소에 실패했습니다: {message}", previewTitle: "구문", copy: "복사", copied: "복사했습니다", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9f4f718da7..2c38d04bfe 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7281,13 +7281,13 @@ export default withEnglishFallback({ colInfo: "SQL", colActions: "Ações", self: "Atual", - kill: "Encerrar", - cannotKillSelf: "Não é possível encerrar a sessão atual", + kill: "Cancelar consulta", + cannotKillSelf: "Não é possível cancelar uma consulta na sessão atual", empty: "Nenhuma sessão ativa.", - killTitle: "Encerrar sessão", - killConfirm: "Tem certeza de que deseja encerrar a sessão {id} ({user})? Sua instrução atual será abortada e a conexão será fechada.", - killSuccess: "Sessão {id} encerrada.", - killFailed: "Falha ao encerrar a sessão: {message}", + killTitle: "Cancelar consulta em execução", + killConfirm: "Cancelar a consulta em execução da sessão {id} ({user})? A sessão permanecerá conectada.", + killSuccess: "Consulta em execução da sessão {id} cancelada.", + killFailed: "Falha ao cancelar a consulta: {message}", colPid: "PID", colClient: "Cliente", colApp: "Aplicativo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 2a8de1484d..f709350003 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2881,13 +2881,13 @@ export default withEnglishFallback({ colQuery: "查询", colActions: "操作", self: "当前", - kill: "终止", - cannotKillSelf: "无法终止当前会话", + kill: "取消查询", + cannotKillSelf: "无法取消当前会话中的查询", empty: "没有活动会话。", - killTitle: "终止会话", - killConfirm: "确定终止会话 {id}({user})?其当前语句将被中止,连接将被关闭。", - killSuccess: "已终止会话 {id}", - killFailed: "终止会话失败:{message}", + killTitle: "取消正在运行的查询", + killConfirm: "确定取消会话 {id}({user})中正在运行的查询?该会话将保持连接。", + killSuccess: "已取消会话 {id} 中正在运行的查询", + killFailed: "取消查询失败:{message}", previewTitle: "语句", copy: "复制", copied: "已复制", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9f262c2dd2..6727e5aecd 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7272,13 +7272,13 @@ export default withEnglishFallback({ colInfo: "SQL", colActions: "操作", self: "目前", - kill: "終止", - cannotKillSelf: "無法終止目前會話", + kill: "取消查詢", + cannotKillSelf: "無法取消目前會話中的查詢", empty: "沒有活動會話。", - killTitle: "終止會話", - killConfirm: "確定終止會話 {id}({user})?其目前的陳述式將被中止,連線將被關閉。", - killSuccess: "已終止會話 {id}", - killFailed: "終止會話失敗:{message}", + killTitle: "取消執行中的查詢", + killConfirm: "確定取消會話 {id}({user})中執行中的查詢?該會話將保持連線。", + killSuccess: "已取消會話 {id} 中執行中的查詢", + killFailed: "取消查詢失敗:{message}", colPid: "PID", colClient: "用戶端", colApp: "應用程式", diff --git a/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts b/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts index 43c2ada939..ee109da8d3 100644 --- a/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/mysqlProcessList.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { QueryResult } from "@/types/database"; -import { buildKillSql, clampInterval, createProcessListLoadCoordinator, mapProcessRows, processListExecutionError, processListSessionCount, supportsProcessList } from "@/lib/database/mysqlProcessList"; +import { buildCancelQuerySql, clampInterval, createProcessListLoadCoordinator, mapProcessRows, processListExecutionError, processListSessionCount, supportsProcessList } from "@/lib/database/mysqlProcessList"; function result(columns: string[], rows: (string | number | boolean | null)[][]): QueryResult { return { columns, rows, affected_rows: 0, execution_time_ms: 0 }; @@ -35,15 +35,16 @@ describe("mapProcessRows", () => { }); }); -describe("buildKillSql", () => { - it("builds KILL CONNECTION for a valid id", () => { - expect(buildKillSql(8213)).toBe("KILL CONNECTION 8213"); +describe("buildCancelQuerySql", () => { + it("builds KILL QUERY for a valid id without disconnecting the session", () => { + expect(buildCancelQuerySql(8213)).toBe("KILL QUERY 8213"); }); - it("rejects non-integer or negative ids", () => { - expect(() => buildKillSql(1.5)).toThrow(); - expect(() => buildKillSql(-1)).toThrow(); - expect(() => buildKillSql(Number.NaN)).toThrow(); + it("rejects non-integer or nonpositive ids", () => { + expect(() => buildCancelQuerySql(1.5)).toThrow(); + expect(() => buildCancelQuerySql(0)).toThrow(); + expect(() => buildCancelQuerySql(-1)).toThrow(); + expect(() => buildCancelQuerySql(Number.NaN)).toThrow(); }); }); diff --git a/apps/desktop/src/lib/__tests__/database/postgresProcessList.spec.ts b/apps/desktop/src/lib/__tests__/database/postgresProcessList.spec.ts index 61e6d9fc9c..3f954c9537 100644 --- a/apps/desktop/src/lib/__tests__/database/postgresProcessList.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/postgresProcessList.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import type { QueryResult } from "@/types/database"; import { - buildKingbaseKillSql, - buildKingbasePgKillSql, - buildPgKillSql, + buildKingbaseCancelQuerySql, + buildKingbasePgCancelQuerySql, + buildPgCancelQuerySql, isKingbaseOwnSessionCatalogCompatibilityError, isKingbaseProcessListCatalogCompatibilityError, - isKingbaseTerminateCatalogCompatibilityError, + isKingbaseCancelCatalogCompatibilityError, isPgProcessListCompatibilityError, KINGBASE_OWN_SESSION_SQL, KINGBASE_PG_OWN_SESSION_SQL, @@ -15,7 +15,9 @@ import { mapPgProcessRows, OPENGAUSS_OWN_SESSION_SQL, OPENGAUSS_PROCESS_LIST_SQL, - pgKillResultError, + kingbaseCancelQueryResultError, + kingbasePgCancelQueryResultError, + pgCancelQueryResultError, PG_PROCESS_LIST_LEGACY_SQL, PG_PROCESS_LIST_SQL, } from "@/lib/database/postgresProcessList"; @@ -56,22 +58,22 @@ describe("mapPgProcessRows", () => { }); }); -describe("buildPgKillSql", () => { - it("builds pg_terminate_backend for a valid pid", () => { - expect(buildPgKillSql(4211)).toBe("SELECT pg_terminate_backend(4211)"); +describe("buildPgCancelQuerySql", () => { + it("builds pg_cancel_backend for a valid pid without disconnecting the session", () => { + expect(buildPgCancelQuerySql(4211)).toBe("SELECT pg_cancel_backend(4211)"); }); it("rejects non-integer, zero, or negative pids", () => { - expect(() => buildPgKillSql(1.5)).toThrow(); - expect(() => buildPgKillSql(0)).toThrow(); - expect(() => buildPgKillSql(-1)).toThrow(); - expect(() => buildPgKillSql(Number.NaN)).toThrow(); + expect(() => buildPgCancelQuerySql(1.5)).toThrow(); + expect(() => buildPgCancelQuerySql(0)).toThrow(); + expect(() => buildPgCancelQuerySql(-1)).toThrow(); + expect(() => buildPgCancelQuerySql(Number.NaN)).toThrow(); }); - it("builds the KingbaseES sys_terminate_backend call with the same PID validation", () => { - expect(buildKingbaseKillSql(4211)).toBe("SELECT sys_terminate_backend(4211)"); - expect(() => buildKingbaseKillSql(0)).toThrow(); - expect(() => buildKingbaseKillSql(1.5)).toThrow(); + it("builds the KingbaseES sys_cancel_backend call with the same PID validation", () => { + expect(buildKingbaseCancelQuerySql(4211)).toBe("SELECT sys_cancel_backend(4211)"); + expect(() => buildKingbaseCancelQuerySql(0)).toThrow(); + expect(() => buildKingbaseCancelQuerySql(1.5)).toThrow(); }); }); @@ -83,7 +85,7 @@ describe("PostgreSQL-family process SQL", () => { expect(OPENGAUSS_PROCESS_LIST_SQL).toContain("FROM pg_catalog.pg_stat_activity"); expect(OPENGAUSS_PROCESS_LIST_SQL).toContain("CASE WHEN waiting"); expect(OPENGAUSS_PROCESS_LIST_SQL).not.toContain("wait_event_type"); - expect(driver?.buildKillSql(7)).toBe("SELECT pg_terminate_backend(7)"); + expect(driver?.buildCancelQuerySql(7)).toBe("SELECT pg_cancel_backend(7)"); }); it("uses KingbaseES's sys catalog and session functions", () => { @@ -95,26 +97,26 @@ describe("PostgreSQL-family process SQL", () => { expect(KINGBASE_PROCESS_LIST_SQL).toContain("extract(epoch FROM CAST(CURRENT_TIMESTAMP AS TIMESTAMP))"); expect(KINGBASE_PROCESS_LIST_SQL).toContain("extract(epoch FROM CAST(coalesce(query_start, xact_start, backend_start) AS TIMESTAMP))"); expect(KINGBASE_PROCESS_LIST_SQL).not.toContain("CURRENT_TIMESTAMP - coalesce(query_start"); - expect(driver?.buildKillSql(7)).toBe("SELECT sys_terminate_backend(7)"); + expect(driver?.buildCancelQuerySql(7)).toBe("SELECT sys_cancel_backend(7)"); }); it("provides pg_catalog fallbacks for pg-compatible KingbaseES servers", () => { const driver = resolveProcessListDriver("kingbase"); expect(driver?.fallbackListSql).toBe(KINGBASE_PG_PROCESS_LIST_SQL); expect(driver?.fallbackOwnSessionSql).toBe(KINGBASE_PG_OWN_SESSION_SQL); - expect(driver?.buildFallbackKillSql?.(7)).toBe("SELECT pg_terminate_backend(7)"); + expect(driver?.buildFallbackCancelQuerySql?.(7)).toBe("SELECT pg_cancel_backend(7)"); expect(KINGBASE_PG_PROCESS_LIST_SQL).toContain("FROM pg_catalog.pg_stat_activity"); expect(KINGBASE_PG_PROCESS_LIST_SQL).not.toContain("sys_catalog"); - expect(buildKingbasePgKillSql(4211)).toBe("SELECT pg_terminate_backend(4211)"); + expect(buildKingbasePgCancelQuerySql(4211)).toBe("SELECT pg_cancel_backend(4211)"); }); it("retries only missing sys relation/function errors", () => { expect(isKingbaseProcessListCatalogCompatibilityError(new Error('relation "sys_catalog.sys_stat_activity" does not exist (SQLSTATE 42P01)'))).toBe(true); expect(isKingbaseOwnSessionCatalogCompatibilityError(Object.assign(new Error("function sys_backend_pid() does not exist"), { code: "42883" }))).toBe(true); - expect(isKingbaseTerminateCatalogCompatibilityError(new Error("function sys_terminate_backend(integer) does not exist (SQLSTATE 42883)"))).toBe(true); + expect(isKingbaseCancelCatalogCompatibilityError(new Error("function sys_cancel_backend(integer) does not exist (SQLSTATE 42883)"))).toBe(true); expect(isKingbaseProcessListCatalogCompatibilityError(Object.assign(new Error("permission denied for relation sys_catalog.sys_stat_activity"), { code: "42501" }))).toBe(false); expect(isKingbaseOwnSessionCatalogCompatibilityError(new Error("authentication failed for sys_backend_pid"))).toBe(false); - expect(isKingbaseTerminateCatalogCompatibilityError(new Error("connection refused"))).toBe(false); + expect(isKingbaseCancelCatalogCompatibilityError(new Error("connection refused"))).toBe(false); }); }); @@ -128,11 +130,13 @@ describe("Postgres compatibility", () => { expect(isPgProcessListCompatibilityError(new Error("permission denied for view pg_stat_activity"))).toBe(false); }); - it("requires pg_terminate_backend to confirm termination", () => { - expect(pgKillResultError([result(["pg_terminate_backend"], [[true]])])).toBeNull(); - expect(pgKillResultError([result(["pg_terminate_backend"], [["t"]])])).toBeNull(); - expect(pgKillResultError([result(["pg_terminate_backend"], [[false]])])).toContain("did not terminate"); - expect(pgKillResultError([result(["pg_terminate_backend"], [])])).toContain("did not terminate"); + it("requires cancellation functions to confirm that a running query was canceled", () => { + expect(pgCancelQueryResultError([result(["pg_cancel_backend"], [[true]])])).toBeNull(); + expect(pgCancelQueryResultError([result(["pg_cancel_backend"], [["t"]])])).toBeNull(); + expect(pgCancelQueryResultError([result(["pg_cancel_backend"], [[false]])])).toContain("did not cancel"); + expect(pgCancelQueryResultError([result(["pg_cancel_backend"], [])])).toContain("did not cancel"); + expect(kingbaseCancelQueryResultError([result(["sys_cancel_backend"], [[false]])])).toContain("did not cancel"); + expect(kingbasePgCancelQueryResultError([result(["pg_cancel_backend"], [[true]])])).toBeNull(); }); }); @@ -140,11 +144,11 @@ describe("resolveProcessListDriver", () => { it("routes MySQL and Postgres engines to distinct drivers", () => { const mysql = resolveProcessListDriver("mysql"); const postgres = resolveProcessListDriver("postgres"); - expect(mysql?.buildKillSql(7)).toBe("KILL CONNECTION 7"); - expect(postgres?.buildKillSql(7)).toBe("SELECT pg_terminate_backend(7)"); + expect(mysql?.buildCancelQuerySql(7)).toBe("KILL QUERY 7"); + expect(postgres?.buildCancelQuerySql(7)).toBe("SELECT pg_cancel_backend(7)"); expect(postgres?.fallbackListSql).toBe(PG_PROCESS_LIST_LEGACY_SQL); expect(postgres?.shouldUseFallbackListSql?.(new Error('column "wait_event" does not exist'))).toBe(true); - expect(postgres?.killResultError?.([result(["pg_terminate_backend"], [[false]])])).toContain("did not terminate"); + expect(postgres?.cancelQueryResultError?.([result(["pg_cancel_backend"], [[false]])])).toContain("did not cancel"); expect(resolveProcessListDriver("sqlite")).toBeNull(); }); @@ -190,7 +194,7 @@ describe("connectionSupportsProcessList", () => { describe("resolveProcessListDriverForConnection", () => { it("returns the engine driver for a JDBC MySQL connection and null for lookalikes", () => { - expect(resolveProcessListDriverForConnection(conn({ db_type: "jdbc", connection_string: "jdbc:mysql://db/app" }))?.buildKillSql(9)).toBe("KILL CONNECTION 9"); + expect(resolveProcessListDriverForConnection(conn({ db_type: "jdbc", connection_string: "jdbc:mysql://db/app" }))?.buildCancelQuerySql(9)).toBe("KILL QUERY 9"); expect(resolveProcessListDriverForConnection(conn({ db_type: "jdbc", connection_string: "jdbc:hive2://hs2/default" }))).toBeNull(); }); }); diff --git a/apps/desktop/src/lib/database/mysqlProcessList.ts b/apps/desktop/src/lib/database/mysqlProcessList.ts index def833f004..5a23f87465 100644 --- a/apps/desktop/src/lib/database/mysqlProcessList.ts +++ b/apps/desktop/src/lib/database/mysqlProcessList.ts @@ -2,7 +2,7 @@ import type { DatabaseType, QueryResult } from "@/types/database"; /** * Engines that speak the MySQL protocol and support `SHOW FULL PROCESSLIST` / - * `KILL CONNECTION`. MariaDB, TiDB, and OceanBase ride the `mysql` dbType via a + * `KILL QUERY`. MariaDB, TiDB, and OceanBase ride the `mysql` dbType via a * driver profile, so they are covered by the `"mysql"` entry. */ const PROCESS_LIST_DB_TYPES = new Set(["mysql"]); @@ -127,14 +127,14 @@ export function mapProcessRows(result: QueryResult | null | undefined): ProcessR } /** - * Build the `KILL CONNECTION ` statement. `id` must be a finite integer; it + * Build the `KILL QUERY ` statement. `id` must be a finite positive integer; it * is validated (never interpolated as free text) so there is no injection path. */ -export function buildKillSql(id: number): string { - if (!Number.isInteger(id) || id < 0) { +export function buildCancelQuerySql(id: number): string { + if (!Number.isInteger(id) || id <= 0) { throw new Error(`Invalid session id: ${id}`); } - return `KILL CONNECTION ${id}`; + return `KILL QUERY ${id}`; } /** Clamp a user-entered refresh interval to a safe integer range of seconds. */ diff --git a/apps/desktop/src/lib/database/postgresProcessList.ts b/apps/desktop/src/lib/database/postgresProcessList.ts index a16a29517e..0b70dd670d 100644 --- a/apps/desktop/src/lib/database/postgresProcessList.ts +++ b/apps/desktop/src/lib/database/postgresProcessList.ts @@ -149,7 +149,7 @@ export function mapPgProcessRows(result: QueryResult | null | undefined): PgProc } /** - * Build a `SELECT pg_terminate_backend()` statement. `pid` is validated as a + * Build a `SELECT pg_cancel_backend()` statement. `pid` is validated as a * finite positive integer (never interpolated as free text) so there is no * injection path. */ @@ -159,39 +159,39 @@ function validateBackendPid(pid: number): void { } } -export function buildPgKillSql(pid: number): string { +export function buildPgCancelQuerySql(pid: number): string { validateBackendPid(pid); - return `SELECT pg_terminate_backend(${pid})`; + return `SELECT pg_cancel_backend(${pid})`; } -export function buildKingbaseKillSql(pid: number): string { +export function buildKingbaseCancelQuerySql(pid: number): string { validateBackendPid(pid); - return `SELECT sys_terminate_backend(${pid})`; + return `SELECT sys_cancel_backend(${pid})`; } -export function buildKingbasePgKillSql(pid: number): string { +export function buildKingbasePgCancelQuerySql(pid: number): string { validateBackendPid(pid); - return `SELECT pg_terminate_backend(${pid})`; + return `SELECT pg_cancel_backend(${pid})`; } -/** Return an error when PostgreSQL declines to terminate the target backend. */ -export function pgKillResultError(results: QueryResult[]): string | null { - return backendKillResultError(results, "pg_terminate_backend"); +/** Return an error when PostgreSQL reports that no running query was canceled. */ +export function pgCancelQueryResultError(results: QueryResult[]): string | null { + return backendCancelQueryResultError(results, "pg_cancel_backend"); } -export function kingbaseKillResultError(results: QueryResult[]): string | null { - return backendKillResultError(results, "sys_terminate_backend"); +export function kingbaseCancelQueryResultError(results: QueryResult[]): string | null { + return backendCancelQueryResultError(results, "sys_cancel_backend"); } -export function kingbasePgKillResultError(results: QueryResult[]): string | null { - return backendKillResultError(results, "pg_terminate_backend"); +export function kingbasePgCancelQueryResultError(results: QueryResult[]): string | null { + return backendCancelQueryResultError(results, "pg_cancel_backend"); } -function backendKillResultError(results: QueryResult[], functionName: string): string | null { +function backendCancelQueryResultError(results: QueryResult[], functionName: string): string | null { const result = results.find((item) => item.execution_error !== true); const value = result?.rows?.[0]?.[0]; if (value === true || value === 1 || String(value).toLowerCase() === "t" || String(value).toLowerCase() === "true") return null; - return `${functionName} did not terminate the backend`; + return `${functionName} did not cancel a running query`; } /** Detect the undefined-column failure produced by pre-9.6 pg_stat_activity. */ @@ -210,6 +210,6 @@ export function isKingbaseOwnSessionCatalogCompatibilityError(error: unknown): b return isMissingKingbaseSysFunction(error, ["sys_backend_pid"]); } -export function isKingbaseTerminateCatalogCompatibilityError(error: unknown): boolean { - return isMissingKingbaseSysFunction(error, ["sys_terminate_backend"]); +export function isKingbaseCancelCatalogCompatibilityError(error: unknown): boolean { + return isMissingKingbaseSysFunction(error, ["sys_cancel_backend"]); } diff --git a/apps/desktop/src/lib/database/processListDrivers.ts b/apps/desktop/src/lib/database/processListDrivers.ts index f90cb835f2..eb4ae0c039 100644 --- a/apps/desktop/src/lib/database/processListDrivers.ts +++ b/apps/desktop/src/lib/database/processListDrivers.ts @@ -1,16 +1,16 @@ import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import { buildKillSql as buildMysqlKillSql, mapProcessRows as mapMysqlProcessRows, PROCESS_LIST_SQL as MYSQL_PROCESS_LIST_SQL, supportsProcessList as supportsMysqlProcessList } from "./mysqlProcessList"; +import { buildCancelQuerySql as buildMysqlCancelQuerySql, mapProcessRows as mapMysqlProcessRows, PROCESS_LIST_SQL as MYSQL_PROCESS_LIST_SQL, supportsProcessList as supportsMysqlProcessList } from "./mysqlProcessList"; import { - buildKingbaseKillSql, - buildKingbasePgKillSql, - buildPgKillSql, + buildKingbaseCancelQuerySql, + buildKingbasePgCancelQuerySql, + buildPgCancelQuerySql, isKingbaseOwnSessionCatalogCompatibilityError, isKingbaseProcessListCatalogCompatibilityError, - isKingbaseTerminateCatalogCompatibilityError, + isKingbaseCancelCatalogCompatibilityError, isPgProcessListCompatibilityError, - kingbaseKillResultError, - kingbasePgKillResultError, + kingbaseCancelQueryResultError, + kingbasePgCancelQueryResultError, KINGBASE_OWN_SESSION_SQL, KINGBASE_PG_OWN_SESSION_SQL, KINGBASE_PG_PROCESS_LIST_SQL, @@ -18,7 +18,7 @@ import { mapPgProcessRows, OPENGAUSS_OWN_SESSION_SQL, OPENGAUSS_PROCESS_LIST_SQL, - pgKillResultError, + pgCancelQueryResultError, PG_OWN_SESSION_SQL, PG_PROCESS_LIST_LEGACY_SQL, PG_PROCESS_LIST_SQL, @@ -67,16 +67,16 @@ export interface ProcessListDriver { maxRows: number; /** Map a raw list result into typed rows. */ mapRows(result: QueryResult | null | undefined): ProcessRow[]; - /** Build the validated statement that kills the given session id. */ - buildKillSql(id: number): string; - /** Build the compatibility statement used when the primary kill function is unavailable. */ - buildFallbackKillSql?(id: number): string; - /** Restrict kill fallback attempts to known compatibility failures. */ - shouldUseFallbackKillSql?(error: unknown): boolean; - /** Validate any engine-specific success value returned by the kill statement. */ - killResultError?(results: QueryResult[]): string | null; - /** Validate the success value returned by the compatibility kill statement. */ - fallbackKillResultError?(results: QueryResult[]): string | null; + /** Build the validated statement that cancels the selected session's running query. */ + buildCancelQuerySql(id: number): string; + /** Build the compatibility statement used when the primary cancellation function is unavailable. */ + buildFallbackCancelQuerySql?(id: number): string; + /** Restrict cancellation fallback attempts to known compatibility failures. */ + shouldUseFallbackCancelQuerySql?(error: unknown): boolean; + /** Validate any engine-specific success value returned by the cancellation statement. */ + cancelQueryResultError?(results: QueryResult[]): string | null; + /** Validate the success value returned by the compatibility cancellation statement. */ + fallbackCancelQueryResultError?(results: QueryResult[]): string | null; } const MYSQL_COLUMNS: ProcessColumn[] = [ @@ -110,7 +110,7 @@ const MYSQL_DRIVER: ProcessListDriver = { maxRows: 5000, // Typed structs carry no index signature; they are plain string-keyed objects at runtime. mapRows: (result) => mapMysqlProcessRows(result) as unknown as ProcessRow[], - buildKillSql: buildMysqlKillSql, + buildCancelQuerySql: buildMysqlCancelQuerySql, }; const POSTGRES_DRIVER: ProcessListDriver = { @@ -122,8 +122,8 @@ const POSTGRES_DRIVER: ProcessListDriver = { defaultSortKey: "time", maxRows: 5000, mapRows: (result) => mapPgProcessRows(result) as unknown as ProcessRow[], - buildKillSql: buildPgKillSql, - killResultError: pgKillResultError, + buildCancelQuerySql: buildPgCancelQuerySql, + cancelQueryResultError: pgCancelQueryResultError, }; const OPENGAUSS_DRIVER: ProcessListDriver = { @@ -133,8 +133,8 @@ const OPENGAUSS_DRIVER: ProcessListDriver = { defaultSortKey: "time", maxRows: 5000, mapRows: (result) => mapPgProcessRows(result) as unknown as ProcessRow[], - buildKillSql: buildPgKillSql, - killResultError: pgKillResultError, + buildCancelQuerySql: buildPgCancelQuerySql, + cancelQueryResultError: pgCancelQueryResultError, }; const KINGBASE_DRIVER: ProcessListDriver = { @@ -148,11 +148,11 @@ const KINGBASE_DRIVER: ProcessListDriver = { defaultSortKey: "time", maxRows: 5000, mapRows: (result) => mapPgProcessRows(result) as unknown as ProcessRow[], - buildKillSql: buildKingbaseKillSql, - buildFallbackKillSql: buildKingbasePgKillSql, - shouldUseFallbackKillSql: isKingbaseTerminateCatalogCompatibilityError, - killResultError: kingbaseKillResultError, - fallbackKillResultError: kingbasePgKillResultError, + buildCancelQuerySql: buildKingbaseCancelQuerySql, + buildFallbackCancelQuerySql: buildKingbasePgCancelQuerySql, + shouldUseFallbackCancelQuerySql: isKingbaseCancelCatalogCompatibilityError, + cancelQueryResultError: kingbaseCancelQueryResultError, + fallbackCancelQueryResultError: kingbasePgCancelQueryResultError, }; /** Resolve the process-list driver for a connection, or null if unsupported. */ diff --git a/crates/dbx-core/src/sql_risk.rs b/crates/dbx-core/src/sql_risk.rs index 375b80d904..827dd154cb 100644 --- a/crates/dbx-core/src/sql_risk.rs +++ b/crates/dbx-core/src/sql_risk.rs @@ -603,6 +603,7 @@ const SIDE_EFFECT_SELECT_FUNCTIONS: &[&str] = &[ "pg_try_advisory_lock", "pg_try_advisory_xact_lock", "setval", + "sys_cancel_backend", ]; fn query_is_write_capable(query: &Query, detect_select_into: bool) -> bool { @@ -1020,6 +1021,24 @@ mod tests { } } + #[test] + fn classify_backend_query_cancellation_as_a_side_effect() { + for (sql, database_type) in [ + ("SELECT pg_cancel_backend(42)", DatabaseType::Postgres), + ("SELECT sys_cancel_backend(42)", DatabaseType::Kingbase), + ] { + assert_eq!( + classify_sql_risk_for_database(sql, database_type).unwrap(), + SqlRisk::Write, + "expected query cancellation to be write-capable: {sql}" + ); + assert!( + is_dangerous_sql_for_database(sql, database_type), + "expected query cancellation to require production protection: {sql}" + ); + } + } + #[test] fn classify_dialect_specific_select_into_as_write() { for sql in [