mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 08:53:46 +08:00
⚡️ perf(store): 限制表访问频次状态增长
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
179
frontend/src/utils/tableAccessCount.ts
Normal file
179
frontend/src/utils/tableAccessCount.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user