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

This commit is contained in:
soddygo authored and GitHub committed 2026-09-15 12:21:18 +08:00
1 parent f95650f039
commit faf259fcc2
17 files changed
+536 -32

No files matched your search

@@ -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. */