mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:20:02 +00:00
fix(memory): release closed query and connection resources
This commit is contained in:
@@ -277,6 +277,7 @@ watch(
|
||||
);
|
||||
|
||||
watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, wasRegexMode]) => {
|
||||
if (!isRegexMode || !newQuery) regexTableSearchScopes.value = [];
|
||||
// The regex source is a client-side projection; the remote tree-loading
|
||||
// search state must never carry it, or explicit node expansion would leak
|
||||
// the expression as a remote searchFilter.
|
||||
|
||||
+43
-1
@@ -1,11 +1,13 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import { clearDataGridStructuredFilterStatesForTab, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
|
||||
describe("data grid structured filter persistence", () => {
|
||||
const cacheKey = "issue-436-filter-view";
|
||||
const scopeKey = "mysql\0demo\0users";
|
||||
|
||||
beforeEach(() => {
|
||||
clearDataGridStructuredFilterStatesForTab("issue-436-filter-view");
|
||||
clearDataGridStructuredFilterStatesForTab("lru-tab");
|
||||
saveDataGridStructuredFilterState(cacheKey, {
|
||||
scopeKey,
|
||||
manualWhereInput: "tenant_id = 7",
|
||||
@@ -36,4 +38,44 @@ describe("data grid structured filter persistence", () => {
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("clears every result-grid state owned by a closed tab", () => {
|
||||
saveDataGridStructuredFilterState("tab-1-run-1-0", {
|
||||
scopeKey,
|
||||
manualWhereInput: "id > 1",
|
||||
rules: [],
|
||||
appliedWhereInput: "id > 1",
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
saveDataGridStructuredFilterState("tab-10-run-1-0", {
|
||||
scopeKey,
|
||||
manualWhereInput: "id > 10",
|
||||
rules: [],
|
||||
appliedWhereInput: "id > 10",
|
||||
serverColumnFilters: {},
|
||||
});
|
||||
|
||||
clearDataGridStructuredFilterStatesForTab("tab-1");
|
||||
|
||||
expect(loadDataGridStructuredFilterState("tab-1-run-1-0", scopeKey)).toBeUndefined();
|
||||
expect(loadDataGridStructuredFilterState("tab-10-run-1-0", scopeKey)?.manualWhereInput).toBe("id > 10");
|
||||
clearDataGridStructuredFilterStatesForTab("tab-10");
|
||||
});
|
||||
|
||||
it("bounds cached result-grid states and refreshes recency on read", () => {
|
||||
const state = {
|
||||
scopeKey,
|
||||
manualWhereInput: "",
|
||||
rules: [],
|
||||
appliedWhereInput: "",
|
||||
serverColumnFilters: {},
|
||||
};
|
||||
for (let index = 0; index < 128; index += 1) saveDataGridStructuredFilterState(`lru-tab-${index}`, state);
|
||||
expect(loadDataGridStructuredFilterState("lru-tab-0", scopeKey)).toBeDefined();
|
||||
|
||||
saveDataGridStructuredFilterState("lru-tab-128", state);
|
||||
|
||||
expect(loadDataGridStructuredFilterState("lru-tab-0", scopeKey)).toBeDefined();
|
||||
expect(loadDataGridStructuredFilterState("lru-tab-1", scopeKey)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export type DataGridStructuredFilterCacheState = {
|
||||
serverColumnFilters: Record<number, DataGridCachedServerColumnFilter>;
|
||||
};
|
||||
|
||||
const STRUCTURED_FILTER_STATE_CACHE_MAX_ENTRIES = 128;
|
||||
const structuredFilterStateCache = new Map<string, DataGridStructuredFilterCacheState>();
|
||||
|
||||
export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStructuredFilterRule[]): DataGridStructuredFilterRule[] {
|
||||
@@ -23,6 +24,8 @@ export function cloneDataGridStructuredFilterRules(rules: readonly DataGridStruc
|
||||
export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: string): DataGridStructuredFilterCacheState | undefined {
|
||||
const cached = structuredFilterStateCache.get(cacheKey);
|
||||
if (!cached || cached.scopeKey !== scopeKey) return undefined;
|
||||
structuredFilterStateCache.delete(cacheKey);
|
||||
structuredFilterStateCache.set(cacheKey, cached);
|
||||
return {
|
||||
...cached,
|
||||
rules: cloneDataGridStructuredFilterRules(cached.rules),
|
||||
@@ -31,9 +34,22 @@ export function loadDataGridStructuredFilterState(cacheKey: string, scopeKey: st
|
||||
}
|
||||
|
||||
export function saveDataGridStructuredFilterState(cacheKey: string, state: DataGridStructuredFilterCacheState) {
|
||||
structuredFilterStateCache.delete(cacheKey);
|
||||
structuredFilterStateCache.set(cacheKey, {
|
||||
...state,
|
||||
rules: cloneDataGridStructuredFilterRules(state.rules),
|
||||
serverColumnFilters: structuredClone(state.serverColumnFilters),
|
||||
});
|
||||
while (structuredFilterStateCache.size > STRUCTURED_FILTER_STATE_CACHE_MAX_ENTRIES) {
|
||||
const oldest = structuredFilterStateCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
structuredFilterStateCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDataGridStructuredFilterStatesForTab(tabId: string) {
|
||||
structuredFilterStateCache.delete(tabId);
|
||||
for (const cacheKey of structuredFilterStateCache.keys()) {
|
||||
if (cacheKey.startsWith(`${tabId}-`)) structuredFilterStateCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,17 @@ describe("objectBrowserRowsCache", () => {
|
||||
expect(getCachedObjectBrowserRows(isolatedScope)).toEqual([row]);
|
||||
});
|
||||
|
||||
it("rejects old write tokens after their bounded generation state is evicted", () => {
|
||||
const staleScope = { connectionId: "stale", database: "db", schema: "public" };
|
||||
const staleToken = createObjectBrowserRowsCacheWriteToken(staleScope);
|
||||
for (let index = 0; index < 128; index += 1) {
|
||||
createObjectBrowserRowsCacheWriteToken({ connectionId: `c${index}`, database: "db", schema: "public" });
|
||||
}
|
||||
|
||||
expect(cacheObjectBrowserRows(staleToken, [row])).toBeUndefined();
|
||||
expect(getCachedObjectBrowserRows(staleScope)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves the original object list freshness when statistics update cached rows", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
|
||||
|
||||
@@ -5070,12 +5070,20 @@ function computeBoost(candidate: string, prefix: string): number {
|
||||
}
|
||||
|
||||
// --- History-based ranking ---
|
||||
const COMPLETION_STATS_MAX_ENTRIES = 512;
|
||||
const completionStats = new Map<string, number>();
|
||||
|
||||
/** Record a user selection to boost future rankings. */
|
||||
export function recordCompletionSelection(label: string, type: string): void {
|
||||
const key = `${type}:${label}`;
|
||||
completionStats.set(key, (completionStats.get(key) || 0) + 1);
|
||||
const count = completionStats.get(key) || 0;
|
||||
completionStats.delete(key);
|
||||
completionStats.set(key, count + 1);
|
||||
while (completionStats.size > COMPLETION_STATS_MAX_ENTRIES) {
|
||||
const oldest = completionStats.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
completionStats.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function getHistoryBoost(label: string, type: string): number {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
|
||||
const OBJECT_BROWSER_ROWS_CACHE_TTL_MS = 30_000;
|
||||
const OBJECT_BROWSER_ROWS_CACHE_MAX_ENTRIES = 24;
|
||||
const OBJECT_BROWSER_ROWS_GENERATION_MAX_ENTRIES = 128;
|
||||
|
||||
export interface ObjectBrowserRowsCacheScope {
|
||||
connectionId: string;
|
||||
@@ -28,6 +29,7 @@ const objectBrowserRowsCache = new MetadataResultCache<ObjectBrowserRow[]>({
|
||||
now: () => Date.now(),
|
||||
});
|
||||
const objectBrowserRowsCacheGenerations = new Map<string, ObjectBrowserRowsCacheGeneration>();
|
||||
let nextObjectBrowserRowsCacheGeneration = 0;
|
||||
|
||||
function objectBrowserRowsScope(scope: ObjectBrowserRowsCacheScope): MetadataScopeInput {
|
||||
return {
|
||||
@@ -48,7 +50,15 @@ function objectBrowserRowsCacheGeneration(scope: ObjectBrowserRowsCacheScope): O
|
||||
const key = metadataScopeKey(cacheScope);
|
||||
let state = objectBrowserRowsCacheGenerations.get(key);
|
||||
if (!state) {
|
||||
state = { generation: 0, scope: metadataScopeParts(cacheScope) };
|
||||
state = { generation: ++nextObjectBrowserRowsCacheGeneration, scope: metadataScopeParts(cacheScope) };
|
||||
objectBrowserRowsCacheGenerations.set(key, state);
|
||||
while (objectBrowserRowsCacheGenerations.size > OBJECT_BROWSER_ROWS_GENERATION_MAX_ENTRIES) {
|
||||
const oldest = objectBrowserRowsCacheGenerations.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
objectBrowserRowsCacheGenerations.delete(oldest);
|
||||
}
|
||||
} else {
|
||||
objectBrowserRowsCacheGenerations.delete(key);
|
||||
objectBrowserRowsCacheGenerations.set(key, state);
|
||||
}
|
||||
return state;
|
||||
@@ -83,13 +93,13 @@ export function cacheObjectBrowserRows(token: ObjectBrowserRowsCacheWriteToken,
|
||||
export function invalidateObjectBrowserRowsCache(match: MetadataCacheInvalidation): number {
|
||||
const projected = objectBrowserRowsCacheInvalidation(match);
|
||||
const matches = metadataCacheInvalidationMatcher(projected);
|
||||
for (const state of objectBrowserRowsCacheGenerations.values()) {
|
||||
if (matches(state.scope)) state.generation++;
|
||||
for (const [key, state] of objectBrowserRowsCacheGenerations) {
|
||||
if (matches(state.scope)) objectBrowserRowsCacheGenerations.delete(key);
|
||||
}
|
||||
return objectBrowserRowsCache.invalidate(projected);
|
||||
}
|
||||
|
||||
export function clearObjectBrowserRowsCache(): void {
|
||||
objectBrowserRowsCache.clear();
|
||||
for (const state of objectBrowserRowsCacheGenerations.values()) state.generation++;
|
||||
objectBrowserRowsCacheGenerations.clear();
|
||||
}
|
||||
|
||||
+79
@@ -107,4 +107,83 @@ describe("connectionStore sidebar table storage", () => {
|
||||
expect(currentExistingTable?.sizeBytes).toBe(8192);
|
||||
expect(newTable?.sizeBytes).toBe(16384);
|
||||
});
|
||||
|
||||
it("does not cache a table-size request that finishes after the connection is lost", async () => {
|
||||
const staleStatistics = deferred<Array<{ name: string; schema: string; total_bytes: number }>>();
|
||||
const listObjectStatistics = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(staleStatistics.promise)
|
||||
.mockResolvedValueOnce([{ name: "USERS", schema: "APP", total_bytes: 8192 }]);
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
listObjectStatistics,
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useConnectionStore();
|
||||
useSettingsStore().editorSettings.sidebarObjectInfoMode = "size";
|
||||
const connection = { id: "oracle-1", name: "Oracle", db_type: "oracle", host: "127.0.0.1", port: 1521, username: "APP", password: "", database: "ORCL" } as ConnectionConfig;
|
||||
const tableNode = (): TreeNode => ({ id: "oracle-1:ORCL:APP:USERS", label: "USERS", type: "table", connectionId: connection.id, database: connection.database, schema: "APP" });
|
||||
const setLiveTree = () => {
|
||||
store.connectedIds.add(connection.id);
|
||||
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tableNode()] }];
|
||||
};
|
||||
store.connections = [connection];
|
||||
setLiveTree();
|
||||
|
||||
const staleLoad = store.loadSidebarTableStorage({ connectionId: connection.id, database: connection.database, schema: "APP" });
|
||||
store.markConnectionLost(connection.id, new Error("connection closed"));
|
||||
staleStatistics.resolve([{ name: "USERS", schema: "APP", total_bytes: 4096 }]);
|
||||
await staleLoad;
|
||||
|
||||
setLiveTree();
|
||||
await store.loadSidebarTableStorage({ connectionId: connection.id, database: connection.database, schema: "APP" });
|
||||
|
||||
expect(listObjectStatistics).toHaveBeenCalledTimes(2);
|
||||
expect(store.treeNodes[0]?.children?.[0]?.sizeBytes).toBe(8192);
|
||||
});
|
||||
|
||||
it("does not cache a database-size request that finishes after the connection is lost", async () => {
|
||||
const staleStorage = deferred<Array<{ name: string; size_bytes: number }>>();
|
||||
const listDatabaseStorage = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(staleStorage.promise)
|
||||
.mockResolvedValueOnce([{ name: "app", size_bytes: 16384 }]);
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
listDatabaseStorage,
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useConnectionStore();
|
||||
useSettingsStore().editorSettings.sidebarObjectInfoMode = "size";
|
||||
const connection = { id: "pg-1", name: "PostgreSQL", db_type: "postgres", host: "127.0.0.1", port: 5432, username: "postgres", password: "" } as ConnectionConfig;
|
||||
const setLiveTree = () => {
|
||||
store.connectedIds.add(connection.id);
|
||||
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [{ id: "pg-1:app", label: "app", type: "database", connectionId: connection.id, database: "app" }] }];
|
||||
};
|
||||
store.connections = [connection];
|
||||
setLiveTree();
|
||||
|
||||
const staleLoad = store.loadSidebarDatabaseStorage(connection.id);
|
||||
store.markConnectionLost(connection.id, new Error("connection closed"));
|
||||
staleStorage.resolve([{ name: "app", size_bytes: 4096 }]);
|
||||
await staleLoad;
|
||||
|
||||
setLiveTree();
|
||||
await store.loadSidebarDatabaseStorage(connection.id);
|
||||
|
||||
expect(listDatabaseStorage).toHaveBeenCalledTimes(2);
|
||||
expect(store.treeNodes[0]?.children?.[0]?.sizeBytes).toBe(16384);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,6 +180,9 @@ const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30;
|
||||
const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000;
|
||||
const METADATA_LIST_PAGE_CACHE_MAX_ENTRIES = 160;
|
||||
const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000;
|
||||
const SIDEBAR_DATABASE_STORAGE_CACHE_MAX_ENTRIES = 32;
|
||||
const SIDEBAR_TABLE_STORAGE_CACHE_MAX_ENTRIES = 64;
|
||||
const SIDEBAR_TABLE_SEARCH_INDEX_CACHE_MAX_ENTRIES = 32;
|
||||
export const COMPLETION_METADATA_CONCURRENCY = 2;
|
||||
const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy";
|
||||
const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)";
|
||||
@@ -418,9 +421,15 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
});
|
||||
const treeNodes = ref<TreeNode[]>([]);
|
||||
const sidebarDatabaseStorageCache = new Map<string, { expiresAt: number; value: DatabaseStorageInfo[] }>();
|
||||
const sidebarDatabaseStorageCache = new MetadataResultCache<DatabaseStorageInfo[]>({
|
||||
ttlMs: SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
|
||||
maxEntries: SIDEBAR_DATABASE_STORAGE_CACHE_MAX_ENTRIES,
|
||||
});
|
||||
const sidebarDatabaseStorageInFlight = new Map<string, Promise<DatabaseStorageInfo[]>>();
|
||||
const sidebarTableStorageCache = new Map<string, { expiresAt: number; value: ObjectStatistics[] }>();
|
||||
const sidebarTableStorageCache = new MetadataResultCache<ObjectStatistics[]>({
|
||||
ttlMs: SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
|
||||
maxEntries: SIDEBAR_TABLE_STORAGE_CACHE_MAX_ENTRIES,
|
||||
});
|
||||
const sidebarTableStorageInFlight = new Map<string, Promise<ObjectStatistics[]>>();
|
||||
const pinnedTreeNodeOrder = ref<string[]>([]);
|
||||
const pinnedTreeNodeIds = ref<Set<string>>(new Set());
|
||||
@@ -1078,6 +1087,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
|
||||
function markConnectionLost(connectionId: string, error: unknown) {
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearSidebarStorageCaches(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
clearConnectionNodeLoading(connectionId);
|
||||
@@ -2383,6 +2393,24 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return `${treeCacheKey}:table-search-index-v1`;
|
||||
}
|
||||
|
||||
function getSidebarTableSearchIndexMemoryCache(cacheKey: string): TableInfo[] | null | undefined {
|
||||
if (!sidebarTableSearchIndexCache.has(cacheKey)) return undefined;
|
||||
const cached = sidebarTableSearchIndexCache.get(cacheKey) ?? null;
|
||||
sidebarTableSearchIndexCache.delete(cacheKey);
|
||||
sidebarTableSearchIndexCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setSidebarTableSearchIndexMemoryCache(cacheKey: string, entries: TableInfo[] | null) {
|
||||
sidebarTableSearchIndexCache.delete(cacheKey);
|
||||
sidebarTableSearchIndexCache.set(cacheKey, entries);
|
||||
while (sidebarTableSearchIndexCache.size > SIDEBAR_TABLE_SEARCH_INDEX_CACHE_MAX_ENTRIES) {
|
||||
const oldest = sidebarTableSearchIndexCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
sidebarTableSearchIndexCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarTableSearchIndexManifestCacheKey = "dbx:sidebar-table-search-index-manifest-v1";
|
||||
|
||||
function sidebarTableSearchIndexManifestEntry(parent: TreeNode, cacheKey: string): TableSearchIndexManifestEntry | null {
|
||||
@@ -2619,7 +2647,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
|
||||
async function readSidebarTableSearchIndexCache(cacheKey: string, connectionId: string): Promise<TableInfo[] | null> {
|
||||
if (sidebarTableSearchIndexCache.has(cacheKey)) return sidebarTableSearchIndexCache.get(cacheKey) ?? null;
|
||||
const cached = getSidebarTableSearchIndexMemoryCache(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
const pending = sidebarTableSearchIndexInFlight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
const connectionGeneration = sidebarTableSearchIndexConnectionGeneration(connectionId);
|
||||
@@ -2629,7 +2658,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const index = decoded?.tableSearchIndex;
|
||||
const entries = index ? index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })) : null;
|
||||
if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return null;
|
||||
sidebarTableSearchIndexCache.set(cacheKey, entries);
|
||||
setSidebarTableSearchIndexMemoryCache(cacheKey, entries);
|
||||
return entries;
|
||||
})();
|
||||
sidebarTableSearchIndexInFlight.set(cacheKey, read);
|
||||
@@ -2705,7 +2734,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
persisted = true;
|
||||
});
|
||||
if (!persisted || !sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return [];
|
||||
sidebarTableSearchIndexCache.set(cacheKey, deduped);
|
||||
setSidebarTableSearchIndexMemoryCache(cacheKey, deduped);
|
||||
await registerSidebarTableSearchIndexScope(parent, cacheKey);
|
||||
return deduped;
|
||||
}
|
||||
@@ -3158,6 +3187,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
clearConnectionError(id);
|
||||
connectionErrorRevisions.delete(id);
|
||||
connectedIds.value.delete(id);
|
||||
clearSidebarStorageCaches(id);
|
||||
clearPrimaryVisibleObjectNames(id);
|
||||
clearConnectionIdentifierQuote(id);
|
||||
clearConnectionHealthCheck(id);
|
||||
@@ -3198,6 +3228,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (!runtimeConfigChanged) return;
|
||||
clearPrimaryVisibleObjectNames(config.id);
|
||||
connectedIds.value.delete(config.id);
|
||||
clearSidebarStorageCaches(config.id);
|
||||
clearConnectionIdentifierQuote(config.id);
|
||||
clearConnectionHealthCheck(config.id);
|
||||
invalidateCompletionCache(config.id);
|
||||
@@ -3692,6 +3723,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (!cancelled) return false;
|
||||
clearConnectionError(connectionId);
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearSidebarStorageCaches(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
clearConnectionHealthCheck(connectionId);
|
||||
@@ -3711,6 +3743,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
cancelLocalConnectionAttempt(connectionId);
|
||||
|
||||
connectedIds.value.delete(connectionId);
|
||||
clearSidebarStorageCaches(connectionId);
|
||||
clearPrimaryVisibleObjectNames(connectionId);
|
||||
clearConnectionIdentifierQuote(connectionId);
|
||||
forgetSuccessfulLocalConnectionAttempt(connectionId);
|
||||
@@ -3797,6 +3830,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
clearLoadedChildrenCache(node.id);
|
||||
}
|
||||
invalidateCompletionCache(connectionId, database);
|
||||
clearSidebarStorageCaches(connectionId, database);
|
||||
invalidateObjectBrowserRowsCache({ connectionId, database });
|
||||
// 数据库级生命周期边界:与连接级断开一致,数据标签页的元数据 freshness
|
||||
// 戳必须作废,重开/刷新时重新拉取结构(issue #6623)。bump 数据库级代次
|
||||
@@ -3920,6 +3954,29 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return `${connectionId}\0${[...databases].sort().join("\0")}`;
|
||||
}
|
||||
|
||||
function sidebarDatabaseStorageCacheScope(connectionId: string, databases: readonly string[]): MetadataScopeInput {
|
||||
return { kind: "sidebar-database-storage", connectionId, extra: { databases: [...databases].sort() } };
|
||||
}
|
||||
|
||||
function sidebarTableStorageCacheScope(scope: SidebarTableStorageScope): MetadataScopeInput {
|
||||
return { kind: "sidebar-table-storage", connectionId: scope.connectionId, database: scope.database, schema: scope.schema };
|
||||
}
|
||||
|
||||
function clearSidebarStorageCaches(connectionId: string, database?: string) {
|
||||
if (database === undefined) sidebarDatabaseStorageCache.invalidate({ connectionId });
|
||||
sidebarTableStorageCache.invalidate(database === undefined ? { connectionId } : { connectionId, database });
|
||||
const connectionPrefix = `${connectionId}\0`;
|
||||
if (database === undefined) {
|
||||
for (const key of sidebarDatabaseStorageInFlight.keys()) {
|
||||
if (key.startsWith(connectionPrefix)) sidebarDatabaseStorageInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
const tablePrefix = database === undefined ? connectionPrefix : `${connectionId}\0${database}\0`;
|
||||
for (const key of sidebarTableStorageInFlight.keys()) {
|
||||
if (key.startsWith(tablePrefix)) sidebarTableStorageInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSidebarDatabaseStorage(connectionId: string, options?: { force?: boolean }): Promise<void> {
|
||||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(connectionId)) return;
|
||||
if (!supportsSidebarDatabaseStorage(getConfig(connectionId))) return;
|
||||
@@ -3928,8 +3985,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (!databases.length) return;
|
||||
|
||||
const requestKey = sidebarDatabaseStorageRequestKey(connectionId, databases);
|
||||
const cached = sidebarDatabaseStorageCache.get(requestKey);
|
||||
if (!options?.force && cached && cached.expiresAt > Date.now()) {
|
||||
const cacheScope = sidebarDatabaseStorageCacheScope(connectionId, databases);
|
||||
const cached = sidebarDatabaseStorageCache.get(cacheScope);
|
||||
if (!options?.force && cached) {
|
||||
applySidebarDatabaseStorage(connectionNode?.children, cached.value);
|
||||
return;
|
||||
}
|
||||
@@ -3941,10 +3999,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
try {
|
||||
const storage = await request;
|
||||
sidebarDatabaseStorageCache.set(requestKey, {
|
||||
expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
|
||||
value: storage,
|
||||
});
|
||||
if (sidebarDatabaseStorageInFlight.get(requestKey) !== request || !connectedIds.value.has(connectionId)) return;
|
||||
sidebarDatabaseStorageCache.set(cacheScope, storage);
|
||||
const currentNode = findConnectionNode(connectionId);
|
||||
const currentNames = sidebarDatabaseNames(currentNode?.children);
|
||||
if (sidebarDatabaseStorageRequestKey(connectionId, currentNames) === requestKey) {
|
||||
@@ -3967,8 +4023,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(scope.connectionId)) return;
|
||||
if (!supportsSidebarTableStorage(getConfig(scope.connectionId))) return;
|
||||
const requestKey = sidebarTableStorageRequestKey(scope);
|
||||
const cached = sidebarTableStorageCache.get(requestKey);
|
||||
if (!options?.force && cached && cached.expiresAt > Date.now()) {
|
||||
const cacheScope = sidebarTableStorageCacheScope(scope);
|
||||
const cached = sidebarTableStorageCache.get(cacheScope);
|
||||
if (!options?.force && cached) {
|
||||
applySidebarTableStorage(treeNodes.value, scope, cached.value);
|
||||
return;
|
||||
}
|
||||
@@ -3980,11 +4037,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
try {
|
||||
const statistics = await request;
|
||||
if (sidebarTableStorageInFlight.get(requestKey) !== request) return;
|
||||
sidebarTableStorageCache.set(requestKey, {
|
||||
expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
|
||||
value: statistics,
|
||||
});
|
||||
if (sidebarTableStorageInFlight.get(requestKey) !== request || !connectedIds.value.has(scope.connectionId)) return;
|
||||
sidebarTableStorageCache.set(cacheScope, statistics);
|
||||
applySidebarTableStorage(treeNodes.value, scope, statistics);
|
||||
} catch (error) {
|
||||
console.debug("[DBX][sidebar-table-storage:unavailable]", { ...scope, error });
|
||||
|
||||
@@ -57,6 +57,7 @@ import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStat
|
||||
import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, sqlServerUseDatabaseFromStatement } from "@/lib/sql/sqlCompletionLookupTarget";
|
||||
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
|
||||
import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
|
||||
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize";
|
||||
import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey } from "@/lib/tabs/tabPresentation";
|
||||
@@ -259,6 +260,27 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns
|
||||
return resultRuns;
|
||||
}
|
||||
|
||||
/** Drop heavyweight fields even when a deactivated DataGrid still retains the result object. */
|
||||
function releaseResultObjectPayload(result: QueryResult): void {
|
||||
result.columns = [];
|
||||
result.rows = [];
|
||||
result.column_types = undefined;
|
||||
result.column_sortables = undefined;
|
||||
result.spatial_columns = undefined;
|
||||
result.spatial_values = undefined;
|
||||
result.hidden_column_indexes = undefined;
|
||||
result.local_column_filters = undefined;
|
||||
result.local_hidden_column_keys = undefined;
|
||||
result.mongo_documents = undefined;
|
||||
result.mongo_copy_documents = undefined;
|
||||
result.large_value_cells = undefined;
|
||||
result.elasticsearch_raw_body = undefined;
|
||||
result.messages = undefined;
|
||||
result.error = undefined;
|
||||
result.sourceLabel = undefined;
|
||||
result.sourceStatement = undefined;
|
||||
}
|
||||
|
||||
function preservedResultIndex(results: QueryResult[], currentIndex: number | undefined, preserve: boolean | undefined): number | undefined {
|
||||
if (preserve !== true || typeof currentIndex !== "number" || currentIndex < 0 || currentIndex >= results.length) return undefined;
|
||||
return currentIndex;
|
||||
@@ -1029,7 +1051,26 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function releaseTabResultObjectPayloads(tab: QueryTab) {
|
||||
const results = new Set<QueryResult>();
|
||||
if (tab.result) results.add(tab.result);
|
||||
for (const result of tab.results ?? []) results.add(result);
|
||||
for (const run of tab.resultRuns ?? []) {
|
||||
if (run.result) results.add(run.result);
|
||||
for (const result of run.results ?? []) results.add(result);
|
||||
}
|
||||
for (const result of results) releaseResultObjectPayload(result);
|
||||
}
|
||||
|
||||
function clearResultRuns(tab: QueryTab) {
|
||||
for (const run of tab.resultRuns ?? []) clearResultRunPayload(run);
|
||||
tab.resultRuns = undefined;
|
||||
tab.activeResultRunId = undefined;
|
||||
}
|
||||
|
||||
function clearResultRunPayload(run: NonNullable<QueryTab["resultRuns"]>[number], options: { evicted?: boolean } = {}) {
|
||||
if (run.result) releaseResultObjectPayload(run.result);
|
||||
for (const result of run.results ?? []) releaseResultObjectPayload(result);
|
||||
run.result = undefined;
|
||||
run.results = undefined;
|
||||
run.resultLocalSortOriginalRows = undefined;
|
||||
@@ -1224,6 +1265,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const removedRun = tab.resultRuns[runIndex];
|
||||
if (removedRun?.resultSessionId) void closeResultRunSession(tab, removedRun);
|
||||
if (removedRun?.resultCacheKey) void deleteTabResultSnapshot(removedRun.resultCacheKey);
|
||||
if (removedRun) clearResultRunPayload(removedRun);
|
||||
const wasActive = tab.activeResultRunId === runId;
|
||||
const remainingRuns = tab.resultRuns.filter((run) => run.id !== runId);
|
||||
tab.resultRuns = remainingRuns;
|
||||
@@ -1250,6 +1292,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (!tab.result && !tab.results?.length && !tab.resultEvicted) return false;
|
||||
|
||||
const closeSession = closeResultSession(tab);
|
||||
releaseTabResultObjectPayloads(tab);
|
||||
clearResultPayload(tab);
|
||||
await closeSession;
|
||||
return true;
|
||||
@@ -1274,8 +1317,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
closeOperations.push(closeResultRunSession(tab, run));
|
||||
}
|
||||
|
||||
tab.resultRuns = undefined;
|
||||
tab.activeResultRunId = undefined;
|
||||
releaseTabResultObjectPayloads(tab);
|
||||
clearResultRuns(tab);
|
||||
clearResultPayload(tab);
|
||||
await Promise.all(closeOperations);
|
||||
return true;
|
||||
@@ -2627,6 +2670,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
persistSavedSqlEditorPosition(tabs.value[idx]);
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(id);
|
||||
clearDataGridStructuredFilterStatesForTab(id);
|
||||
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
|
||||
if (tabs.value[idx].isExecuting) void cancelTabExecution(id);
|
||||
if (tabs.value[idx].isExplaining) void cancelTabExplain(id);
|
||||
@@ -2634,6 +2678,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
void closeClientConnectionSession(tabs.value[idx]);
|
||||
clearResultRunSnapshots(tabs.value[idx]);
|
||||
void deleteTabResultSnapshot(tabResultCacheKey(id));
|
||||
releaseTabResultObjectPayloads(tabs.value[idx]);
|
||||
clearResultRuns(tabs.value[idx]);
|
||||
clearResultPayload(tabs.value[idx]);
|
||||
tabs.value.splice(idx, 1);
|
||||
if (tab.externalSqlPath) refreshExternalSqlFileTitles();
|
||||
@@ -2922,6 +2968,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
.forEach((tab) => {
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||||
@@ -2929,6 +2976,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
void closeClientConnectionSession(tab);
|
||||
clearResultRunSnapshots(tab);
|
||||
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
|
||||
releaseTabResultObjectPayloads(tab);
|
||||
clearResultRuns(tab);
|
||||
clearResultPayload(tab);
|
||||
});
|
||||
|
||||
@@ -3104,10 +3153,16 @@ export const useQueryStore = defineStore("query", () => {
|
||||
.filter((tab) => predicate(tab))
|
||||
.forEach((tab) => {
|
||||
rollbackTabTransaction(tab, { resetAutoCommit: true });
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
if (tab.isExplaining) void cancelTabExplain(tab.id);
|
||||
void closeResultSession(tab);
|
||||
void closeClientConnectionSession(tab);
|
||||
clearResultRunSnapshots(tab);
|
||||
void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
|
||||
releaseTabResultObjectPayloads(tab);
|
||||
clearResultRuns(tab);
|
||||
clearResultPayload(tab);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ struct CachedAgentQuery {
|
||||
|
||||
pub struct AgentRuntimeClient {
|
||||
child: Arc<Mutex<Child>>,
|
||||
child_reaper_started: Arc<AtomicBool>,
|
||||
child_reaped: Arc<AtomicBool>,
|
||||
stdin: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingAgentResponse>>>,
|
||||
stderr_tail: Arc<Mutex<StderrTail>>,
|
||||
@@ -66,6 +68,8 @@ impl AgentRuntimeClient {
|
||||
|
||||
let runtime = Arc::new(Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
child_reaper_started: Arc::new(AtomicBool::new(false)),
|
||||
child_reaped: Arc::new(AtomicBool::new(false)),
|
||||
stdin: Arc::new(Mutex::new(BufWriter::new(child_stdin))),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
stderr_tail,
|
||||
@@ -106,12 +110,16 @@ impl AgentRuntimeClient {
|
||||
fn start_response_reader(self: &Arc<Self>, mut stdout: BufReader<ChildStdout>) {
|
||||
let pending = self.pending.clone();
|
||||
let failed = self.failed.clone();
|
||||
let child = self.child.clone();
|
||||
let child_reaper_started = self.child_reaper_started.clone();
|
||||
let child_reaped = self.child_reaped.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
let response = match read_agent_json_response(&mut stdout) {
|
||||
Ok((_, response)) => response,
|
||||
Err(err) => {
|
||||
failed.store(true, Ordering::Release);
|
||||
fail_pending_requests(&pending, err);
|
||||
terminate_and_reap_shared_agent(child, child_reaper_started, child_reaped);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -285,20 +293,30 @@ impl AgentRuntimeClient {
|
||||
if let Ok(mut child) = self.child.lock() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
start_shared_agent_reaper(self.child.clone(), self.child_reaper_started.clone(), self.child_reaped.clone());
|
||||
fail_pending_requests(&self.pending, "Agent runtime terminated".to_string());
|
||||
}
|
||||
|
||||
pub async fn kill_and_wait(&self) {
|
||||
self.failed.store(true, Ordering::Release);
|
||||
fail_pending_requests(&self.pending, "Agent runtime terminated".to_string());
|
||||
if self.child_reaper_started.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() {
|
||||
wait_for_shared_agent_reaper(self.child_reaped.clone()).await;
|
||||
return;
|
||||
}
|
||||
let child = self.child.clone();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
let mut child = child.lock().map_err(|_| "Shared agent process lock poisoned".to_string())?;
|
||||
let _ = child.kill();
|
||||
child.wait().map(|_| ()).map_err(|err| format!("Failed to wait for shared agent runtime: {err}"))
|
||||
let child_reaped = self.child_reaped.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let result =
|
||||
child.lock().map_err(|_| "Shared agent process lock poisoned".to_string()).and_then(|mut child| {
|
||||
let _ = child.kill();
|
||||
child.wait().map(|_| ()).map_err(|err| format!("Failed to wait for shared agent runtime: {err}"))
|
||||
});
|
||||
child_reaped.store(true, Ordering::Release);
|
||||
result
|
||||
})
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => log::warn!("{err}"),
|
||||
Err(err) => log::warn!("Failed to join shared agent shutdown task: {err}"),
|
||||
@@ -306,6 +324,44 @@ impl AgentRuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_and_reap_shared_agent(
|
||||
child: Arc<Mutex<Child>>,
|
||||
child_reaper_started: Arc<AtomicBool>,
|
||||
child_reaped: Arc<AtomicBool>,
|
||||
) {
|
||||
if let Ok(mut child) = child.lock() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
start_shared_agent_reaper(child, child_reaper_started, child_reaped);
|
||||
}
|
||||
|
||||
fn start_shared_agent_reaper(
|
||||
child: Arc<Mutex<Child>>,
|
||||
child_reaper_started: Arc<AtomicBool>,
|
||||
child_reaped: Arc<AtomicBool>,
|
||||
) {
|
||||
if child_reaper_started.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() {
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
child.lock().map_err(|_| "Shared agent process lock poisoned".to_string()).and_then(|mut child| {
|
||||
child.wait().map(|_| ()).map_err(|err| format!("Failed to wait for shared agent runtime: {err}"))
|
||||
});
|
||||
child_reaped.store(true, Ordering::Release);
|
||||
match result {
|
||||
Ok(()) => {}
|
||||
Err(err) => log::warn!("{err}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn wait_for_shared_agent_reaper(child_reaped: Arc<AtomicBool>) {
|
||||
while !child_reaped.load(Ordering::Acquire) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_agent_response<T: DeserializeOwned>(
|
||||
response: Value,
|
||||
strict_structured_errors: bool,
|
||||
@@ -3328,6 +3384,7 @@ mod tests {
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -4103,6 +4160,39 @@ for line in sys.stdin:
|
||||
runtime.call(method, serde_json::json!({}), Some(Duration::from_secs(2)), None).await.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for_runtime_reap(runtime: &AgentRuntimeClient) {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !runtime.child_reaped.load(Ordering::Acquire) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("shared Agent child should be reaped");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_runtime_kill_reaps_child_process() {
|
||||
let (runtime, script_path) = spawn_stateful_test_runtime("kill-reap-test").await;
|
||||
|
||||
runtime.kill();
|
||||
wait_for_runtime_reap(&runtime).await;
|
||||
|
||||
assert!(runtime.child.lock().unwrap().try_wait().unwrap().is_some());
|
||||
let _ = std::fs::remove_file(script_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_runtime_kill_and_wait_joins_existing_reaper() {
|
||||
let (runtime, script_path) = spawn_stateful_test_runtime("kill-and-wait-reap-test").await;
|
||||
|
||||
runtime.kill();
|
||||
runtime.kill_and_wait().await;
|
||||
|
||||
assert!(runtime.child_reaped.load(Ordering::Acquire));
|
||||
assert!(runtime.child.lock().unwrap().try_wait().unwrap().is_some());
|
||||
let _ = std::fs::remove_file(script_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_runtime_timeout_cancels_session_with_and_without_token() {
|
||||
let (runtime, script_path) = spawn_stateful_test_runtime("timeout-cancel-test").await;
|
||||
@@ -4663,6 +4753,7 @@ for line in sys.stdin:
|
||||
.unwrap_err();
|
||||
assert!(error.contains("end of stream") || error.contains("response channel closed"));
|
||||
assert!(runtime.is_failed());
|
||||
wait_for_runtime_reap(&runtime).await;
|
||||
let _ = std::fs::remove_file(script_path);
|
||||
}
|
||||
|
||||
|
||||
+54
@@ -1855,6 +1855,47 @@ test("closing an ordinary query result preserves the query tab", async () => {
|
||||
assert.equal(tab.activeResultRunId, undefined);
|
||||
});
|
||||
|
||||
test("closing a tab releases result payloads retained by deactivated grids", () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("conn-1", "db");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.ok(tab);
|
||||
|
||||
tab.result = {
|
||||
columns: ["payload"],
|
||||
column_types: ["TEXT"],
|
||||
rows: [["x".repeat(10_000)]],
|
||||
spatial_values: [[4326]],
|
||||
mongo_documents: [{ payload: "x".repeat(10_000) }],
|
||||
mongo_copy_documents: [{ $binary: "x".repeat(10_000) }],
|
||||
large_value_cells: [{ row_index: 0, column_index: 0, original_bytes: 10_000 }],
|
||||
elasticsearch_raw_body: "x".repeat(10_000),
|
||||
messages: [{ severity: "NOTICE", message: "x".repeat(10_000) }],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
const retainedResult = tab.result;
|
||||
const retainedRunResult: QueryResult = {
|
||||
columns: ["older"],
|
||||
rows: [["y".repeat(10_000)]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
tab.resultRuns = [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: retainedRunResult }];
|
||||
tab.activeResultRunId = "run-1";
|
||||
|
||||
store.closeTab(tabId, { force: true });
|
||||
|
||||
assert.equal(store.tabs.some((item) => item.id === tabId), false);
|
||||
assert.deepEqual(retainedResult.columns, []);
|
||||
assert.deepEqual(retainedResult.rows, []);
|
||||
assert.equal(retainedResult.mongo_documents, undefined);
|
||||
assert.equal(retainedResult.elasticsearch_raw_body, undefined);
|
||||
assert.deepEqual(retainedRunResult.rows, []);
|
||||
assert.equal(tab.resultRuns, undefined);
|
||||
});
|
||||
|
||||
test("removing the active result run clears output when remaining caches are unavailable", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
@@ -4045,6 +4086,15 @@ test("releasing connection tabs keeps SQL tabs and closes object tabs", async ()
|
||||
session_id: "session-query",
|
||||
};
|
||||
queryTab.resultSessionId = "session-query";
|
||||
const retainedQueryResult = queryTab.result;
|
||||
const retainedRunResult: QueryResult = {
|
||||
columns: ["previous"],
|
||||
rows: [["previous payload"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
queryTab.resultRuns = [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: retainedRunResult }];
|
||||
queryTab.activeResultRunId = "run-1";
|
||||
dataTab.result = {
|
||||
columns: ["payload"],
|
||||
rows: [["data"]],
|
||||
@@ -4069,6 +4119,10 @@ test("releasing connection tabs keeps SQL tabs and closes object tabs", async ()
|
||||
);
|
||||
assert.equal(queryTab.result, undefined);
|
||||
assert.equal(queryTab.resultSessionId, undefined);
|
||||
assert.equal(queryTab.resultRuns, undefined);
|
||||
assert.equal(queryTab.activeResultRunId, undefined);
|
||||
assert.deepEqual(retainedQueryResult.rows, []);
|
||||
assert.deepEqual(retainedRunResult.rows, []);
|
||||
assert.equal(dataTab.result, undefined);
|
||||
assert.equal(dataTab.resultSessionId, undefined);
|
||||
} finally {
|
||||
|
||||
@@ -253,6 +253,30 @@ test("concurrent reads of the same index cache key hit the storage once", async
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds in-memory regex indexes while keeping persisted scopes reloadable", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
try {
|
||||
setActivePinia(createPinia());
|
||||
const store = useConnectionStore();
|
||||
store.addEphemeralConnection(conn("conn-1"));
|
||||
const scopes = Array.from({ length: 33 }, (_, index) => manifestScope(`index-${index}`, `parent-${index}`, { database: `db-${index}` }));
|
||||
persistedCache.set(MANIFEST_CACHE_KEY, encodeTableSearchIndexManifest(scopes));
|
||||
for (let index = 0; index < scopes.length; index += 1) {
|
||||
persistedCache.set(`index-${index}`, indexEnvelope([{ name: `table_${index}`, tableType: "TABLE" }]));
|
||||
}
|
||||
|
||||
assert.equal((await store.loadSidebarTableSearchIndexScopes()).length, 33);
|
||||
loadSchemaCacheMock.mockClear();
|
||||
|
||||
// Iterating again from the oldest scope reloads each persisted entry because
|
||||
// only the most recent 32 indexes are kept in memory.
|
||||
assert.equal((await store.loadSidebarTableSearchIndexScopes()).length, 33);
|
||||
assert.equal(loadSchemaCacheMock.mock.calls.length, 33);
|
||||
} finally {
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves the index parent by composite identity when ids collide", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user