修复前端长时间挂机内存累积:城市缓存 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,
|
string,
|
||||||
{ state: AiCityForecastState; updatedAt: number }
|
{ 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) {
|
function isHkoObservationCity(detail?: CityDetail | null) {
|
||||||
const source = String(
|
const source = String(
|
||||||
@@ -100,6 +110,7 @@ export function writeCachedAiForecastState(
|
|||||||
state,
|
state,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
trimAiForecastCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
|
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
|
||||||
|
|||||||
@@ -659,6 +659,42 @@ export function DashboardStoreProvider({
|
|||||||
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
||||||
Record<string, { cachedAt: number; revision: string }>
|
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 [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||||
const [selectedForecastDate, setSelectedForecastDate] = useState<
|
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 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();
|
const cityName = (row.city || row.city_display_name || row.display_name || "").trim();
|
||||||
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
|
if (!cityName || findCachedCityDetail(cityDetailsByName, cityName)) return;
|
||||||
|
touchCityLru(cityName);
|
||||||
// Pre-populate cache from scan terminal row so detail panel shows data immediately
|
// Pre-populate cache from scan terminal row so detail panel shows data immediately
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const currentTemp = Number(row.current_temp ?? row.current_max_so_far);
|
const currentTemp = Number(row.current_temp ?? row.current_max_so_far);
|
||||||
@@ -837,6 +874,7 @@ export function DashboardStoreProvider({
|
|||||||
force = false,
|
force = false,
|
||||||
depth: CityDetailDepth = "panel",
|
depth: CityDetailDepth = "panel",
|
||||||
) => {
|
) => {
|
||||||
|
touchCityLru(cityName);
|
||||||
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
const cached = findCachedCityDetail(cityDetailsByName, cityName);
|
||||||
const cachedMeta = cityDetailMetaByName[cityName];
|
const cachedMeta = cityDetailMetaByName[cityName];
|
||||||
const marketTargetDate =
|
const marketTargetDate =
|
||||||
@@ -1146,6 +1184,7 @@ export function DashboardStoreProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const ensureCitySummary = async (cityName: string, force = false) => {
|
const ensureCitySummary = async (cityName: string, force = false) => {
|
||||||
|
touchCityLru(cityName);
|
||||||
const existing = citySummariesRef.current[cityName];
|
const existing = citySummariesRef.current[cityName];
|
||||||
if (!force && existing) {
|
if (!force && existing) {
|
||||||
return existing;
|
return existing;
|
||||||
@@ -1346,7 +1385,9 @@ export function DashboardStoreProvider({
|
|||||||
const refreshAll = async () => {
|
const refreshAll = async () => {
|
||||||
dashboardClient.clearCityDetailCache();
|
dashboardClient.clearCityDetailCache();
|
||||||
setCityDetailsByName({});
|
setCityDetailsByName({});
|
||||||
|
setCitySummariesByName({});
|
||||||
setCityDetailMetaByName({});
|
setCityDetailMetaByName({});
|
||||||
|
cityLruRef.current = [];
|
||||||
if (!citiesRef.current.length) {
|
if (!citiesRef.current.length) {
|
||||||
await loadCities();
|
await loadCities();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user