️ perf(store): 限制表访问频次状态增长

This commit is contained in:
Syngnat
2026-07-22 09:35:41 +08:00
parent 78880cfce5
commit 23d95f2771
6 changed files with 364 additions and 26 deletions

View File

@@ -9,6 +9,7 @@ import {
} from '../store';
import type { ConnectionTag, SavedConnection } from '../types';
import type { SidebarTableMetadataField } from '../utils/sidebarTableMetadata';
import { readTableAccessCount } from '../utils/tableAccessCount';
import { t } from '../i18n';
import { t as catalogTranslate } from '../i18n/catalog';
import {
@@ -125,10 +126,18 @@ export const sortSidebarTableEntries = <T extends SidebarTableEntryForSort>(
const compareByName = (a: T, b: T) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase());
const compareWithinPinnedGroup = (a: T, b: T) => {
if (options.sortBy === 'frequency') {
const keyA = `${options.connectionId}-${options.dbName}-${a.tableName}`;
const keyB = `${options.connectionId}-${options.dbName}-${b.tableName}`;
const countA = accessCount[keyA] || 0;
const countB = accessCount[keyB] || 0;
const countA = readTableAccessCount(
accessCount,
options.connectionId,
options.dbName,
a.tableName,
);
const countB = readTableAccessCount(
accessCount,
options.connectionId,
options.dbName,
b.tableName,
);
if (countA !== countB) {
return countB - countA;
}

View File

@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SIDEBAR_RESIZE_MAX_WIDTH } from './utils/sidebarLayout';
import type { AIChatMessage } from './types';
import {
buildLegacyTableAccessCountKey,
buildTableAccessCountKey,
MAX_TABLE_ACCESS_COUNT_ENTRIES,
} from './utils/tableAccessCount';
class MemoryStorage implements Storage {
private data = new Map<string, string>();
@@ -1535,6 +1540,130 @@ describe('store appearance persistence', () => {
)?.connectionIds).toEqual(['host-2']);
});
it('bounds hydrated table access counts while retaining frequent and recent entries', async () => {
const tableAccessCount = Object.fromEntries([
['priority-main-users', 100],
...Array.from(
{ length: MAX_TABLE_ACCESS_COUNT_ENTRIES + 1 },
(_, index) => [`connection-${index}-main-table`, 1] as const,
),
]);
storage.setItem('lite-db-storage', JSON.stringify({
state: { tableAccessCount },
version: 17,
}));
const { useStore } = await importStore();
const hydrated = useStore.getState().tableAccessCount;
expect(Object.keys(hydrated)).toHaveLength(MAX_TABLE_ACCESS_COUNT_ENTRIES);
expect(hydrated['priority-main-users']).toBe(100);
expect(hydrated['connection-0-main-table']).toBeUndefined();
expect(hydrated[`connection-${MAX_TABLE_ACCESS_COUNT_ENTRIES}-main-table`]).toBe(1);
});
it('bounds directly injected table access counts before persistence', async () => {
const { useStore } = await importStore();
useStore.setState({
tableAccessCount: Object.fromEntries(
Array.from(
{ length: MAX_TABLE_ACCESS_COUNT_ENTRIES + 10 },
(_, index) => [`injected-${index}`, index + 1],
),
),
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(Object.keys(persisted.state.tableAccessCount)).toHaveLength(
MAX_TABLE_ACCESS_COUNT_ENTRIES,
);
expect(persisted.state.tableAccessCount['injected-0']).toBeUndefined();
expect(persisted.state.tableAccessCount[
`injected-${MAX_TABLE_ACCESS_COUNT_ENTRIES + 9}`
]).toBe(MAX_TABLE_ACCESS_COUNT_ENTRIES + 10);
});
it('bounds runtime table access counts and evicts the oldest least-used entry', async () => {
const { useStore } = await importStore();
useStore.setState({
tableAccessCount: Object.fromEntries(
Array.from(
{ length: MAX_TABLE_ACCESS_COUNT_ENTRIES },
(_, index) => [buildTableAccessCountKey('conn', 'main', `table-${index}`), 1],
),
),
});
useStore.getState().recordTableAccess('conn', 'main', 'table-0');
useStore.getState().recordTableAccess('conn', 'main', 'new-table');
const counts = useStore.getState().tableAccessCount;
expect(Object.keys(counts)).toHaveLength(MAX_TABLE_ACCESS_COUNT_ENTRIES);
expect(counts[buildTableAccessCountKey('conn', 'main', 'table-0')]).toBe(2);
expect(counts[buildTableAccessCountKey('conn', 'main', 'table-1')]).toBeUndefined();
expect(counts[buildTableAccessCountKey('conn', 'main', 'new-table')]).toBe(1);
});
it('uses a legacy table access count and migrates it on the next access', async () => {
const { useStore } = await importStore();
const legacyKey = buildLegacyTableAccessCountKey('conn', 'main', 'users');
const currentKey = buildTableAccessCountKey('conn', 'main', 'users');
useStore.setState({ tableAccessCount: { [legacyKey]: 4 } });
useStore.getState().recordTableAccess('conn', 'main', 'users');
expect(useStore.getState().tableAccessCount).toEqual({ [currentKey]: 5 });
});
it('cleans deleted connection access counts without matching a longer connection id', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections(
['conn', 'conn-prod'].map((id) => ({
id,
name: id,
config: { id, type: 'mysql', host: `${id}.local`, port: 3306, user: 'root' },
})),
);
useStore.setState({
tableAccessCount: {
[buildLegacyTableAccessCountKey('conn', 'main', 'users')]: 3,
[buildLegacyTableAccessCountKey('conn-prod', 'main', 'orders')]: 5,
},
});
useStore.getState().removeConnection('conn');
expect(useStore.getState().tableAccessCount).toEqual({
[buildLegacyTableAccessCountKey('conn-prod', 'main', 'orders')]: 5,
});
});
it('keeps colliding legacy tuples isolated with versioned table access keys', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections(
['conn', 'conn-prod'].map((id) => ({
id,
name: id,
config: { id, type: 'mysql', host: `${id}.local`, port: 3306, user: 'root' },
})),
);
useStore.getState().recordTableAccess('conn', 'prod', 'main-orders');
useStore.getState().recordTableAccess('conn-prod', 'main', 'orders');
expect(buildLegacyTableAccessCountKey('conn', 'prod', 'main-orders')).toBe(
buildLegacyTableAccessCountKey('conn-prod', 'main', 'orders'),
);
expect(buildTableAccessCountKey('conn', 'prod', 'main-orders')).not.toBe(
buildTableAccessCountKey('conn-prod', 'main', 'orders'),
);
useStore.getState().removeConnection('conn');
expect(useStore.getState().tableAccessCount).toEqual({
[buildTableAccessCountKey('conn-prod', 'main', 'orders')]: 1,
});
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -131,6 +131,11 @@ import {
import { supportsSSLForType } from "./utils/connectionTypeCapabilities";
import { normalizeDriverType } from "./utils/connectionDriverType";
import { createDebouncedPersistStorage } from "./utils/debouncedPersistStorage";
import {
incrementTableAccessCount,
removeConnectionTableAccessCounts,
sanitizeTableAccessCount,
} from "./utils/tableAccessCount";
export type TableDoubleClickAction = "open-data" | "open-design";
export type ThemeMode = "light" | "dark";
@@ -2799,20 +2804,6 @@ const sanitizeSqlEditorTransactionOptions = (
};
};
const sanitizeTableAccessCount = (value: unknown): Record<string, number> => {
const raw =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const result: Record<string, number> = {};
Object.entries(raw).forEach(([key, count]) => {
const parsed = Number(count);
if (!Number.isFinite(parsed) || parsed < 0) return;
result[key] = Math.trunc(parsed);
});
return result;
};
const sanitizeTableSortPreference = (
value: unknown,
): Record<string, "name" | "frequency"> => {
@@ -3352,7 +3343,7 @@ const buildPersistedStateProjection = (
state.tableExportHistories,
),
sqlSnippets: state.sqlSnippets,
tableAccessCount: state.tableAccessCount,
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
@@ -3532,6 +3523,11 @@ export const useStore = create<AppState>()(
recentSQLFiles: state.recentSQLFiles.filter(
(file) => file.connectionId !== id,
),
tableAccessCount: removeConnectionTableAccessCounts(
state.tableAccessCount,
id,
nextConnections.map((connection) => connection.id),
),
sidebarRootOrder: normalized.sidebarRootOrder,
};
}),
@@ -5067,13 +5063,13 @@ export const useStore = create<AppState>()(
recordTableAccess: (connectionId, dbName, tableName) =>
set((state) => {
const key = `${connectionId}-${dbName}-${tableName}`;
const currentCount = state.tableAccessCount[key] || 0;
return {
tableAccessCount: {
...state.tableAccessCount,
[key]: currentCount + 1,
},
tableAccessCount: incrementTableAccessCount(
state.tableAccessCount,
connectionId,
dbName,
tableName,
),
};
}),

View File

@@ -30,6 +30,7 @@ import {
getQueryTabDraft,
setQueryTabDraft,
} from './sqlFileTabDrafts';
import { MAX_TABLE_ACCESS_COUNT_ENTRIES } from './tableAccessCount';
const queryTab: TabData = {
id: 'query-1',
@@ -212,6 +213,26 @@ describe('nativeDetachedWindowClient', () => {
});
});
it('bounds table access counts merged from detached workbench windows', () => {
const current = Object.fromEntries(
Array.from(
{ length: MAX_TABLE_ACCESS_COUNT_ENTRIES },
(_, index) => [`current-${index}`, index + 2],
),
);
const merged = mergeNativeDetachedStoreDelta(
{ tableAccessCount: current },
{ tableAccessCount: {} },
{ tableAccessCount: { 'child-new': 1 } },
);
expect(Object.keys(merged.tableAccessCount as object)).toHaveLength(
MAX_TABLE_ACCESS_COUNT_ENTRIES,
);
expect((merged.tableAccessCount as Record<string, number>)['current-0']).toBe(2);
expect((merged.tableAccessCount as Record<string, number>)['child-new']).toBeUndefined();
});
it('merges identity-based array deltas without deleting concurrent peer additions', () => {
expect(mergeNativeDetachedStoreDelta(
{

View File

@@ -7,6 +7,7 @@ import type { QueryEditorResultSessionSnapshot } from './queryEditorResultSessio
import { isNativeDetachedWindowRoute } from './nativeDetachedWindowRoute';
import { resolveLiveQueryTab, resolveLiveQueryTabs } from './liveQueryTabs';
import { setQueryTabDraft } from './sqlFileTabDrafts';
import { sanitizeTableAccessCount } from './tableAccessCount';
export const NATIVE_DETACHED_BOOTSTRAP_URL = '/__gonavi/detached/bootstrap';
export const NATIVE_DETACHED_ACTION_URL = '/__gonavi/detached/action';
@@ -549,12 +550,15 @@ export const mergeNativeDetachedStoreDelta = (
const nextState = { ...currentState };
for (const [key, nextSourceValue] of Object.entries(changedSource)) {
if (UNSAFE_OBJECT_KEYS.has(key)) continue;
nextState[key] = mergeNativeDetachedValueDelta(
const mergedValue = mergeNativeDetachedValueDelta(
currentState[key],
previousSource[key],
nextSourceValue,
[key],
);
nextState[key] = key === 'tableAccessCount'
? sanitizeTableAccessCount(mergedValue)
: mergedValue;
}
return nextState;
};

View File

@@ -0,0 +1,179 @@
export const MAX_TABLE_ACCESS_COUNT_ENTRIES = 2048;
const TABLE_ACCESS_COUNT_KEY_PREFIX = "v2:";
const normalizeTableAccessCount = (value: unknown): number | null => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return Math.min(Math.trunc(parsed), Number.MAX_SAFE_INTEGER);
};
export const buildTableAccessCountKey = (
connectionId: string,
dbName: string,
tableName: string,
): string => `${TABLE_ACCESS_COUNT_KEY_PREFIX}${JSON.stringify([
connectionId,
dbName,
tableName,
])}`;
export const buildLegacyTableAccessCountKey = (
connectionId: string,
dbName: string,
tableName: string,
): string => `${connectionId}-${dbName}-${tableName}`;
const parseTableAccessCountKey = (key: string): [string, string, string] | null => {
if (!key.startsWith(TABLE_ACCESS_COUNT_KEY_PREFIX)) {
return null;
}
try {
const parsed = JSON.parse(key.slice(TABLE_ACCESS_COUNT_KEY_PREFIX.length));
return Array.isArray(parsed)
&& parsed.length === 3
&& parsed.every((part) => typeof part === "string")
? parsed as [string, string, string]
: null;
} catch {
return null;
}
};
export const readTableAccessCount = (
value: Record<string, number>,
connectionId: string,
dbName: string,
tableName: string,
): number => {
const current = normalizeTableAccessCount(
value[buildTableAccessCountKey(connectionId, dbName, tableName)],
);
const legacy = normalizeTableAccessCount(
value[buildLegacyTableAccessCountKey(connectionId, dbName, tableName)],
);
return Math.max(current ?? 0, legacy ?? 0);
};
export const sanitizeTableAccessCount = (
value: unknown,
): Record<string, number> => {
const raw =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const rawEntries = Object.entries(raw);
if (
!Array.isArray(value)
&& rawEntries.length <= MAX_TABLE_ACCESS_COUNT_ENTRIES
&& rawEntries.every(([, count]) => (
typeof count === "number"
&& Number.isSafeInteger(count)
&& count >= 0
))
) {
return raw as Record<string, number>;
}
const entries = rawEntries.flatMap(([key, count], index) => {
const normalizedCount = normalizeTableAccessCount(count);
return normalizedCount === null
? []
: [{ key, count: normalizedCount, index }];
});
if (entries.length > MAX_TABLE_ACCESS_COUNT_ENTRIES) {
entries.sort((left, right) => {
if (left.count !== right.count) {
return left.count > right.count ? -1 : 1;
}
// Object insertion order represents recency: prefer newer entries on ties.
return right.index - left.index;
});
entries.length = MAX_TABLE_ACCESS_COUNT_ENTRIES;
entries.sort((left, right) => left.index - right.index);
}
return Object.fromEntries(entries.map(({ key, count }) => [key, count]));
};
export const incrementTableAccessCount = (
value: Record<string, number>,
connectionId: string,
dbName: string,
tableName: string,
): Record<string, number> => {
const source =
Object.keys(value).length > MAX_TABLE_ACCESS_COUNT_ENTRIES
? sanitizeTableAccessCount(value)
: value;
const key = buildTableAccessCountKey(connectionId, dbName, tableName);
const legacyKey = buildLegacyTableAccessCountKey(connectionId, dbName, tableName);
if (
Object.prototype.hasOwnProperty.call(source, key)
|| Object.prototype.hasOwnProperty.call(source, legacyKey)
) {
const currentCount = readTableAccessCount(
source,
connectionId,
dbName,
tableName,
);
const next = { ...source };
// Reinsert the key so insertion order continues to carry recency for ties.
delete next[key];
delete next[legacyKey];
next[key] = Math.min(currentCount + 1, Number.MAX_SAFE_INTEGER);
return next;
}
const entries = Object.entries(source);
if (entries.length < MAX_TABLE_ACCESS_COUNT_ENTRIES) {
return { ...source, [key]: 1 };
}
let evictionIndex = 0;
let evictionCount = normalizeTableAccessCount(entries[0]?.[1]) ?? 0;
for (let index = 1; index < entries.length; index += 1) {
const candidateCount = normalizeTableAccessCount(entries[index][1]) ?? 0;
if (candidateCount < evictionCount) {
evictionIndex = index;
evictionCount = candidateCount;
}
}
const next: Record<string, number> = {};
entries.forEach(([entryKey, count], index) => {
if (index !== evictionIndex) {
next[entryKey] = count;
}
});
next[key] = 1;
return next;
};
export const removeConnectionTableAccessCounts = (
value: Record<string, number>,
removedConnectionId: string,
remainingConnectionIds: readonly string[],
): Record<string, number> => {
const knownConnectionIds = [removedConnectionId, ...remainingConnectionIds];
const entries = Object.entries(value);
const retainedEntries = entries.filter(([key]) => {
const parsed = parseTableAccessCountKey(key);
if (parsed) {
return parsed[0] !== removedConnectionId;
}
const legacyMatches = knownConnectionIds.filter(
(connectionId) => connectionId && key.startsWith(`${connectionId}-`),
);
// Legacy keys are ambiguous when multiple connection ids match. Keep those
// conservatively so deleting one connection cannot erase another's count.
return legacyMatches.length !== 1
|| legacyMatches[0] !== removedConnectionId;
});
return retainedEntries.length === entries.length
? value
: Object.fromEntries(retainedEntries);
};