fix(metadata): bound cache maintenance and pool recovery

This commit is contained in:
miracle authored and GitHub committed 2026-08-19 11:10:12 +08:00
1 parent 21af5cf600
commit 8c2b232243
41 files changed
+2747 -350

No files matched your search

@@ -146,6 +146,7 @@ import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE } from "@/lib/dataGrid/pagin
import { MAX_QUERY_RESULT_MAX_ROWS } from "@/lib/dataGrid/queryResultRowLimit";
import type { PromptTemplate } from "@/types/promptTemplate";
import { GLOBAL_INSTRUCTIONS_MAX, PROMPT_TEMPLATE_CONTENT_MAX, PROMPT_TEMPLATE_NAME_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate";
import { METADATA_CACHE_HARD_MAX_MEMORY_MB, METADATA_CACHE_MIN_MEMORY_MB, normalizeMetadataCacheMemoryMb } from "@/lib/metadata/metadataRuntimeCache";
const { t } = useI18n();
const { toast } = useToast();
@@ -342,6 +343,7 @@ const editQuitOnClose = ref(settingsStore.desktopSettings.quit_on_close);
const desktopCloseBehaviorResetPending = ref(false);
const editIconTheme = ref<DesktopIconTheme>(settingsStore.desktopSettings.icon_theme);
const editDebugLoggingEnabled = ref(settingsStore.desktopSettings.debug_logging_enabled);
const editMetadataCacheMaxMemoryMb = ref(settingsStore.desktopSettings.metadata_cache_max_memory_mb);
const editDuckDbWorkerProcessIsolation = ref(settingsStore.desktopSettings.duckdb_worker_process_isolation);
const editDuckDbWorkerMaxProcesses = ref(settingsStore.desktopSettings.duckdb_worker_max_processes);
const startupDuckDbWorkerProcessIsolation = ref(settingsStore.desktopSettings.duckdb_worker_process_isolation);
@@ -887,6 +889,7 @@ watch(
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = settingsStore.desktopSettings.metadata_cache_max_memory_mb;
editDuckDbWorkerProcessIsolation.value = settingsStore.desktopSettings.duckdb_worker_process_isolation;
editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
}
@@ -960,6 +963,7 @@ function hasChanges(): boolean {
editQuitOnClose.value !== settingsStore.desktopSettings.quit_on_close ||
editIconTheme.value !== settingsStore.desktopSettings.icon_theme ||
editDebugLoggingEnabled.value !== settingsStore.desktopSettings.debug_logging_enabled ||
editMetadataCacheMaxMemoryMb.value !== settingsStore.desktopSettings.metadata_cache_max_memory_mb ||
editDuckDbWorkerProcessIsolation.value !== settingsStore.desktopSettings.duckdb_worker_process_isolation ||
normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses.value) !== settingsStore.desktopSettings.duckdb_worker_max_processes ||
editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE)
@@ -976,16 +980,19 @@ async function persistSettings() {
await settingsStore.persistEditorSettings();
editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);
}
const metadataCacheMaxMemoryMb = normalizeMetadataCacheMemoryMb(editMetadataCacheMaxMemoryMb.value);
await settingsStore.updateDesktopSettings({
show_tray_icon: editShowTrayIcon.value,
quit_on_close: editQuitOnClose.value,
close_action_prompted: desktopCloseBehaviorResetPending.value ? false : true,
icon_theme: editIconTheme.value,
debug_logging_enabled: editDebugLoggingEnabled.value,
metadata_cache_max_memory_mb: metadataCacheMaxMemoryMb,
duckdb_worker_process_isolation: editDuckDbWorkerProcessIsolation.value,
duckdb_worker_max_processes: normalizeDuckDbWorkerMaxProcesses(editDuckDbWorkerMaxProcesses.value),
sidebar_table_page_size: editSidebarTablePageSize.value,
});
editMetadataCacheMaxMemoryMb.value = settingsStore.desktopSettings.metadata_cache_max_memory_mb;
desktopCloseBehaviorResetPending.value = false;
if (sidebarObjectDisplayChanged) {
await connectionStore.refreshAllTree();
@@ -1081,6 +1088,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
desktopCloseBehaviorResetPending.value = true;
editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme;
editDebugLoggingEnabled.value = DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = DEFAULT_DESKTOP_SETTINGS.metadata_cache_max_memory_mb;
} else if (tab === "navigation") {
editSidebarTablePageSize.value = DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
editSidebarActivation.value = DEFAULT_EDITOR_SETTINGS.sidebarActivation;
@@ -1176,6 +1184,7 @@ function resetAllDefaults() {
desktopCloseBehaviorResetPending.value = true;
editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme;
editDebugLoggingEnabled.value = DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = DEFAULT_DESKTOP_SETTINGS.metadata_cache_max_memory_mb;
editDuckDbWorkerProcessIsolation.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_process_isolation;
editDuckDbWorkerMaxProcesses.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_max_processes;
editSidebarTablePageSize.value = DEFAULT_SIDEBAR_TABLE_PAGE_SIZE;
@@ -2441,6 +2450,7 @@ watch(
editQuitOnClose.value = settingsStore.desktopSettings.quit_on_close;
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled;
editMetadataCacheMaxMemoryMb.value = settingsStore.desktopSettings.metadata_cache_max_memory_mb;
editDuckDbWorkerProcessIsolation.value = settingsStore.desktopSettings.duckdb_worker_process_isolation;
editDuckDbWorkerMaxProcesses.value = settingsStore.desktopSettings.duckdb_worker_max_processes;
if (!duckDbWorkerStartupCaptured.value) {
@@ -5365,6 +5375,26 @@ onUnmounted(() => {
<Separator />
<div data-settings-search-id="data-performance" :class="['space-y-3', settingsSearchTargetClass('data-performance')]">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.performanceSection") }}
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="min-w-0 space-y-1">
<Label for="metadata-cache-memory-limit">{{ t("settings.metadataCacheMemoryLimit") }}</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.metadataCacheMemoryLimitDescription") }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Input id="metadata-cache-memory-limit" v-model.number="editMetadataCacheMaxMemoryMb" type="number" :min="METADATA_CACHE_MIN_MEMORY_MB" :max="METADATA_CACHE_HARD_MAX_MEMORY_MB" :step="16" class="h-8 w-24 text-right" />
<span class="text-xs text-muted-foreground">MB</span>
</div>
</div>
</div>
<Separator />
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.tableStructureSection") }}
@@ -2612,7 +2612,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
try {
const [columnsResult, indexesResult] = await Promise.all([
loadObjectMetadataFacet(objectMetadataRequest, "columns", () => api.getColumns(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)),
loadObjectMetadataFacet(objectMetadataRequest, "indexes", () => api.listIndexes(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog).catch(() => [])),
loadObjectMetadataFacet(objectMetadataRequest, "indexes", () => api.listIndexes(props.connectionId!, hoverDatabase, hoverSchema, table.name, hoverScope.catalog)).catch(() => ({ value: [] as IndexInfo[], cacheStatus: "remote" as const })),
]);
fullColumns = columnsResult.value;
fullIndexes = indexesResult.value;
@@ -129,6 +129,8 @@ import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPr
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache";
import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard";
import { loadObjectDdl, type ObjectDdlRequest } from "@/lib/metadata/objectDdlCache";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
type ObjectFilter = ObjectBrowserFilter;
type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
@@ -183,14 +185,19 @@ const sidePanelMode = ref<"table-info" | "source" | "type-info">("source");
const tableInfoTab = ref<TableInfoTab>("ddl");
const tableColumns = ref<ColumnInfo[]>([]);
const tableColumnsLoading = ref(false);
const tableColumnsLoaded = ref(false);
const tableDdlContent = ref("");
const tableDdlLoading = ref(false);
const tableDdlLoaded = ref(false);
const tableIndexes = ref<IndexInfo[]>([]);
const tableIndexesLoading = ref(false);
const tableIndexesLoaded = ref(false);
const tableForeignKeys = ref<ForeignKeyInfo[]>([]);
const tableForeignKeysLoading = ref(false);
const tableForeignKeysLoaded = ref(false);
const tableTriggers = ref<TriggerInfo[]>([]);
const tableTriggersLoading = ref(false);
const tableTriggersLoaded = ref(false);
const tableInfoSearchQuery = ref("");
const tableInfoDdlPreRef = ref<HTMLPreElement | null>(null);
const SIDE_PANEL_MIN_WIDTH = 280;
@@ -973,6 +980,11 @@ async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
tableIndexes.value = [];
tableForeignKeys.value = [];
tableTriggers.value = [];
tableColumnsLoaded.value = false;
tableDdlLoaded.value = false;
tableIndexesLoaded.value = false;
tableForeignKeysLoaded.value = false;
tableTriggersLoaded.value = false;
tableInfoSearchQuery.value = "";
// Determine initial tab: explicit request > previously activated
const firstTab = initialTab ?? tableInfoTab.value;
@@ -991,93 +1003,132 @@ async function selectTableInfoTab(tab: TableInfoTab) {
else if (nextTab === "triggers") await fetchTableTriggers();
}
async function fetchTableDdl() {
function tableMetadataRequest(row: ObjectBrowserRow): ObjectDdlRequest {
return {
connectionId: props.connection.id,
database: props.database || "",
schema: row.schema || selectedSchema.value || props.database,
tableName: row.name,
objectType: tableDdlObjectType(row.type),
catalog: props.catalog,
};
}
async function fetchTableDdl(force = false) {
const row = sidePanelRow.value;
if (!row) return;
if (!row || (tableDdlLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tableDdlLoading.value = true;
let loadedSuccessfully = false;
try {
const schema = row.schema || selectedSchema.value || props.database;
const ddl = await api.getTableDisplayDdl(props.connection.id, props.database || "", schema, row.name, tableDdlObjectType(row.type), props.catalog);
const { ddl } = await loadObjectDdl(tableMetadataRequest(row), { force });
if (sidePanelGuard.isStale(epoch)) return;
tableDdlContent.value = ddl;
loadedSuccessfully = true;
} catch (e: any) {
if (sidePanelGuard.isStale(epoch)) return;
tableDdlContent.value = `-- Error: ${e?.message || e}`;
} finally {
if (sidePanelGuard.isFresh(epoch)) tableDdlLoading.value = false;
if (sidePanelGuard.isFresh(epoch)) {
tableDdlLoaded.value = loadedSuccessfully;
tableDdlLoading.value = false;
}
}
}
async function fetchTableColumns() {
async function fetchTableColumns(force = false) {
const row = sidePanelRow.value;
if (!row || tableColumns.value.length > 0) return;
if (!row || (tableColumnsLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tableColumnsLoading.value = true;
let loadedSuccessfully = false;
try {
const schema = row.schema || selectedSchema.value || props.database;
const columns = await api.getColumns(props.connection.id, props.database || "", schema, row.name, props.catalog);
const request = tableMetadataRequest(row);
const { value: columns } = await loadObjectMetadataFacet(request, "columns", () => api.getColumns(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
if (sidePanelGuard.isStale(epoch)) return;
tableColumns.value = columns;
} catch {
loadedSuccessfully = true;
} catch (error) {
if (sidePanelGuard.isStale(epoch)) return;
tableColumns.value = [];
toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) tableColumnsLoading.value = false;
if (sidePanelGuard.isFresh(epoch)) {
tableColumnsLoaded.value = loadedSuccessfully;
tableColumnsLoading.value = false;
}
}
}
async function fetchTableIndexes() {
async function fetchTableIndexes(force = false) {
const row = sidePanelRow.value;
if (!row || tableIndexes.value.length > 0) return;
if (!row || (tableIndexesLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tableIndexesLoading.value = true;
let loadedSuccessfully = false;
try {
const schema = row.schema || selectedSchema.value || props.database;
const indexes = await api.listIndexes(props.connection.id, props.database || "", schema, row.name, props.catalog);
const request = tableMetadataRequest(row);
const { value: indexes } = await loadObjectMetadataFacet(request, "indexes", () => api.listIndexes(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
if (sidePanelGuard.isStale(epoch)) return;
tableIndexes.value = indexes;
} catch {
loadedSuccessfully = true;
} catch (error) {
if (sidePanelGuard.isStale(epoch)) return;
tableIndexes.value = [];
toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) tableIndexesLoading.value = false;
if (sidePanelGuard.isFresh(epoch)) {
tableIndexesLoaded.value = loadedSuccessfully;
tableIndexesLoading.value = false;
}
}
}
async function fetchTableForeignKeys() {
async function fetchTableForeignKeys(force = false) {
const row = sidePanelRow.value;
if (!row || tableForeignKeys.value.length > 0) return;
if (!row || (tableForeignKeysLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tableForeignKeysLoading.value = true;
let loadedSuccessfully = false;
try {
const schema = row.schema || selectedSchema.value || props.database;
const fks = await api.listForeignKeys(props.connection.id, props.database || "", schema, row.name, props.catalog);
const request = tableMetadataRequest(row);
const { value: fks } = await loadObjectMetadataFacet(request, "foreign-keys", () => api.listForeignKeys(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
if (sidePanelGuard.isStale(epoch)) return;
tableForeignKeys.value = fks;
} catch {
loadedSuccessfully = true;
} catch (error) {
if (sidePanelGuard.isStale(epoch)) return;
tableForeignKeys.value = [];
toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) tableForeignKeysLoading.value = false;
if (sidePanelGuard.isFresh(epoch)) {
tableForeignKeysLoaded.value = loadedSuccessfully;
tableForeignKeysLoading.value = false;
}
}
}
async function fetchTableTriggers() {
async function fetchTableTriggers(force = false) {
const row = sidePanelRow.value;
if (!row || tableTriggers.value.length > 0) return;
if (!row || (tableTriggersLoaded.value && !force)) return;
const epoch = sidePanelGuard.capture();
tableTriggersLoading.value = true;
let loadedSuccessfully = false;
try {
const schema = row.schema || selectedSchema.value || props.database;
const triggers = await api.listTriggers(props.connection.id, props.database || "", schema, row.name, props.catalog);
const request = tableMetadataRequest(row);
const { value: triggers } = await loadObjectMetadataFacet(request, "triggers", () => api.listTriggers(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
if (sidePanelGuard.isStale(epoch)) return;
tableTriggers.value = triggers;
} catch {
loadedSuccessfully = true;
} catch (error) {
if (sidePanelGuard.isStale(epoch)) return;
tableTriggers.value = [];
toast(translateBackendError(t, error), 5000);
} finally {
if (sidePanelGuard.isFresh(epoch)) tableTriggersLoading.value = false;
if (sidePanelGuard.isFresh(epoch)) {
tableTriggersLoaded.value = loadedSuccessfully;
tableTriggersLoading.value = false;
}
}
}
@@ -1087,19 +1138,24 @@ async function refreshActiveTableInfo() {
if (tableInfoTab.value === "ddl") {
tableDdlContent.value = "";
await fetchTableDdl();
tableDdlLoaded.value = false;
await fetchTableDdl(true);
} else if (tableInfoTab.value === "columns") {
tableColumns.value = [];
await fetchTableColumns();
tableColumnsLoaded.value = false;
await fetchTableColumns(true);
} else if (tableInfoTab.value === "indexes") {
tableIndexes.value = [];
await fetchTableIndexes();
tableIndexesLoaded.value = false;
await fetchTableIndexes(true);
} else if (tableInfoTab.value === "foreignKeys") {
tableForeignKeys.value = [];
await fetchTableForeignKeys();
tableForeignKeysLoaded.value = false;
await fetchTableForeignKeys(true);
} else if (tableInfoTab.value === "triggers") {
tableTriggers.value = [];
await fetchTableTriggers();
tableTriggersLoaded.value = false;
await fetchTableTriggers(true);
}
}
@@ -30,14 +30,33 @@ describe("ObjectBrowser table metadata refresh", () => {
expect(refreshTableInfo).toContain('sidePanelMode.value !== "table-info" || !sidePanelRow.value');
expect(refreshTableInfo).toContain("sidePanelGuard.bump();");
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "ddl"[\s\S]*?tableDdlContent\.value = "";[\s\S]*?await fetchTableDdl\(\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "columns"[\s\S]*?tableColumns\.value = \[\];[\s\S]*?await fetchTableColumns\(\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "indexes"[\s\S]*?tableIndexes\.value = \[\];[\s\S]*?await fetchTableIndexes\(\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "foreignKeys"[\s\S]*?tableForeignKeys\.value = \[\];[\s\S]*?await fetchTableForeignKeys\(\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "triggers"[\s\S]*?tableTriggers\.value = \[\];[\s\S]*?await fetchTableTriggers\(\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "ddl"[\s\S]*?tableDdlContent\.value = "";[\s\S]*?await fetchTableDdl\(true\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "columns"[\s\S]*?tableColumns\.value = \[\];[\s\S]*?await fetchTableColumns\(true\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "indexes"[\s\S]*?tableIndexes\.value = \[\];[\s\S]*?await fetchTableIndexes\(true\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "foreignKeys"[\s\S]*?tableForeignKeys\.value = \[\];[\s\S]*?await fetchTableForeignKeys\(true\);/);
expect(refreshTableInfo).toMatch(/tableInfoTab\.value === "triggers"[\s\S]*?tableTriggers\.value = \[\];[\s\S]*?await fetchTableTriggers\(true\);/);
});
it("routes table-info metadata through the object caches", () => {
expect(source).toContain('from "@/lib/metadata/objectDdlCache"');
expect(source).toContain('from "@/lib/metadata/objectMetadataCache"');
expect(functionBody("fetchTableDdl")).toContain("loadObjectDdl(");
for (const name of ["fetchTableColumns", "fetchTableIndexes", "fetchTableForeignKeys", "fetchTableTriggers"]) {
expect(functionBody(name)).toContain("loadObjectMetadataFacet(");
expect(functionBody(name)).not.toContain(".value.length > 0");
}
});
it("keeps automatic object reloads free of extra metadata requests", () => {
expect(functionBody("reload")).not.toContain("refreshActiveTableInfo");
});
it("leaves a failed facet eligible for retry", () => {
for (const name of ["fetchTableDdl", "fetchTableColumns", "fetchTableIndexes", "fetchTableForeignKeys", "fetchTableTriggers"]) {
const body = functionBody(name);
expect(body).toContain("let loadedSuccessfully = false;");
expect(body).toMatch(/loadedSuccessfully = true;/);
expect(body).toMatch(/Loaded\.value = loadedSuccessfully;/);
}
});
});
@@ -1505,7 +1505,7 @@ async function fetchTableCommentValue(connectionId: string, database: string, sc
}
}
function loadCachedTableComment(request: ReturnType<typeof ddlRequest>, force = false): Promise<{ value: string | undefined; cacheStatus: "disk" | "remote" }> {
function loadCachedTableComment(request: ReturnType<typeof ddlRequest>, force = false): Promise<{ value: string | undefined; cacheStatus: "memory" | "disk" | "remote" }> {
return loadObjectMetadataFacet(request, "comment", () => fetchTableCommentValue(request.connectionId, request.database, request.schema, request.tableName, request.catalog), { force });
}
@@ -1547,22 +1547,22 @@ async function loadStructure(
const columnsPromise = scope.columns ? loadObjectMetadataFacet(metadataRequest, "columns", () => api.getColumns(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value) : Promise.resolve(undefined);
const indexesPromise = scope.indexes
? tableMetadataCapabilities.value.indexes
? loadObjectMetadataFacet(metadataRequest, "indexes", () => api.listIndexes(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value)
? loadObjectMetadataFacet(metadataRequest, "indexes", () => api.listIndexes(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value)
: Promise.resolve([])
: Promise.resolve(undefined);
const foreignKeysPromise = scope.foreignKeys
? tableMetadataCapabilities.value.foreignKeys
? loadObjectMetadataFacet(metadataRequest, "foreign-keys", () => api.listForeignKeys(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value)
? loadObjectMetadataFacet(metadataRequest, "foreign-keys", () => api.listForeignKeys(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value)
: Promise.resolve([])
: Promise.resolve(undefined);
const constraintsPromise = scope.constraints
? tableMetadataCapabilities.value.constraints
? loadObjectMetadataFacet(metadataRequest, "constraints", () => api.listConstraints(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value)
? loadObjectMetadataFacet(metadataRequest, "constraints", () => api.listConstraints(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value)
: Promise.resolve([])
: Promise.resolve(undefined);
const triggersPromise = scope.triggers
? tableMetadataCapabilities.value.triggers
? loadObjectMetadataFacet(metadataRequest, "triggers", () => api.listTriggers(connectionId, database, schema, tableName, catalog).catch(() => []), { force: forceMetadata }).then((result) => result.value)
? loadObjectMetadataFacet(metadataRequest, "triggers", () => api.listTriggers(connectionId, database, schema, tableName, catalog), { force: forceMetadata }).then((result) => result.value)
: Promise.resolve([])
: Promise.resolve(undefined);
const tableCommentPromise = scope.tableComment && structureCapabilities.value.comment ? loadCachedTableComment(metadataRequest, forceMetadata).then((result) => result.value) : Promise.resolve(undefined);
@@ -1636,6 +1636,7 @@ async function loadStructure(
const secondaryMetadataPromise = applySecondaryMetadata()
.catch((error) => {
console.warn("[DBX][structure-editor:secondary-metadata-failed]", error);
if (showErrors && requestId === structureLoadRequestId) errorMessage.value = error?.message || String(error);
})
.finally(() => {
if (requestId === structureLoadRequestId) setSecondaryMetadataLoading(scope, false);
@@ -128,6 +128,33 @@ describe("useSqlExecution", () => {
setActivePinia(createPinia());
});
it("invalidates object metadata after successful connection-level DDL", async () => {
const sql = "CREATE DATABASE app_db";
const activeTab = ref<QueryTab | undefined>({ ...queryTab(), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const invalidateMetadata = vi.spyOn(connectionStore, "invalidateMetadataCache");
const loadDatabases = vi.spyOn(connectionStore, "loadDatabases").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(invalidateMetadata).toHaveBeenCalledWith("conn-1");
expect(loadDatabases).toHaveBeenCalledWith("conn-1", { force: true });
});
it("sends every placeholder syntax and @set unchanged when substitution is disabled", async () => {
const sql = ["@set tenant = 42;", "SELECT ?, :named, ${shell}, #{mybatis}, @sqlserver, @tenant;"].join("\n");
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
@@ -327,6 +327,7 @@ export function useSqlExecution(deps: {
if (success) {
const refreshTarget = sqlMetadataRefreshTarget(sql, tab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(tab.connectionId);
await connectionStore.loadDatabases(tab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema);
@@ -468,6 +469,7 @@ export function useSqlExecution(deps: {
if (success) {
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(executionTab.connectionId);
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
+3
View File
@@ -5851,6 +5851,8 @@ export default {
updateDownloadSourceCnb: "CNB",
debugLoggingEnabled: "Enable debug logs",
debugLoggingEnabledDescription: "Record more detailed local user-side diagnostic logs while troubleshooting reports. Disabled by default.",
metadataCacheMemoryLimit: "Metadata memory cache limit",
metadataCacheMemoryLimitDescription: "Defaults to 64 MB. Keep it at or below 256 MB when possible; the hard limit is 512 MB. Covers DDL, columns, indexes, foreign keys, triggers, and comments.",
duckDbWorkerProcessIsolation: "DuckDB process isolation mode (Experimental)",
duckDbWorkerProcessIsolationDescription: "Runs DuckDB queries in a separate worker process. If a long query does not respond to cancellation, DBX can stop the worker process to release the database file lock.",
duckDbWorkerMaxProcesses: "DuckDB max concurrent workers",
@@ -6172,6 +6174,7 @@ export default {
queryExportKeysetOptimizationEnabled: "Try keyset optimization",
queryExportKeysetOptimizationEnabledDescription: "Only applies to safely recognized single-table queries; complex queries automatically fall back.",
exportSection: "Export",
performanceSection: "Performance",
shortcutExecuteSql: "Execute SQL",
shortcutExecuteSqlInNewResultTab: "Execute SQL in new result tab",
shortcutFind: "Find",
+3
View File
@@ -5592,6 +5592,8 @@ export default withEnglishFallback({
updateDownloadSourceCnb: "CNB",
debugLoggingEnabled: "Activar logs de depuración",
debugLoggingEnabledDescription: "Registra logs de diagnóstico locales del lado del usuario al investigar reportes. Desactivado por defecto.",
metadataCacheMemoryLimit: "Límite de memoria de la caché de metadatos",
metadataCacheMemoryLimitDescription: "El valor predeterminado es 64 MB; se recomiendan 256 MB o menos y el máximo es 512 MB. Incluye la caché de DDL, columnas, índices, claves foráneas, disparadores y comentarios.",
duckDbWorkerProcessIsolation: "Modo de aislamiento de proceso de DuckDB (experimental)",
duckDbWorkerProcessIsolationDescription: "Ejecuta las consultas de DuckDB en un proceso worker independiente. Si una consulta larga no responde a la cancelación, DBX puede detener el proceso worker para liberar el bloqueo del archivo de base de datos.",
duckDbWorkerMaxProcesses: "Workers concurrentes máximos de DuckDB",
@@ -5864,6 +5866,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabled: "Intentar optimización keyset",
queryExportKeysetOptimizationEnabledDescription: "Solo aplica a consultas de una sola tabla reconocidas de forma segura; las consultas complejas usan el método estándar automáticamente.",
exportSection: "Exportar",
performanceSection: "Rendimiento",
shortcutExecuteSql: "Ejecutar SQL",
shortcutExecuteSqlInNewResultTab: "Ejecutar SQL en una nueva pestaña de resultados",
shortcutFind: "Buscar",
+3
View File
@@ -5592,6 +5592,8 @@ export default withEnglishFallback({
updateDownloadSourceCnb: "CNB",
debugLoggingEnabled: "Abilita log di debug",
debugLoggingEnabledDescription: "Registra log diagnostici locali lato utente durante l'analisi delle segnalazioni. Disattivato per impostazione predefinita.",
metadataCacheMemoryLimit: "Limite di memoria della cache dei metadati",
metadataCacheMemoryLimitDescription: "Il valore predefinito è 64 MB; si consigliano al massimo 256 MB e il limite massimo è 512 MB. Include DDL, colonne, indici, chiavi esterne, trigger e commenti.",
duckDbWorkerProcessIsolation: "Modalità di isolamento del processo DuckDB (sperimentale)",
duckDbWorkerProcessIsolationDescription: "Esegue le query DuckDB in un processo worker separato. Se una query lunga non risponde all'annullamento, DBX può arrestare il processo worker per rilasciare il blocco del file di database.",
duckDbWorkerMaxProcesses: "Worker DuckDB simultanei massimi",
@@ -5864,6 +5866,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabled: "Prova ottimizzazione keyset",
queryExportKeysetOptimizationEnabledDescription: "Si applica solo a query a tabella singola riconosciute in sicurezza; le query complesse ricadono automaticamente.",
exportSection: "Esportazione",
performanceSection: "Prestazioni",
shortcutExecuteSql: "Esegui SQL",
shortcutExecuteSqlInNewResultTab: "Esegui SQL in una nuova scheda dei risultati",
shortcutFind: "Trova",
+3
View File
@@ -5627,6 +5627,8 @@ export default withEnglishFallback({
updateNotificationsEnabledDescription: "無効時、DBXはアプリやドライバーのアップデートを自動確認せず、アップデートバッジも表示しません。手動確認は引き続き利用可能です。",
debugLoggingEnabled: "デバッグログを有効にする",
debugLoggingEnabledDescription: "トラブルシューティング時に、より詳細なローカル診断ログを記録します。デフォルトでは無効です。",
metadataCacheMemoryLimit: "メタデータメモリキャッシュ上限",
metadataCacheMemoryLimitDescription: "デフォルトは64 MB、256 MB以下を推奨、最大512 MBです。DDL、フィールド、インデックス、外部キー、トリガー、コメントのキャッシュに使用されます。",
duckDbWorkerProcessIsolation: "DuckDB プロセス分離モード(実験的)",
duckDbWorkerProcessIsolationDescription: "DuckDB クエリを独立した worker プロセスで実行します。長時間クエリがキャンセルに応答しない場合、DBX は worker プロセスを停止してデータベースファイルのロックを解放できます。",
duckDbWorkerMaxProcesses: "DuckDB の最大同時 worker 数",
@@ -5879,6 +5881,7 @@ export default withEnglishFallback({
exportBatchSize: "エクスポートバッチサイズ",
exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。",
exportSection: "エクスポート",
performanceSection: "パフォーマンス",
shortcutExecuteSql: "SQLを実行",
shortcutExecuteSqlInNewResultTab: "新しい結果タブでSQLを実行",
shortcutFind: "検索",
+1
View File
@@ -5605,6 +5605,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabled: "키셋 최적화 시도",
queryExportKeysetOptimizationEnabledDescription: "안전하게 인식된 단일 테이블 쿼리에만 적용되며 복잡한 쿼리는 자동으로 폴백됩니다.",
exportSection: "내보내기",
performanceSection: "성능",
shortcutExecuteSql: "SQL 실행",
shortcutExecuteSqlInNewResultTab: "새 결과 탭에서 SQL 실행",
shortcutFind: "찾기",
+3
View File
@@ -5594,6 +5594,8 @@ export default withEnglishFallback({
updateDownloadSourceCnb: "CNB",
debugLoggingEnabled: "Ativar logs de depuração",
debugLoggingEnabledDescription: "Registra logs locais de diagnóstico do lado do usuário ao investigar relatos. Desativado por padrão.",
metadataCacheMemoryLimit: "Limite de memória do cache de metadados",
metadataCacheMemoryLimitDescription: "O padrão é 64 MB; recomenda-se no máximo 256 MB, com limite máximo de 512 MB. Abrange o cache de DDL, colunas, índices, chaves estrangeiras, gatilhos e comentários.",
duckDbWorkerProcessIsolation: "Modo de isolamento de processo do DuckDB (experimental)",
duckDbWorkerProcessIsolationDescription: "Executa consultas DuckDB em um processo worker separado. Se uma consulta longa não responder ao cancelamento, o DBX pode encerrar o processo worker para liberar o bloqueio do arquivo de banco de dados.",
duckDbWorkerMaxProcesses: "Máximo de workers simultâneos do DuckDB",
@@ -5866,6 +5868,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabled: "Tentar otimização keyset",
queryExportKeysetOptimizationEnabledDescription: "Aplica-se apenas a consultas de tabela única reconhecidas com segurança; consultas complexas recuam automaticamente.",
exportSection: "Exportar",
performanceSection: "Desempenho",
shortcutExecuteSql: "Executar SQL",
shortcutExecuteSqlInNewResultTab: "Executar SQL em uma nova aba de resultados",
shortcutFind: "Localizar",
+3
View File
@@ -5837,6 +5837,8 @@ export default withEnglishFallback({
updateDownloadSourceCnb: "CNB",
debugLoggingEnabled: "启用调试日志",
debugLoggingEnabledDescription: "开启后在本机记录更详细的用户侧诊断日志,反馈问题时可临时开启;默认关闭。",
metadataCacheMemoryLimit: "元数据内存缓存上限",
metadataCacheMemoryLimitDescription: "默认 64 MB,建议不超过 256 MB,最大 512 MB。用于 DDL、字段、索引、外键、触发器和注释缓存。",
duckDbWorkerProcessIsolation: "DuckDB 进程隔离模式(实验性)",
duckDbWorkerProcessIsolationDescription: "将 DuckDB 查询放到独立 worker 进程中运行。停止长查询时,如果 DuckDB 未及时响应取消请求,DBX 会结束 worker 进程以释放数据库文件锁。",
duckDbWorkerMaxProcesses: "DuckDB 最大并发 worker 数",
@@ -6157,6 +6159,7 @@ export default withEnglishFallback({
queryExportKeysetOptimizationEnabled: "尝试 keyset 优化",
queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。",
exportSection: "导出",
performanceSection: "性能",
shortcutExecuteSql: "执行 SQL",
shortcutExecuteSqlInNewResultTab: "在新结果标签页中执行 SQL",
shortcutFind: "查找",
+3
View File
@@ -4914,6 +4914,8 @@ export default withEnglishFallback({
updateNotificationsEnabledDescription: "關閉後,DBX 不會自動檢查應用程式和驅動程式更新,也不會顯示更新紅點;仍可手動檢查更新。",
debugLoggingEnabled: "啟用偵錯日誌",
debugLoggingEnabledDescription: "開啟後在本機記錄更詳細的使用者側診斷日誌,回報問題時可暫時開啟;預設關閉。",
metadataCacheMemoryLimit: "中繼資料記憶體快取上限",
metadataCacheMemoryLimitDescription: "預設為 64 MB,建議不超過 256 MB,最大為 512 MB。用於快取 DDL、欄位、索引、外部索引鍵、觸發程序及註解。",
duckDbWorkerProcessIsolation: "DuckDB 進程隔離模式(實驗性)",
duckDbWorkerProcessIsolationDescription: "將 DuckDB 查詢放到獨立 worker 進程中執行。停止長查詢時,如果 DuckDB 未及時回應取消請求,DBX 會結束 worker 進程以釋放資料庫檔案鎖。",
duckDbWorkerMaxProcesses: "DuckDB 最大並行 worker 數",
@@ -5431,6 +5433,7 @@ export default withEnglishFallback({
updateDownloadSourceOfficial: "官方來源(建議)",
updateDownloadSourceCnb: "CNB",
exportSection: "匯出",
performanceSection: "效能",
exportBatchSize: "匯出批次大小",
dateTimeSection: "日期與時間",
globalDateTimeDisplayFormat: "全域顯示格式",
@@ -5,7 +5,7 @@ const contentAreaSource = readFileSync(new URL("../../../components/layout/Conte
const connectionTreeSource = readFileSync(new URL("../../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
const ddlViewDialogSource = readFileSync(new URL("../../../components/objects/DdlViewDialog.vue", import.meta.url), "utf8");
const objectBrowserSource = readFileSync(new URL("../../../components/objects/ObjectBrowser.vue", import.meta.url), "utf8");
const fetchTableDdlSource = objectBrowserSource.match(/async function fetchTableDdl\(\)[\s\S]*?(?=\nasync function fetchTableColumns\()/)?.[0] ?? "";
const fetchTableDdlSource = objectBrowserSource.match(/async function fetchTableDdl(?:\([^)]*\))?[\s\S]*?(?=\nasync function fetchTableColumns\()/)?.[0] ?? "";
const exportStructureSource = objectBrowserSource.match(/async function exportStructure\([\s\S]*?(?=\nasync function exportDataLegacy\()/)?.[0] ?? "";
function openingTag(source: string, componentName: string): string {
@@ -47,8 +47,9 @@ describe("ContentArea object browser refresh wiring", () => {
});
describe("ObjectBrowser DDL API boundaries", () => {
it("uses display DDL for the table information panel", () => {
expect(fetchTableDdlSource).toMatch(/api\.getTableDisplayDdl\([\s\S]*?props\.catalog\);/);
it("uses the cached display DDL boundary for the table information panel", () => {
expect(fetchTableDdlSource).toMatch(/loadObjectDdl\(tableMetadataRequest\(row\), \{ force \}\)/);
expect(objectBrowserSource).toMatch(/function tableMetadataRequest\([\s\S]*?catalog: props\.catalog/);
});
it("keeps structure exports on the portable base DDL", () => {
+1
View File
@@ -232,6 +232,7 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
quit_on_close: false,
close_action_prompted: false,
debug_logging_enabled: false,
metadata_cache_max_memory_mb: 64,
duckdb_worker_process_isolation: false,
duckdb_worker_max_processes: 4,
saved_sql_sync_dir: null,
+1
View File
@@ -188,6 +188,7 @@ export interface DesktopSettings {
quit_on_close: boolean;
close_action_prompted: boolean;
debug_logging_enabled: boolean;
metadata_cache_max_memory_mb: number;
duckdb_worker_process_isolation: boolean;
duckdb_worker_max_processes: number;
saved_sql_sync_dir?: string | null;
@@ -0,0 +1,19 @@
import { beforeEach, describe, expect, it } from "vitest";
import { resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
import { clearMetadataRuntimeCache, setMetadataRuntimeCache } from "@/lib/metadata/metadataRuntimeCache";
describe("resourceLifecycleDiagnostics", () => {
beforeEach(() => {
clearMetadataRuntimeCache();
});
it("includes metadata cache diagnostics in the existing lifecycle snapshot", () => {
setMetadataRuntimeCache("metadata-key", { value: 1 }, "connection-1");
const diagnostics = resourceLifecycleDiagnostics([]);
expect(diagnostics.metadataCache.entries).toBe(1);
expect(diagnostics.metadataCache.bytes).toBeGreaterThan(0);
});
});
@@ -1,5 +1,6 @@
import type { QueryTab } from "@/types/database";
import { getResultCacheDiagnostics } from "@/lib/tabs/tabResultCache";
import { getMetadataRuntimeCacheDiagnostics } from "@/lib/metadata/metadataRuntimeCache";
const cancellation = {
count: 0,
@@ -15,11 +16,13 @@ export function recordQueryCancellationLatency(durationMs: number) {
export function resourceLifecycleDiagnostics(tabs: readonly QueryTab[]) {
const cache = getResultCacheDiagnostics();
const metadataCache = getMetadataRuntimeCacheDiagnostics();
return {
activeTasks: tabs.filter((tab) => tab.isExecuting || tab.isExplaining || tab.isCancelling).length,
cancellationCount: cancellation.count,
averageCancellationLatencyMs: cancellation.count ? cancellation.totalLatencyMs / cancellation.count : 0,
maxCancellationLatencyMs: cancellation.maxLatencyMs,
...cache,
metadataCache,
};
}
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearMetadataRuntimeCache,
clearMetadataRuntimeCacheForDatabase,
clearMetadataRuntimeCacheForConnection,
configureMetadataRuntimeCache,
getMetadataRuntimeCache,
getMetadataRuntimeCacheDiagnostics,
METADATA_CACHE_DEFAULT_MEMORY_MB,
normalizeMetadataCacheMemoryMb,
setMetadataRuntimeCache,
} from "@/lib/metadata/metadataRuntimeCache";
describe("metadataRuntimeCache", () => {
beforeEach(() => {
clearMetadataRuntimeCache();
configureMetadataRuntimeCache(METADATA_CACHE_DEFAULT_MEMORY_MB);
vi.restoreAllMocks();
});
it("normalizes the configured memory budget", () => {
expect(normalizeMetadataCacheMemoryMb(undefined)).toBe(64);
expect(normalizeMetadataCacheMemoryMb(1)).toBe(16);
expect(normalizeMetadataCacheMemoryMb(256.4)).toBe(256);
expect(normalizeMetadataCacheMemoryMb(512)).toBe(512);
expect(normalizeMetadataCacheMemoryMb(513)).toBe(64);
});
it("warns above the recommended limit and falls back above the hard limit", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
expect(configureMetadataRuntimeCache(384)).toBe(384);
expect(configureMetadataRuntimeCache(513)).toBe(64);
expect(warn).toHaveBeenCalledTimes(2);
});
it("rejects a single entry larger than one megabyte", () => {
expect(setMetadataRuntimeCache("large", "x".repeat(600_000), "connection-1")).toBe(false);
expect(getMetadataRuntimeCacheDiagnostics().entries).toBe(0);
});
it("evicts the least recently used entry within a connection budget", () => {
configureMetadataRuntimeCache(16);
const value = "x".repeat(300_000);
for (let index = 0; index < 6; index += 1) {
expect(setMetadataRuntimeCache(`key-${index}`, value, "connection-1")).toBe(true);
}
expect(getMetadataRuntimeCache("key-0")).toBeDefined();
expect(setMetadataRuntimeCache("key-6", value, "connection-1")).toBe(true);
expect(getMetadataRuntimeCache("key-0")).toBeDefined();
expect(getMetadataRuntimeCache("key-1")).toBeUndefined();
expect(getMetadataRuntimeCacheDiagnostics().evictions).toBeGreaterThan(0);
});
it("reapplies the per-connection budget when the configured limit shrinks", () => {
const value = "x".repeat(300_000);
for (let index = 0; index < 10; index += 1) {
setMetadataRuntimeCache(`key-${index}`, value, "connection-1");
}
configureMetadataRuntimeCache(16);
expect(getMetadataRuntimeCacheDiagnostics().bytes).toBeLessThanOrEqual(4 * 1024 * 1024);
});
it("clears only entries owned by the requested connection", () => {
setMetadataRuntimeCache("a", { value: 1 }, "connection-1");
setMetadataRuntimeCache("b", { value: 2 }, "connection-2");
expect(clearMetadataRuntimeCacheForConnection("connection-1")).toBe(1);
expect(getMetadataRuntimeCache("a")).toBeUndefined();
expect(getMetadataRuntimeCache("b")?.value).toEqual({ value: 2 });
expect(getMetadataRuntimeCacheDiagnostics().connectionClears).toBe(1);
});
it("clears only entries owned by the requested database", () => {
setMetadataRuntimeCache("object-meta:v1:connection-1:database-1:schema:table:catalog:TABLE:columns:", { value: 1 }, "connection-1");
setMetadataRuntimeCache("object-meta:v1:connection-1:database-2:schema:table:catalog:TABLE:columns:", { value: 2 }, "connection-1");
setMetadataRuntimeCache("object-meta:v1:connection-2:database-1:schema:table:catalog:TABLE:columns:", { value: 3 }, "connection-2");
expect(clearMetadataRuntimeCacheForDatabase("connection-1", "database-1")).toBe(1);
expect(getMetadataRuntimeCache("object-meta:v1:connection-1:database-1:schema:table:catalog:TABLE:columns:")).toBeUndefined();
expect(getMetadataRuntimeCache("object-meta:v1:connection-1:database-2:schema:table:catalog:TABLE:columns:")).toBeDefined();
expect(getMetadataRuntimeCache("object-meta:v1:connection-2:database-1:schema:table:catalog:TABLE:columns:")).toBeDefined();
});
});
@@ -10,12 +10,14 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/backend/api", () => mocks);
import { invalidateObjectDdl, invalidateObjectDdlCache, loadObjectDdl, objectDdlCacheKey } from "@/lib/metadata/objectDdlCache";
import { cancelObjectDdlLoadsForConnection, getObjectDdlCacheDebugStateForTests, invalidateObjectDdl, invalidateObjectDdlCache, loadObjectDdl, objectDdlCacheKey } from "@/lib/metadata/objectDdlCache";
import { clearMetadataRuntimeCache } from "@/lib/metadata/metadataRuntimeCache";
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
describe("objectDdlCache", () => {
beforeEach(() => {
clearMetadataRuntimeCache();
vi.clearAllMocks();
mocks.persisted.clear();
mocks.loadSchemaCache.mockImplementation(async (cacheKey: string) => mocks.persisted.get(cacheKey) ?? null);
@@ -36,6 +38,23 @@ describe("objectDdlCache", () => {
expect(mocks.getTableDisplayDdl).not.toHaveBeenCalled();
});
it("backfills memory after a disk hit", async () => {
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), ddl: "CREATE TABLE users (id int)" });
await expect(loadObjectDdl(request)).resolves.toMatchObject({ cacheStatus: "disk" });
await expect(loadObjectDdl(request)).resolves.toMatchObject({ cacheStatus: "memory" });
expect(mocks.loadSchemaCache).toHaveBeenCalledTimes(1);
expect(mocks.getTableDisplayDdl).not.toHaveBeenCalled();
});
it("caches an empty DDL string in memory", async () => {
mocks.getTableDisplayDdl.mockResolvedValue("");
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "", cacheStatus: "remote" });
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "", cacheStatus: "memory" });
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1);
});
it("persists a remote cache miss", async () => {
mocks.getTableDisplayDdl.mockResolvedValue("CREATE TABLE users (id bigint)");
@@ -43,6 +62,41 @@ describe("objectDdlCache", () => {
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(objectDdlCacheKey(request), expect.objectContaining({ version: 1, ddl: "CREATE TABLE users (id bigint)" }));
});
it("does not wait for a slow SQLite write before returning the remote result", async () => {
let releaseWrite: () => void = () => {};
mocks.saveSchemaCache.mockReturnValue(
new Promise<void>((resolve) => {
releaseWrite = resolve;
}),
);
mocks.getTableDisplayDdl.mockResolvedValue("CREATE TABLE users (id bigint)");
await expect(loadObjectDdl(request)).resolves.toMatchObject({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" });
expect(mocks.saveSchemaCache).toHaveBeenCalledTimes(1);
releaseWrite();
await Promise.resolve();
await Promise.resolve();
});
it("waits for a pending SQLite write before deleting an invalidated entry", async () => {
let releaseWrite: () => void = () => {};
mocks.saveSchemaCache.mockReturnValue(
new Promise<void>((resolve) => {
releaseWrite = resolve;
}),
);
mocks.getTableDisplayDdl.mockResolvedValue("stale ddl");
await loadObjectDdl(request);
const invalidation = invalidateObjectDdl(request);
await Promise.resolve();
expect(mocks.deleteSchemaCachePrefix).not.toHaveBeenCalledWith(objectDdlCacheKey(request));
releaseWrite();
await invalidation;
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith(objectDdlCacheKey(request));
});
it("deduplicates concurrent remote loads", async () => {
let release: (ddl: string) => void = () => {};
mocks.getTableDisplayDdl.mockReturnValue(
@@ -68,6 +122,23 @@ describe("objectDdlCache", () => {
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(objectDdlCacheKey(request), expect.objectContaining({ ddl: "new ddl" }));
});
it("force refresh bypasses memory and replaces it", async () => {
mocks.getTableDisplayDdl.mockResolvedValueOnce("old ddl").mockResolvedValueOnce("new ddl");
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "old ddl", cacheStatus: "remote" });
await expect(loadObjectDdl(request, { force: true })).resolves.toEqual({ ddl: "new ddl", cacheStatus: "remote" });
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "new ddl", cacheStatus: "memory" });
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(2);
});
it("ignores an expired persisted DDL", async () => {
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), ddl: "stale ddl" });
mocks.getTableDisplayDdl.mockResolvedValue("fresh ddl");
await expect(loadObjectDdl(request)).resolves.toEqual({ ddl: "fresh ddl", cacheStatus: "remote" });
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1);
});
it("deletes the exact persisted entry", async () => {
await invalidateObjectDdl(request);
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith(objectDdlCacheKey(request));
@@ -97,4 +168,99 @@ describe("objectDdlCache", () => {
await expect(load).resolves.toEqual({ ddl: "stale ddl", cacheStatus: "remote" });
expect(mocks.saveSchemaCache).not.toHaveBeenCalled();
});
it("does not resurrect a stale disk result when invalidated during the read", async () => {
let releaseDiskRead: (value: unknown) => void = () => {};
mocks.loadSchemaCache.mockReturnValueOnce(
new Promise((resolve) => {
releaseDiskRead = resolve;
}),
);
mocks.getTableDisplayDdl.mockResolvedValue("fresh ddl");
const load = loadObjectDdl(request);
await vi.waitFor(() => expect(mocks.loadSchemaCache).toHaveBeenCalledTimes(1));
const invalidation = invalidateObjectDdl(request);
releaseDiskRead({ version: 1, cachedAt: new Date().toISOString(), ddl: "stale ddl" });
await expect(load).resolves.toEqual({ ddl: "fresh ddl", cacheStatus: "remote" });
await invalidation;
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1);
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(objectDdlCacheKey(request), expect.objectContaining({ ddl: "fresh ddl" }));
});
it("refreshing object B does not invalidate object A's concurrent disk read", async () => {
const requestA = { ...request, tableName: "accounts" };
const requestB = { ...request, tableName: "billing" };
let releaseA: (value: unknown) => void = () => {};
let releaseB: (value: unknown) => void = () => {};
mocks.loadSchemaCache.mockImplementation((cacheKey: string) => {
return new Promise((resolve) => {
if (cacheKey === objectDdlCacheKey(requestA)) releaseA = resolve;
if (cacheKey === objectDdlCacheKey(requestB)) releaseB = resolve;
});
});
mocks.getTableDisplayDdl.mockImplementation(async (_connectionId: string, _database: string, _schema: string, tableName: string) => `remote ${tableName}`);
const loadA = loadObjectDdl(requestA);
const loadB = loadObjectDdl(requestB);
await vi.waitFor(() => expect(mocks.loadSchemaCache).toHaveBeenCalledTimes(2));
const invalidation = invalidateObjectDdl(requestB);
releaseA({ version: 1, cachedAt: new Date().toISOString(), ddl: "cached accounts" });
releaseB({ version: 1, cachedAt: new Date().toISOString(), ddl: "stale billing" });
await expect(loadA).resolves.toEqual({ ddl: "cached accounts", cacheStatus: "disk" });
await expect(loadB).resolves.toEqual({ ddl: "remote billing", cacheStatus: "remote" });
await invalidation;
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1);
});
it("reclaims invalidation state after long-running multi-object refreshes", async () => {
for (let index = 0; index < 2_000; index += 1) {
await invalidateObjectDdlCache({ ...request, tableName: `history_${index}` });
}
expect(getObjectDdlCacheDebugStateForTests()).toEqual({ activeReads: 0 });
});
it("makes a concurrent normal read wait for a force refresh", async () => {
mocks.getTableDisplayDdl.mockResolvedValueOnce("old ddl");
await loadObjectDdl(request);
let releaseRemote: (value: string) => void = () => {};
mocks.getTableDisplayDdl.mockReturnValueOnce(
new Promise((resolve) => {
releaseRemote = resolve;
}),
);
const force = loadObjectDdl(request, { force: true });
const normal = loadObjectDdl(request);
await vi.waitFor(() => expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(2));
releaseRemote("new ddl");
await expect(Promise.all([force, normal])).resolves.toEqual([
{ ddl: "new ddl", cacheStatus: "remote" },
{ ddl: "new ddl", cacheStatus: "remote" },
]);
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(2);
});
it("cancels an old connection load without repopulating the new session cache", async () => {
let releaseOld: (value: string) => void = () => {};
mocks.getTableDisplayDdl.mockReturnValueOnce(
new Promise((resolve) => {
releaseOld = resolve;
}),
);
const oldLoad = loadObjectDdl(request);
await vi.waitFor(() => expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(1));
cancelObjectDdlLoadsForConnection(request.connectionId);
releaseOld("old session ddl");
await expect(oldLoad).resolves.toMatchObject({ ddl: "old session ddl" });
mocks.getTableDisplayDdl.mockResolvedValueOnce("new session ddl");
await expect(loadObjectDdl(request)).resolves.toMatchObject({ ddl: "new session ddl", cacheStatus: "remote" });
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(2);
expect(mocks.deleteSchemaCachePrefix).not.toHaveBeenCalledWith("object-ddl:v1:c1:");
});
});
@@ -9,12 +9,14 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/backend/api", () => mocks);
import { invalidateObjectMetadataCache, loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { cancelObjectMetadataLoadsForConnection, getObjectMetadataCacheDebugStateForTests, invalidateObjectMetadataCache, loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { clearMetadataRuntimeCache } from "@/lib/metadata/metadataRuntimeCache";
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
describe("objectMetadataCache", () => {
beforeEach(() => {
clearMetadataRuntimeCache();
vi.clearAllMocks();
mocks.persisted.clear();
mocks.loadSchemaCache.mockImplementation(async (cacheKey: string) => mocks.persisted.get(cacheKey) ?? null);
@@ -36,6 +38,46 @@ describe("objectMetadataCache", () => {
expect(loader).not.toHaveBeenCalled();
});
it("backfills memory after a disk hit", async () => {
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: [] });
const loader = vi.fn();
await expect(loadObjectMetadataFacet(request, "columns", loader)).resolves.toEqual({ value: [], cacheStatus: "disk" });
await expect(loadObjectMetadataFacet(request, "columns", loader)).resolves.toEqual({ value: [], cacheStatus: "memory" });
expect(mocks.loadSchemaCache).toHaveBeenCalledTimes(1);
expect(loader).not.toHaveBeenCalled();
});
it("caches null and empty-array facet values", async () => {
const nullLoader = vi.fn().mockResolvedValue(null);
const emptyLoader = vi.fn().mockResolvedValue([]);
await expect(loadObjectMetadataFacet(request, "comment", nullLoader)).resolves.toMatchObject({ value: null, cacheStatus: "remote" });
await expect(loadObjectMetadataFacet(request, "comment", nullLoader)).resolves.toMatchObject({ value: null, cacheStatus: "memory" });
await expect(loadObjectMetadataFacet(request, "indexes", emptyLoader)).resolves.toMatchObject({ value: [], cacheStatus: "remote" });
await expect(loadObjectMetadataFacet(request, "indexes", emptyLoader)).resolves.toMatchObject({ value: [], cacheStatus: "memory" });
expect(nullLoader).toHaveBeenCalledTimes(1);
expect(emptyLoader).toHaveBeenCalledTimes(1);
});
it("does not cache undefined failure sentinels", async () => {
const loader = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce("fresh comment");
await expect(loadObjectMetadataFacet(request, "comment", loader)).resolves.toMatchObject({ value: undefined, cacheStatus: "remote" });
await expect(loadObjectMetadataFacet(request, "comment", loader)).resolves.toMatchObject({ value: "fresh comment", cacheStatus: "remote" });
expect(loader).toHaveBeenCalledTimes(2);
});
it("isolates facets by object type", async () => {
const tableLoader = vi.fn().mockResolvedValue(["table"]);
const viewLoader = vi.fn().mockResolvedValue(["view"]);
await loadObjectMetadataFacet({ ...request, objectType: "TABLE" }, "columns", tableLoader);
await loadObjectMetadataFacet({ ...request, objectType: "VIEW" }, "columns", viewLoader);
expect(tableLoader).toHaveBeenCalledTimes(1);
expect(viewLoader).toHaveBeenCalledTimes(1);
});
it("persists a remote facet and force bypasses disk", async () => {
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date().toISOString(), value: ["old"] });
const loader = vi.fn().mockResolvedValue(["new"]);
@@ -43,7 +85,57 @@ describe("objectMetadataCache", () => {
await expect(loadObjectMetadataFacet(request, "indexes", loader, { force: true })).resolves.toEqual({ value: ["new"], cacheStatus: "remote" });
expect(loader).toHaveBeenCalledTimes(1);
expect(mocks.loadSchemaCache).not.toHaveBeenCalled();
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(expect.stringContaining("object-meta:v1:c1:app:public:users:analytics:indexes:"), expect.objectContaining({ value: ["new"] }));
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(expect.stringContaining("object-meta:v1:c1:app:public:users:analytics:TABLE:indexes:"), expect.objectContaining({ value: ["new"] }));
});
it("does not wait for a slow SQLite write before returning a remote facet", async () => {
let releaseWrite: () => void = () => {};
mocks.saveSchemaCache.mockReturnValue(
new Promise<void>((resolve) => {
releaseWrite = resolve;
}),
);
const loader = vi.fn().mockResolvedValue(["id"]);
await expect(loadObjectMetadataFacet(request, "columns", loader)).resolves.toMatchObject({ value: ["id"], cacheStatus: "remote" });
expect(mocks.saveSchemaCache).toHaveBeenCalledTimes(1);
releaseWrite();
});
it("deduplicates concurrent remote facet loads", async () => {
let release: (value: string[]) => void = () => {};
const loader = vi.fn().mockReturnValue(
new Promise<string[]>((resolve) => {
release = resolve;
}),
);
const first = loadObjectMetadataFacet(request, "columns", loader);
const second = loadObjectMetadataFacet(request, "columns", loader);
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1));
release(["id"]);
await expect(Promise.all([first, second])).resolves.toEqual([
{ value: ["id"], cacheStatus: "remote" },
{ value: ["id"], cacheStatus: "remote" },
]);
});
it("force refresh bypasses memory and replaces it", async () => {
const loader = vi.fn().mockResolvedValueOnce(["old"]).mockResolvedValueOnce(["new"]);
await expect(loadObjectMetadataFacet(request, "indexes", loader)).resolves.toMatchObject({ value: ["old"], cacheStatus: "remote" });
await expect(loadObjectMetadataFacet(request, "indexes", loader, { force: true })).resolves.toMatchObject({ value: ["new"], cacheStatus: "remote" });
await expect(loadObjectMetadataFacet(request, "indexes", loader)).resolves.toMatchObject({ value: ["new"], cacheStatus: "memory" });
expect(loader).toHaveBeenCalledTimes(2);
});
it("ignores an expired persisted facet", async () => {
mocks.loadSchemaCache.mockResolvedValue({ version: 1, cachedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), value: ["stale"] });
const loader = vi.fn().mockResolvedValue(["fresh"]);
await expect(loadObjectMetadataFacet(request, "indexes", loader)).resolves.toMatchObject({ value: ["fresh"], cacheStatus: "remote" });
expect(loader).toHaveBeenCalledTimes(1);
});
it("invalidates only the requested object's facets", async () => {
@@ -60,4 +152,104 @@ describe("objectMetadataCache", () => {
await expect(loadObjectMetadataFacet(request, "indexes", refreshedLoader)).resolves.toEqual({ value: ["new"], cacheStatus: "remote" });
expect(refreshedLoader).toHaveBeenCalledTimes(1);
});
it("does not resurrect a stale disk facet when invalidated during the read", async () => {
let releaseDiskRead: (value: unknown) => void = () => {};
mocks.loadSchemaCache.mockReturnValueOnce(
new Promise((resolve) => {
releaseDiskRead = resolve;
}),
);
const loader = vi.fn().mockResolvedValue(["fresh"]);
const load = loadObjectMetadataFacet(request, "columns", loader);
await vi.waitFor(() => expect(mocks.loadSchemaCache).toHaveBeenCalledTimes(1));
const invalidation = invalidateObjectMetadataCache(request);
releaseDiskRead({ version: 1, cachedAt: new Date().toISOString(), value: ["stale"] });
await expect(load).resolves.toEqual({ value: ["fresh"], cacheStatus: "remote" });
await invalidation;
expect(loader).toHaveBeenCalledTimes(1);
expect(mocks.saveSchemaCache).toHaveBeenCalledWith(expect.stringContaining("object-meta:v1:c1:app:public:users:"), expect.objectContaining({ value: ["fresh"] }));
});
it("refreshing object B does not invalidate object A's concurrent facet read", async () => {
const requestA = { ...request, tableName: "accounts" };
const requestB = { ...request, tableName: "billing" };
let releaseA: (value: unknown) => void = () => {};
let releaseB: (value: unknown) => void = () => {};
let diskReads = 0;
mocks.loadSchemaCache.mockImplementation((cacheKey: string) => {
diskReads += 1;
if (diskReads > 2) return Promise.resolve(null);
return new Promise((resolve) => {
if (cacheKey.includes(":accounts:")) releaseA = resolve;
if (cacheKey.includes(":billing:")) releaseB = resolve;
});
});
const loaderA = vi.fn().mockResolvedValue(["remote accounts"]);
const loaderB = vi.fn().mockResolvedValue(["remote billing"]);
const loadA = loadObjectMetadataFacet(requestA, "columns", loaderA);
const loadB = loadObjectMetadataFacet(requestB, "columns", loaderB);
await vi.waitFor(() => expect(mocks.loadSchemaCache).toHaveBeenCalledTimes(2));
const invalidation = invalidateObjectMetadataCache(requestB);
releaseA({ version: 1, cachedAt: new Date().toISOString(), value: ["cached accounts"] });
releaseB({ version: 1, cachedAt: new Date().toISOString(), value: ["stale billing"] });
await expect(loadA).resolves.toEqual({ value: ["cached accounts"], cacheStatus: "disk" });
await expect(loadB).resolves.toEqual({ value: ["remote billing"], cacheStatus: "remote" });
await invalidation;
expect(loaderA).not.toHaveBeenCalled();
expect(loaderB).toHaveBeenCalledTimes(1);
});
it("reclaims invalidation state after long-running multi-object refreshes", async () => {
for (let index = 0; index < 2_000; index += 1) {
await invalidateObjectMetadataCache({ ...request, tableName: `history_${index}` });
}
expect(getObjectMetadataCacheDebugStateForTests()).toEqual({ activeReads: 0 });
});
it("makes a concurrent normal facet read wait for a force refresh", async () => {
const loader = vi.fn().mockResolvedValueOnce(["old"]);
await loadObjectMetadataFacet(request, "indexes", loader);
let releaseRemote: (value: string[]) => void = () => {};
loader.mockReturnValueOnce(
new Promise((resolve) => {
releaseRemote = resolve;
}),
);
const force = loadObjectMetadataFacet(request, "indexes", loader, { force: true });
const normal = loadObjectMetadataFacet(request, "indexes", loader);
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2));
releaseRemote(["new"]);
await expect(Promise.all([force, normal])).resolves.toEqual([
{ value: ["new"], cacheStatus: "remote" },
{ value: ["new"], cacheStatus: "remote" },
]);
expect(loader).toHaveBeenCalledTimes(2);
});
it("cancels an old connection facet load without repopulating the new session cache", async () => {
let releaseOld: (value: string[]) => void = () => {};
const loader = vi.fn().mockReturnValueOnce(
new Promise((resolve) => {
releaseOld = resolve;
}),
);
const oldLoad = loadObjectMetadataFacet(request, "columns", loader);
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1));
cancelObjectMetadataLoadsForConnection(request.connectionId);
releaseOld(["old session"]);
await expect(oldLoad).resolves.toMatchObject({ value: ["old session"] });
loader.mockResolvedValueOnce(["new session"]);
await expect(loadObjectMetadataFacet(request, "columns", loader)).resolves.toMatchObject({ value: ["new session"], cacheStatus: "remote" });
expect(loader).toHaveBeenCalledTimes(2);
expect(mocks.deleteSchemaCachePrefix).not.toHaveBeenCalledWith("object-meta:v1:c1:");
});
});
@@ -0,0 +1,37 @@
export interface ActiveCacheReadToken {
readonly cacheKey: string;
invalidated: boolean;
}
/** Tracks only currently running L2 reads, so invalidation state is bounded by concurrency. */
export class ActiveCacheReadTracker {
private readonly readsByKey = new Map<string, Set<ActiveCacheReadToken>>();
private count = 0;
begin(cacheKey: string): ActiveCacheReadToken {
const token: ActiveCacheReadToken = { cacheKey, invalidated: false };
const existing = this.readsByKey.get(cacheKey);
if (existing) existing.add(token);
else this.readsByKey.set(cacheKey, new Set([token]));
this.count += 1;
return token;
}
finish(token: ActiveCacheReadToken): void {
const reads = this.readsByKey.get(token.cacheKey);
if (!reads?.delete(token)) return;
this.count -= 1;
if (reads.size === 0) this.readsByKey.delete(token.cacheKey);
}
invalidatePrefix(prefix: string): void {
for (const [cacheKey, reads] of this.readsByKey) {
if (!cacheKey.startsWith(prefix)) continue;
for (const token of reads) token.invalidated = true;
}
}
get activeCount(): number {
return this.count;
}
}
@@ -0,0 +1,284 @@
import { appendDebugLog } from "@/lib/backend/debugLog";
export const METADATA_CACHE_DEFAULT_MEMORY_MB = 64;
export const METADATA_CACHE_MIN_MEMORY_MB = 16;
export const METADATA_CACHE_RECOMMENDED_MAX_MEMORY_MB = 256;
export const METADATA_CACHE_HARD_MAX_MEMORY_MB = 512;
export const METADATA_CACHE_MAX_ENTRY_BYTES = 1024 * 1024;
const METADATA_CACHE_CONNECTION_SHARE = 0.25;
const METADATA_CACHE_KEY_PREFIX = "dbx-metadata-cache:";
interface RuntimeCacheEntry<T = unknown> {
key: string;
connectionId: string;
value: T;
sizeBytes: number;
touchedAt: number;
}
interface MetadataCacheLogContext {
connectionId: string;
database: string;
facet: string;
}
export interface MetadataRuntimeCacheHit<T> {
value: T;
sizeBytes: number;
}
export interface MetadataRuntimeCacheDiagnostics {
entries: number;
bytes: number;
maxMemoryBytes: number;
maxEntryBytes: number;
configuredMemoryMb: number;
l1Hits: number;
l1Misses: number;
l2Hits: number;
remoteMisses: number;
evictions: number;
connectionClears: number;
}
const entries = new Map<string, RuntimeCacheEntry>();
let configuredMemoryMb = METADATA_CACHE_DEFAULT_MEMORY_MB;
let totalBytes = 0;
let l1Hits = 0;
let l1Misses = 0;
let l2Hits = 0;
let remoteMisses = 0;
let evictions = 0;
let connectionClears = 0;
function now(): number {
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
}
function memoryBudgetBytes(): number {
return configuredMemoryMb * 1024 * 1024;
}
function connectionBudgetBytes(): number {
return Math.floor(memoryBudgetBytes() * METADATA_CACHE_CONNECTION_SHARE);
}
function keyConnectionId(key: string, connectionId?: string): string {
if (connectionId) return connectionId;
const marker = `${METADATA_CACHE_KEY_PREFIX}`;
if (!key.startsWith(marker)) return "";
return key.slice(marker.length).split(":", 1)[0] ?? "";
}
function estimateValueBytes(value: unknown): number {
let serialized = "";
try {
serialized = JSON.stringify(value) ?? "null";
} catch {
serialized = String(value);
}
// JSON byte length is a lower bound for V8 heap usage. Account for UTF-16
// strings and object/array/Map entry overhead with a conservative multiplier.
const utf8Bytes = typeof TextEncoder !== "undefined" ? new TextEncoder().encode(serialized).byteLength : serialized.length;
return Math.max(256, utf8Bytes * 2 + 512);
}
function decodeCacheSegment(value: string | undefined): string {
if (!value) return "";
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function cacheLogContext(key: string, fallbackConnectionId = ""): MetadataCacheLogContext {
const parts = key.split(":");
if ((parts[0] === "object-ddl" || parts[0] === "object-meta") && parts[1] === "v1") {
return {
connectionId: decodeCacheSegment(parts[2]) || fallbackConnectionId,
database: decodeCacheSegment(parts[3]),
facet: parts[0] === "object-ddl" ? "ddl" : decodeCacheSegment(parts[8]),
};
}
return { connectionId: fallbackConnectionId, database: "", facet: "" };
}
function logCacheMetric(metric: string, key: string, details: Record<string, unknown> = {}, fallbackConnectionId = ""): void {
appendDebugLog("debug", "[DBX][metadata-cache]", {
metric,
...cacheLogContext(key, fallbackConnectionId),
...details,
cacheBytes: totalBytes,
cacheEntries: entries.size,
});
}
function removeEntry(key: string, evictionReason?: string): boolean {
const entry = entries.get(key);
if (!entry) return false;
entries.delete(key);
totalBytes = Math.max(0, totalBytes - entry.sizeBytes);
if (evictionReason) {
evictions++;
logCacheMetric("metadata_cache_eviction", key, { entrySizeBytes: entry.sizeBytes, evictionReason }, entry.connectionId);
}
return true;
}
function connectionBytes(connectionId: string): number {
let bytes = 0;
for (const entry of entries.values()) {
if (entry.connectionId === connectionId) bytes += entry.sizeBytes;
}
return bytes;
}
function evictOldest(predicate: (entry: RuntimeCacheEntry) => boolean, reason: string): boolean {
let oldest: RuntimeCacheEntry | undefined;
for (const entry of entries.values()) {
if (!predicate(entry)) continue;
if (!oldest || entry.touchedAt < oldest.touchedAt) oldest = entry;
}
return oldest ? removeEntry(oldest.key, reason) : false;
}
function evictForInsert(connectionId: string, sizeBytes: number): boolean {
const budget = memoryBudgetBytes();
const perConnectionBudget = connectionBudgetBytes();
while (connectionBytes(connectionId) + sizeBytes > perConnectionBudget) {
if (!evictOldest((entry) => entry.connectionId === connectionId, "connection-budget")) return false;
}
while (totalBytes + sizeBytes > budget) {
if (!evictOldest(() => true, "total-budget")) return false;
}
return true;
}
export function normalizeMetadataCacheMemoryMb(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return METADATA_CACHE_DEFAULT_MEMORY_MB;
const rounded = Math.round(value);
if (rounded > METADATA_CACHE_HARD_MAX_MEMORY_MB) return METADATA_CACHE_DEFAULT_MEMORY_MB;
return Math.max(METADATA_CACHE_MIN_MEMORY_MB, rounded);
}
export function configureMetadataRuntimeCache(memoryMb: unknown): number {
if (typeof memoryMb === "number" && Number.isFinite(memoryMb)) {
const rounded = Math.round(memoryMb);
if (rounded > METADATA_CACHE_HARD_MAX_MEMORY_MB) {
console.warn(`[metadata-cache] memory limit ${rounded} MB exceeds the hard limit; falling back to ${METADATA_CACHE_DEFAULT_MEMORY_MB} MB`);
} else if (rounded > METADATA_CACHE_RECOMMENDED_MAX_MEMORY_MB) {
console.warn(`[metadata-cache] memory limit ${rounded} MB exceeds the recommended ${METADATA_CACHE_RECOMMENDED_MAX_MEMORY_MB} MB`);
}
}
configuredMemoryMb = normalizeMetadataCacheMemoryMb(memoryMb);
const connectionIds = new Set([...entries.values()].map((entry) => entry.connectionId));
for (const connectionId of connectionIds) {
while (connectionBytes(connectionId) > connectionBudgetBytes()) {
if (!evictOldest((entry) => entry.connectionId === connectionId, "connection-budget-reconfigured")) break;
}
}
while (totalBytes > memoryBudgetBytes()) {
if (!evictOldest(() => true, "total-budget-reconfigured")) break;
}
return configuredMemoryMb;
}
export function getConfiguredMetadataCacheMemoryMb(): number {
return configuredMemoryMb;
}
export function getMetadataRuntimeCache<T>(key: string): MetadataRuntimeCacheHit<T> | undefined {
const entry = entries.get(key) as RuntimeCacheEntry<T> | undefined;
if (!entry) {
l1Misses++;
return undefined;
}
entries.delete(key);
entry.touchedAt = now();
entries.set(key, entry);
l1Hits++;
logCacheMetric("metadata_cache_l1_hit", key, { entrySizeBytes: entry.sizeBytes }, entry.connectionId);
return { value: entry.value, sizeBytes: entry.sizeBytes };
}
export function setMetadataRuntimeCache<T>(key: string, value: T, connectionId?: string): boolean {
const sizeBytes = estimateValueBytes(value);
if (sizeBytes > METADATA_CACHE_MAX_ENTRY_BYTES) {
logCacheMetric("metadata_cache_l1_skip", key, { entrySizeBytes: sizeBytes, evictionReason: "entry-too-large" }, connectionId);
return false;
}
removeEntry(key);
const owner = keyConnectionId(key, connectionId);
if (!evictForInsert(owner, sizeBytes)) return false;
entries.set(key, { key, connectionId: owner, value, sizeBytes, touchedAt: now() });
totalBytes += sizeBytes;
logCacheMetric("metadata_cache_store", key, { entrySizeBytes: sizeBytes }, owner);
return true;
}
export function invalidateMetadataRuntimeCachePrefix(prefix: string): number {
let removed = 0;
for (const key of [...entries.keys()]) {
if (key === prefix || key.startsWith(prefix)) removed += removeEntry(key) ? 1 : 0;
}
return removed;
}
export function clearMetadataRuntimeCacheForConnection(connectionId: string): number {
let removed = 0;
for (const [key, entry] of [...entries.entries()]) {
if (entry.connectionId === connectionId) removed += removeEntry(key) ? 1 : 0;
}
connectionClears++;
logCacheMetric("metadata_cache_connection_clear", "", { connectionId, removedEntries: removed }, connectionId);
return removed;
}
export function clearMetadataRuntimeCacheForDatabase(connectionId: string, database: string): number {
let removed = 0;
for (const [key, entry] of [...entries.entries()]) {
const context = cacheLogContext(key, entry.connectionId);
if (entry.connectionId === connectionId && context.database === database) removed += removeEntry(key) ? 1 : 0;
}
logCacheMetric("metadata_cache_database_clear", "", { connectionId, database, removedEntries: removed }, connectionId);
return removed;
}
export function clearMetadataRuntimeCache(): void {
entries.clear();
totalBytes = 0;
l1Hits = 0;
l1Misses = 0;
l2Hits = 0;
remoteMisses = 0;
evictions = 0;
connectionClears = 0;
}
export function recordMetadataCacheL2Hit(key: string, value: unknown): void {
l2Hits++;
logCacheMetric("metadata_cache_l2_hit", key, { entrySizeBytes: estimateValueBytes(value) });
}
export function recordMetadataCacheRemoteMiss(key: string): void {
remoteMisses++;
logCacheMetric("metadata_cache_remote_miss", key);
}
export function getMetadataRuntimeCacheDiagnostics(): MetadataRuntimeCacheDiagnostics {
return {
entries: entries.size,
bytes: totalBytes,
maxMemoryBytes: memoryBudgetBytes(),
maxEntryBytes: METADATA_CACHE_MAX_ENTRY_BYTES,
configuredMemoryMb,
l1Hits,
l1Misses,
l2Hits,
remoteMisses,
evictions,
connectionClears,
};
}
+113 -29
View File
@@ -1,10 +1,13 @@
import * as api from "@/lib/backend/api";
import { ActiveCacheReadTracker } from "./activeCacheReadTracker";
import type { ObjectSourceKind } from "@/types/database";
import type { MetadataCacheInvalidation } from "./metadataResultCache";
import { invalidateObjectMetadataCache } from "./objectMetadataCache";
import { getMetadataRuntimeCache, invalidateMetadataRuntimeCachePrefix, recordMetadataCacheL2Hit, recordMetadataCacheRemoteMiss, setMetadataRuntimeCache } from "./metadataRuntimeCache";
const OBJECT_DDL_CACHE_PREFIX = "object-ddl:v1";
const MAX_PERSISTED_DDL_CHARS = 5 * 1024 * 1024;
const MAX_PERSISTED_DDL_AGE_MS = 24 * 60 * 60 * 1000;
interface ObjectDdlCacheEnvelope {
version: 1;
@@ -29,11 +32,13 @@ export interface ObjectDdlRequest {
export interface ObjectDdlLoadResult {
ddl: string;
cacheStatus: "disk" | "remote";
cacheStatus: "memory" | "disk" | "remote";
}
const remoteLoads = new Map<string, InFlightDdlLoad>();
const activeCacheReads = new ActiveCacheReadTracker();
const pendingInvalidations = new Map<string, Promise<void>>();
const pendingWrites = new Map<string, Promise<void>>();
async function loadSchemaCacheSafe<T>(cacheKey: string): Promise<T | null> {
try {
@@ -83,7 +88,8 @@ function invalidationPrefix(match: MetadataCacheInvalidation): string {
function decodeCachedDdl(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const envelope = payload as Partial<ObjectDdlCacheEnvelope>;
if (envelope.version !== 1 || typeof envelope.cachedAt !== "string" || !Number.isFinite(Date.parse(envelope.cachedAt)) || typeof envelope.ddl !== "string") return null;
const cachedAt = typeof envelope.cachedAt === "string" ? Date.parse(envelope.cachedAt) : Number.NaN;
if (envelope.version !== 1 || !Number.isFinite(cachedAt) || Date.now() - cachedAt > MAX_PERSISTED_DDL_AGE_MS || typeof envelope.ddl !== "string") return null;
return envelope.ddl;
}
@@ -92,21 +98,62 @@ async function waitForPendingInvalidations(cacheKey: string): Promise<void> {
if (pending.length) await Promise.all(pending);
}
function invalidateRemoteLoad(cacheKey: string): void {
const entry = remoteLoads.get(cacheKey);
if (!entry) return;
entry.invalidated = true;
remoteLoads.delete(cacheKey);
}
function persistSchemaCache(cacheKey: string, payload: unknown, isInvalidated: () => boolean): void {
const previous = pendingWrites.get(cacheKey);
const write = (async () => {
if (previous) await previous;
if (!isInvalidated()) await saveSchemaCacheSafe(cacheKey, payload);
})().finally(() => {
if (pendingWrites.get(cacheKey) === write) pendingWrites.delete(cacheKey);
});
pendingWrites.set(cacheKey, write);
}
async function waitForPendingWrites(prefix: string): Promise<void> {
while (true) {
const pending = [...pendingWrites.entries()].filter(([cacheKey]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise);
if (!pending.length) return;
await Promise.all(pending);
}
}
function invalidatePersistedPrefix(prefix: string): Promise<void> {
const existing = pendingInvalidations.get(prefix);
if (existing) return existing;
const deletion = (async () => {
await waitForPendingWrites(prefix);
await deleteSchemaCachePrefixSafe(prefix);
})().finally(() => {
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
});
pendingInvalidations.set(prefix, deletion);
return deletion;
}
async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force: boolean): Promise<string> {
const existing = remoteLoads.get(cacheKey);
if (existing && (!force || existing.force)) return existing.promise;
if (existing) {
existing.invalidated = true;
remoteLoads.delete(cacheKey);
}
if (existing) invalidateRemoteLoad(cacheKey);
const entry: InFlightDdlLoad = { force, invalidated: false, promise: Promise.resolve("") };
recordMetadataCacheRemoteMiss(cacheKey);
entry.promise = api
.getTableDisplayDdl(request.connectionId, request.database, request.schema, request.tableName, request.objectType, request.catalog)
.then(async (ddl) => {
if (!entry.invalidated && ddl.length <= MAX_PERSISTED_DDL_CHARS) {
.then((ddl) => {
const current = !entry.invalidated;
if (current && ddl.length <= MAX_PERSISTED_DDL_CHARS) {
const envelope: ObjectDdlCacheEnvelope = { version: 1, cachedAt: new Date().toISOString(), ddl };
await saveSchemaCacheSafe(cacheKey, envelope);
setMetadataRuntimeCache(cacheKey, ddl, request.connectionId);
persistSchemaCache(cacheKey, envelope, () => entry.invalidated);
} else if (current) {
setMetadataRuntimeCache(cacheKey, ddl, request.connectionId);
}
return ddl;
})
@@ -119,11 +166,38 @@ async function loadRemoteDdl(request: ObjectDdlRequest, cacheKey: string, force:
export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force?: boolean }): Promise<ObjectDdlLoadResult> {
const cacheKey = objectDdlCacheKey(request);
await waitForPendingInvalidations(cacheKey);
if (options?.force) {
const existing = remoteLoads.get(cacheKey);
if (existing?.force) return { ddl: await existing.promise, cacheStatus: "remote" };
invalidateRemoteLoad(cacheKey);
activeCacheReads.invalidatePrefix(cacheKey);
invalidateMetadataRuntimeCachePrefix(cacheKey);
const priorInvalidations = waitForPendingInvalidations(cacheKey);
const deletion = invalidatePersistedPrefix(cacheKey);
await Promise.all([priorInvalidations, deletion]);
} else {
await waitForPendingInvalidations(cacheKey);
}
if (!options?.force) {
const cached = decodeCachedDdl(await loadSchemaCacheSafe<unknown>(cacheKey));
if (cached !== null) return { ddl: cached, cacheStatus: "disk" };
const runtime = getMetadataRuntimeCache<string>(cacheKey);
if (runtime) return { ddl: runtime.value, cacheStatus: "memory" };
const readToken = activeCacheReads.begin(cacheKey);
let cached: string | null;
try {
cached = decodeCachedDdl(await loadSchemaCacheSafe<unknown>(cacheKey));
} finally {
activeCacheReads.finish(readToken);
}
if (readToken.invalidated) {
await waitForPendingInvalidations(cacheKey);
return { ddl: await loadRemoteDdl(request, cacheKey, false), cacheStatus: "remote" };
}
if (cached !== null) {
recordMetadataCacheL2Hit(cacheKey, cached);
setMetadataRuntimeCache(cacheKey, cached, request.connectionId);
return { ddl: cached, cacheStatus: "disk" };
}
}
return { ddl: await loadRemoteDdl(request, cacheKey, options?.force === true), cacheStatus: "remote" };
@@ -131,30 +205,21 @@ export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force
export async function invalidateObjectDdlCache(match: MetadataCacheInvalidation): Promise<void> {
const prefix = invalidationPrefix(match);
for (const [cacheKey, entry] of remoteLoads) {
if (!cacheKey.startsWith(prefix)) continue;
entry.invalidated = true;
remoteLoads.delete(cacheKey);
}
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...remoteLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
const existing = pendingInvalidations.get(prefix);
if (existing) return existing;
const deletion = deleteSchemaCachePrefixSafe(prefix).finally(() => {
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
});
pendingInvalidations.set(prefix, deletion);
const deletion = invalidatePersistedPrefix(prefix);
await Promise.all([deletion, invalidateObjectMetadataCache(match)]);
}
export async function invalidateObjectDdl(request: ObjectDdlRequest): Promise<void> {
const cacheKey = objectDdlCacheKey(request);
const entry = remoteLoads.get(cacheKey);
if (entry) {
entry.invalidated = true;
remoteLoads.delete(cacheKey);
}
activeCacheReads.invalidatePrefix(cacheKey);
invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(cacheKey);
await Promise.all([
deleteSchemaCachePrefixSafe(cacheKey),
invalidatePersistedPrefix(cacheKey),
invalidateObjectMetadataCache({
connectionId: request.connectionId,
database: request.database,
@@ -163,3 +228,22 @@ export async function invalidateObjectDdl(request: ObjectDdlRequest): Promise<vo
}),
]);
}
/** Invalidate active loads for a disconnected connection without deleting its persisted snapshot. */
export function cancelObjectDdlLoadsForConnection(connectionId: string): void {
const prefix = `${OBJECT_DDL_CACHE_PREFIX}:${cacheSegment(connectionId)}:`;
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...remoteLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
}
export function cancelObjectDdlLoadsForDatabase(connectionId: string, database: string): void {
const prefix = `${OBJECT_DDL_CACHE_PREFIX}:${cacheSegment(connectionId)}:${cacheSegment(database)}:`;
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...remoteLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
}
export function getObjectDdlCacheDebugStateForTests(): { activeReads: number } {
return { activeReads: activeCacheReads.activeCount };
}
@@ -1,9 +1,12 @@
import * as api from "@/lib/backend/api";
import { ActiveCacheReadTracker } from "./activeCacheReadTracker";
import type { MetadataCacheInvalidation } from "./metadataResultCache";
import type { ObjectDdlRequest } from "./objectDdlCache";
import { getMetadataRuntimeCache, invalidateMetadataRuntimeCachePrefix, recordMetadataCacheL2Hit, recordMetadataCacheRemoteMiss, setMetadataRuntimeCache } from "./metadataRuntimeCache";
const OBJECT_METADATA_CACHE_PREFIX = "object-meta:v1";
const MAX_PERSISTED_METADATA_CHARS = 5 * 1024 * 1024;
const MAX_PERSISTED_METADATA_AGE_MS = 24 * 60 * 60 * 1000;
interface ObjectMetadataCacheEnvelope<T> {
version: 1;
@@ -18,7 +21,9 @@ interface InFlightLoad<T> {
}
const inFlightLoads = new Map<string, InFlightLoad<unknown>>();
const activeCacheReads = new ActiveCacheReadTracker();
const pendingInvalidations = new Map<string, Promise<void>>();
const pendingWrites = new Map<string, Promise<void>>();
async function loadSchemaCacheSafe<T>(cacheKey: string): Promise<T | null> {
try {
@@ -51,7 +56,7 @@ function cacheSegment(value: string | undefined): string {
}
function facetKey(request: ObjectDdlRequest, facet: ObjectMetadataFacet): string {
return `${[OBJECT_METADATA_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), facet].join(":")}:`;
return `${[OBJECT_METADATA_CACHE_PREFIX, cacheSegment(request.connectionId), cacheSegment(request.database), cacheSegment(request.schema), cacheSegment(request.tableName), cacheSegment(request.catalog), cacheSegment(request.objectType ?? "TABLE"), facet].join(":")}:`;
}
function invalidationPrefix(match: MetadataCacheInvalidation): string {
@@ -67,11 +72,12 @@ function invalidationPrefix(match: MetadataCacheInvalidation): string {
return `${parts.join(":")}:`;
}
function decodeEnvelope<T>(payload: unknown): T | null {
if (!payload || typeof payload !== "object") return null;
function decodeEnvelope<T>(payload: unknown): { hit: true; value: T } | undefined {
if (!payload || typeof payload !== "object") return undefined;
const envelope = payload as Partial<ObjectMetadataCacheEnvelope<T>>;
if (envelope.version !== 1 || typeof envelope.cachedAt !== "string" || !Number.isFinite(Date.parse(envelope.cachedAt)) || !("value" in envelope)) return null;
return envelope.value === undefined ? null : envelope.value;
const cachedAt = typeof envelope.cachedAt === "string" ? Date.parse(envelope.cachedAt) : Number.NaN;
if (envelope.version !== 1 || !Number.isFinite(cachedAt) || Date.now() - cachedAt > MAX_PERSISTED_METADATA_AGE_MS || !("value" in envelope)) return undefined;
return { hit: true, value: envelope.value as T };
}
async function waitForPendingInvalidations(cacheKey: string): Promise<void> {
@@ -79,29 +85,99 @@ async function waitForPendingInvalidations(cacheKey: string): Promise<void> {
if (pending.length) await Promise.all(pending);
}
export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, facet: ObjectMetadataFacet, loader: () => Promise<T>, options?: { force?: boolean }): Promise<{ value: T; cacheStatus: "disk" | "remote" }> {
function invalidateInFlightLoad(cacheKey: string): void {
const entry = inFlightLoads.get(cacheKey);
if (!entry) return;
entry.invalidated = true;
inFlightLoads.delete(cacheKey);
}
function persistSchemaCache(cacheKey: string, payload: unknown, isInvalidated: () => boolean): void {
const previous = pendingWrites.get(cacheKey);
const write = (async () => {
if (previous) await previous;
if (!isInvalidated()) await saveSchemaCacheSafe(cacheKey, payload);
})().finally(() => {
if (pendingWrites.get(cacheKey) === write) pendingWrites.delete(cacheKey);
});
pendingWrites.set(cacheKey, write);
}
async function waitForPendingWrites(prefix: string): Promise<void> {
while (true) {
const pending = [...pendingWrites.entries()].filter(([cacheKey]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise);
if (!pending.length) return;
await Promise.all(pending);
}
}
function invalidatePersistedPrefix(prefix: string): Promise<void> {
const existing = pendingInvalidations.get(prefix);
if (existing) return existing;
const deletion = (async () => {
await waitForPendingWrites(prefix);
await deleteSchemaCachePrefixSafe(prefix);
})().finally(() => {
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
});
pendingInvalidations.set(prefix, deletion);
return deletion;
}
export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, facet: ObjectMetadataFacet, loader: () => Promise<T>, options?: { force?: boolean }): Promise<{ value: T; cacheStatus: "memory" | "disk" | "remote" }> {
const cacheKey = facetKey(request, facet);
await waitForPendingInvalidations(cacheKey);
if (options?.force) {
const existing = inFlightLoads.get(cacheKey);
if (existing?.force) return { value: (await existing.promise) as T, cacheStatus: "remote" };
invalidateInFlightLoad(cacheKey);
activeCacheReads.invalidatePrefix(cacheKey);
invalidateMetadataRuntimeCachePrefix(cacheKey);
const priorInvalidations = waitForPendingInvalidations(cacheKey);
const deletion = invalidatePersistedPrefix(cacheKey);
await Promise.all([priorInvalidations, deletion]);
} else {
await waitForPendingInvalidations(cacheKey);
}
if (!options?.force) {
const cached = decodeEnvelope<T>(await loadSchemaCacheSafe<unknown>(cacheKey));
if (cached !== null) return { value: cached, cacheStatus: "disk" };
const runtime = getMetadataRuntimeCache<T>(cacheKey);
if (runtime) return { value: runtime.value, cacheStatus: "memory" };
const readToken = activeCacheReads.begin(cacheKey);
let cached: ReturnType<typeof decodeEnvelope<T>>;
try {
cached = decodeEnvelope<T>(await loadSchemaCacheSafe<unknown>(cacheKey));
} finally {
activeCacheReads.finish(readToken);
}
if (readToken.invalidated) {
const value = await loadObjectMetadataFacet(request, facet, loader, { force: false });
return { value: value.value, cacheStatus: "remote" };
}
if (cached?.hit) {
recordMetadataCacheL2Hit(cacheKey, cached.value);
setMetadataRuntimeCache(cacheKey, cached.value, request.connectionId);
return { value: cached.value, cacheStatus: "disk" };
}
}
const existing = inFlightLoads.get(cacheKey);
if (existing && (!options?.force || existing.force)) return { value: (await existing.promise) as T, cacheStatus: "remote" };
if (existing) {
existing.invalidated = true;
inFlightLoads.delete(cacheKey);
}
if (existing) invalidateInFlightLoad(cacheKey);
const entry: InFlightLoad<T> = { force: options?.force === true, invalidated: false, promise: Promise.resolve(undefined as T) };
recordMetadataCacheRemoteMiss(cacheKey);
entry.promise = loader()
.then(async (value) => {
.then((value) => {
// Callers use undefined to signal that all metadata fallbacks failed.
// Keep that result visible to the current request, but do not turn a
// transient failure into a successful cache hit.
if (value === undefined) return value;
const serialized = JSON.stringify(value);
if (!entry.invalidated && serialized.length <= MAX_PERSISTED_METADATA_CHARS) {
const current = !entry.invalidated;
if (current) setMetadataRuntimeCache(cacheKey, value, request.connectionId);
if (current && serialized !== undefined && serialized.length <= MAX_PERSISTED_METADATA_CHARS) {
const envelope: ObjectMetadataCacheEnvelope<T> = { version: 1, cachedAt: new Date().toISOString(), value };
await saveSchemaCacheSafe(cacheKey, envelope);
persistSchemaCache(cacheKey, envelope, () => entry.invalidated);
}
return value;
})
@@ -114,17 +190,28 @@ export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, face
export async function invalidateObjectMetadataCache(match: MetadataCacheInvalidation): Promise<void> {
const prefix = invalidationPrefix(match);
for (const [cacheKey, entry] of inFlightLoads) {
if (!cacheKey.startsWith(prefix)) continue;
entry.invalidated = true;
inFlightLoads.delete(cacheKey);
}
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...inFlightLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateInFlightLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
const existing = pendingInvalidations.get(prefix);
if (existing) return existing;
const deletion = deleteSchemaCachePrefixSafe(prefix).finally(() => {
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
});
pendingInvalidations.set(prefix, deletion);
return deletion;
return invalidatePersistedPrefix(prefix);
}
/** Invalidate active loads for a disconnected connection without deleting its persisted snapshot. */
export function cancelObjectMetadataLoadsForConnection(connectionId: string): void {
const prefix = `${OBJECT_METADATA_CACHE_PREFIX}:${cacheSegment(connectionId)}:`;
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...inFlightLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateInFlightLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
}
export function cancelObjectMetadataLoadsForDatabase(connectionId: string, database: string): void {
const prefix = `${OBJECT_METADATA_CACHE_PREFIX}:${cacheSegment(connectionId)}:${cacheSegment(database)}:`;
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...inFlightLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateInFlightLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
}
export function getObjectMetadataCacheDebugStateForTests(): { activeReads: number } {
return { activeReads: activeCacheReads.activeCount };
}
@@ -127,6 +127,45 @@ describe("settings search", () => {
expect(searchSettings(entries, "hidden in the background", "en").map((entry) => entry.id)).toEqual(["appearance-tray"]);
});
it("indexes the metadata cache memory limit under data settings", () => {
expect(SETTINGS_SEARCH_DEFINITIONS).toContainEqual(
expect.objectContaining({
id: "data-performance",
category: "data",
titleKey: "settings.performanceSection",
targetId: "data-performance",
}),
);
expect(SETTINGS_SEARCH_DEFINITIONS).toContainEqual(
expect.objectContaining({
id: "data-metadata-cache",
category: "data",
titleKey: "settings.metadataCacheMemoryLimit",
targetId: "data-performance",
}),
);
});
it("renders the metadata cache memory limit in a performance section after export", () => {
const exportSectionStart = settingsDialogSource.indexOf('t("settings.exportSection")');
const performanceSectionStart = settingsDialogSource.indexOf('data-settings-search-id="data-performance"');
const tableStructureSectionStart = settingsDialogSource.indexOf('t("settings.tableStructureSection")');
const metadataCacheControl = settingsDialogSource.indexOf('id="metadata-cache-memory-limit"');
expect(exportSectionStart).toBeGreaterThan(-1);
expect(performanceSectionStart).toBeGreaterThan(exportSectionStart);
expect(tableStructureSectionStart).toBeGreaterThan(performanceSectionStart);
expect(metadataCacheControl).toBeGreaterThan(performanceSectionStart);
expect(metadataCacheControl).toBeLessThan(tableStructureSectionStart);
});
it("defines the performance section title in every supported locale", () => {
for (const locale of ["zh-CN", "zh-TW", "en", "es", "it", "ja", "ko", "pt-BR"]) {
const source = readFileSync(new URL(`../../../i18n/locales/${locale}.ts`, import.meta.url), "utf8");
expect(source, locale).toContain("performanceSection:");
}
});
it("activates result buttons through click for keyboard and assistive technology", () => {
expect(settingsDialogSource).toMatch(/role="option"[\s\S]*?@mousedown\.prevent[\s\S]*?@click="void selectSettingsSearchResult\(result\)"/);
});
@@ -200,6 +200,8 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "data-export-row-limit-enabled", category: "data", titleKey: "settings.exportRowLimitEnabled", descriptionKey: "settings.exportRowLimitEnabledDescription", targetId: "data" },
{ id: "data-export-row-limit", category: "data", titleKey: "settings.exportRowLimit", descriptionKey: "settings.exportRowLimitDescription", targetId: "data" },
{ id: "data-export-keyset", category: "data", titleKey: "settings.queryExportKeysetOptimizationEnabled", descriptionKey: "settings.queryExportKeysetOptimizationEnabledDescription", targetId: "data" },
{ id: "data-performance", category: "data", titleKey: "settings.performanceSection", targetId: "data-performance" },
{ id: "data-metadata-cache", category: "data", titleKey: "settings.metadataCacheMemoryLimit", descriptionKey: "settings.metadataCacheMemoryLimitDescription", targetId: "data-performance" },
{ id: "data-table-template", category: "data", titleKey: "settings.tableColumnTemplateFields", descriptionKey: "settings.tableColumnTemplateFieldsDescription", targetId: "table-column-templates" },
{ id: "data-duckdb", category: "data", titleKey: "settings.duckDbWorkerProcessIsolation", descriptionKey: "settings.duckDbWorkerProcessIsolationDescription", targetId: "data", visible: desktopOnly },
{ id: "data-duckdb-process-limit", category: "data", titleKey: "settings.duckDbWorkerMaxProcesses", descriptionKey: "settings.duckDbWorkerMaxProcessesDescription", targetId: "data", visible: desktopOnly },
@@ -345,6 +345,13 @@ describe("right sidebar panel transitions", () => {
});
describe("normalizeDesktopSettings", () => {
it("normalizes the metadata cache memory budget", () => {
expect(normalizeDesktopSettings({}).metadata_cache_max_memory_mb).toBe(64);
expect(normalizeDesktopSettings({ metadata_cache_max_memory_mb: 1 }).metadata_cache_max_memory_mb).toBe(16);
expect(normalizeDesktopSettings({ metadata_cache_max_memory_mb: 384 }).metadata_cache_max_memory_mb).toBe(384);
expect(normalizeDesktopSettings({ metadata_cache_max_memory_mb: 513 }).metadata_cache_max_memory_mb).toBe(64);
});
it("defaults DuckDB worker process isolation to disabled for old settings", () => {
expect(normalizeDesktopSettings({}).duckdb_worker_process_isolation).toBe(false);
});
+14 -1
View File
@@ -130,7 +130,9 @@ import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator,
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
import { invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
import { cancelObjectDdlLoadsForConnection, cancelObjectDdlLoadsForDatabase, invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
import { cancelObjectMetadataLoadsForConnection, cancelObjectMetadataLoadsForDatabase } from "@/lib/metadata/objectMetadataCache";
import { clearMetadataRuntimeCacheForConnection, clearMetadataRuntimeCacheForDatabase } from "@/lib/metadata/metadataRuntimeCache";
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree";
@@ -3322,6 +3324,8 @@ export const useConnectionStore = defineStore("connection", () => {
clearConnectionHealthCheck(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
invalidateCompletionCache(connectionId);
cancelObjectDdlLoadsForConnection(connectionId);
cancelObjectMetadataLoadsForConnection(connectionId);
await disconnectRequest;
return true;
}
@@ -3345,6 +3349,9 @@ export const useConnectionStore = defineStore("connection", () => {
node.children = [];
}
clearConnectionRootMetadataLoad(connectionId);
cancelObjectDdlLoadsForConnection(connectionId);
cancelObjectMetadataLoadsForConnection(connectionId);
clearMetadataRuntimeCacheForConnection(connectionId);
// Disconnecting only tears down the live session. Keep the schema snapshot so
// reconnecting can render databases and table names before the remote refresh.
clearLoadedChildrenCache(connectionId, { deletePersisted: false });
@@ -3387,7 +3394,10 @@ export const useConnectionStore = defineStore("connection", () => {
async function closeDatabaseConnection(connectionId: string, database: string) {
if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database);
cancelObjectDdlLoadsForDatabase(connectionId, database);
cancelObjectMetadataLoadsForDatabase(connectionId, database);
await api.closeDatabaseConnection(connectionId, database);
clearMetadataRuntimeCacheForDatabase(connectionId, database);
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
@@ -3425,6 +3435,9 @@ export const useConnectionStore = defineStore("connection", () => {
return;
} catch {
// Backend pool is dead — remove from connectedIds and reconnect
cancelObjectDdlLoadsForConnection(connectionId);
cancelObjectMetadataLoadsForConnection(connectionId);
clearMetadataRuntimeCacheForConnection(connectionId);
connectedIds.value.delete(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionHealthCheck(connectionId);
+7
View File
@@ -23,6 +23,7 @@ import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides }
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates";
import { type DataTabReuseMode, DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode } from "@/lib/tabs/dataTabReuseMode";
import { normalizeCompletionTriggerMode, type SqlCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy";
import { configureMetadataRuntimeCache, METADATA_CACHE_DEFAULT_MEMORY_MB, normalizeMetadataCacheMemoryMb } from "@/lib/metadata/metadataRuntimeCache";
import type { AiApiStyle, AiAssistantMode, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiModelEffortPreference, AiProvider, AiReasoningLevel, AiTestConnectionResult } from "@/types/ai";
import type { SqlSnippet, TableInfoTab } from "@/types/database";
@@ -34,6 +35,7 @@ export interface DesktopSettings {
quit_on_close: boolean;
close_action_prompted: boolean;
debug_logging_enabled: boolean;
metadata_cache_max_memory_mb: number;
duckdb_worker_process_isolation: boolean;
duckdb_worker_max_processes: number;
saved_sql_sync_dir?: string | null;
@@ -70,6 +72,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
quit_on_close: false,
close_action_prompted: false,
debug_logging_enabled: false,
metadata_cache_max_memory_mb: METADATA_CACHE_DEFAULT_MEMORY_MB,
duckdb_worker_process_isolation: false,
duckdb_worker_max_processes: DUCKDB_WORKER_MAX_PROCESSES_DEFAULT,
saved_sql_sync_dir: null,
@@ -105,6 +108,7 @@ export function normalizeDesktopSettings(settings: Partial<DesktopSettings> | nu
quit_on_close: settings?.quit_on_close ?? DEFAULT_DESKTOP_SETTINGS.quit_on_close,
close_action_prompted: settings?.close_action_prompted ?? DEFAULT_DESKTOP_SETTINGS.close_action_prompted,
debug_logging_enabled: settings?.debug_logging_enabled ?? DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled,
metadata_cache_max_memory_mb: normalizeMetadataCacheMemoryMb(settings?.metadata_cache_max_memory_mb),
duckdb_worker_process_isolation: settings?.duckdb_worker_process_isolation ?? DEFAULT_DESKTOP_SETTINGS.duckdb_worker_process_isolation,
duckdb_worker_max_processes: normalizeDuckDbWorkerMaxProcesses(settings?.duckdb_worker_max_processes),
saved_sql_sync_dir: settings?.saved_sql_sync_dir?.trim() || DEFAULT_DESKTOP_SETTINGS.saved_sql_sync_dir,
@@ -1389,6 +1393,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (isDesktopSettingsLoaded.value) return;
desktopSettings.value = normalizeDesktopSettings(await api.loadDesktopSettings().catch(() => null));
setDebugLoggingEnabled(desktopSettings.value.debug_logging_enabled);
configureMetadataRuntimeCache(desktopSettings.value.metadata_cache_max_memory_mb);
isDesktopSettingsLoaded.value = true;
}
@@ -1400,11 +1405,13 @@ export const useSettingsStore = defineStore("settings", () => {
};
desktopSettings.value = normalizeDesktopSettings(next);
setDebugLoggingEnabled(desktopSettings.value.debug_logging_enabled);
configureMetadataRuntimeCache(desktopSettings.value.metadata_cache_max_memory_mb);
try {
await api.saveDesktopSettings(desktopSettings.value);
} catch (error) {
desktopSettings.value = previous;
setDebugLoggingEnabled(previous.debug_logging_enabled);
configureMetadataRuntimeCache(previous.metadata_cache_max_memory_mb);
throw error;
}
}