fix(metadata): await object cache invalidation on connection refresh
This commit is contained in:
1 parent
f95650f039
commit
faf259fcc2
17 files changed
+536
-32
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isObjectCacheInvalidationError } from "@/lib/metadata/objectCacheInvalidationError";
|
||||
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
@@ -167,3 +168,202 @@ describe("connectionStore tree refresh state", () => {
|
||||
expect(store.completionCacheRevision("pg-1", "app")).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection node refresh awaits object cache invalidation", () => {
|
||||
function installConnectionApiMocks(options?: { deleteSchemaCachePrefix?: ReturnType<typeof vi.fn>; getColumns?: ReturnType<typeof vi.fn>; getTableDisplayDdl?: ReturnType<typeof vi.fn>; listDatabases?: ReturnType<typeof vi.fn>; listSchemaInfos?: ReturnType<typeof vi.fn> }) {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSchemaCachePrefix: options?.deleteSchemaCachePrefix ?? vi.fn().mockResolvedValue(undefined),
|
||||
getColumns: options?.getColumns ?? vi.fn().mockResolvedValue([]),
|
||||
getTableDisplayDdl: options?.getTableDisplayDdl ?? vi.fn().mockResolvedValue("CREATE TABLE users (id int)"),
|
||||
listDatabases: options?.listDatabases ?? vi.fn().mockResolvedValue([{ name: "app", comment: null }]),
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
listObjects: vi.fn().mockResolvedValue([]),
|
||||
listSchemaInfos: options?.listSchemaInfos ?? vi.fn().mockResolvedValue([{ name: "public", comment: null }]),
|
||||
listTables: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
}
|
||||
|
||||
/** A database node under the connection node: refresh reloads it via listSchemaInfos. */
|
||||
function databaseNode(): TreeNode {
|
||||
return { id: "pg-1:app", label: "app", type: "database", connectionId: "pg-1", database: "app", isExpanded: true, children: [] };
|
||||
}
|
||||
|
||||
async function createStoreWithConnection(connectionId = "pg-1") {
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useConnectionStore();
|
||||
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
|
||||
const connection: ConnectionConfig = {
|
||||
...postgresConnection(),
|
||||
id: connectionId,
|
||||
};
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
const connectionNode: TreeNode = {
|
||||
id: connection.id,
|
||||
label: connection.name,
|
||||
type: "connection",
|
||||
connectionId: connection.id,
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
store.treeNodes = [connectionNode];
|
||||
return { store, connectionNode };
|
||||
}
|
||||
|
||||
function objectCacheDeletePrefixes(mock: ReturnType<typeof vi.fn>): string[] {
|
||||
return mock.mock.calls.filter(([prefix]) => (prefix as string).startsWith("object-")).map(([prefix]) => prefix as string);
|
||||
}
|
||||
|
||||
/** A prefix-deletion mock whose object-cache deletions share one deferred gate. */
|
||||
function gatedObjectCacheDeletes() {
|
||||
const deleteSchemaCachePrefix = vi.fn((prefix: string) => {
|
||||
if (!prefix.startsWith("object-")) return Promise.resolve();
|
||||
return gate.then(() => undefined);
|
||||
});
|
||||
let resolveGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resolveGate = resolve;
|
||||
});
|
||||
return { deleteSchemaCachePrefix, releaseObjectCacheDeletes: () => resolveGate() };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("blocks the tree reload until both object cache deletions settle", async () => {
|
||||
const { deleteSchemaCachePrefix, releaseObjectCacheDeletes } = gatedObjectCacheDeletes();
|
||||
const listDatabases = vi.fn().mockResolvedValue([{ name: "app", comment: null }]);
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix, listDatabases });
|
||||
const { store, connectionNode } = await createStoreWithConnection();
|
||||
|
||||
let settled = false;
|
||||
const refresh = store.refreshConnectionTreeNode(connectionNode).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).length).toBe(2));
|
||||
expect(settled).toBe(false);
|
||||
expect(listDatabases).not.toHaveBeenCalled();
|
||||
|
||||
releaseObjectCacheDeletes();
|
||||
await refresh;
|
||||
expect(listDatabases).toHaveBeenCalledTimes(1);
|
||||
expect(listDatabases).toHaveBeenCalledWith("pg-1");
|
||||
});
|
||||
|
||||
it("issues exactly one object-cache deletion per namespace on a connection refresh", async () => {
|
||||
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix });
|
||||
const { store, connectionNode } = await createStoreWithConnection();
|
||||
|
||||
await store.refreshConnectionTreeNode(connectionNode);
|
||||
|
||||
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg-1:", "object-meta:v1:pg-1:"]);
|
||||
});
|
||||
|
||||
it("lets concurrent connection refreshes share the pending deletions", async () => {
|
||||
const { deleteSchemaCachePrefix, releaseObjectCacheDeletes } = gatedObjectCacheDeletes();
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix });
|
||||
const { store, connectionNode } = await createStoreWithConnection();
|
||||
|
||||
const first = store.refreshConnectionTreeNode(connectionNode);
|
||||
const second = store.refreshConnectionTreeNode(connectionNode);
|
||||
await vi.waitFor(() => expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).length).toBe(2));
|
||||
|
||||
releaseObjectCacheDeletes();
|
||||
await Promise.all([first, second]);
|
||||
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects with the object cache error and skips the tree reload when deletion fails", async () => {
|
||||
const deleteSchemaCachePrefix = vi.fn((prefix: string) => (prefix.startsWith("object-") ? Promise.reject(new Error("sqlite locked")) : Promise.resolve()));
|
||||
const listDatabases = vi.fn().mockResolvedValue([{ name: "app", comment: null }]);
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix, listDatabases });
|
||||
const { store, connectionNode } = await createStoreWithConnection();
|
||||
const previousChildren = connectionNode.children;
|
||||
|
||||
const failure = await store.refreshConnectionTreeNode(connectionNode).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
expect(isObjectCacheInvalidationError(failure)).toBe(true);
|
||||
expect((failure as Error).message).toBe("sqlite locked");
|
||||
expect(listDatabases).not.toHaveBeenCalled();
|
||||
expect(connectionNode.children).toBe(previousChildren);
|
||||
});
|
||||
|
||||
it("retries the deletion and completes the tree reload on a subsequent refresh", async () => {
|
||||
let objectDeletionFailures = 2;
|
||||
const deleteSchemaCachePrefix = vi.fn((prefix: string) => {
|
||||
if (!prefix.startsWith("object-")) return Promise.resolve();
|
||||
return objectDeletionFailures-- > 0 ? Promise.reject(new Error("locked")) : Promise.resolve();
|
||||
});
|
||||
const listDatabases = vi.fn().mockResolvedValue([{ name: "app", comment: null }]);
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix, listDatabases });
|
||||
const { store, connectionNode } = await createStoreWithConnection();
|
||||
|
||||
await expect(store.refreshConnectionTreeNode(connectionNode)).rejects.toThrow("locked");
|
||||
expect(listDatabases).not.toHaveBeenCalled();
|
||||
|
||||
await expect(store.refreshConnectionTreeNode(connectionNode)).resolves.toBeUndefined();
|
||||
expect(listDatabases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("encodes connection ids in the invalidated object cache prefixes", async () => {
|
||||
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix });
|
||||
const { store, connectionNode } = await createStoreWithConnection("pg:1");
|
||||
|
||||
await store.refreshConnectionTreeNode(connectionNode);
|
||||
|
||||
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg%3A1:", "object-meta:v1:pg%3A1:"]);
|
||||
});
|
||||
|
||||
it("keeps non-connection node refresh best-effort when the object deletion fails", async () => {
|
||||
const deleteSchemaCachePrefix = vi.fn((prefix: string) => (prefix.startsWith("object-") ? Promise.reject(new Error("locked")) : Promise.resolve()));
|
||||
const listSchemaInfos = vi.fn().mockResolvedValue([{ name: "public", comment: null }]);
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix, listSchemaInfos });
|
||||
const { store } = await createStoreWithConnection();
|
||||
const database = databaseNode();
|
||||
store.treeNodes[0]!.children = [database];
|
||||
|
||||
await expect(store.refreshTreeNode(database)).resolves.toBeUndefined();
|
||||
// The tree still reloads even though the object-cache deletion failed.
|
||||
expect(listSchemaInfos).toHaveBeenCalledTimes(1);
|
||||
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg-1:app:", "object-meta:v1:pg-1:app:"]);
|
||||
});
|
||||
|
||||
it("does not fetch object DDL or column metadata during a connection refresh", async () => {
|
||||
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
|
||||
const getTableDisplayDdl = vi.fn();
|
||||
const getColumns = vi.fn();
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix, getTableDisplayDdl, getColumns });
|
||||
const { store, connectionNode } = await createStoreWithConnection();
|
||||
|
||||
await store.refreshConnectionTreeNode(connectionNode);
|
||||
|
||||
expect(getTableDisplayDdl).not.toHaveBeenCalled();
|
||||
expect(getColumns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes non-connection nodes to the regular refresh path", async () => {
|
||||
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
|
||||
installConnectionApiMocks({ deleteSchemaCachePrefix });
|
||||
const { store } = await createStoreWithConnection();
|
||||
const database = databaseNode();
|
||||
store.treeNodes[0]!.children = [database];
|
||||
|
||||
await expect(store.refreshConnectionTreeNode(database)).resolves.toBeUndefined();
|
||||
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg-1:app:", "object-meta:v1:pg-1:app:"]);
|
||||
});
|
||||
});
|
||||
@@ -2272,7 +2272,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateMetadataCachesForNode(node: TreeNode) {
|
||||
function invalidateMetadataCachesForNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) {
|
||||
if (!node.connectionId) return;
|
||||
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
|
||||
const match = {
|
||||
@@ -2282,7 +2282,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
tableName,
|
||||
};
|
||||
invalidateMetadataCaches(match);
|
||||
void invalidateObjectDdlCache(match);
|
||||
// Connection-node refresh already awaited a strict object-cache invalidation;
|
||||
// skip the fire-and-forget duplicate so no late async deletion trails it.
|
||||
if (!options?.skipObjectCacheInvalidation) void invalidateObjectDdlCache(match);
|
||||
}
|
||||
|
||||
function invalidateMetadataCache(connectionId: string, database?: string, schema?: string, tableName?: string) {
|
||||
@@ -7054,9 +7056,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTreeNode(node: TreeNode) {
|
||||
async function refreshTreeNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) {
|
||||
invalidateCompletionCachesForNode(node);
|
||||
invalidateMetadataCachesForNode(node);
|
||||
invalidateMetadataCachesForNode(node, options);
|
||||
if (objectTypesForGroupNode(node.type)) {
|
||||
clearLoadedChildrenCache(node.id, { deletePersisted: false });
|
||||
await loadObjectGroupChildren(node, { force: true });
|
||||
@@ -7067,7 +7069,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const parentId = objectGroupRefreshParentId(node);
|
||||
const parentNode = parentId ? findNode(treeNodes.value, parentId) : null;
|
||||
if (parentNode) {
|
||||
await refreshTreeNode(parentNode);
|
||||
await refreshTreeNode(parentNode, options);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7119,6 +7121,18 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-node refresh: fully invalidate the connection's object caches
|
||||
* (DDL + object metadata, memory and persisted) and surface deletion
|
||||
* failures before the tree reload runs. Tree-reload failures propagate
|
||||
* unmarked and keep the original connect-failure handling.
|
||||
*/
|
||||
async function refreshConnectionTreeNode(node: TreeNode): Promise<void> {
|
||||
if (node.type !== "connection" || !node.connectionId) return refreshTreeNode(node);
|
||||
await invalidateObjectDdlCache({ connectionId: node.connectionId }, { strict: true });
|
||||
await refreshTreeNode(node, { skipObjectCacheInvalidation: true });
|
||||
}
|
||||
|
||||
async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) {
|
||||
invalidateMetadataCachesForNode(node);
|
||||
if (objectTypesForGroupNode(node.type)) {
|
||||
@@ -9223,6 +9237,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
collapseAllTreeNodes,
|
||||
refreshSidebarObjectPagination,
|
||||
refreshTreeNode,
|
||||
refreshConnectionTreeNode,
|
||||
refreshDatabaseTreeNode,
|
||||
refreshObjectListTreeNode,
|
||||
connectedIds,
|
||||
|
||||
Reference in new issue
Block a user