修复前端长时间挂机内存累积:城市缓存 LRU 逐出 + AI 预测缓存上限

Constraint: cityDetailsByName/citySummariesByName/cityDetailMetaByName 只增不减,52 城全量可达 ~5MB+
Constraint: aiCityForecastStateCache 无上限,随城市×日期组合膨胀
Tested: tsc --noEmit OK, ruff OK, pytest 184 passed
This commit is contained in:
2569718930@qq.com
2026-05-23 21:18:32 +08:00
parent 3bad968844
commit 864b1fa1f9
2 changed files with 52 additions and 0 deletions
@@ -20,6 +20,16 @@ const aiCityForecastStateCache = new Map<
string,
{ state: AiCityForecastState; updatedAt: number }
>();
const MAX_AI_FORECAST_CACHE_SIZE = 40;
function trimAiForecastCache() {
if (aiCityForecastStateCache.size <= MAX_AI_FORECAST_CACHE_SIZE) return;
const excess = aiCityForecastStateCache.size - MAX_AI_FORECAST_CACHE_SIZE;
const keys = Array.from(aiCityForecastStateCache.keys());
for (let i = 0; i < excess && i < keys.length; i++) {
aiCityForecastStateCache.delete(keys[i]);
}
}
function isHkoObservationCity(detail?: CityDetail | null) {
const source = String(
@@ -100,6 +110,7 @@ export function writeCachedAiForecastState(
state,
updatedAt: Date.now(),
});
trimAiForecastCache();
}
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
+41
View File
@@ -659,6 +659,42 @@ export function DashboardStoreProvider({
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record<string, { cachedAt: number; revision: string }>
>({});
const MAX_CACHED_CITIES = 20;
const cityLruRef = useRef<string[]>([]);
const touchCityLru = (cityName: string) => {
cityLruRef.current = [
...cityLruRef.current.filter((c) => c !== cityName),
cityName,
];
};
// Evict LRU cities when cache exceeds limit
useEffect(() => {
const detailsCount = Object.keys(cityDetailsByName).length;
if (detailsCount <= MAX_CACHED_CITIES) return;
const lru = cityLruRef.current;
const excess = detailsCount - MAX_CACHED_CITIES;
const toEvict = lru.slice(0, excess);
if (!toEvict.length) return;
cityLruRef.current = lru.slice(excess);
setCityDetailsByName((current) => {
const next = { ...current };
toEvict.forEach((c) => delete next[c]);
return next;
});
setCitySummariesByName((current) => {
const next = { ...current };
toEvict.forEach((c) => delete next[c]);
return next;
});
setCityDetailMetaByName((current) => {
const next = { ...current };
toEvict.forEach((c) => delete next[c]);
return next;
});
}, [cityDetailsByName]);
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [selectedForecastDate, setSelectedForecastDate] = useState<
@@ -766,6 +802,7 @@ export function DashboardStoreProvider({
const preloadCityFromRow = (row: { city?: string | null; city_display_name?: string | null; display_name?: string | null; [key: string]: unknown }) => {
const cityName = (row.city || row.city_display_name || row.display_name || "").trim();
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
touchCityLru(cityName);
// Pre-populate cache from scan terminal row so detail panel shows data immediately
const now = Date.now();
const currentTemp = Number(row.current_temp ?? row.current_max_so_far);
@@ -837,6 +874,7 @@ export function DashboardStoreProvider({
force = false,
depth: CityDetailDepth = "panel",
) => {
touchCityLru(cityName);
const cached = findCachedCityDetail(cityDetailsByName, cityName);
const cachedMeta = cityDetailMetaByName[cityName];
const marketTargetDate =
@@ -1146,6 +1184,7 @@ export function DashboardStoreProvider({
}, []);
const ensureCitySummary = async (cityName: string, force = false) => {
touchCityLru(cityName);
const existing = citySummariesRef.current[cityName];
if (!force && existing) {
return existing;
@@ -1346,7 +1385,9 @@ export function DashboardStoreProvider({
const refreshAll = async () => {
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCitySummariesByName({});
setCityDetailMetaByName({});
cityLruRef.current = [];
if (!citiesRef.current.length) {
await loadCities();
}