feat(metadata): share table metadata cache

This commit is contained in:
vrustx authored and GitHub committed 2026-07-19 08:52:00 +08:00
1 parent 81f787e8d1
commit 3da3849953
5 files changed
+269 -39

No files matched your search

@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ColumnInfo } from "@/types/database";
const mocks = vi.hoisted(() => ({
getColumns: vi.fn(),
listIndexes: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
getColumns: mocks.getColumns,
listIndexes: mocks.listIndexes,
}));
import { clearTableMetadataCache, getCachedTableMetadata, invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
function column(name: string): ColumnInfo {
return { name, data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null };
}
const request = { connectionId: "c1", database: "db", schema: "public", tableName: "users", databaseType: "postgres" } as const;
describe("tableMetadataCache invalidation", () => {
beforeEach(() => {
clearTableMetadataCache();
vi.clearAllMocks();
mocks.listIndexes.mockResolvedValue([]);
});
it("a stale in-flight load cannot overwrite the cache after invalidation", async () => {
// 旧加载(结构变更前启动)被挂起
let releaseOldColumns: (columns: ColumnInfo[]) => void = () => {};
mocks.getColumns.mockReturnValueOnce(
new Promise<ColumnInfo[]>((resolve) => {
releaseOldColumns = resolve;
}),
);
const oldLoad = loadTableMetadata({ ...request });
// 结构保存:作废缓存并 force 拉新
invalidateTableMetadataCache({ connectionId: "c1", database: "db", tableName: "users" });
mocks.getColumns.mockResolvedValueOnce([column("id_new")]);
const fresh = await loadTableMetadata({ ...request, force: true });
expect(fresh.metadata.columns[0]?.name).toBe("id_new");
// 旧加载最后返回:不得回填覆盖新值
releaseOldColumns([column("id_old")]);
const oldResult = await oldLoad;
expect(oldResult.metadata.columns[0]?.name).toBe("id_old");
expect(getCachedTableMetadata(request)?.metadata.columns[0]?.name).toBe("id_new");
});
it("a follower starting after invalidation does not join the stale in-flight load", async () => {
// 旧加载(失效前启动、non-force、已登记在途)被挂起
let releaseOldColumns: (columns: ColumnInfo[]) => void = () => {};
mocks.getColumns.mockReturnValueOnce(
new Promise<ColumnInfo[]>((resolve) => {
releaseOldColumns = resolve;
}),
);
const oldLoad = loadTableMetadata({ ...request });
invalidateTableMetadataCache({ connectionId: "c1", database: "db", tableName: "users" });
// 失效后的 non-force 跟随者:不得加入旧在途(否则其失效代数取自失效后,
// 完成时会把旧结果写回缓存),必须自起新加载
mocks.getColumns.mockResolvedValueOnce([column("id_new")]);
const follower = await loadTableMetadata({ ...request });
expect(follower.metadata.columns[0]?.name).toBe("id_new");
expect(mocks.getColumns).toHaveBeenCalledTimes(2);
releaseOldColumns([column("id_old")]);
await oldLoad;
expect(getCachedTableMetadata(request)?.metadata.columns[0]?.name).toBe("id_new");
});
it("invalidating one table does not disturb another table's in-flight load", async () => {
const requestB = { ...request, tableName: "orders" } as const;
// A、B 同时挂起
const releases = new Map<string, (columns: ColumnInfo[]) => void>();
mocks.getColumns.mockImplementation(
(_connectionId: string, _database: string, _schema: string, tableName: string) =>
new Promise<ColumnInfo[]>((resolve) => {
releases.set(tableName, resolve);
}),
);
const loadA = loadTableMetadata({ ...request });
const loadB = loadTableMetadata({ ...requestB });
await Promise.resolve();
// 仅失效 A
invalidateTableMetadataCache({ connectionId: "c1", database: "db", tableName: "users" });
// B 的 follower 必须复用原在途请求:总请求数保持 2(A 一次 + B 一次)。
// 先让出 microtask,coordinator 的 load 才会真正启动,断言才有观察力
const followerB = loadTableMetadata({ ...requestB });
await Promise.resolve();
expect(mocks.getColumns).toHaveBeenCalledTimes(2);
// B 返回后正常写入缓存
releases.get("orders")?.([column("order_id")]);
expect((await loadB).metadata.columns[0]?.name).toBe("order_id");
expect((await followerB).metadata.columns[0]?.name).toBe("order_id");
expect(getCachedTableMetadata(requestB)?.metadata.columns[0]?.name).toBe("order_id");
// A 跨越失效边界:完成后不得写缓存
releases.get("users")?.([column("id_old")]);
await loadA;
expect(getCachedTableMetadata(request)).toBeUndefined();
});
it("caches normally when no invalidation crosses the load", async () => {
mocks.getColumns.mockResolvedValueOnce([column("id")]);
await loadTableMetadata({ ...request });
expect(getCachedTableMetadata(request)?.metadata.columns[0]?.name).toBe("id");
// 命中缓存:不再发起请求
await loadTableMetadata({ ...request });
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
});
});
@@ -40,6 +40,12 @@ function valuesEqual(left: unknown, right: unknown): boolean {
return left === right;
}
/** 将失效 match 编译为作用于 metadataScopeParts 结果的谓词,供缓存外的在途登记复用同一匹配语义 */
export function metadataCacheInvalidationMatcher(match: MetadataCacheInvalidation): (scope: ReturnType<typeof metadataScopeParts>) => boolean {
const normalized = normalizeInvalidation(match);
return (scope) => Object.entries(normalized).every(([field, value]) => valuesEqual(scope[field as keyof typeof scope], value));
}
export class MetadataResultCache<T> {
private readonly entries = new Map<string, MetadataCacheEntry<T>>();
private readonly now: () => number;
@@ -69,11 +75,10 @@ export class MetadataResultCache<T> {
}
invalidate(match: MetadataCacheInvalidation): number {
const normalized = normalizeInvalidation(match);
const matches = metadataCacheInvalidationMatcher(match);
let removed = 0;
for (const [key, entry] of this.entries) {
const matches = Object.entries(normalized).every(([field, value]) => valuesEqual(entry.scope[field as keyof typeof entry.scope], value));
if (matches) {
if (matches(entry.scope)) {
this.entries.delete(key);
removed++;
}
@@ -2,8 +2,8 @@ import type { ColumnInfo, DatabaseType, IndexInfo, QueryTab } from "@/types/data
import * as api from "@/lib/backend/api";
import { editableRowIdentifierColumns } from "@/lib/table/tableEditing";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadCacheStatus, type MetadataLoadTraceLogger } from "./metadataLoadCoordinator";
import type { MetadataScopeInput } from "./metadataLoadScope";
import { MetadataResultCache, type MetadataCacheInvalidation } from "./metadataResultCache";
import { metadataScopeKey, metadataScopeParts, type MetadataScopeInput } from "./metadataLoadScope";
import { metadataCacheInvalidationMatcher, MetadataResultCache, type MetadataCacheInvalidation } from "./metadataResultCache";
export const TABLE_METADATA_CACHE_TTL_MS = 30_000;
const TABLE_METADATA_CACHE_MAX_ENTRIES = 120;
@@ -48,6 +48,41 @@ const tableMetadataCoordinator = new MetadataLoadCoordinator((event) => {
console.debug("[DBX][metadata-load:table-coordinator]", event);
});
// 失效代数(按 scope key 隔离):跨越失效边界的旧加载完成后不得写缓存——
// 结构变更后 force 拉到的新值可能被保存前启动、最后返回的在途加载回填覆盖。
// 只登记在途 scope 并只对匹配失效条件的 key 递增代数,避免失效表 A 时
// 波及无关表 B 的在途去重与缓存写入
interface InFlightTableMetadataScope {
parts: ReturnType<typeof metadataScopeParts>;
count: number;
}
const inFlightTableMetadataScopes = new Map<string, InFlightTableMetadataScope>();
const tableMetadataInvalidationStamps = new Map<string, number>();
function registerInFlightTableMetadataScope(scopeKey: string, scope: MetadataScopeInput): void {
const entry = inFlightTableMetadataScopes.get(scopeKey);
if (entry) {
entry.count++;
} else {
inFlightTableMetadataScopes.set(scopeKey, { parts: metadataScopeParts(scope), count: 1 });
}
}
function unregisterInFlightTableMetadataScope(scopeKey: string): void {
const entry = inFlightTableMetadataScopes.get(scopeKey);
if (!entry) return;
entry.count--;
if (entry.count > 0) return;
inFlightTableMetadataScopes.delete(scopeKey);
// 代数只在加载的 start→end 窗口内比较,且失效只 bump 在途 key;
// 无在途加载时清掉代数,两张 Map 都随在途集合有界
tableMetadataInvalidationStamps.delete(scopeKey);
}
function bumpTableMetadataInvalidationStamp(scopeKey: string): void {
tableMetadataInvalidationStamps.set(scopeKey, (tableMetadataInvalidationStamps.get(scopeKey) ?? 0) + 1);
}
export function tableMetadataScope(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): MetadataScopeInput {
return {
kind: "table-metadata",
@@ -95,28 +130,39 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
}
logMetadataLoadTrace(request.traceLogger, trace, "cache-miss", { cacheStatus: request.force ? "refresh" : "miss", force: request.force === true });
const metadata = await tableMetadataCoordinator.run(
scope,
async () => {
const columns = await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog);
const indexes = await api.listIndexes(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog).catch((): IndexInfo[] => []);
const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType);
return {
schema: request.schema || undefined,
tableName: request.tableName,
tableType: request.tableType,
catalog: request.catalog,
database: request.database,
columns,
indexes,
primaryKeys,
cachedAt: Date.now(),
};
},
{ force: request.force, kind: scope.kind },
);
const scopeKey = metadataScopeKey(scope);
const invalidationStampAtStart = tableMetadataInvalidationStamps.get(scopeKey) ?? 0;
registerInFlightTableMetadataScope(scopeKey, scope);
let metadata: TableMetadata;
try {
metadata = await tableMetadataCoordinator.run(
scope,
async () => {
const columns = await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog);
const indexes = await api.listIndexes(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog).catch((): IndexInfo[] => []);
const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType);
return {
schema: request.schema || undefined,
tableName: request.tableName,
tableType: request.tableType,
catalog: request.catalog,
database: request.database,
columns,
indexes,
primaryKeys,
cachedAt: Date.now(),
};
},
{ force: request.force, kind: scope.kind },
);
tableMetadataCache.set(scope, metadata);
// 必须在 unregister 前比较:最后一个在途加载注销时会顺带清掉代数记录
if (invalidationStampAtStart === (tableMetadataInvalidationStamps.get(scopeKey) ?? 0)) {
tableMetadataCache.set(scope, metadata);
}
} finally {
unregisterInFlightTableMetadataScope(scopeKey);
}
logMetadataLoadTrace(request.traceLogger, trace, "done", {
cacheStatus: request.force ? "refresh" : "miss",
resultCount: metadata.columns.length,
@@ -126,9 +172,23 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise<
}
export function invalidateTableMetadataCache(match: MetadataCacheInvalidation): number {
// 只处理匹配失效条件的在途 scope,不波及其他表/连接的在途去重与缓存写入:
// 1) bump 该 scope 的失效代数——跨边界的旧加载完成后不得写缓存;
// 2) 甩掉该 scope 的在途登记——否则失效后启动的 non-force 调用会加入
// 失效前的旧加载,且其失效代数取自失效之后,完成时把旧结果写回缓存
const matches = metadataCacheInvalidationMatcher(match);
for (const [scopeKey, entry] of inFlightTableMetadataScopes) {
if (!matches(entry.parts)) continue;
bumpTableMetadataInvalidationStamp(scopeKey);
tableMetadataCoordinator.clear(scopeKey);
}
return tableMetadataCache.invalidate(match);
}
export function clearTableMetadataCache(): void {
for (const scopeKey of inFlightTableMetadataScopes.keys()) {
bumpTableMetadataInvalidationStamp(scopeKey);
}
tableMetadataCoordinator.clear();
tableMetadataCache.clear();
}