8000 perf(reports): shorten the refresh after opening an idle app for 0.9.9 by InfinityPacer · Pull Request #37 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [未发布]

## [0.9.9] - 2026-10-13

- 空闲一段时间后打开页面,最新数据出现得更快。服务端每小时把采集器推来的新用量补进报表索引,打开时最多只需补一小时的量,没有新推送时不写盘。
- 报表接口不再返回页面用不到的历史额度重置记录,响应和磁盘上的报表缓存都缩小约三分之二。
- 服务端刷新报表时只读取额度预估用得到的本周期和上一周期读数,订阅与 API 两种口径共用这次读取和窗口汇总,刷新耗时更短,也不再随额度历史变长而变慢。
- 手机上首屏和图表渲染更快,数字与日期格式化不再反复创建格式器。
- 升级后保留已有的报表缓存。如需回滚到 0.9.8,旧版本会清空报表缓存并重新计算一次,首次打开稍慢,账本数据不受影响。

## [0.9.8] - 2026-10-09

- 桌面 Tokens 摘要的环比、每次请求和输出改为同一行排列,与缓存命中率图例的排法一致。
Expand Down
2 changes: 1 addition & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ docker compose up -d --no-build --pull never

`SUB2API_DATABASE_URL` 与 `METERLEAF_INGEST_KEYS` 至少填写一项,未填写的来源不启用。

打开页面时报表会先显示上次结果并立即在后台刷新,自动同步也按可见间隔运行,因此两个较长的默认间隔只影响无人查看时的后台频率,用来减少 NAS 磁盘写入。
打开页面时报表会先显示上次结果并立即在后台刷新,自动同步也按可见间隔运行,因此两个较长的默认间隔只影响无人查看时的后台频率,用来减少 NAS 磁盘写入。采集器推来的新用量每小时补进一次报表索引,没有新推送时不写盘,空闲后打开页面最多只需补最近一小时的量。

Compose 将容器内数据目录、监听地址和端口固定为 `/app/app_data`、`0.0.0.0` 和 `4318`;`METERLEAF_DATA_DIR`、`METERLEAF_HOST`、`METERLEAF_PORT`、`METERLEAF_DEMO` 仅用于直接以 Bun 启动时的本地配置。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.9.8",
"version": "0.9.9",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
74 changes: 48 additions & 26 deletions src/domain/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,12 @@ export type QuotaChargeReader = (
export function quotaModelScope(
window: QuotaFact["window"],
): ((model: string) => boolean) | null {
if (window === "seven-day-fable")
return (model) => model.startsWith("claude-fable-");
return null;
return window === "seven-day-fable" ? fableModelScope : null;
}

// 固定函数身份,报表索引按身份复用同一窗口的汇总。
const fableModelScope = (model: string) => model.startsWith("claude-fable-");

/** 到期等待新快照时仅展示已重置状态,旧百分比不能带入下一周期。 */
export interface QuotaView {
window: QuotaFact["window"];
Expand Down Expand Up @@ -84,6 +85,20 @@ function cycleResetKey(resetsAt: string | null) {
return Number.isFinite(value) ? Math.round(value / 60_000) * 60_000 : NaN;
}

/**
* quotaView 只用到最新读数所在周期和紧邻的上一周期,返回同一来源窗口需要保留的最早采样时刻。
* 读数的重置时间不会晚于采样时刻一个窗口,上一周期的读数因此都晚于本周期重置前三个窗口,
* 另留一小时吸收重置时间抖动。周期未知或已到期时只需最新读数。
*/
export function quotaHistoryStart(latest: QuotaFact, now: string): number {
const sampled = latest.sampledAt ? Date.parse(latest.sampledAt) : NaN;
const reset = cycleResetKey(latest.resetsAt);
const duration = (latest.windowMinutes ?? 0) * 60_000;
if (!Number.isFinite(reset) || duration <= 0 || reset <= Date.parse(now))
return sampled;
return Math.min(sampled, reset - 3 * duration - 3_600_000);
}

/**
* 同一周期、同一套餐内已用百分比只增不减。多个 Claude Code 会话会轮流写入各自较早看到的额度,
* 小幅回落是过时读数,忽略后保留此前最高值;至少回落 5 个百分点且不高于此前最高值一半时,
Expand Down Expand Up @@ -159,11 +174,14 @@ export function quotaView(
now: string,
): QuotaView | null {
if (!history.length) return null;
const ordered = [...history].sort(
(a, b) =>
Date.parse(a.fact.sampledAt ?? a.collectedAt) -
Date.parse(b.fact.sampledAt ?? b.collectedAt),
);
// 每条读数只解析一次时间,历史较长时排序和逐周期筛选不再重复解析。
const ordered = history
.map((row) => ({
row,
order: Date.parse(row.fact.sampledAt ?? row.collectedAt),
}))
.sort((a, b) => a.order - b.order)
.map(({ row }) => row);
const latest = ordered.at(-1)!;
const fact = latest.fact;
const reset = cycleResetKey(fact.resetsAt);
Expand Down Expand Up @@ -194,13 +212,19 @@ export function quotaView(
: { usd: "0", credits: "0", count: 0, tokens: 0 }
: null;
// 合并账户可能同时持有多个采样来源,只用最新来源比较百分比。
const sameSource = ordered.filter(
({ fact: sample }) =>
sample.sourceId === fact.sourceId &&
sample.accountExternalId === fact.accountExternalId &&
sample.window === fact.window &&
sample.windowMinutes === fact.windowMinutes,
);
const sameSource = ordered
.filter(
({ fact: sample }) =>
sample.sourceId === fact.sourceId &&
sample.accountExternalId === fact.accountExternalId &&
sample.window === fact.window &&
sample.windowMinutes === fact.windowMinutes,
)
.map(({ fact: sample }) => ({
sample,
at: Date.parse(sample.sampledAt ?? ""),
cycle: cycleResetKey(sample.resetsAt),
}));
const plan = [...(latest.planHistory ?? [])]
.filter((entry) => Date.parse(entry.observedAt) <= time)
.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt))
Expand All @@ -215,10 +239,9 @@ export function quotaView(
plan && (plan.changed || cycleReset !== reset)
? Math.max(cycleStart, planStart)
: cycleStart;
const observations = sameSource.flatMap(({ fact: sample }) => {
const at = Date.parse(sample.sampledAt ?? "");
const observations = sameSource.flatMap(({ sample, at, cycle }) => {
if (
cycleResetKey(sample.resetsAt) !== cycleReset ||
cycle !== cycleReset ||
sample.percent === null ||
!Number.isFinite(sample.percent) ||
sample.percent < 0 ||
Expand Down Expand Up @@ -330,14 +353,13 @@ export function quotaView(
};
const current = estimateCycle(reset, sampled);
// 只借用紧邻的上一周期;缺少上一周期或长时间停用后,不沿用久远额度。
const previousReset = sameSource.reduce((latestReset, { fact: sample }) => {
const candidate = cycleResetKey(sample.resetsAt);
return candidate <= start &&
candidate > start - duration &&
candidate > latestReset
? candidate
: latestReset;
}, -Infinity);
const previousReset = sameSource.reduce(
(latestReset, { cycle }) =>
cycle <= start && cycle > start - duration && cycle > latestReset
? cycle
: latestReset,
-Infinity,
);
// 只有当前已知套餐区间内的历史才有资格参考,未知和旧版本快照不能证明套餐相同。
const previous =
plan?.plan &&
Expand Down
35 changes: 0 additions & 35 deletions src/server/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,30 +239,12 @@ export function liveSnapshot(
sevenDayFable,
};
});
const resets = [
...new Map(
history.flatMap(({ fact }) =>
fact.resetsAt && Date.parse(fact.resetsAt) <= Date.parse(now)
? [
[
`${root(fact.sourceId, fact.accountExternalId)}:${fact.resetsAt}`,
{
accountId: root(fact.sourceId, fact.accountExternalId),
at: fact.resetsAt,
},
] as const,
]
: [],
),
).values(),
];
return {
mode: "live",
usdBasis,
asOf: now,
accounts,
records,
resets,
sync: sync?.status(),
pricing: {
version: priceBookKey(store.book),
Expand Down Expand Up @@ -394,28 +376,11 @@ export function indexedSnapshot(
sevenDayFable,
};
});
const nowTime = Date.parse(now);
const resets = [
...new Map(
history.flatMap(({ fact }) => {
if (!fact.resetsAt || !(Date.parse(fact.resetsAt) <= nowTime))
return [];
const accountId = root(fact.sourceId, fact.accountExternalId);
return [
[
`${accountId}:${fact.resetsAt}`,
{ accountId, at: fact.resetsAt },
] as const,
];
}),
).values(),
];
return {
mode: "live",
usdBasis,
asOf: now,
accounts,
resets,
sync: sync?.status(),
pricing: {
version: priceBookKey(store.book),
Expand Down
56 changes: 50 additions & 6 deletions src/server/view-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import type { SyncStatus } from "./sync";
const CACHE_LIMIT = 16;
/** 定时刷新只是兜底;打开页面时读取会先返回缓存再后台刷新。 */
const DEFAULT_REFRESH_INTERVAL_MS = 86_400_000;
/**
* 无人查看时索引不随每次推送写盘;按此间隔把新推送补进索引,下次打开只需补最近一段。
* 没有新用量时补齐是空操作,不产生写入。
*/
const DEFAULT_INDEX_CATCH_UP_INTERVAL_MS = 3_600_000;

/** 报表结果的可见同步状态;账本结果本身仍保持 shared 契约不变。 */
export interface ReportStatus {
Expand All @@ -35,6 +40,8 @@ export interface ViewServiceOptions {
/** 成功视图独立持久化;null 禁用,默认从磁盘索引路径派生,内存索引不落盘。 */
cachePath?: string | null;
refreshIntervalMs?: number;
/** 后台补齐索引的间隔,0 表示只在查询时补齐。 */
indexCatchUpIntervalMs?: number;
/** 账本事实少于此数时同步重建索引,默认 2000;测试用 0 验证后台重建。 */
inlineRebuildLimit?: number;
diagnostics?: DiagnosticsLogger;
Expand All @@ -45,8 +52,14 @@ export interface ViewServiceOptions {

interface WorkerMessage {
/** index-rebuilt 表示后台重建结束(成功或放弃),此前的过渡结果需要重新计算。 */
type?: "index-rebuilt";
type?: "index-rebuilt" | "caught-up";
id: number;
/** 后台补齐的结果;changed 表示补进了新推送的用量。 */
catchUp?: {
mode: "current" | "transitional" | "building";
changed: boolean;
durationMs: number;
};
result?: LedgerView;
/** 结果来自后台重建期间保留的旧索引,可能使用旧价格表或缺少最新事实。 */
transitional?: boolean;
Expand Down Expand Up @@ -214,6 +227,7 @@ export class ViewService {
private readonly getSyncStatus?: () => SyncStatus;
private readonly now: () => number;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private catchUpTimer: ReturnType<typeof setInterval> | null = null;
private scheduledRefresh: Promise<void> | null = null;
private closeTask: Promise<void> | null = null;
private sequence = 0;
Expand Down Expand Up @@ -265,6 +279,14 @@ export class ViewService {
},
});
this.worker.on("message", (message: WorkerMessage) => {
if (message.type === "caught-up") {
this.diagnostics.log(
message.catchUp?.changed ? "info" : "debug",
"report.index_catch_up",
{ ...message.catchUp },
);
return;
}
if (message.type === "index-rebuilt") {
this.indexRebuilding = false;
this.refreshAfterRebuild();
Expand Down Expand Up @@ -292,6 +314,9 @@ export class ViewService {
this.worker.on("error", () => this.fail());
this.worker.on("exit", () => this.fail());
this.startRefreshTimer();
this.startCatchUpTimer(
options.indexCatchUpIntervalMs ?? DEFAULT_INDEX_CATCH_UP_INTERVAL_MS,
);
}

/** 已有缓存先返回旧结果;refresh=false 只观察任务结果,防止轮询持续重算。 */
Expand Down Expand Up @@ -355,13 +380,17 @@ export class ViewService {
private restoreCache(cachePath: string, sourcePath: string, book: PriceBook) {
let cache: ViewCache | null = null;
try {
const namespace = JSON.stringify({
// 套餐边界参与预估,旧口径的派生结果不能恢复;原始账本保持完整。
format: 3,
const identity = {
source: sourceFileState(sourcePath).identity,
book: book.id,
});
cache = new ViewCache(cachePath, namespace);
};
// 套餐边界参与预估,格式 2 及更早的派生结果不能恢复;原始账本保持完整。
// 格式 4 不再保存额度重置记录,格式 3 的结果仍可直接使用。旧版本校验要求该字段,
// 换用新格式号让回滚后的旧版本清空缓存重新计算,而不是停用持久缓存。
const namespace = JSON.stringify({ format: 4, ...identity });
cache = new ViewCache(cachePath, namespace, [
JSON.stringify({ format: 3, ...identity }),
]);
for (const saved of cache.load()) {
if (saved.key !== this.cacheKey(saved.query, saved.basis)) {
// 合并版本变化及首次升级的旧键属于正常失效,不能因此停用持久缓存。
Expand Down Expand Up @@ -806,6 +835,17 @@ export class ViewService {
unref?.call(this.refreshTimer);
}

private startCatchUpTimer(intervalMs: number) {
if (!(intervalMs > 0)) return;
this.catchUpTimer = setInterval(() => {
if (this.closed || this.failed) return;
this.worker.postMessage({ type: "catch-up" });
}, intervalMs);
const unref = (this.catchUpTimer as unknown as { unref?: () => void })
.unref;
unref?.call(this.catchUpTimer);
}

/** 等进行中的定时刷新结束后再完整刷新一轮,确保每个过渡结果都换成新索引的结果。 */
private refreshAfterRebuild() {
void (this.scheduledRefresh ?? Promise.resolve()).then(() =>
Expand Down Expand Up @@ -850,6 +890,10 @@ export class ViewService {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
if (this.catchUpTimer) {
clearInterval(this.catchUpTimer);
this.catchUpTimer = null;
}
const scheduled = this.scheduledRefresh;
this.fail();
this.closeTask = (async () => {
Expand Down
Loading
Loading
0