perf: reuse historical mileage range snapshots

This commit is contained in:
kkfluous
2026-08-08 11:07:22 +08:00
parent 559094cdc5
commit 1674f8b931
4 changed files with 203 additions and 2 deletions
+1
View File
@@ -180,6 +180,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
+13 -2
View File
@@ -18,6 +18,10 @@ import {
storeManualMonitoringSnapshot,
} from './manual-snapshot.js';
import { sumMileageKm } from './precision.js';
import {
rangeMileageSnapshotCache,
type RangeSnapshotStatus,
} from './range-snapshot.js';
const app = new Hono();
@@ -148,7 +152,7 @@ app.get('/', async (c) => {
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
let dateRange: { start: string; end: string } | undefined;
let dataUpdatedAt: string | undefined;
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' = 'bypass';
let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' | RangeSnapshotStatus = 'bypass';
if (range) {
const cache = getCache();
@@ -193,7 +197,14 @@ app.get('/', async (c) => {
} else {
cacheStatus = cacheEligible ? 'miss' : 'bypass';
try {
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
const loaded = await rangeMileageSnapshotCache.load({
startDate: range.start,
endDate: range.end,
protocolPriority,
force,
}, () => queryRangeMileage(range.start, range.end, protocolPriority));
const { result } = loaded;
cacheStatus = loaded.status;
allVehicles = result.vehicles;
rangeDailyTotals = result.dailyTotals;
dateRange = { start: result.start, end: result.end };
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { RangeMileageSnapshotCache } from './range-snapshot.js';
import type { RangeMileageResult } from './cache.js';
function result(start = '2026-08-01', end = '2026-08-07'): RangeMileageResult {
return { vehicles: [], dailyTotals: [], start, end };
}
const input = {
startDate: '2026-08-01',
endDate: '2026-08-07',
protocolPriority: ['GB32960'] as const,
};
test('reuses a completed range snapshot until its TTL expires', async () => {
let now = 1_000;
let calls = 0;
const cache = new RangeMileageSnapshotCache({ ttlMs: 60_000, now: () => now });
const loader = async () => {
calls += 1;
return result();
};
assert.equal((await cache.load(input, loader)).status, 'range-miss');
assert.equal((await cache.load(input, loader)).status, 'range-hit');
now = 61_000;
assert.equal((await cache.load(input, loader)).status, 'range-miss');
assert.equal(calls, 2);
});
test('deduplicates concurrent requests for the same range', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
let resolve!: (value: RangeMileageResult) => void;
const pending = new Promise<RangeMileageResult>(done => { resolve = done; });
const loader = async () => {
calls += 1;
return pending;
};
const first = cache.load(input, loader);
const second = cache.load(input, loader);
resolve(result());
assert.equal((await first).status, 'range-miss');
assert.equal((await second).status, 'range-inflight');
assert.equal(calls, 1);
});
test('keeps date ranges and source protocols in separate cache keys', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
return result();
};
await cache.load(input, loader);
await cache.load({ ...input, endDate: '2026-08-06' }, loader);
await cache.load({ ...input, protocolPriority: ['MQTT'] }, loader);
assert.equal(calls, 3);
});
test('force refresh bypasses a completed snapshot', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
return result();
};
await cache.load(input, loader);
const refreshed = await cache.load({ ...input, force: true }, loader);
assert.equal(refreshed.status, 'range-refresh');
assert.equal(calls, 2);
});
test('does not cache failed range requests', async () => {
const cache = new RangeMileageSnapshotCache();
let calls = 0;
const loader = async () => {
calls += 1;
if (calls === 1) throw new Error('upstream unavailable');
return result();
};
await assert.rejects(cache.load(input, loader), /upstream unavailable/);
const recovered = await cache.load(input, loader);
assert.equal(recovered.status, 'range-miss');
assert.equal(calls, 2);
});
@@ -0,0 +1,97 @@
import type { RangeMileageResult } from './cache.js';
import type { OneOsProtocol } from './source-policy.js';
export type RangeSnapshotStatus = 'range-hit' | 'range-miss' | 'range-refresh' | 'range-inflight';
export interface RangeSnapshotLoadResult {
result: RangeMileageResult;
status: RangeSnapshotStatus;
}
interface RangeSnapshotEntry {
result: RangeMileageResult;
expiresAt: number;
}
interface RangeSnapshotCacheOptions {
ttlMs?: number;
maxEntries?: number;
now?: () => number;
}
interface RangeSnapshotInput {
startDate: string;
endDate: string;
protocolPriority: readonly OneOsProtocol[];
force?: boolean;
}
const DEFAULT_TTL_MS = 60 * 1000;
const DEFAULT_MAX_ENTRIES = 8;
function snapshotKey(input: RangeSnapshotInput): string {
return [input.startDate, input.endDate, input.protocolPriority.join('>')].join(':');
}
export class RangeMileageSnapshotCache {
private readonly entries = new Map<string, RangeSnapshotEntry>();
private readonly inflight = new Map<string, Promise<RangeMileageResult>>();
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly now: () => number;
constructor(options: RangeSnapshotCacheOptions = {}) {
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
this.now = options.now ?? Date.now;
}
async load(
input: RangeSnapshotInput,
loader: () => Promise<RangeMileageResult>,
): Promise<RangeSnapshotLoadResult> {
const key = snapshotKey(input);
if (!input.force) {
const entry = this.entries.get(key);
if (entry && entry.expiresAt > this.now()) {
this.entries.delete(key);
this.entries.set(key, entry);
return { result: entry.result, status: 'range-hit' };
}
}
const existing = this.inflight.get(key);
if (existing) {
return { result: await existing, status: 'range-inflight' };
}
const pending = loader()
.then(result => {
this.entries.delete(key);
this.entries.set(key, {
result,
expiresAt: this.now() + this.ttlMs,
});
while (this.entries.size > this.maxEntries) {
const oldest = this.entries.keys().next().value as string | undefined;
if (!oldest) break;
this.entries.delete(oldest);
}
return result;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, pending);
return {
result: await pending,
status: input.force ? 'range-refresh' : 'range-miss',
};
}
clear(): void {
this.entries.clear();
this.inflight.clear();
}
}
export const rangeMileageSnapshotCache = new RangeMileageSnapshotCache();