修复前端长时间挂机内存累积:城市缓存 LRU 逐出 + AI 预测缓存上限
Constraint: cityDetailsByName/citySummariesByName/cityDetailMetaByName 只增不减,52 城全量可达 ~5MB+ Constraint: aiCityForecastStateCache 无上限,随城市×日期组合膨胀 Tested: tsc --noEmit OK, ruff OK, pytest 184 passed
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user