perf: reuse historical mileage range snapshots
This commit is contained in:
@@ -180,6 +180,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
||||
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
|
||||
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
|
||||
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
|
||||
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
|
||||
- 已完成电能数据截至时间和实际趋势月展示。
|
||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||
- 已完成氢能结构化故障状态与重试体验。
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user