fix(objects): avoid repeated object list reloads

This commit is contained in:
zipg authored and GitHub committed 2026-07-28 21:09:06 +08:00
1 parent d24cd98776
commit 8bd92e4f2c
8 files changed
+390 -48

No files matched your search

@@ -10,6 +10,14 @@ describe("ObjectBrowser context watcher", () => {
expect(objectBrowserSource).not.toContain("() => [props.connection.id, props.database, props.schema]");
});
it("invalidates object requests before waiting for the new connection", () => {
const watcher = objectBrowserSource.match(/watch\(\s*\[\(\) => props\.connection\.id[\s\S]*?\{ immediate: true \},\s*\);/)?.[0] ?? "";
expect(watcher).toContain("const contextEpoch = objectBrowserRowsLoadGuard.invalidate();");
expect(watcher.indexOf("objectBrowserRowsLoadGuard.invalidate()")).toBeLessThan(watcher.indexOf("await connectionStore.ensureConnected"));
expect(watcher).toContain("reload({ allowCachedObjects: true, contextEpoch })");
});
it("ignores connection object replacement when the context values stay unchanged", async () => {
const connection = ref({ id: "connection-1" });
const database = ref("database-1");
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createObjectBrowserRowsLoadGuard } from "@/lib/table/objectBrowserRowsLoadGuard";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("ObjectBrowserRowsLoadGuard", () => {
it.each([
{
name: "connection",
nextScope: { connectionId: "new", database: "db1", schema: "public" },
},
{
name: "database",
nextScope: { connectionId: "old", database: "db2", schema: "public" },
},
])("drops statistics that finish after switching $name", async ({ nextScope }) => {
const guard = createObjectBrowserRowsLoadGuard();
const oldRequest = guard.start({ connectionId: "old", database: "db1", schema: "public" });
const pendingStatistics = deferred<number>();
const cacheWrites: Array<{ connectionId: string; database: string }> = [];
const oldStatisticsTask = (async () => {
await pendingStatistics.promise;
if (!guard.isCurrent(oldRequest)) return;
cacheWrites.push(oldRequest.scope);
})();
guard.invalidate();
const currentRequest = guard.start(nextScope);
pendingStatistics.resolve(1);
await oldStatisticsTask;
expect(guard.isCurrent(oldRequest)).toBe(false);
expect(guard.isCurrent(currentRequest)).toBe(true);
expect(oldRequest.scope).toMatchObject({ connectionId: "old", database: "db1" });
expect(cacheWrites).toEqual([]);
});
it("captures an immutable copy of the complete cache scope", () => {
const guard = createObjectBrowserRowsLoadGuard();
const scope = { connectionId: "c1", database: "db1", schema: "public", catalog: "catalog1" };
const request = guard.start(scope);
scope.database = "db2";
expect(request.scope).toEqual({ connectionId: "c1", database: "db1", schema: "public", catalog: "catalog1" });
expect(Object.isFrozen(request.scope)).toBe(true);
});
});
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
import { cacheObjectBrowserRows, clearObjectBrowserRowsCache, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
const row: ObjectBrowserRow = {
id: "table:users",
name: "users",
displayName: "users",
type: "TABLE",
};
function cacheRows(scope: Parameters<typeof createObjectBrowserRowsCacheWriteToken>[0], rows: readonly ObjectBrowserRow[] = [row]) {
return cacheObjectBrowserRows(createObjectBrowserRowsCacheWriteToken(scope), rows);
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("objectBrowserRowsCache", () => {
beforeEach(() => clearObjectBrowserRowsCache());
afterEach(() => vi.useRealTimers());
it("restores cached rows only for the same object browser scope", () => {
const scope = { connectionId: "c1", database: "db", schema: "public" };
cacheRows(scope);
expect(getCachedObjectBrowserRows(scope)).toEqual([row]);
expect(getCachedObjectBrowserRows({ ...scope, schema: "archive" })).toBeUndefined();
expect(getCachedObjectBrowserRows({ ...scope, connectionId: "c2" })).toBeUndefined();
});
it("returns copies so component updates do not mutate the cached rows", () => {
const scope = { connectionId: "c1", database: "db", schema: "public" };
cacheRows(scope);
const cached = getCachedObjectBrowserRows(scope)!;
cached[0].displayName = "changed";
expect(getCachedObjectBrowserRows(scope)?.[0].displayName).toBe("users");
});
it("restores fresh rows when a remounted component reads the shared cache", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
const scope = { connectionId: "c1", database: "db", schema: "public" };
cacheRows(scope);
expect(getCachedObjectBrowserRows(scope)).toEqual([row]);
});
it("rejects cached rows after the TTL", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
const scope = { connectionId: "c1", database: "db", schema: "public" };
cacheRows(scope);
vi.advanceTimersByTime(30_001);
expect(getCachedObjectBrowserRows(scope)).toBeUndefined();
});
it("invalidates matching connection and database scopes", () => {
const first = { connectionId: "c1", database: "db1", schema: "public" };
const second = { connectionId: "c1", database: "db2", schema: "public" };
const third = { connectionId: "c2", database: "db1", schema: "public" };
cacheRows(first);
cacheRows(second);
cacheRows(third);
expect(invalidateObjectBrowserRowsCache({ connectionId: "c1", database: "db1" })).toBe(1);
expect(getCachedObjectBrowserRows(first)).toBeUndefined();
expect(getCachedObjectBrowserRows(second)).toEqual([row]);
expect(getCachedObjectBrowserRows(third)).toEqual([row]);
});
it("rejects a late cache write started before matching metadata invalidation", async () => {
const invalidatedScope = { connectionId: "c1", database: "db1", schema: "public" };
const isolatedScope = { connectionId: "c1", database: "db2", schema: "public" };
const invalidatedToken = createObjectBrowserRowsCacheWriteToken(invalidatedScope);
const isolatedToken = createObjectBrowserRowsCacheWriteToken(isolatedScope);
const pendingRows = deferred<readonly ObjectBrowserRow[]>();
const lateWrite = pendingRows.promise.then((rows) => cacheObjectBrowserRows(invalidatedToken, rows));
invalidateObjectBrowserRowsCache({ connectionId: "c1", database: "db1", schema: "public" });
pendingRows.resolve([row]);
expect(await lateWrite).toBeUndefined();
expect(cacheObjectBrowserRows(isolatedToken, [row])).toEqual(expect.any(Number));
expect(getCachedObjectBrowserRows(invalidatedScope)).toBeUndefined();
expect(getCachedObjectBrowserRows(isolatedScope)).toEqual([row]);
});
it("preserves the original object list freshness when statistics update cached rows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T00:00:00Z"));
const scope = { connectionId: "c1", database: "db", schema: "public" };
const token = createObjectBrowserRowsCacheWriteToken(scope);
const fetchedAt = cacheObjectBrowserRows(token, [row]);
vi.advanceTimersByTime(29_000);
cacheObjectBrowserRows(token, [{ ...row, estimatedRows: 42 }], { cachedAt: fetchedAt });
expect(getCachedObjectBrowserRows(scope)?.[0].estimatedRows).toBe(42);
vi.advanceTimersByTime(1_001);
expect(getCachedObjectBrowserRows(scope)).toBeUndefined();
});
it("projects table invalidation onto the object list cache scope", () => {
const scope = { connectionId: "c1", database: "db", schema: "public" };
cacheRows(scope, [{ ...row, estimatedRows: 42 }]);
expect(invalidateObjectBrowserRowsCache({ ...scope, tableName: "users" })).toBe(1);
expect(getCachedObjectBrowserRows(scope)).toBeUndefined();
});
});
@@ -67,10 +67,10 @@ export class MetadataResultCache<T> {
return { value: entry.value, cachedAt: entry.cachedAt, ageMs, stale };
}
set(scope: MetadataScopeInput, value: T): void {
set(scope: MetadataScopeInput, value: T, options?: { cachedAt?: number }): void {
const key = metadataScopeKey(scope);
this.entries.delete(key);
this.entries.set(key, { scope: metadataScopeParts(scope), value, cachedAt: this.now() });
this.entries.set(key, { scope: metadataScopeParts(scope), value, cachedAt: options?.cachedAt ?? this.now() });
this.evictOldest();
}
@@ -0,0 +1,95 @@
import { metadataScopeKey, metadataScopeParts, type MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
import { metadataCacheInvalidationMatcher, MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
const OBJECT_BROWSER_ROWS_CACHE_TTL_MS = 30_000;
const OBJECT_BROWSER_ROWS_CACHE_MAX_ENTRIES = 24;
export interface ObjectBrowserRowsCacheScope {
connectionId: string;
database: string;
schema: string;
catalog?: string;
}
export interface ObjectBrowserRowsCacheWriteToken {
generation: number;
scope: Readonly<ObjectBrowserRowsCacheScope>;
}
interface ObjectBrowserRowsCacheGeneration {
generation: number;
scope: ReturnType<typeof metadataScopeParts>;
}
const objectBrowserRowsCache = new MetadataResultCache<ObjectBrowserRow[]>({
ttlMs: OBJECT_BROWSER_ROWS_CACHE_TTL_MS,
maxEntries: OBJECT_BROWSER_ROWS_CACHE_MAX_ENTRIES,
now: () => Date.now(),
});
const objectBrowserRowsCacheGenerations = new Map<string, ObjectBrowserRowsCacheGeneration>();
function objectBrowserRowsScope(scope: ObjectBrowserRowsCacheScope): MetadataScopeInput {
return {
kind: "object-browser-rows",
connectionId: scope.connectionId,
database: scope.database,
schema: scope.schema,
extra: scope.catalog ? { catalog: scope.catalog } : undefined,
};
}
function cloneRows(rows: readonly ObjectBrowserRow[]): ObjectBrowserRow[] {
return rows.map((row) => ({ ...row }));
}
function objectBrowserRowsCacheGeneration(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRowsCacheGeneration {
const cacheScope = objectBrowserRowsScope(scope);
const key = metadataScopeKey(cacheScope);
let state = objectBrowserRowsCacheGenerations.get(key);
if (!state) {
state = { generation: 0, scope: metadataScopeParts(cacheScope) };
objectBrowserRowsCacheGenerations.set(key, state);
}
return state;
}
function objectBrowserRowsCacheInvalidation(match: MetadataCacheInvalidation): MetadataCacheInvalidation {
const projected: MetadataCacheInvalidation = {};
if (match.kind !== undefined) projected.kind = match.kind;
if (match.connectionId !== undefined) projected.connectionId = match.connectionId;
if (match.database !== undefined) projected.database = match.database;
if (match.schema !== undefined) projected.schema = match.schema;
return projected;
}
export function getCachedObjectBrowserRows(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRow[] | undefined {
const hit = objectBrowserRowsCache.get(objectBrowserRowsScope(scope));
return hit ? cloneRows(hit.value) : undefined;
}
export function createObjectBrowserRowsCacheWriteToken(scope: ObjectBrowserRowsCacheScope): ObjectBrowserRowsCacheWriteToken {
const frozenScope = Object.freeze({ ...scope });
return Object.freeze({ generation: objectBrowserRowsCacheGeneration(frozenScope).generation, scope: frozenScope });
}
export function cacheObjectBrowserRows(token: ObjectBrowserRowsCacheWriteToken, rows: readonly ObjectBrowserRow[], options?: { cachedAt?: number }): number | undefined {
if (objectBrowserRowsCacheGeneration(token.scope).generation !== token.generation) return undefined;
const cachedAt = options?.cachedAt ?? Date.now();
objectBrowserRowsCache.set(objectBrowserRowsScope(token.scope), cloneRows(rows), { cachedAt });
return cachedAt;
}
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++;
}
return objectBrowserRowsCache.invalidate(projected);
}
export function clearObjectBrowserRowsCache(): void {
objectBrowserRowsCache.clear();
for (const state of objectBrowserRowsCacheGenerations.values()) state.generation++;
}
@@ -0,0 +1,23 @@
import type { ObjectBrowserRowsCacheScope } from "@/lib/table/objectBrowserRowsCache";
export interface ObjectBrowserRowsLoadHandle {
epoch: number;
scope: Readonly<ObjectBrowserRowsCacheScope>;
}
export interface ObjectBrowserRowsLoadGuard {
invalidate: () => number;
start: (scope: ObjectBrowserRowsCacheScope) => ObjectBrowserRowsLoadHandle;
isEpochCurrent: (epoch: number) => boolean;
isCurrent: (handle: ObjectBrowserRowsLoadHandle) => boolean;
}
export function createObjectBrowserRowsLoadGuard(): ObjectBrowserRowsLoadGuard {
let epoch = 0;
return {
invalidate: () => ++epoch,
start: (scope) => ({ epoch: ++epoch, scope: Object.freeze({ ...scope }) }),
isEpochCurrent: (capturedEpoch) => capturedEpoch === epoch,
isCurrent: (handle) => handle.epoch === epoch,
};
}