fix(objects): avoid repeated object list reloads
This commit is contained in:
8 files changed
+390
-48
No files matched your search
@@ -117,6 +117,8 @@ import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableI
|
||||
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||||
import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache";
|
||||
import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard";
|
||||
|
||||
type ObjectFilter = ObjectBrowserFilter;
|
||||
type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
|
||||
@@ -244,7 +246,7 @@ const objectColumnWidths = ref<Record<ObjectBrowserColumnKey, number>>({
|
||||
updated_at: 150,
|
||||
comment: 260,
|
||||
});
|
||||
let loadId = 0;
|
||||
const objectBrowserRowsLoadGuard = createObjectBrowserRowsLoadGuard();
|
||||
let stopColumnResize: (() => void) | null = null;
|
||||
let preserveObjectFilterScrollOnce = false;
|
||||
|
||||
@@ -2151,72 +2153,104 @@ async function saveSource() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchemas() {
|
||||
async function loadSchemas(epoch: number): Promise<boolean> {
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false;
|
||||
if (!needsSchema.value) {
|
||||
schemas.value = [];
|
||||
selectedSchema.value = undefined;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
loadingSchemas.value = true;
|
||||
const connectionId = props.connection.id;
|
||||
const database = props.database;
|
||||
try {
|
||||
const names = await api.listSchemas(props.connection.id, props.database);
|
||||
const names = await api.listSchemas(connectionId, database);
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false;
|
||||
schemas.value = names;
|
||||
if (!selectedSchema.value || !names.includes(selectedSchema.value)) {
|
||||
selectedSchema.value = names.includes("public") ? "public" : names[0];
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
loadingSchemas.value = false;
|
||||
if (objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) loadingSchemas.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadObjects() {
|
||||
const id = ++loadId;
|
||||
loadingObjects.value = true;
|
||||
function objectBrowserRowsCacheScope(schema: string): ObjectBrowserRowsCacheScope {
|
||||
return {
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema,
|
||||
catalog: props.catalog,
|
||||
};
|
||||
}
|
||||
|
||||
function applyObjectBrowserRows(nextRows: ObjectBrowserRow[]) {
|
||||
rows.value = nextRows;
|
||||
const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id));
|
||||
setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id))));
|
||||
expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount)));
|
||||
}
|
||||
|
||||
function finishObjectBrowserRowsLoad() {
|
||||
loadingObjects.value = false;
|
||||
if (!userHasSelectedFilter.value && objectCounts.value.tables > 0) {
|
||||
// The default table filter is a presentation choice, not a user query
|
||||
// change, so preserve the tab's saved scroll offset across remounts.
|
||||
preserveObjectFilterScrollOnce = objectFilter.value !== "tables";
|
||||
objectFilter.value = "tables";
|
||||
}
|
||||
restoreObjectBrowserViewport();
|
||||
}
|
||||
|
||||
async function loadObjects(options?: { allowCached?: boolean }) {
|
||||
error.value = "";
|
||||
rows.value = [];
|
||||
try {
|
||||
const schema = needsSchema.value ? selectedSchema.value || "" : props.database;
|
||||
const objects: ObjectInfo[] = await api.listObjects(props.connection.id, props.database, schema, undefined, undefined, undefined, undefined, props.catalog);
|
||||
if (id !== loadId) return;
|
||||
rows.value = buildObjectBrowserRows({
|
||||
objects,
|
||||
database: props.database,
|
||||
fallbackSchema: schema,
|
||||
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
|
||||
});
|
||||
const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id));
|
||||
setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id))));
|
||||
expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount)));
|
||||
void loadObjectStatistics(id, schema);
|
||||
} catch (e: any) {
|
||||
if (id !== loadId) return;
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (id === loadId) {
|
||||
loadingObjects.value = false;
|
||||
if (!userHasSelectedFilter.value && objectCounts.value.tables > 0) {
|
||||
// The default table filter is a presentation choice, not a user query
|
||||
// change, so preserve the tab's saved scroll offset across remounts.
|
||||
preserveObjectFilterScrollOnce = objectFilter.value !== "tables";
|
||||
objectFilter.value = "tables";
|
||||
}
|
||||
restoreObjectBrowserViewport();
|
||||
const schema = needsSchema.value ? selectedSchema.value || "" : props.database;
|
||||
const request = objectBrowserRowsLoadGuard.start(objectBrowserRowsCacheScope(schema));
|
||||
const cacheWriteToken = createObjectBrowserRowsCacheWriteToken(request.scope);
|
||||
if (options?.allowCached) {
|
||||
const cachedRows = getCachedObjectBrowserRows(request.scope);
|
||||
if (cachedRows) {
|
||||
applyObjectBrowserRows(cachedRows);
|
||||
finishObjectBrowserRowsLoad();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loadingObjects.value = true;
|
||||
rows.value = [];
|
||||
try {
|
||||
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
|
||||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||||
const nextRows = buildObjectBrowserRows({
|
||||
objects,
|
||||
database: request.scope.database,
|
||||
fallbackSchema: request.scope.schema,
|
||||
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
|
||||
});
|
||||
applyObjectBrowserRows(nextRows);
|
||||
const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows);
|
||||
void loadObjectStatistics(request, cacheWriteToken, cachedAt);
|
||||
} catch (e: any) {
|
||||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
if (objectBrowserRowsLoadGuard.isCurrent(request)) finishObjectBrowserRowsLoad();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadObjectStatistics(id: number, schema: string) {
|
||||
async function loadObjectStatistics(request: ObjectBrowserRowsLoadHandle, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) {
|
||||
if (!rows.value.some((row) => row.type === "TABLE")) return;
|
||||
try {
|
||||
const stats = await api.listObjectStatistics(props.connection.id, props.database, schema);
|
||||
if (id !== loadId || stats.length === 0) return;
|
||||
mergeObjectStatistics(stats, schema);
|
||||
const stats = await api.listObjectStatistics(request.scope.connectionId, request.scope.database, request.scope.schema);
|
||||
if (!objectBrowserRowsLoadGuard.isCurrent(request) || stats.length === 0) return;
|
||||
mergeObjectStatistics(stats, request.scope.schema, cacheWriteToken, cachedAt);
|
||||
} catch (e) {
|
||||
console.debug("[ObjectBrowser] table statistics unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeObjectStatistics(stats: ObjectStatistics[], fallbackSchema: string) {
|
||||
function mergeObjectStatistics(stats: ObjectStatistics[], fallbackSchema: string, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) {
|
||||
const statsByKey = new Map(stats.map((stat) => [objectStatisticKey(stat.schema || fallbackSchema, stat.name), stat]));
|
||||
rows.value = rows.value.map((row) => {
|
||||
if (row.type !== "TABLE") return row;
|
||||
@@ -2228,6 +2262,7 @@ function mergeObjectStatistics(stats: ObjectStatistics[], fallbackSchema: string
|
||||
totalBytes: normalizeStatisticNumber(stat.total_bytes),
|
||||
};
|
||||
});
|
||||
cacheObjectBrowserRows(cacheWriteToken, rows.value, { cachedAt });
|
||||
}
|
||||
|
||||
function objectStatisticKey(schema: string | undefined, name: string) {
|
||||
@@ -2238,9 +2273,11 @@ function normalizeStatisticNumber(value: number | null | undefined): number | nu
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await loadSchemas();
|
||||
await loadObjects();
|
||||
async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number }) {
|
||||
const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate();
|
||||
if (!(await loadSchemas(epoch))) return;
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return;
|
||||
await loadObjects({ allowCached: options?.allowCachedObjects });
|
||||
}
|
||||
|
||||
function refresh(): boolean {
|
||||
@@ -2305,12 +2342,14 @@ function onSearchKeydown(event: KeyboardEvent) {
|
||||
defineExpose({ focusSearch, refresh });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
objectBrowserRowsLoadGuard.invalidate();
|
||||
stopColumnResize?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.connection.id, () => props.database, () => props.schema],
|
||||
async () => {
|
||||
const contextEpoch = objectBrowserRowsLoadGuard.invalidate();
|
||||
selectedSchema.value = props.schema;
|
||||
userHasSelectedFilter.value = false;
|
||||
objectFilter.value = "all";
|
||||
@@ -2329,7 +2368,8 @@ watch(
|
||||
} catch (e) {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connection.id, e);
|
||||
}
|
||||
void reload();
|
||||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(contextEpoch)) return;
|
||||
void reload({ allowCachedObjects: true, contextEpoch });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -110,6 +110,7 @@ import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator,
|
||||
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
|
||||
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
|
||||
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||||
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
|
||||
import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle";
|
||||
import i18n from "@/i18n";
|
||||
@@ -1471,7 +1472,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
|
||||
function invalidateMetadataCaches(match: MetadataCacheInvalidation): number {
|
||||
return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match);
|
||||
return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match) + invalidateObjectBrowserRowsCache(match);
|
||||
}
|
||||
|
||||
function invalidateMetadataCachesByTreePrefix(prefix: string) {
|
||||
@@ -2601,6 +2602,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
activeConnectionId.value = null;
|
||||
}
|
||||
invalidateCompletionCache(connectionId);
|
||||
invalidateObjectBrowserRowsCache({ connectionId });
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
|
||||
@@ -2645,6 +2647,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
clearLoadedChildrenCache(node.id);
|
||||
}
|
||||
invalidateCompletionCache(connectionId, database);
|
||||
invalidateObjectBrowserRowsCache({ connectionId, database });
|
||||
}
|
||||
|
||||
async function ensureConnected(connectionId: string) {
|
||||
|
||||
Reference in new issue
Block a user