fix(metadata): await object cache invalidation on connection refresh

This commit is contained in:
soddygo
2026-09-15 12:21:18 +08:00
committed by GitHub
parent f95650f039
commit faf259fcc2
17 changed files with 536 additions and 32 deletions
@@ -162,6 +162,7 @@ import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFor
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabilities";
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionTableSqlSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { isObjectCacheInvalidationError } from "@/lib/metadata/objectCacheInvalidationError";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import {
defaultPasteTableMode,
@@ -2191,6 +2192,19 @@ function openElasticsearchIndexMetadata(kind: ElasticsearchIndexMetadataKind) {
async function refresh() {
const node = activeNode.value;
if (node.type === "connection" && node.connectionId) {
try {
await connectionStore.refreshConnectionTreeNode(node);
} catch (e: any) {
if (isObjectCacheInvalidationError(e)) {
toast(t("connection.objectCacheRefreshFailed", { message: translateBackendError(t, e) }), 5000);
return;
}
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
openDriverStoreForInstallError(e?.message || String(e), node);
}
return;
}
try {
await connectionStore.refreshTreeNode(node);
} catch (e: any) {
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
/**
* Secondary evidence for the connection-node refresh wiring. The primary
* behavioral coverage lives in connectionStore.refresh.spec.ts and the
* metadata cache specs; this guards the component-side routing and error
* classification without mounting the full sidebar tree host.
*/
describe("SidebarTreeRuntimeHost connection refresh cache invalidation", () => {
it("routes connection-node refresh through the awaited strict store method", () => {
expect(source).toMatch(/if \(node\.type === "connection" && node\.connectionId\) \{\s*try \{\s*await connectionStore\.refreshConnectionTreeNode\(node\);/);
});
it("reports object cache failures distinctly without opening the driver store", () => {
const connectionBranch = source.slice(source.indexOf("await connectionStore.refreshConnectionTreeNode(node);"), source.indexOf("await connectionStore.refreshTreeNode(node);", source.indexOf("await connectionStore.refreshConnectionTreeNode(node);")));
const cacheFailureBlock = connectionBranch.match(/if \(isObjectCacheInvalidationError\(e\)\) \{[\s\S]*?\n\s*return;\s*\n/);
expect(cacheFailureBlock).not.toBeNull();
expect(cacheFailureBlock![0]).toContain('t("connection.objectCacheRefreshFailed"');
expect(cacheFailureBlock![0]).not.toContain("openDriverStoreForInstallError");
});
it("keeps the regular refresh path and its connection-error handling for other nodes", () => {
const refresh = source.slice(source.indexOf("async function refresh()"), source.indexOf("async function copyName()"));
const nonConnection = refresh.slice(refresh.indexOf("await connectionStore.refreshTreeNode(node);"));
expect(nonConnection).toMatch(/toast\(t\("connection\.connectFailed"/);
expect(nonConnection).toContain("openDriverStoreForInstallError");
});
});
+1
View File
@@ -1204,6 +1204,7 @@ export default {
connecting: "Connecting to {name}...",
connectSuccess: "Connected to {name}",
connectFailed: "Connection failed: {message}",
objectCacheRefreshFailed: "Object cache refresh failed: {message}",
driverNotInstalled: "{driver} driver is not installed. Please install it from the Driver Manager.",
phoenixDriverNotInstalled: "The Apache Phoenix JDBC driver is not installed. Install it from the Driver Manager, then retry.",
jreNotInstalled: "JRE {jre} runtime is not installed. Please install it from the Driver Manager.",
+1
View File
@@ -1403,6 +1403,7 @@ export default withEnglishFallback({
pluginSshKeyPickerPlaceholder: "Seleccionar clave SSH local…",
pluginSshKeyPickerEmpty: "No se encontraron claves",
pluginSshKeyEncrypted: "Cifrado",
objectCacheRefreshFailed: "Error al actualizar la caché de objetos: {message}",
},
editor: {
duckdbDraining: "La consulta anterior de DuckDB aún se está deteniendo. Vuelva a intentarlo en breve.",
+1
View File
@@ -1401,6 +1401,7 @@ export default withEnglishFallback({
pluginSshKeyPickerPlaceholder: "Seleziona chiave SSH locale…",
pluginSshKeyPickerEmpty: "Nessuna chiave trovata",
pluginSshKeyEncrypted: "Crittografata",
objectCacheRefreshFailed: "Aggiornamento della cache degli oggetti non riuscito: {message}",
},
editor: {
duckdbDraining: "La query DuckDB precedente è ancora in fase di arresto. Riprova a breve.",
+1
View File
@@ -1422,6 +1422,7 @@ export default withEnglishFallback({
pluginSshKeyPickerPlaceholder: "ローカルの SSH キーを選択…",
pluginSshKeyPickerEmpty: "キーが見つかりません",
pluginSshKeyEncrypted: "暗号化済み",
objectCacheRefreshFailed: "オブジェクトキャッシュの更新に失敗しました:{message}",
},
editor: {
duckdbDraining: "前回の DuckDB クエリはまだ停止処理中です。しばらくしてから再試行してください。",
+1
View File
@@ -1320,6 +1320,7 @@ export default withEnglishFallback({
pluginSshKeyPickerPlaceholder: "로컬 SSH 키 선택…",
pluginSshKeyPickerEmpty: "키를 찾을 수 없음",
pluginSshKeyEncrypted: "암호화됨",
objectCacheRefreshFailed: "개체 캐시 새로 고침 실패: {message}",
},
editor: {
duckdbDraining: "이전 DuckDB 쿼리가 아직 중지 중입니다. 잠시 후 다시 시도해 주세요.",
+1
View File
@@ -1402,6 +1402,7 @@ export default withEnglishFallback({
pluginSshKeyPickerPlaceholder: "Selecionar chave SSH local…",
pluginSshKeyPickerEmpty: "Nenhuma chave encontrada",
pluginSshKeyEncrypted: "Criptografada",
objectCacheRefreshFailed: "Falha ao atualizar o cache de objetos: {message}",
},
editor: {
duckdbDraining: "A consulta anterior do DuckDB ainda está sendo interrompida. Tente novamente em breve.",
+1
View File
@@ -1122,6 +1122,7 @@ export default withEnglishFallback({
sshTotpCancelled: "已取消输入一次性验证码",
connectSuccess: "已连接 {name}",
connectFailed: "连接失败:{message}",
objectCacheRefreshFailed: "对象缓存刷新失败:{message}",
driverNotInstalled: "{driver} 驱动未安装,请在驱动管理器中安装。",
phoenixDriverNotInstalled: "Apache Phoenix JDBC 驱动未安装,请在驱动管理器中安装后重试。",
jreNotInstalled: "JRE {jre} 运行环境未安装,请在驱动管理器中安装。",
+1
View File
@@ -978,6 +978,7 @@ export default withEnglishFallback({
connecting: "正在連線 {name}……",
connectSuccess: "已連線 {name}",
connectFailed: "連線失敗:{message}",
objectCacheRefreshFailed: "物件快取重新整理失敗:{message}",
driverNotInstalled: "{driver} 驅動程式未安裝,請在驅動程式管理器中安裝。",
phoenixDriverNotInstalled: "Apache Phoenix JDBC 驅動程式未安裝,請在驅動程式管理器中安裝後再試。",
jreNotInstalled: "JRE {jre} 執行環境未安裝,請在驅動程式管理器中安裝。",
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/backend/api", () => mocks);
import { cancelObjectDdlLoadsForConnection, getObjectDdlCacheDebugStateForTests, invalidateObjectDdl, invalidateObjectDdlCache, loadObjectDdl, objectDdlCacheKey } from "@/lib/metadata/objectDdlCache";
import { isObjectCacheInvalidationError } from "@/lib/metadata/objectCacheInvalidationError";
import { clearMetadataRuntimeCache } from "@/lib/metadata/metadataRuntimeCache";
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
@@ -263,4 +264,156 @@ describe("objectDdlCache", () => {
expect(mocks.getTableDisplayDdl).toHaveBeenCalledTimes(2);
expect(mocks.deleteSchemaCachePrefix).not.toHaveBeenCalledWith("object-ddl:v1:c1:");
});
describe("strict connection-level invalidation", () => {
const connectionMatch = { connectionId: "c1" };
it.each(["object-ddl:v1:c1:", "object-meta:v1:c1:"])("waits for the other namespace when %s fails", async (failedPrefix) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
mocks.deleteSchemaCachePrefix.mockImplementation((prefix: string) => (prefix === failedPrefix ? Promise.reject(new Error("locked")) : pending));
let settled = false;
const outcome = invalidateObjectDdlCache(connectionMatch, { strict: true }).then(
() => {
settled = true;
return undefined;
},
(error: unknown) => {
settled = true;
return error;
},
);
await vi.waitFor(() => expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledTimes(2));
// Drain the rejection handlers, without releasing the other deletion.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(settled).toBe(false);
release();
const failure = await outcome;
expect(isObjectCacheInvalidationError(failure)).toBe(true);
expect(failure).toMatchObject({ scope: failedPrefix, message: "locked" });
});
function objectPrefixes(): string[] {
return mocks.deleteSchemaCachePrefix.mock.calls.map(([prefix]) => prefix as string);
}
function rejectAllDeletions(error: Error) {
let rejectGate: (reason: Error) => void = () => {};
const gate = new Promise<void>((_resolve, reject) => {
rejectGate = reject;
});
mocks.deleteSchemaCachePrefix.mockImplementation(() =>
gate.then(
() => undefined,
() => {
throw error;
},
),
);
return () => rejectGate(error);
}
it("rejects with a marked object cache error when the persisted deletion fails", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValue(new Error("sqlite locked"));
const failure = await invalidateObjectDdlCache(connectionMatch, { strict: true }).then(
() => undefined,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(Error);
expect(isObjectCacheInvalidationError(failure)).toBe(true);
expect((failure as Error).message).toBe("sqlite locked");
// Both the DDL and the object-metadata namespace deletions were attempted.
expect(objectPrefixes()).toContain("object-ddl:v1:c1:");
expect(objectPrefixes()).toContain("object-meta:v1:c1:");
});
it("keeps default invalidation best-effort when the persisted deletion fails", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValue(new Error("sqlite locked"));
await expect(invalidateObjectDdlCache(connectionMatch)).resolves.toBeUndefined();
expect(objectPrefixes()).toEqual(["object-ddl:v1:c1:", "object-meta:v1:c1:"]);
});
it("still rejects in strict mode when a deletion rejects without a reason", async () => {
// A bare Promise.reject() has reason undefined; settled status, not the
// reason's value, must decide failure.
mocks.deleteSchemaCachePrefix.mockRejectedValue(undefined as never);
const failure = await invalidateObjectDdlCache(connectionMatch, { strict: true }).then(
() => undefined,
(error: unknown) => error,
);
expect(isObjectCacheInvalidationError(failure)).toBe(true);
});
it("lets a strict call sharing a pending best-effort deletion observe the failure", async () => {
const failAll = rejectAllDeletions(new Error("disk full"));
const bestEffort = invalidateObjectDdlCache(connectionMatch);
const strict = invalidateObjectDdlCache(connectionMatch, { strict: true });
// The strict call must reuse the in-flight deletions instead of starting new ones.
await vi.waitFor(() => expect(objectPrefixes()).toEqual(["object-ddl:v1:c1:", "object-meta:v1:c1:"]));
failAll();
await expect(bestEffort).resolves.toBeUndefined();
const failure = await strict.then(
() => undefined,
(error: unknown) => error,
);
expect(isObjectCacheInvalidationError(failure)).toBe(true);
expect((failure as Error).message).toBe("disk full");
});
it("retries the persisted deletion on the next strict invalidation after a failure", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValueOnce(new Error("locked")).mockRejectedValueOnce(new Error("locked"));
await expect(invalidateObjectDdlCache(connectionMatch, { strict: true })).rejects.toThrow("locked");
await expect(invalidateObjectDdlCache(connectionMatch, { strict: true })).resolves.toBeUndefined();
const prefixes = objectPrefixes();
expect(prefixes.filter((prefix) => prefix === "object-ddl:v1:c1:")).toHaveLength(2);
expect(prefixes.filter((prefix) => prefix === "object-meta:v1:c1:")).toHaveLength(2);
});
it("keeps non-force reads working when a concurrent deletion fails", async () => {
const failAll = rejectAllDeletions(new Error("locked"));
mocks.getTableDisplayDdl.mockResolvedValue("fresh ddl");
const invalidation = invalidateObjectDdlCache({ connectionId: request.connectionId, database: request.database, schema: request.schema, tableName: request.tableName });
const read = loadObjectDdl(request);
failAll();
await expect(read).resolves.toEqual({ ddl: "fresh ddl", cacheStatus: "remote" });
await expect(invalidation).resolves.toBeUndefined();
});
it("force refresh proceeds when the persisted deletion fails", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValue(new Error("locked"));
mocks.getTableDisplayDdl.mockResolvedValue("new ddl");
await expect(loadObjectDdl(request, { force: true })).resolves.toEqual({ ddl: "new ddl", cacheStatus: "remote" });
});
it("keeps object-level invalidation best-effort when the deletion fails", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValue(new Error("locked"));
await expect(invalidateObjectDdl(request)).resolves.toBeUndefined();
});
it("encodes connection ids and isolates similar ids in persisted deletions", async () => {
mocks.persisted.set("object-ddl:v1:conn10:db:public:t::TABLE:", { version: 1, cachedAt: new Date().toISOString(), ddl: "conn10 ddl" });
await invalidateObjectDdlCache({ connectionId: "conn1" });
expect(objectPrefixes()).toContain("object-ddl:v1:conn1:");
expect(objectPrefixes()).toContain("object-meta:v1:conn1:");
expect([...mocks.persisted.keys()]).toContain("object-ddl:v1:conn10:db:public:t::TABLE:");
await invalidateObjectDdlCache({ connectionId: "a:b c%中" });
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-ddl:v1:a%3Ab%20c%25%E4%B8%AD:");
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-meta:v1:a%3Ab%20c%25%E4%B8%AD:");
});
});
});
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/backend/api", () => mocks);
import { cancelObjectMetadataLoadsForConnection, getObjectMetadataCacheDebugStateForTests, invalidateObjectMetadataCache, loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { isObjectCacheInvalidationError } from "@/lib/metadata/objectCacheInvalidationError";
import { clearMetadataRuntimeCache } from "@/lib/metadata/metadataRuntimeCache";
const request = { connectionId: "c1", database: "app", schema: "public", tableName: "users", catalog: "analytics" } as const;
@@ -252,4 +253,32 @@ describe("objectMetadataCache", () => {
expect(loader).toHaveBeenCalledTimes(2);
expect(mocks.deleteSchemaCachePrefix).not.toHaveBeenCalledWith("object-meta:v1:c1:");
});
describe("strict invalidation", () => {
it("rejects with a marked object cache error in strict mode and stays best-effort by default", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValue(new Error("sqlite locked"));
const failure = await invalidateObjectMetadataCache({ connectionId: "c1" }, { strict: true }).then(
() => undefined,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(Error);
expect(isObjectCacheInvalidationError(failure)).toBe(true);
expect((failure as Error).message).toBe("sqlite locked");
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-meta:v1:c1:");
mocks.deleteSchemaCachePrefix.mockClear();
mocks.deleteSchemaCachePrefix.mockRejectedValue(new Error("sqlite locked"));
await expect(invalidateObjectMetadataCache({ connectionId: "c1" })).resolves.toBeUndefined();
expect(mocks.deleteSchemaCachePrefix).toHaveBeenCalledWith("object-meta:v1:c1:");
});
it("force facet loads tolerate a failed persisted deletion", async () => {
mocks.deleteSchemaCachePrefix.mockRejectedValue(new Error("locked"));
const loader = vi.fn().mockResolvedValue(["fresh"]);
await expect(loadObjectMetadataFacet(request, "columns", loader, { force: true })).resolves.toEqual({ value: ["fresh"], cacheStatus: "remote" });
expect(loader).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,41 @@
/**
* Error marker for strict object-cache invalidation failures.
*
* Best-effort invalidation callers keep swallowing persistence errors, so the
* only signal a strict caller (connection-node refresh) can rely on is an
* explicitly marked error. Detection is name-based rather than instanceof so
* duplicated module instances (tests reset modules) still match.
*/
export interface ObjectCacheInvalidationOptions {
/** Propagate persistence deletion failures instead of swallowing them. */
strict?: boolean;
}
const OBJECT_CACHE_INVALIDATION_ERROR_NAME = "ObjectCacheInvalidationError";
export class ObjectCacheInvalidationError extends Error {
readonly code = "OBJECT_CACHE_INVALIDATION_FAILED";
/** Cache prefix whose persisted deletion failed. */
readonly scope: string;
/** Original error thrown by the deletion. */
readonly reason: unknown;
constructor(message: string, scope: string, reason?: unknown) {
super(message);
this.name = OBJECT_CACHE_INVALIDATION_ERROR_NAME;
this.scope = scope;
this.reason = reason;
}
}
export function isObjectCacheInvalidationError(value: unknown): value is ObjectCacheInvalidationError {
return value instanceof Error && value.name === OBJECT_CACHE_INVALIDATION_ERROR_NAME;
}
/** Wrap a deletion failure, passing through an already-marked error unchanged. */
export function toObjectCacheInvalidationError(error: unknown, scope: string): ObjectCacheInvalidationError {
if (isObjectCacheInvalidationError(error)) return error;
const message = error instanceof Error ? error.message : String(error);
return new ObjectCacheInvalidationError(message, scope, error);
}
+20 -14
View File
@@ -3,6 +3,7 @@ import { ActiveCacheReadTracker } from "./activeCacheReadTracker";
import type { ObjectSourceKind } from "@/types/database";
import type { MetadataCacheInvalidation } from "./metadataResultCache";
import { invalidateObjectMetadataCache } from "./objectMetadataCache";
import { toObjectCacheInvalidationError, type ObjectCacheInvalidationOptions } from "./objectCacheInvalidationError";
import { getMetadataRuntimeCache, invalidateMetadataRuntimeCachePrefix, recordMetadataCacheL2Hit, recordMetadataCacheRemoteMiss, setMetadataRuntimeCache } from "./metadataRuntimeCache";
const OBJECT_DDL_CACHE_PREFIX = "object-ddl:v1";
@@ -56,14 +57,6 @@ async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise<
}
}
async function deleteSchemaCachePrefixSafe(prefix: string): Promise<void> {
try {
await api.deleteSchemaCachePrefix(prefix);
} catch {
// Cache invalidation is best effort when running with a reduced backend.
}
}
function cacheSegment(value: string | undefined): string {
return encodeURIComponent(value ?? "");
}
@@ -94,7 +87,9 @@ function decodeCachedDdl(payload: unknown): string | null {
}
async function waitForPendingInvalidations(cacheKey: string): Promise<void> {
const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise);
// Readers only wait for pending deletions to settle; the failure itself is
// reported by the caller that started the invalidation.
const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise.catch(() => undefined));
if (pending.length) await Promise.all(pending);
}
@@ -129,7 +124,9 @@ function invalidatePersistedPrefix(prefix: string): Promise<void> {
if (existing) return existing;
const deletion = (async () => {
await waitForPendingWrites(prefix);
await deleteSchemaCachePrefixSafe(prefix);
// Deletion failures propagate so strict callers can observe them;
// best-effort callers catch at their own boundary.
await api.deleteSchemaCachePrefix(prefix);
})().finally(() => {
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
});
@@ -174,7 +171,8 @@ export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force
invalidateMetadataRuntimeCachePrefix(cacheKey);
const priorInvalidations = waitForPendingInvalidations(cacheKey);
const deletion = invalidatePersistedPrefix(cacheKey);
await Promise.all([priorInvalidations, deletion]);
// A failed prefix deletion must not fail the force load itself.
await Promise.allSettled([priorInvalidations, deletion]);
} else {
await waitForPendingInvalidations(cacheKey);
}
@@ -203,14 +201,22 @@ export async function loadObjectDdl(request: ObjectDdlRequest, options?: { force
return { ddl: await loadRemoteDdl(request, cacheKey, options?.force === true), cacheStatus: "remote" };
}
export async function invalidateObjectDdlCache(match: MetadataCacheInvalidation): Promise<void> {
export async function invalidateObjectDdlCache(match: MetadataCacheInvalidation, options?: ObjectCacheInvalidationOptions): Promise<void> {
const prefix = invalidationPrefix(match);
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...remoteLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
const deletion = invalidatePersistedPrefix(prefix);
await Promise.all([deletion, invalidateObjectMetadataCache(match)]);
const metadataInvalidation = invalidateObjectMetadataCache(match, options);
// Wait for both namespace deletions to settle before deciding, so a strict
// caller never reports failure while the other deletion is still in flight.
const [ddlSettled, metaSettled] = await Promise.allSettled([deletion, metadataInvalidation]);
if (!options?.strict) return;
// Judge by settled status, never by the rejection reason's value: a bare
// Promise.reject() has reason undefined and must still count as a failure.
if (ddlSettled.status === "rejected") throw toObjectCacheInvalidationError(ddlSettled.reason, prefix);
if (metaSettled.status === "rejected") throw toObjectCacheInvalidationError(metaSettled.reason, prefix);
}
export async function invalidateObjectDdl(request: ObjectDdlRequest): Promise<void> {
@@ -218,7 +224,7 @@ export async function invalidateObjectDdl(request: ObjectDdlRequest): Promise<vo
activeCacheReads.invalidatePrefix(cacheKey);
invalidateRemoteLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(cacheKey);
await Promise.all([
await Promise.allSettled([
invalidatePersistedPrefix(cacheKey),
invalidateObjectMetadataCache({
connectionId: request.connectionId,
@@ -2,6 +2,7 @@ import * as api from "@/lib/backend/api";
import { ActiveCacheReadTracker } from "./activeCacheReadTracker";
import type { MetadataCacheInvalidation } from "./metadataResultCache";
import type { ObjectDdlRequest } from "./objectDdlCache";
import { toObjectCacheInvalidationError, type ObjectCacheInvalidationOptions } from "./objectCacheInvalidationError";
import { getMetadataRuntimeCache, invalidateMetadataRuntimeCachePrefix, recordMetadataCacheL2Hit, recordMetadataCacheRemoteMiss, setMetadataRuntimeCache } from "./metadataRuntimeCache";
const OBJECT_METADATA_CACHE_PREFIX = "object-meta:v1";
@@ -41,14 +42,6 @@ async function saveSchemaCacheSafe(cacheKey: string, payload: unknown): Promise<
}
}
async function deleteSchemaCachePrefixSafe(prefix: string): Promise<void> {
try {
await api.deleteSchemaCachePrefix(prefix);
} catch {
// Cache invalidation is best effort when running with a reduced backend.
}
}
export type ObjectMetadataFacet = "columns" | "indexes" | "foreign-keys" | "constraints" | "triggers" | "comment" | "owner";
function cacheSegment(value: string | undefined): string {
@@ -81,7 +74,9 @@ function decodeEnvelope<T>(payload: unknown): { hit: true; value: T } | undefine
}
async function waitForPendingInvalidations(cacheKey: string): Promise<void> {
const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise);
// Readers only wait for pending deletions to settle; the failure itself is
// reported by the caller that started the invalidation.
const pending = [...pendingInvalidations.entries()].filter(([prefix]) => cacheKey.startsWith(prefix)).map(([, promise]) => promise.catch(() => undefined));
if (pending.length) await Promise.all(pending);
}
@@ -116,7 +111,9 @@ function invalidatePersistedPrefix(prefix: string): Promise<void> {
if (existing) return existing;
const deletion = (async () => {
await waitForPendingWrites(prefix);
await deleteSchemaCachePrefixSafe(prefix);
// Deletion failures propagate so strict callers can observe them;
// best-effort callers catch at their own boundary.
await api.deleteSchemaCachePrefix(prefix);
})().finally(() => {
if (pendingInvalidations.get(prefix) === deletion) pendingInvalidations.delete(prefix);
});
@@ -134,7 +131,8 @@ export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, face
invalidateMetadataRuntimeCachePrefix(cacheKey);
const priorInvalidations = waitForPendingInvalidations(cacheKey);
const deletion = invalidatePersistedPrefix(cacheKey);
await Promise.all([priorInvalidations, deletion]);
// A failed prefix deletion must not fail the force load itself.
await Promise.allSettled([priorInvalidations, deletion]);
} else {
await waitForPendingInvalidations(cacheKey);
}
@@ -188,13 +186,22 @@ export async function loadObjectMetadataFacet<T>(request: ObjectDdlRequest, face
return { value: await entry.promise, cacheStatus: "remote" };
}
export async function invalidateObjectMetadataCache(match: MetadataCacheInvalidation): Promise<void> {
export async function invalidateObjectMetadataCache(match: MetadataCacheInvalidation, options?: ObjectCacheInvalidationOptions): Promise<void> {
const prefix = invalidationPrefix(match);
activeCacheReads.invalidatePrefix(prefix);
for (const cacheKey of [...inFlightLoads.keys()]) if (cacheKey.startsWith(prefix)) invalidateInFlightLoad(cacheKey);
invalidateMetadataRuntimeCachePrefix(prefix);
return invalidatePersistedPrefix(prefix);
const deletion = invalidatePersistedPrefix(prefix);
if (!options?.strict) {
await deletion.catch(() => undefined);
return;
}
try {
await deletion;
} catch (error) {
throw toObjectCacheInvalidationError(error, prefix);
}
}
/** Invalidate active loads for a disconnected connection without deleting its persisted snapshot. */
@@ -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:"]);
});
});
+20 -5
View File
@@ -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,