fix(metadata): bound cache maintenance and pool recovery
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-5
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "検索",
|
||||
|
||||
@@ -5605,6 +5605,7 @@ export default withEnglishFallback({
|
||||
queryExportKeysetOptimizationEnabled: "키셋 최적화 시도",
|
||||
queryExportKeysetOptimizationEnabledDescription: "안전하게 인식된 단일 테이블 쿼리에만 적용되며 복잡한 쿼리는 자동으로 폴백됩니다.",
|
||||
exportSection: "내보내기",
|
||||
performanceSection: "성능",
|
||||
shortcutExecuteSql: "SQL 실행",
|
||||
shortcutExecuteSqlInNewResultTab: "새 결과 탭에서 SQL 실행",
|
||||
shortcutFind: "찾기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "查找",
|
||||
|
||||
@@ -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: "全域顯示格式",
|
||||
|
||||
+4
-3
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
@@ -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();
|
||||
});
|
||||
});
|
||||
+167
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user