mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(mysql): terminate sessions from the process list
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Activity, AlertTriangle, ArrowDown, ArrowUp, Ban, Copy, Loader2, RefreshCcw, Search } from "@lucide/vue";
|
||||
import { Activity, AlertTriangle, ArrowDown, ArrowUp, Ban, Copy, Loader2, PlugZap, RefreshCcw, Search } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -54,14 +54,18 @@ let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const cancelTarget = ref<ProcessRow | null>(null);
|
||||
const canceling = ref(false);
|
||||
const terminateTarget = ref<ProcessRow | null>(null);
|
||||
const terminating = ref(false);
|
||||
const batchSupported = computed(() => driver.value?.supportsBatchCancel === true);
|
||||
// Engines without a terminate statement only expose query cancellation.
|
||||
const terminateSupported = computed(() => typeof driver.value?.buildTerminateSessionSql === "function");
|
||||
const selectedIds = ref(new Set<number>());
|
||||
// Anchor row for Shift+click range selection, in the current display order.
|
||||
const selectionAnchorId = ref<number | null>(null);
|
||||
const batchTargets = ref<ProcessRow[] | null>(null);
|
||||
const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null);
|
||||
const refreshing = ref(false);
|
||||
const actionsLocked = computed(() => canceling.value || cancelTarget.value !== null || batchTargets.value !== null);
|
||||
const actionsLocked = computed(() => canceling.value || cancelTarget.value !== null || terminating.value || terminateTarget.value !== null || batchTargets.value !== null);
|
||||
let connectionGeneration = 0;
|
||||
let disposed = false;
|
||||
const fallbackListSql = ref<string | null>(null);
|
||||
@@ -307,6 +311,62 @@ async function confirmCancel() {
|
||||
}
|
||||
}
|
||||
|
||||
function requestTerminate(row: ProcessRow) {
|
||||
if (isOwnSession(row) || actionsLocked.value || refreshing.value || !terminateSupported.value) return;
|
||||
terminateTarget.value = row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the session itself. `KILL QUERY` / `pg_cancel_backend` leave an idle session
|
||||
* connected, so an idle row could never be removed from the list before this action.
|
||||
*/
|
||||
async function confirmTerminate() {
|
||||
const target = terminateTarget.value;
|
||||
const activeDriver = driver.value;
|
||||
const buildTerminateSql = activeDriver?.buildTerminateSessionSql;
|
||||
if (!target || !activeDriver || !buildTerminateSql || terminating.value) return;
|
||||
const connection = { ...props.connection };
|
||||
const generation = connectionGeneration;
|
||||
const isCurrent = () => !disposed && generation === connectionGeneration;
|
||||
terminating.value = true;
|
||||
try {
|
||||
const terminateSql = buildTerminateSql.call(activeDriver, target.id);
|
||||
let usedFallbackTerminateSql = false;
|
||||
const executeTerminateSql = async (sql: string) => {
|
||||
if (!isCurrent()) return undefined;
|
||||
const results = await api.executeMulti(connection.id, "", sql, undefined, undefined, { maxRows: 1 });
|
||||
const executionError = processListExecutionError(results);
|
||||
if (executionError) throw new Error(executionError);
|
||||
return results;
|
||||
};
|
||||
const result = await executeWithProductionSqlGuard({
|
||||
connection,
|
||||
database: "",
|
||||
sql: terminateSql,
|
||||
source: t("production.sourceAdmin"),
|
||||
execute: async () => {
|
||||
try {
|
||||
return await executeTerminateSql(terminateSql);
|
||||
} catch (error) {
|
||||
if (!activeDriver.buildFallbackTerminateSessionSql || !activeDriver.shouldUseFallbackTerminateSessionSql?.(error)) throw error;
|
||||
usedFallbackTerminateSql = true;
|
||||
return executeTerminateSql(activeDriver.buildFallbackTerminateSessionSql(target.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
if (result === undefined || !isCurrent()) return;
|
||||
const terminateResultError = usedFallbackTerminateSql ? activeDriver.fallbackTerminateSessionResultError?.(result) : activeDriver.terminateSessionResultError?.(result);
|
||||
if (terminateResultError) throw new Error(terminateResultError);
|
||||
toast(t("processList.terminateSuccess", { id: target.id }), 2500);
|
||||
terminateTarget.value = null;
|
||||
} catch (error: any) {
|
||||
if (isCurrent()) toast(t("processList.terminateFailed", { message: error?.message || String(error) }), 5000);
|
||||
} finally {
|
||||
terminating.value = false;
|
||||
if (terminateTarget.value === null) void load({ silent: true });
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
@@ -433,7 +493,7 @@ onBeforeUnmount(() => {
|
||||
<ArrowDown v-else-if="sortKey === column.key && sortDir === 'desc'" class="h-3 w-3" />
|
||||
</span>
|
||||
</th>
|
||||
<th class="w-16 whitespace-nowrap border-b px-3 py-2 text-right font-medium">{{ t("processList.colActions") }}</th>
|
||||
<th class="w-40 whitespace-nowrap border-b px-3 py-2 text-right font-medium">{{ t("processList.colActions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -463,17 +523,31 @@ onBeforeUnmount(() => {
|
||||
<template v-else>{{ row[column.key] === null || row[column.key] === undefined ? "—" : row[column.key] }}</template>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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) || actionsLocked || refreshing"
|
||||
:title="isOwnSession(row) ? t('processList.cannotKillSelf') : t('processList.kill')"
|
||||
@click="requestCancel(row)"
|
||||
>
|
||||
<Ban class="h-3.5 w-3.5" />
|
||||
{{ t("processList.kill") }}
|
||||
</Button>
|
||||
<span class="inline-flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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) || actionsLocked || refreshing"
|
||||
:title="isOwnSession(row) ? t('processList.cannotKillSelf') : t('processList.kill')"
|
||||
@click="requestCancel(row)"
|
||||
>
|
||||
<Ban class="h-3.5 w-3.5" />
|
||||
{{ t("processList.kill") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="terminateSupported"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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) || actionsLocked || refreshing"
|
||||
:title="isOwnSession(row) ? t('processList.cannotTerminateSelf') : t('processList.terminate')"
|
||||
@click="requestTerminate(row)"
|
||||
>
|
||||
<PlugZap class="h-3.5 w-3.5" />
|
||||
{{ t("processList.terminate") }}
|
||||
</Button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && filteredRows.length === 0">
|
||||
@@ -513,6 +587,34 @@ onBeforeUnmount(() => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
:open="terminateTarget !== null"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open && !terminating) terminateTarget = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="max-w-sm" :show-close-button="!terminating">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<AlertTriangle class="h-4 w-4 text-destructive" />
|
||||
{{ t("processList.terminateTitle") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p v-if="terminateTarget" class="text-sm text-muted-foreground">
|
||||
{{ t("processList.terminateConfirm", { id: terminateTarget.id, user: terminateTarget.user }) }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="terminating" @click="terminateTarget = null">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" :disabled="terminating" @click="confirmTerminate">
|
||||
<Loader2 v-if="terminating" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("processList.terminate") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
:open="batchTargets !== null"
|
||||
@update:open="
|
||||
|
||||
@@ -3850,6 +3850,12 @@ export default withEnglishFallback({
|
||||
killConfirm: "{id} ({user}) sessiyasında icra olunan sorğu ləğv edilsin? Sessiyanın əlaqəsi saxlanacaq.",
|
||||
killSuccess: "{id} sessiyasında icra olunan sorğu ləğv edildi",
|
||||
killFailed: "Sorğunu ləğv etmək mümkün olmadı: {message}",
|
||||
terminate: "Sessiyanı sonlandır",
|
||||
cannotTerminateSelf: "Öz sessiyanızı sonlandıra bilməzsiniz",
|
||||
terminateTitle: "Sessiyanı sonlandır",
|
||||
terminateConfirm: "{id} ({user}) sessiyası sonlandırılsın? Əlaqə dərhal bağlanır və təsdiqlənməmiş tranzaksiyalar geri alınır.",
|
||||
terminateSuccess: "{id} sessiyası sonlandırıldı",
|
||||
terminateFailed: "Sessiyanı sonlandırmaq mümkün olmadı: {message}",
|
||||
previewTitle: "İfadə",
|
||||
copy: "Kopyala",
|
||||
copied: "Kopyalandı",
|
||||
|
||||
@@ -4048,6 +4048,12 @@ export default {
|
||||
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}",
|
||||
terminate: "Terminate session",
|
||||
cannotTerminateSelf: "You cannot terminate your own session",
|
||||
terminateTitle: "Terminate session",
|
||||
terminateConfirm: "Terminate session {id} ({user})? The connection is closed immediately and any uncommitted transaction is rolled back.",
|
||||
terminateSuccess: "Terminated session {id}",
|
||||
terminateFailed: "Failed to terminate session: {message}",
|
||||
previewTitle: "Statement",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
|
||||
@@ -9538,6 +9538,12 @@ export default withEnglishFallback({
|
||||
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}",
|
||||
terminate: "Terminar sesión",
|
||||
cannotTerminateSelf: "No se puede terminar la sesión actual",
|
||||
terminateTitle: "Terminar sesión",
|
||||
terminateConfirm: "¿Terminar la sesión {id} ({user})? La conexión se cierra de inmediato y cualquier transacción sin confirmar se revierte.",
|
||||
terminateSuccess: "Sesión {id} terminada",
|
||||
terminateFailed: "Error al terminar la sesión: {message}",
|
||||
colPid: "PID",
|
||||
colClient: "Cliente",
|
||||
colApp: "Aplicación",
|
||||
|
||||
@@ -9538,6 +9538,12 @@ export default withEnglishFallback({
|
||||
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}",
|
||||
terminate: "Termina sessione",
|
||||
cannotTerminateSelf: "Impossibile terminare la sessione corrente",
|
||||
terminateTitle: "Termina sessione",
|
||||
terminateConfirm: "Terminare la sessione {id} ({user})? La connessione viene chiusa immediatamente e le transazioni non confermate vengono annullate.",
|
||||
terminateSuccess: "Sessione {id} terminata",
|
||||
terminateFailed: "Impossibile terminare la sessione: {message}",
|
||||
colPid: "PID",
|
||||
colClient: "Client",
|
||||
colApp: "Applicazione",
|
||||
|
||||
@@ -9588,6 +9588,12 @@ export default withEnglishFallback({
|
||||
killConfirm: "セッション {id}({user})で実行中のクエリをキャンセルしますか?セッションの接続は維持されます。",
|
||||
killSuccess: "セッション {id} で実行中のクエリをキャンセルしました。",
|
||||
killFailed: "クエリのキャンセルに失敗しました:{message}",
|
||||
terminate: "セッションを終了",
|
||||
cannotTerminateSelf: "現在のセッションは終了できません",
|
||||
terminateTitle: "セッションを終了",
|
||||
terminateConfirm: "セッション {id}({user})を終了しますか?接続は直ちに切断され、未コミットのトランザクションはロールバックされます。",
|
||||
terminateSuccess: "セッション {id} を終了しました。",
|
||||
terminateFailed: "セッションの終了に失敗しました:{message}",
|
||||
colPid: "PID",
|
||||
colClient: "クライアント",
|
||||
colApp: "アプリケーション",
|
||||
|
||||
@@ -3812,6 +3812,12 @@ export default withEnglishFallback({
|
||||
killConfirm: "세션 {id} ({user})에서 실행 중인 쿼리를 취소하시겠습니까? 세션 연결은 유지됩니다.",
|
||||
killSuccess: "세션 {id}에서 실행 중인 쿼리를 취소했습니다",
|
||||
killFailed: "쿼리 취소에 실패했습니다: {message}",
|
||||
terminate: "세션 종료",
|
||||
cannotTerminateSelf: "현재 세션은 종료할 수 없습니다",
|
||||
terminateTitle: "세션 종료",
|
||||
terminateConfirm: "세션 {id} ({user})을(를) 종료하시겠습니까? 연결이 즉시 끊기고 커밋되지 않은 트랜잭션은 롤백됩니다.",
|
||||
terminateSuccess: "세션 {id}을(를) 종료했습니다",
|
||||
terminateFailed: "세션 종료에 실패했습니다: {message}",
|
||||
previewTitle: "구문",
|
||||
copy: "복사",
|
||||
copied: "복사했습니다",
|
||||
|
||||
@@ -9539,6 +9539,12 @@ export default withEnglishFallback({
|
||||
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}",
|
||||
terminate: "Encerrar sessão",
|
||||
cannotTerminateSelf: "Não é possível encerrar a sessão atual",
|
||||
terminateTitle: "Encerrar sessão",
|
||||
terminateConfirm: "Encerrar a sessão {id} ({user})? A conexão é fechada imediatamente e qualquer transação não confirmada é revertida.",
|
||||
terminateSuccess: "Sessão {id} encerrada",
|
||||
terminateFailed: "Falha ao encerrar a sessão: {message}",
|
||||
colPid: "PID",
|
||||
colClient: "Cliente",
|
||||
colApp: "Aplicativo",
|
||||
|
||||
@@ -3822,6 +3822,12 @@ export default withEnglishFallback({
|
||||
killConfirm: "{id} ({user}) oturumundaki çalışan sorgu iptal edilsin mi? Oturum bağlı kalmaya devam eder.",
|
||||
killSuccess: "{id} oturumundaki çalışan sorgu iptal edildi",
|
||||
killFailed: "Sorgu iptal edilemedi: {message}",
|
||||
terminate: "Oturumu sonlandır",
|
||||
cannotTerminateSelf: "Kendi oturumunuzu sonlandıramazsınız",
|
||||
terminateTitle: "Oturumu sonlandır",
|
||||
terminateConfirm: "{id} ({user}) oturumu sonlandırılsın mı? Bağlantı hemen kapatılır ve işlenmemiş işlemler geri alınır.",
|
||||
terminateSuccess: "{id} oturumu sonlandırıldı",
|
||||
terminateFailed: "Oturum sonlandırılamadı: {message}",
|
||||
previewTitle: "İfade",
|
||||
copy: "Kopyala",
|
||||
copied: "Kopyalandı",
|
||||
|
||||
@@ -3958,6 +3958,12 @@ export default withEnglishFallback({
|
||||
killConfirm: "确定取消会话 {id}({user})中正在运行的查询?该会话将保持连接。",
|
||||
killSuccess: "已取消会话 {id} 中正在运行的查询",
|
||||
killFailed: "取消查询失败:{message}",
|
||||
terminate: "终止会话",
|
||||
cannotTerminateSelf: "无法终止当前会话",
|
||||
terminateTitle: "终止会话",
|
||||
terminateConfirm: "确定终止会话 {id}({user})?该连接会立即断开,未提交的事务将回滚。",
|
||||
terminateSuccess: "已终止会话 {id}",
|
||||
terminateFailed: "终止会话失败:{message}",
|
||||
previewTitle: "语句",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
|
||||
@@ -9537,6 +9537,12 @@ export default withEnglishFallback({
|
||||
killConfirm: "確定取消會話 {id}({user})中執行中的查詢?該會話將保持連線。",
|
||||
killSuccess: "已取消會話 {id} 中執行中的查詢",
|
||||
killFailed: "取消查詢失敗:{message}",
|
||||
terminate: "終止會話",
|
||||
cannotTerminateSelf: "無法終止目前會話",
|
||||
terminateTitle: "終止會話",
|
||||
terminateConfirm: "確定終止會話 {id}({user})?該連線會立即中斷,未提交的交易將回滾。",
|
||||
terminateSuccess: "已終止會話 {id}",
|
||||
terminateFailed: "終止會話失敗:{message}",
|
||||
colPid: "PID",
|
||||
colClient: "用戶端",
|
||||
colApp: "應用程式",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { buildCancelQuerySql, clampInterval, createProcessListLoadCoordinator, mapProcessRows, processListExecutionError, processListSessionCount, supportsProcessList } from "@/lib/database/mysqlProcessList";
|
||||
import { buildCancelQuerySql, buildTerminateSessionSql, 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 };
|
||||
@@ -48,6 +48,19 @@ describe("buildCancelQuerySql", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTerminateSessionSql", () => {
|
||||
it("builds KILL for a valid id so an idle session is really disconnected", () => {
|
||||
expect(buildTerminateSessionSql(8213)).toBe("KILL 8213");
|
||||
});
|
||||
|
||||
it("rejects non-integer or nonpositive ids", () => {
|
||||
expect(() => buildTerminateSessionSql(1.5)).toThrow();
|
||||
expect(() => buildTerminateSessionSql(0)).toThrow();
|
||||
expect(() => buildTerminateSessionSql(-1)).toThrow();
|
||||
expect(() => buildTerminateSessionSql(Number.NaN)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampInterval", () => {
|
||||
it("clamps below the minimum to 1 second", () => {
|
||||
expect(clampInterval(0)).toBe(1);
|
||||
|
||||
@@ -3,10 +3,14 @@ import type { QueryResult } from "@/types/database";
|
||||
import {
|
||||
buildKingbaseCancelQuerySql,
|
||||
buildKingbasePgCancelQuerySql,
|
||||
buildKingbasePgTerminateSessionSql,
|
||||
buildKingbaseTerminateSessionSql,
|
||||
buildPgCancelQuerySql,
|
||||
buildPgTerminateSessionSql,
|
||||
isKingbaseOwnSessionCatalogCompatibilityError,
|
||||
isKingbaseProcessListCatalogCompatibilityError,
|
||||
isKingbaseCancelCatalogCompatibilityError,
|
||||
isKingbaseTerminateCatalogCompatibilityError,
|
||||
isPgProcessListCompatibilityError,
|
||||
KINGBASE_OWN_SESSION_SQL,
|
||||
KINGBASE_PG_OWN_SESSION_SQL,
|
||||
@@ -18,6 +22,7 @@ import {
|
||||
kingbaseCancelQueryResultError,
|
||||
kingbasePgCancelQueryResultError,
|
||||
pgCancelQueryResultError,
|
||||
pgTerminateSessionResultError,
|
||||
PG_PROCESS_LIST_LEGACY_SQL,
|
||||
PG_PROCESS_LIST_SQL,
|
||||
} from "@/lib/database/postgresProcessList";
|
||||
@@ -120,6 +125,32 @@ describe("PostgreSQL-family process SQL", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session termination statements", () => {
|
||||
it("builds pg_terminate_backend so an idle backend is really disconnected", () => {
|
||||
expect(buildPgTerminateSessionSql(4211)).toBe("SELECT pg_terminate_backend(4211)");
|
||||
expect(buildKingbaseTerminateSessionSql(4211)).toBe("SELECT sys_terminate_backend(4211)");
|
||||
expect(buildKingbasePgTerminateSessionSql(4211)).toBe("SELECT pg_terminate_backend(4211)");
|
||||
});
|
||||
|
||||
it("rejects non-integer or nonpositive pids", () => {
|
||||
expect(() => buildPgTerminateSessionSql(1.5)).toThrow();
|
||||
expect(() => buildPgTerminateSessionSql(0)).toThrow();
|
||||
expect(() => buildPgTerminateSessionSql(Number.NaN)).toThrow();
|
||||
});
|
||||
|
||||
it("requires the server to confirm that a backend was terminated", () => {
|
||||
expect(pgTerminateSessionResultError([result(["pg_terminate_backend"], [[true]])])).toBeNull();
|
||||
expect(pgTerminateSessionResultError([result(["pg_terminate_backend"], [["t"]])])).toBeNull();
|
||||
expect(pgTerminateSessionResultError([result(["pg_terminate_backend"], [[false]])])).toContain("did not terminate");
|
||||
expect(pgTerminateSessionResultError([result(["pg_terminate_backend"], [])])).toContain("did not terminate");
|
||||
});
|
||||
|
||||
it("detects the Kingbase fallback trigger for a missing sys_terminate_backend", () => {
|
||||
expect(isKingbaseTerminateCatalogCompatibilityError(new Error("function sys_terminate_backend(integer) does not exist"))).toBe(true);
|
||||
expect(isKingbaseTerminateCatalogCompatibilityError(new Error("permission denied"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Postgres compatibility", () => {
|
||||
it("provides a pre-9.6 query and only falls back for missing wait-event columns", () => {
|
||||
expect(PG_PROCESS_LIST_SQL).toContain("wait_event_type");
|
||||
@@ -145,7 +176,10 @@ describe("resolveProcessListDriver", () => {
|
||||
const mysql = resolveProcessListDriver("mysql");
|
||||
const postgres = resolveProcessListDriver("postgres");
|
||||
expect(mysql?.buildCancelQuerySql(7)).toBe("KILL QUERY 7");
|
||||
expect(mysql?.buildTerminateSessionSql?.(7)).toBe("KILL 7");
|
||||
expect(postgres?.buildCancelQuerySql(7)).toBe("SELECT pg_cancel_backend(7)");
|
||||
expect(postgres?.buildTerminateSessionSql?.(7)).toBe("SELECT pg_terminate_backend(7)");
|
||||
expect(postgres?.terminateSessionResultError?.([result(["pg_terminate_backend"], [[false]])])).toContain("did not terminate");
|
||||
expect(postgres?.fallbackListSql).toBe(PG_PROCESS_LIST_LEGACY_SQL);
|
||||
expect(postgres?.shouldUseFallbackListSql?.(new Error('column "wait_event" does not exist'))).toBe(true);
|
||||
expect(postgres?.cancelQueryResultError?.([result(["pg_cancel_backend"], [[false]])])).toContain("did not cancel");
|
||||
@@ -177,6 +211,8 @@ describe("connectionSupportsProcessList", () => {
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "gaussdb", driver_profile: "opengauss" }))).toBe(true);
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "gaussdb", driver_profile: "gaussdb" }))).toBe(false);
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "kingbase" }))).toBe(true);
|
||||
expect(resolveProcessListDriver("kingbase")?.buildTerminateSessionSql?.(7)).toBe("SELECT sys_terminate_backend(7)");
|
||||
expect(resolveProcessListDriver("opengauss")?.buildTerminateSessionSql?.(7)).toBe("SELECT pg_terminate_backend(7)");
|
||||
expect(connectionSupportsProcessList(conn({ db_type: "sqlite" }))).toBe(false);
|
||||
expect(connectionSupportsProcessList(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -137,6 +137,21 @@ export function buildCancelQuerySql(id: number): string {
|
||||
return `KILL QUERY ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `KILL <id>` statement that disconnects the session itself.
|
||||
*
|
||||
* `KILL QUERY` only interrupts the statement running at that instant, so an idle
|
||||
* (`Sleep`) session survives it and keeps showing up in the process list. When the
|
||||
* user asks to close a session, only this statement actually removes it (and rolls
|
||||
* back the session's uncommitted transaction, the same as the server-side `KILL`).
|
||||
*/
|
||||
export function buildTerminateSessionSql(id: number): string {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw new Error(`Invalid session id: ${id}`);
|
||||
}
|
||||
return `KILL ${id}`;
|
||||
}
|
||||
|
||||
/** Clamp a user-entered refresh interval to a safe integer range of seconds. */
|
||||
export function clampInterval(seconds: number): number {
|
||||
if (!Number.isFinite(seconds)) return DEFAULT_REFRESH_SECONDS;
|
||||
|
||||
@@ -174,6 +174,27 @@ export function buildKingbasePgCancelQuerySql(pid: number): string {
|
||||
return `SELECT pg_cancel_backend(${pid})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the statement that disconnects the backend instead of only interrupting the
|
||||
* statement it is running right now. `pg_cancel_backend` cancels the current query and
|
||||
* leaves an idle backend alive, so the process list keeps listing it; terminating the
|
||||
* backend is what actually removes the session (rolling back its open transaction).
|
||||
*/
|
||||
export function buildPgTerminateSessionSql(pid: number): string {
|
||||
validateBackendPid(pid);
|
||||
return `SELECT pg_terminate_backend(${pid})`;
|
||||
}
|
||||
|
||||
export function buildKingbaseTerminateSessionSql(pid: number): string {
|
||||
validateBackendPid(pid);
|
||||
return `SELECT sys_terminate_backend(${pid})`;
|
||||
}
|
||||
|
||||
export function buildKingbasePgTerminateSessionSql(pid: number): string {
|
||||
validateBackendPid(pid);
|
||||
return `SELECT pg_terminate_backend(${pid})`;
|
||||
}
|
||||
|
||||
/** 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");
|
||||
@@ -187,11 +208,24 @@ export function kingbasePgCancelQueryResultError(results: QueryResult[]): string
|
||||
return backendCancelQueryResultError(results, "pg_cancel_backend");
|
||||
}
|
||||
|
||||
function backendCancelQueryResultError(results: QueryResult[], functionName: string): string | null {
|
||||
/** Return an error when the server reports that no backend was terminated. */
|
||||
export function pgTerminateSessionResultError(results: QueryResult[]): string | null {
|
||||
return backendCancelQueryResultError(results, "pg_terminate_backend", "did not terminate the session");
|
||||
}
|
||||
|
||||
export function kingbaseTerminateSessionResultError(results: QueryResult[]): string | null {
|
||||
return backendCancelQueryResultError(results, "sys_terminate_backend", "did not terminate the session");
|
||||
}
|
||||
|
||||
export function kingbasePgTerminateSessionResultError(results: QueryResult[]): string | null {
|
||||
return backendCancelQueryResultError(results, "pg_terminate_backend", "did not terminate the session");
|
||||
}
|
||||
|
||||
function backendCancelQueryResultError(results: QueryResult[], functionName: string, failure = "did not cancel a running query"): 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 cancel a running query`;
|
||||
return `${functionName} ${failure}`;
|
||||
}
|
||||
|
||||
/** Detect the undefined-column failure produced by pre-9.6 pg_stat_activity. */
|
||||
@@ -213,3 +247,7 @@ export function isKingbaseOwnSessionCatalogCompatibilityError(error: unknown): b
|
||||
export function isKingbaseCancelCatalogCompatibilityError(error: unknown): boolean {
|
||||
return isMissingKingbaseSysFunction(error, ["sys_cancel_backend"]);
|
||||
}
|
||||
|
||||
export function isKingbaseTerminateCatalogCompatibilityError(error: unknown): boolean {
|
||||
return isMissingKingbaseSysFunction(error, ["sys_terminate_backend"]);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { buildCancelQuerySql as buildMysqlCancelQuerySql, mapProcessRows as mapMysqlProcessRows, PROCESS_LIST_SQL as MYSQL_PROCESS_LIST_SQL, supportsProcessList as supportsMysqlProcessList } from "./mysqlProcessList";
|
||||
import { buildCancelQuerySql as buildMysqlCancelQuerySql, buildTerminateSessionSql as buildMysqlTerminateSessionSql, mapProcessRows as mapMysqlProcessRows, PROCESS_LIST_SQL as MYSQL_PROCESS_LIST_SQL, supportsProcessList as supportsMysqlProcessList } from "./mysqlProcessList";
|
||||
import {
|
||||
buildKingbaseCancelQuerySql,
|
||||
buildKingbasePgCancelQuerySql,
|
||||
buildKingbasePgTerminateSessionSql,
|
||||
buildKingbaseTerminateSessionSql,
|
||||
buildPgCancelQuerySql,
|
||||
buildPgTerminateSessionSql,
|
||||
isKingbaseOwnSessionCatalogCompatibilityError,
|
||||
isKingbaseProcessListCatalogCompatibilityError,
|
||||
isKingbaseCancelCatalogCompatibilityError,
|
||||
isKingbaseTerminateCatalogCompatibilityError,
|
||||
isPgProcessListCompatibilityError,
|
||||
kingbaseCancelQueryResultError,
|
||||
kingbasePgCancelQueryResultError,
|
||||
kingbasePgTerminateSessionResultError,
|
||||
kingbaseTerminateSessionResultError,
|
||||
KINGBASE_OWN_SESSION_SQL,
|
||||
KINGBASE_PG_OWN_SESSION_SQL,
|
||||
KINGBASE_PG_PROCESS_LIST_SQL,
|
||||
@@ -19,6 +25,7 @@ import {
|
||||
OPENGAUSS_OWN_SESSION_SQL,
|
||||
OPENGAUSS_PROCESS_LIST_SQL,
|
||||
pgCancelQueryResultError,
|
||||
pgTerminateSessionResultError,
|
||||
PG_OWN_SESSION_SQL,
|
||||
PG_PROCESS_LIST_LEGACY_SQL,
|
||||
PG_PROCESS_LIST_SQL,
|
||||
@@ -79,6 +86,19 @@ export interface ProcessListDriver {
|
||||
cancelQueryResultError?(results: QueryResult[]): string | null;
|
||||
/** Validate the success value returned by the compatibility cancellation statement. */
|
||||
fallbackCancelQueryResultError?(results: QueryResult[]): string | null;
|
||||
/**
|
||||
* Build the validated statement that disconnects the session. Engines that omit it
|
||||
* only offer query cancellation, because they cannot close a session over the wire.
|
||||
*/
|
||||
buildTerminateSessionSql?(id: number): string;
|
||||
/** Build the compatibility statement used when the primary terminate function is unavailable. */
|
||||
buildFallbackTerminateSessionSql?(id: number): string;
|
||||
/** Restrict terminate fallback attempts to known compatibility failures. */
|
||||
shouldUseFallbackTerminateSessionSql?(error: unknown): boolean;
|
||||
/** Validate any engine-specific success value returned by the terminate statement. */
|
||||
terminateSessionResultError?(results: QueryResult[]): string | null;
|
||||
/** Validate the success value returned by the compatibility terminate statement. */
|
||||
fallbackTerminateSessionResultError?(results: QueryResult[]): string | null;
|
||||
}
|
||||
|
||||
const MYSQL_COLUMNS: ProcessColumn[] = [
|
||||
@@ -114,6 +134,7 @@ const MYSQL_DRIVER: ProcessListDriver = {
|
||||
// Typed structs carry no index signature; they are plain string-keyed objects at runtime.
|
||||
mapRows: (result) => mapMysqlProcessRows(result) as unknown as ProcessRow[],
|
||||
buildCancelQuerySql: buildMysqlCancelQuerySql,
|
||||
buildTerminateSessionSql: buildMysqlTerminateSessionSql,
|
||||
};
|
||||
|
||||
const POSTGRES_DRIVER: ProcessListDriver = {
|
||||
@@ -127,6 +148,8 @@ const POSTGRES_DRIVER: ProcessListDriver = {
|
||||
mapRows: (result) => mapPgProcessRows(result) as unknown as ProcessRow[],
|
||||
buildCancelQuerySql: buildPgCancelQuerySql,
|
||||
cancelQueryResultError: pgCancelQueryResultError,
|
||||
buildTerminateSessionSql: buildPgTerminateSessionSql,
|
||||
terminateSessionResultError: pgTerminateSessionResultError,
|
||||
};
|
||||
|
||||
const OPENGAUSS_DRIVER: ProcessListDriver = {
|
||||
@@ -138,6 +161,8 @@ const OPENGAUSS_DRIVER: ProcessListDriver = {
|
||||
mapRows: (result) => mapPgProcessRows(result) as unknown as ProcessRow[],
|
||||
buildCancelQuerySql: buildPgCancelQuerySql,
|
||||
cancelQueryResultError: pgCancelQueryResultError,
|
||||
buildTerminateSessionSql: buildPgTerminateSessionSql,
|
||||
terminateSessionResultError: pgTerminateSessionResultError,
|
||||
};
|
||||
|
||||
const KINGBASE_DRIVER: ProcessListDriver = {
|
||||
@@ -156,6 +181,11 @@ const KINGBASE_DRIVER: ProcessListDriver = {
|
||||
shouldUseFallbackCancelQuerySql: isKingbaseCancelCatalogCompatibilityError,
|
||||
cancelQueryResultError: kingbaseCancelQueryResultError,
|
||||
fallbackCancelQueryResultError: kingbasePgCancelQueryResultError,
|
||||
buildTerminateSessionSql: buildKingbaseTerminateSessionSql,
|
||||
buildFallbackTerminateSessionSql: buildKingbasePgTerminateSessionSql,
|
||||
shouldUseFallbackTerminateSessionSql: isKingbaseTerminateCatalogCompatibilityError,
|
||||
terminateSessionResultError: kingbaseTerminateSessionResultError,
|
||||
fallbackTerminateSessionResultError: kingbasePgTerminateSessionResultError,
|
||||
};
|
||||
|
||||
/** Resolve the process-list driver for a connection, or null if unsupported. */
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Reference in New Issue
Block a user