perf: reuse historical mileage range snapshots
This commit is contained in:
@@ -180,6 +180,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
|
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
|
||||||
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
|
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
|
||||||
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
|
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
|
||||||
|
- 里程历史区间监控已复用权限过滤前的分钟级基础快照;筛选、排序、分页和并发同参请求不再重复拉取 OneOS 区间数据及车辆元数据,强制刷新仍可绕过已完成快照,失败请求不进入缓存。
|
||||||
- 已完成电能数据截至时间和实际趋势月展示。
|
- 已完成电能数据截至时间和实际趋势月展示。
|
||||||
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
|
||||||
- 已完成氢能结构化故障状态与重试体验。
|
- 已完成氢能结构化故障状态与重试体验。
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
storeManualMonitoringSnapshot,
|
storeManualMonitoringSnapshot,
|
||||||
} from './manual-snapshot.js';
|
} from './manual-snapshot.js';
|
||||||
import { sumMileageKm } from './precision.js';
|
import { sumMileageKm } from './precision.js';
|
||||||
|
import {
|
||||||
|
rangeMileageSnapshotCache,
|
||||||
|
type RangeSnapshotStatus,
|
||||||
|
} from './range-snapshot.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -148,7 +152,7 @@ app.get('/', async (c) => {
|
|||||||
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
let rangeDailyTotals: { date: string; totalKm: number }[] | undefined;
|
||||||
let dateRange: { start: string; end: string } | undefined;
|
let dateRange: { start: string; end: string } | undefined;
|
||||||
let dataUpdatedAt: 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) {
|
if (range) {
|
||||||
const cache = getCache();
|
const cache = getCache();
|
||||||
@@ -193,7 +197,14 @@ app.get('/', async (c) => {
|
|||||||
} else {
|
} else {
|
||||||
cacheStatus = cacheEligible ? 'miss' : 'bypass';
|
cacheStatus = cacheEligible ? 'miss' : 'bypass';
|
||||||
try {
|
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;
|
allVehicles = result.vehicles;
|
||||||
rangeDailyTotals = result.dailyTotals;
|
rangeDailyTotals = result.dailyTotals;
|
||||||
dateRange = { start: result.start, end: result.end };
|
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