Optimize terminal detail loading and Redis replay

This commit is contained in:
2569718930@qq.com
2026-05-31 04:32:48 +08:00
parent 2a6d8748f4
commit 46effcd45f
16 changed files with 948 additions and 80 deletions
@@ -61,6 +61,7 @@ import {
mergeScanRowsWithCityFallbackRows,
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
const TrainingDashboard = dynamic(
() =>
@@ -1218,7 +1219,9 @@ function ScanTerminalScreen() {
refreshScanTerminalManually();
}, [refreshScanTerminalManually]);
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>([]);
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
cityListItemsToScanRows(STATIC_CITY_LIST),
);
const rows = useMemo(
() => {
const scanRows = terminalData?.rows || [];
@@ -121,9 +121,11 @@ export function runTests() {
assert(
dashboardSource.includes("cityListItemsToScanRows") &&
dashboardSource.includes("STATIC_CITY_LIST") &&
dashboardSource.includes("useState<ScanOpportunityRow[]>(() =>") &&
dashboardSource.includes("/api/cities") &&
dashboardSource.includes("cityFallbackRows"),
"terminal dashboard should use /api/cities fallback rows when scan terminal rows are not ready",
"terminal dashboard should seed fallback rows from the static city snapshot before refreshing /api/cities",
);
assert(fs.existsSync(staticCitiesPath), "/api/cities route should have a static city snapshot fallback");
assert(
@@ -85,6 +85,17 @@ export async function runTests() {
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
);
assert(
chartLogicSource.includes("/api/cities/detail-batch") &&
chartLogicSource.includes("flushCityDetailBatch") &&
chartLogicSource.includes("primeCityDetailCache"),
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
);
assert(
chartLogicSource.includes("options.ignoreCache\n ? runQueuedHourlyDetailRequest") &&
chartLogicSource.includes(": queueCityDetailBatch(city, resParam)"),
"normal first-paint city detail requests should enter the batch queue before single-request concurrency limiting",
);
assert(
chartSource.includes("IntersectionObserver") &&
chartSource.includes("shouldFetchCityDetailForChart") &&
@@ -965,6 +965,26 @@ type HourlyForecastFetchOptions = {
resolution?: string;
};
type CityDetailBatchPayload = {
details?: Record<string, CityDetail | null | undefined>;
errors?: Record<string, string>;
};
type CityDetailBatchWaiter = {
resolve: (value: HourlyForecast) => void;
reject: (reason?: unknown) => void;
};
type CityDetailBatchQueue = {
cities: Set<string>;
waiters: Map<string, CityDetailBatchWaiter[]>;
timer: ReturnType<typeof setTimeout> | null;
};
const CITY_DETAIL_BATCH_WINDOW_MS = 25;
const CITY_DETAIL_BATCH_MAX_CITIES = 12;
const _cityDetailBatchQueues = new Map<string, CityDetailBatchQueue>();
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
if (!json || !hourlySource) return null;
@@ -993,6 +1013,133 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
};
}
function primeCityDetailCache(
city: string,
resolution: string,
detail: CityDetail | null | undefined,
): HourlyForecast {
const data = parseHourlyForecastFromCityDetail(detail || null);
if (!data) return null;
const cacheKey = `${city}:${resolution}`;
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
writeSessionCache(cacheKey, data);
return data;
}
async function fetchSingleHourlyForecastForCity(
city: string,
resolution: string,
): Promise<HourlyForecast> {
const res = await fetchCityDetailWithTimeout(city, resolution);
if (!res || !res.ok) return null;
const json = await res.json() as CityDetail;
return primeCityDetailCache(city, resolution, json);
}
function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyForecast> {
return new Promise<HourlyForecast>((resolve, reject) => {
const queue = _cityDetailBatchQueues.get(resolution) || {
cities: new Set<string>(),
waiters: new Map<string, CityDetailBatchWaiter[]>(),
timer: null,
};
_cityDetailBatchQueues.set(resolution, queue);
const cityWaiters = queue.waiters.get(city) || [];
cityWaiters.push({ resolve, reject });
queue.waiters.set(city, cityWaiters);
queue.cities.add(city);
if (queue.timer === null) {
queue.timer = setTimeout(() => flushCityDetailBatch(resolution), CITY_DETAIL_BATCH_WINDOW_MS);
}
if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) {
flushCityDetailBatch(resolution);
}
});
}
function resolveBatchWaiters(
waiters: CityDetailBatchWaiter[] | undefined,
value: HourlyForecast,
) {
(waiters || []).forEach((waiter) => waiter.resolve(value));
}
function rejectBatchWaiters(
waiters: CityDetailBatchWaiter[] | undefined,
reason: unknown,
) {
(waiters || []).forEach((waiter) => waiter.reject(reason));
}
async function flushCityDetailBatch(resolution: string) {
const queue = _cityDetailBatchQueues.get(resolution);
if (!queue) return;
_cityDetailBatchQueues.delete(resolution);
if (queue.timer !== null) {
clearTimeout(queue.timer);
queue.timer = null;
}
const cities = Array.from(queue.cities).sort();
if (!cities.length) return;
try {
const payload = await fetchCityDetailBatchWithTimeout(cities, resolution);
const details = payload?.details || {};
await Promise.all(
cities.map(async (city) => {
const waiters = queue.waiters.get(city);
const detail = details[city];
const data = primeCityDetailCache(city, resolution, detail);
if (data) {
resolveBatchWaiters(waiters, data);
return;
}
try {
resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution));
} catch (error) {
rejectBatchWaiters(waiters, error);
}
}),
);
} catch (error) {
await Promise.all(
cities.map(async (city) => {
const waiters = queue.waiters.get(city);
try {
resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution));
} catch (fallbackError) {
rejectBatchWaiters(waiters, fallbackError || error);
}
}),
);
}
}
function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
const params = new URLSearchParams({
cities: cities.join(","),
depth: "full",
force_refresh: "false",
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
resolution,
});
return fetch(`/api/cities/detail-batch?${params.toString()}`, {
headers: { Accept: "application/json" },
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) return null;
return res.json() as Promise<CityDetailBatchPayload>;
})
.catch(() => null)
.finally(() => globalThis.clearTimeout(timeoutId));
}
async function fetchHourlyForecastForCity(
city: string,
options: HourlyForecastFetchOptions = {},
@@ -1011,19 +1158,10 @@ async function fetchHourlyForecastForCity(
const pending = _hourlyRequestCache.get(requestKey);
if (pending) return pending;
const request = runQueuedHourlyDetailRequest(() =>
fetchCityDetailWithTimeout(city, resParam)
.then(async (res) => {
if (!res || !res.ok) return null;
return res.json() as Promise<CityDetail>;
})
.then((json) => {
const data = parseHourlyForecastFromCityDetail(json);
if (!data) return null;
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
writeSessionCache(cacheKey, data);
return data;
}),
const request = (
options.ignoreCache
? runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resParam))
: queueCityDetailBatch(city, resParam)
)
.finally(() => {
_hourlyRequestCache.delete(requestKey);