Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions apps/desktop/src/components/chart/QueryChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const props = defineProps<{
const { t } = useI18n();
const { isDark } = useTheme();

const MAX_CHART_POINTS = 5000;
type ChartType = "line" | "bar" | "pie";
const chartType = ref<ChartType>("bar");
const xColumnIndex = ref(0);
Expand Down Expand Up @@ -73,7 +74,14 @@ const chartOption = computed(() => {
const xIdx = xColumnIndex.value;
if (xIdx < 0 || yColumnIndexes.value.length === 0) return null;

const xData = props.result.rows.map((row) => String(row[xIdx] ?? ""));
const allRows = props.result.rows;
const rowCount = allRows.length;
// Downsample huge result sets so ECharts does not duplicate every row into series data.
const stride = rowCount > MAX_CHART_POINTS ? Math.ceil(rowCount / MAX_CHART_POINTS) : 1;
const picked: number[] = [];
for (let i = 0; i < rowCount; i += stride) picked.push(i);

const xData = picked.map((i) => String(allRows[i][xIdx] ?? ""));

if (chartType.value === "pie") {
const yIdx = yColumnIndexes.value[0];
Expand All @@ -85,9 +93,9 @@ const chartOption = computed(() => {
{
type: "pie",
radius: ["30%", "60%"],
data: xData.map((name, i) => ({
name,
value: toChartNumber(props.result.rows[i][yIdx]) ?? 0,
data: picked.map((rowIdx, j) => ({
name: xData[j],
value: toChartNumber(allRows[rowIdx][yIdx]) ?? 0,
})),
},
],
Expand Down Expand Up @@ -115,7 +123,7 @@ const chartOption = computed(() => {
series: yIndices.map((yIdx) => ({
name: axisColumnLabel(props.result.columns, yIdx),
type: chartType.value,
data: props.result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0),
data: picked.map((rowIdx) => toChartNumber(allRows[rowIdx][yIdx]) ?? 0),
smooth: chartType.value === "line",
})),
};
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src/components/config/DriverStoreDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ const queuedDriverInstalls = ref<string[]>([]);
const reinstallingJre = ref<string | null>(null);
const refreshing = ref(false);
const progress = ref<DriverInstallProgress | null>(null);
const javaRuntimeConfig = ref<JavaRuntimeConfig>({ mode: "managed", custom_java_path: null });
const javaRuntimeConfig = ref<JavaRuntimeConfig>({ mode: "managed", custom_java_path: null, max_heap: null });
const customJavaPath = ref("");
const maxHeapValue = ref("");
const savingJavaRuntime = ref(false);
const driverStoreUsage = ref<DriverStoreUsage | null>(null);
const runtimeSummary = ref<DriverRuntimeSummary | null>(null);
Expand Down Expand Up @@ -321,6 +322,7 @@ async function loadJavaRuntimeConfig() {
const config = await api.getAgentJavaRuntimeConfig();
javaRuntimeConfig.value = config;
customJavaPath.value = config.custom_java_path ?? "";
maxHeapValue.value = config.max_heap ?? "";
}

function setJavaRuntimeMode(value: any) {
Expand All @@ -335,9 +337,11 @@ async function saveJavaRuntimeConfig() {
const config = await api.setAgentJavaRuntimeConfig({
mode: javaRuntimeConfig.value.mode,
custom_java_path: javaRuntimeConfig.value.mode === "custom" ? customJavaPath.value.trim() || null : null,
max_heap: maxHeapValue.value.trim() || null,
});
javaRuntimeConfig.value = config;
customJavaPath.value = config.custom_java_path ?? "";
maxHeapValue.value = config.max_heap ?? "";
toast(t("driverStore.javaRuntimeSaved"));
} catch (e: any) {
toast(t("driverStore.javaRuntimeSaveFailed", { error: e }));
Expand Down Expand Up @@ -1064,6 +1068,17 @@ watch(driverStoreTab, (tab) => {
</Button>
</div>

<div class="flex flex-wrap items-center gap-2">
<Label class="shrink-0">{{ t("driverStore.javaMaxHeap") }}</Label>
<Input v-model="maxHeapValue" class="h-8 min-w-[120px] text-xs" :placeholder="t('driverStore.javaMaxHeapPlaceholder')" @keydown.enter.prevent="saveJavaRuntimeConfig" />
<span class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ t("driverStore.javaMaxHeapHint") }}
</span>
<Button class="h-8 shrink-0 rounded-[6px] text-xs" :disabled="savingJavaRuntime" @click="saveJavaRuntimeConfig">
{{ savingJavaRuntime ? t("driverStore.saving") : t("settings.save") }}
</Button>
</div>

<div v-if="installedJres.length > 0" class="divide-y rounded-lg border bg-background/50">
<div v-for="jre in installedJres" :key="jre.key" class="flex items-center justify-between gap-3 px-3 py-2.5">
<div class="min-w-0">
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ export default {
executionSummary: {
empty: "No execution summary",
executing: "Executing...",
largeResultWarning: "Large result set ({rows} rows, ~{megabytes} MB). Consider decreasing rows per page or exporting to avoid high memory use.",
statement: "Statement",
type: "Type",
rows: "Rows",
Expand Down Expand Up @@ -2917,6 +2918,9 @@ export default {
uninstall: "Uninstall",
jreRuntime: "JRE Runtime",
jreRuntimeAutoDownloadHint: "Downloaded automatically when installing a driver for the first time",
javaMaxHeap: "Max heap",
javaMaxHeapPlaceholder: "512m / 768m / 1g / 2g",
javaMaxHeapHint: "Caps Java agent memory (e.g. 512m, 1g); empty uses the 512m default",
driversUpdatable: "{count} driver(s) can be updated",
upgradingProgress: "Upgrading ({current}/{total})",
upgradeAll: "Upgrade all",
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ export default withEnglishFallback({
executionSummary: {
empty: "暂无执行摘要",
executing: "正在执行...",
largeResultWarning: "结果集较大({rows} 行,约 {megabytes} MB)。建议减小每页行数或导出,以免内存占用过高。",
statement: "语句",
type: "类型",
rows: "返回行",
Expand Down Expand Up @@ -2924,6 +2925,9 @@ export default withEnglishFallback({
uninstall: "卸载",
jreRuntime: "JRE 运行时",
jreRuntimeAutoDownloadHint: "首次安装驱动时自动下载",
javaMaxHeap: "最大堆内存",
javaMaxHeapPlaceholder: "512m / 768m / 1g / 2g",
javaMaxHeapHint: "限制 Java 驱动进程内存(如 512m、1g);留空使用默认 512m",
driversUpdatable: "{count} 个驱动可更新",
upgradingProgress: "升级中 ({current}/{total})",
upgradeAll: "全部升级",
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/lib/__tests__/ai.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { buildSystemPrompt, type AiContext } from "@/lib/ai";

// buildSystemPrompt switches copy by currentLocale(); pin it to English so the assertions below
// (which check the English prompt text) are stable regardless of the host locale.
vi.mock("@/i18n", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/i18n")>();
return { ...actual, currentLocale: () => "en" };
});

function context(overrides: Partial<AiContext> = {}): AiContext {
return {
connectionId: "conn-1",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/paginationPageSize.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const RESULT_PAGE_SIZE_OPTIONS = [50, 100, 500, 1000];
export const DEFAULT_RESULT_PAGE_SIZE = 100;
export const MIN_RESULT_PAGE_SIZE = 1;
export const MAX_RESULT_PAGE_SIZE = 100000;
export const MAX_RESULT_PAGE_SIZE = 50000;

export function normalizeResultPageSize(value: unknown, fallback = DEFAULT_RESULT_PAGE_SIZE): number {
const fallbackValue = Number.isFinite(fallback) && fallback >= MIN_RESULT_PAGE_SIZE ? Math.min(Math.floor(fallback), MAX_RESULT_PAGE_SIZE) : DEFAULT_RESULT_PAGE_SIZE;
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/lib/resultMemoryBudget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Frontend memory-budget helpers for query result sets. The WebView renderer can balloon to
* multiple GB when large results accumulate across inactive tabs; these helpers cap that.
*/

/** Total byte budget for inactive-tab result caches held in memory. Results beyond this are
* evicted to disk (oldest first) even if fewer than MAX_CACHED_RESULTS tabs are inactive. */
export const MAX_RESULT_CACHE_BYTES = 300 * 1024 * 1024;
/** Thresholds that trigger a "large result" warning so the user shrinks page size or exports. */
export const LARGE_RESULT_WARN_BYTES = 50 * 1024 * 1024;
export const LARGE_RESULT_WARN_ROWS = 100_000;
/** Cap the number of rows sampled when estimating, so estimation stays cheap on huge results. */
const ESTIMATE_SAMPLE_ROWS = 512;

export type ResultLike = { rows?: unknown[][] } | null | undefined;

function estimateValueBytes(value: unknown): number {
if (value === null || value === undefined) return 4;
if (typeof value === "number" || typeof value === "boolean") return 8;
if (typeof value === "string") return value.length + 2;
try {
return Math.min(JSON.stringify(value).length, 2048);
} catch {
return 64;
}
}

/** Rough byte estimate of a result set's rows, sampled to stay cheap on huge results. */
export function estimateResultBytes(result: ResultLike): number {
const rows = result?.rows;
if (!rows || rows.length === 0) return 0;
const total = rows.length;
const sample = Math.min(total, ESTIMATE_SAMPLE_ROWS);
let sampled = 0;
for (let i = 0; i < sample; i += 1) {
const row = rows[i];
if (!row) continue;
for (const value of row) sampled += estimateValueBytes(value);
}
return Math.round((sampled / sample) * total);
}

/** True when a result is large enough to warrant a user-facing warning. */
export function isLargeResult(result: ResultLike): boolean {
if (!result?.rows) return false;
return result.rows.length >= LARGE_RESULT_WARN_ROWS || estimateResultBytes(result) >= LARGE_RESULT_WARN_BYTES;
}
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export type JavaRuntimeMode = "managed" | "system" | "custom";
export interface JavaRuntimeConfig {
mode: JavaRuntimeMode;
custom_java_path: string | null;
/** JVM max heap passed to every Java agent as `-Xmx<value>` (e.g. "512m", "1g"). null/empty uses the backend default. */
max_heap: string | null;
}

export interface DriverStoreUsageItem {
Expand Down
27 changes: 24 additions & 3 deletions apps/desktop/src/stores/queryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionCont
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGridSort";
import { normalizeResultPageSize } from "@/lib/paginationPageSize";
import { estimateResultBytes, isLargeResult, MAX_RESULT_CACHE_BYTES } from "@/lib/resultMemoryBudget";
import { useToast } from "@/composables/useToast";
import { splitSqlStatementRanges } from "@/lib/sqlStatementRanges";
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { buildTabResultSnapshot, deleteTabResultSnapshot, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabResultCache";
Expand Down Expand Up @@ -185,6 +187,7 @@ function getI18nT() {

export const useQueryStore = defineStore("query", () => {
const t = getI18nT();
const { toast } = useToast();
const restored = loadSavedTabs();
const tabs = ref<QueryTab[]>(restored.tabs);
const activeTabId = ref<string | null>(restored.activeTabId);
Expand Down Expand Up @@ -1930,6 +1933,7 @@ export const useQueryStore = defineStore("query", () => {
backendMs: current.result?.execution_time_ms,
elapsed: elapsed(),
});
warnLargeResultIfNeeded(current.result);
if (current.mode === "query" && current.result) analyzeQueryMetadataInBackground(id, queryBaseSql, current.result, traceId, elapsed);
} else {
console.warn("[DBX][executeTabSql:stale-result]", {
Expand Down Expand Up @@ -2167,10 +2171,27 @@ export const useQueryStore = defineStore("query", () => {

async function trimResultCache() {
const inactive = tabs.value.filter((t) => t.id !== activeTabId.value && (t.result || t.results)).sort((a, b) => (a.resultAccessedAt ?? 0) - (b.resultAccessedAt ?? 0));
if (inactive.length > MAX_CACHED_RESULTS) {
const toEvict = inactive.slice(0, inactive.length - MAX_CACHED_RESULTS);
await Promise.all(toEvict.map((t) => evictCachedResult(t)));
if (inactive.length === 0) return;
// Evict oldest inactive results while we exceed either the tab-count cap or the byte budget.
// The byte budget prevents a few huge results from holding hundreds of MB across inactive tabs.
let totalBytes = inactive.reduce((sum, tab) => sum + estimateResultBytes(tab.result ?? tab.results?.[0]), 0);
const toEvict: QueryTab[] = [];
for (const tab of inactive) {
const remainingCount = inactive.length - toEvict.length;
if (remainingCount <= MAX_CACHED_RESULTS && totalBytes <= MAX_RESULT_CACHE_BYTES) break;
toEvict.push(tab);
totalBytes -= estimateResultBytes(tab.result ?? tab.results?.[0]);
}
if (toEvict.length > 0) {
await Promise.all(toEvict.map((tab) => evictCachedResult(tab)));
}
}

function warnLargeResultIfNeeded(result: QueryResult | undefined) {
if (!result || !isLargeResult(result)) return;
const rows = result.rows.length;
const megabytes = Math.max(1, Math.round(estimateResultBytes(result) / (1024 * 1024)));
toast(t("query.largeResultWarning", { rows, megabytes }));
}

watch(activeTabId, (id) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/stores/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
snippets: DEFAULT_SQL_SNIPPETS,
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
exportBatchSize: 2000,
exportRowLimitEnabled: false,
exportRowLimitEnabled: true,
exportRowLimit: 100000,
queryExportKeysetOptimizationEnabled: true,
updateDownloadSource: "official",
Expand Down
59 changes: 54 additions & 5 deletions crates/dbx-core/src/agent_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ use crate::models::connection::DatabaseType;
pub const DEFAULT_JRE_KEY: &str = "21";
pub const DOWNLOAD_CACHE_DIR_NAME: &str = "download-cache";
pub const DOWNLOAD_CACHE_MAX_AGE_DAYS: u64 = 7;
/// Default JVM max heap for Java agents. Agents previously launched with no `-Xmx`, so the JVM
/// used its default (up to ~1/4 of physical RAM) and a single agent could reach several GB.
/// Paired with the existing `UseSerialGC` to cap memory under worst-case result/export loads.
/// Users can raise this in the driver store settings.
pub const DEFAULT_AGENT_MAX_HEAP: &str = "512m";

/// Validate a JVM heap size string such as `512m`, `1g`, `2048k`. Must be a positive integer
/// followed by a `k`/`m`/`g` unit (case-insensitive). Malformed values are rejected so launch
/// sites fall back to [`DEFAULT_AGENT_MAX_HEAP`].
pub fn validate_max_heap(value: &str) -> bool {
let trimmed = value.trim();
let Some((unit, digits)) = trimmed.as_bytes().split_last() else {
return false;
};
matches!(*unit, b'k' | b'K' | b'm' | b'M' | b'g' | b'G')
&& !digits.is_empty()
&& digits.iter().all(|c| c.is_ascii_digit())
}

fn default_jre_key() -> String {
DEFAULT_JRE_KEY.to_string()
Expand Down Expand Up @@ -139,6 +157,7 @@ mod tests {
java_runtime: JavaRuntimeConfig {
mode: JavaRuntimeMode::Custom,
custom_java_path: Some(custom_java.to_string_lossy().to_string()),
..Default::default()
},
..AgentState::default()
};
Expand All @@ -153,6 +172,7 @@ mod tests {
java_runtime: JavaRuntimeConfig {
mode: JavaRuntimeMode::Custom,
custom_java_path: Some(manager.base_dir().join("missing-java").to_string_lossy().to_string()),
..Default::default()
},
..AgentState::default()
};
Expand Down Expand Up @@ -254,12 +274,37 @@ mod tests {
.expect("manifest launch should resolve");

assert_eq!(launch.program, driver_dir.join("bin").join("dameng-agent"));
assert_eq!(
launch.args,
vec!["--config".to_string(), driver_dir.join("config.json").to_string_lossy().to_string()]
);
assert_eq!(launch.args, vec!["--config".to_string(), format!("{}/config.json", driver_dir.to_string_lossy())]);
assert_eq!(launch.working_dir.as_deref(), Some(driver_dir.as_path()));
}

#[test]
fn validate_max_heap_accepts_positive_integer_with_unit() {
assert!(validate_max_heap("512m"));
assert!(validate_max_heap("1g"));
assert!(validate_max_heap("2048k"));
assert!(validate_max_heap("2G"));
assert!(validate_max_heap(" 768m "));
}

#[test]
fn validate_max_heap_rejects_malformed_values() {
assert!(!validate_max_heap(""));
assert!(!validate_max_heap("512"));
assert!(!validate_max_heap("abc"));
assert!(!validate_max_heap("1.5g"));
assert!(!validate_max_heap("g"));
assert!(!validate_max_heap("-1g"));
}

#[test]
fn java_runtime_config_deserializes_without_max_heap_field() {
// Older state.json written before max_heap was introduced must still load.
let json = r#"{"mode":"managed","custom_java_path":null}"#;
let config: JavaRuntimeConfig = serde_json::from_str(json).expect("legacy config should deserialize");
assert_eq!(config.mode, JavaRuntimeMode::Managed);
assert_eq!(config.max_heap, None);
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -336,6 +381,10 @@ pub struct JavaRuntimeConfig {
pub mode: JavaRuntimeMode,
#[serde(default)]
pub custom_java_path: Option<String>,
/// JVM max heap passed to every Java agent as `-Xmx<value>` (e.g. `512m`, `1g`).
/// `None`, empty, or invalid values fall back to [`DEFAULT_AGENT_MAX_HEAP`].
#[serde(default)]
pub max_heap: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -570,7 +619,7 @@ impl AgentManager {
let jar_path = self.driver_jar_path(driver_key);
if jar_path.exists() {
let java = self.resolve_java_runtime(state, jre_key)?;
return Ok(AgentLaunchSpec::java_jar(java, jar_path));
return Ok(AgentLaunchSpec::java_jar(java, jar_path, state.java_runtime.max_heap.as_deref()));
}

Err(format!("{driver_key} driver is not installed. Please install it from the Driver Manager."))
Expand Down
1 change: 1 addition & 0 deletions crates/dbx-core/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2929,6 +2929,7 @@ mod tests {
java_runtime: JavaRuntimeConfig {
mode: JavaRuntimeMode::Custom,
custom_java_path: Some(java.to_string_lossy().to_string()),
..Default::default()
},
..AgentState::default()
})
Expand Down
Loading
Loading