8000 fix(collector): keep records written during a sync with the observed account by InfinityPacer · Pull Request #18 · 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
22 changes: 20 additions & 2 deletions src/collector/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,16 @@ export function emptyReport(): CollectReport {
/**
* 一轮采集:先记录当前登录账户并按新时间线修正归属,再读取会话增量,
* 使本轮新事件直接使用延长后的区间;最后记录额度快照。
*
* 读取期间 Claude Code 仍在写入,晚于开始时刻的记录落在观察区间之外。读完后再确认一次
* 登录账户:未变化时把区间延长到确认时刻,这些记录与状态栏采样随即归入该账户;
* 期间切换了账户则保持无法判断。finishedAt 给出确认时刻,测试可固定它。
*/
export function collect(
state: CollectorState,
sources: CollectSources,
now: Date = new Date(),
finishedAt: () => Date = () => now,
): CollectReport {
const report = emptyReport();
const snapshot = readClaudeJson(sources.claudeJson);
Expand All @@ -235,6 +240,20 @@ export function collect(
for (const path of files) ingestFile(state, path, report);
state.forgetMissingFiles(new Set(files));

// 先读状态栏缓存再确认账户,使采样时刻不晚于确认时刻。
const cache = sources.statuslineCache
? readStatuslineCache(sources.statuslineCache)
: undefined;
if (snapshot?.account) {
const accountUuid = snapshot.account.accountUuid;
const confirmedAt = finishedAt().toISOString();
const confirmed = readClaudeJson(sources.claudeJson);
if (confirmed?.account?.accountUuid === accountUuid) {
const since = state.observeAccount(accountUuid, confirmedAt);
report.reattributed += state.reattribute(since);
}
}

if (snapshot?.utilization) {
const quota = quotaSnapshot(snapshot.utilization);
if (!quota) {
Expand All @@ -247,8 +266,7 @@ export function collect(
}
}

if (sources.statuslineCache) {
const cache = readStatuslineCache(sources.statuslineCache);
if (cache !== undefined) {
if (!cache) {
report.statuslineQuotaSkipped = "状态栏额度缓存不存在或格式无效";
} else {
Expand Down
17 changes: 12 additions & 5 deletions src/collector/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,12 @@ function collectSources(paths: CollectorPaths) {
function runScan(paths: CollectorPaths, json: boolean) {
const state = new CollectorState(":memory:");
try {
const report = collect(state, collectSources(paths));
const report = collect(
state,
collectSources(paths),
new Date(),
() => new Date(),
);
const totals = state.usageTotals();
const quotas = state
.pending<{ window: string; percent: number | null; sampledAt: string }>(
Expand Down Expand Up @@ -328,10 +333,12 @@ async function runSync(paths: CollectorPaths): Promise<number> {
let state: CollectorState | null = null;
try {
state = openState(paths);
const report = collect(state, {
...paths,
statuslineCache: config.statuslineCache,
});
const report = collect(
state,
{ ...paths, statuslineCache: config.statuslineCache },
new Date(),
() => new Date(),
);
const now = new Date().toISOString();
// 额度来源的问题与推送无关,每轮都会重复,只在情况变化时记一行,恢复时也记一行。
const quotaIssue = quotaIssueText(report);
Expand Down
8 changes: 7 additions & 1 deletion src/collector/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,14 @@ export class CollectorState {
}

private enqueueUsage(fact: UsageFact, occurredAt: string, account: string) {
// 只保存资料、不单独入队:推送时由引用它的用量带上。同一轮内被重新归属的事件
// 会替换待发送条目,未归属账户就不会因短暂的中间状态出现在服务端。
if (account === UNATTRIBUTED_ACCOUNT_ID) {
this.upsertAccount(unattributedAccount);
this.db
.query(
"INSERT OR IGNORE INTO accounts (external_id, payload) VALUES (?, ?)",
)
.run(UNATTRIBUTED_ACCOUNT_ID, JSON.stringify(unattributedAccount));
}
const payload: IngestUsage = {
externalId: fact.externalId,
Expand Down
8 changes: 6 additions & 2 deletions src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ import {
usePreference,
useScopedPreference,
} from "./lib/preferences";
import { orderedAccounts, moveAccount } from "./lib/account-order";
import {
isEmptyAccount,
moveAccount,
orderedAccounts,
} from "./lib/account-order";
import { useAccountArchive } from "./lib/use-account-archive";
import { accountInitial, withAccountAliases } from "./lib/account-aliases";
import { planBadge } from "./lib/plan";
Expand Down Expand Up @@ -831,7 +835,7 @@ export function App() {
const hiddenIds = new Set(accountArchive.data?.hidden ?? []);
const sortedAccounts = orderedAccounts(
(quotaSnapshot?.accounts ?? []).filter(
(account) => !hiddenIds.has(account.id),
(account) => !hiddenIds.has(account.id) && !isEmptyAccount(account),
),
accountOrder,
);
Expand Down
20 changes: 20 additions & 0 deletions src/web/lib/account-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,23 @@ export function moveAccount(
[next[index], next[target]] = [next[target]!, next[index]!];
return next;
}

/**
* 类型未知、没有任何额度窗口且累计请求确认为 0 的账户在总览中没有可展示的内容,
* 例如采集器短暂建立后又全部重新归属的「未归属」账户。累计缺失表示来源尚未汇总,仍然显示。
*/
export function isEmptyAccount(account: {
kind: string;
fiveHour: unknown;
sevenDay: unknown;
sevenDayFable?: unknown;
lifetime?: { count: number };
}): boolean {
return (
account.kind === "unknown" &&
!account.fiveHour &&
!account.sevenDay &&
!account.sevenDayFable &&
account.lifetime?.count === 0
);
}
15 changes: 14 additions & 1 deletion tests/account-order.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { orderedAccounts, moveAccount } from "../src/web/lib/account-order";
import { isEmptyAccount, moveAccount, orderedAccounts } from "../src/web/lib/account-order";
import { preferenceSchemas, readStoredPreference, writeStoredPreference } from "../src/web/lib/preferences";

test("account order survives storage and appends new accounts without reviving removed ones", () => {
Expand All @@ -21,3 +21,16 @@ test("moving an account skips archived neighbours and leaves them in place", ()
e 10BC8 xpect(moveAccount(["a", "x", "b"], "a", 1, visible)).toEqual(["b", "x", "a"]);
expect(moveAccount(["x", "a", "b"], "a", -1, visible)).toEqual(["x", "a", "b"]);
});

test("empty unknown accounts are left out of the overview, sources without totals stay", () => {
const base = { kind: "unknown", fiveHour: null, sevenDay: null };
expect(isEmptyAccount({ ...base, lifetime: { count: 0 } })).toBe(true);
expect(isEmptyAccount({ ...base, lifetime: { count: 3 } })).toBe(false);
expect(isEmptyAccount(base)).toBe(false);
expect(
isEmptyAccount({ ...base, kind: "api", lifetime: { count: 0 } }),
).toBe(false);
expect(
isEmptyAccount({ ...base, sevenDay: {}, lifetime: { count: 0 } }),
).toBe(false);
});
53 changes: 53 additions & 0 deletions tests/collector-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,59 @@ describe("账户归属", () => {
);
state.close();
});

test("读取期间写入的记录在账户未变时归入该账户,不产生未归属账户", () => {
const w = workspace();
writeAccount(w.claudeJson, FIXTURE_ACCOUNT_UUID);
writeFileSync(w.session, line("first", "2026-09-01T10:00:00Z", 5));
const state = new CollectorState(":memory:");
state.setBinding({
accountUuid: FIXTURE_ACCOUNT_UUID,
before: "2026-09-01T10:01:00.000Z",
});
collect(state, w, new Date("2026-09-01T10:01:00Z"));
ackAll(state);

// 本轮在 10:02:00 开始,记录写于 10:02:02,读完确认时已是 10:02:03。
appendFileSync(w.session, line("race", "2026-09-01T10:02:02Z", 5));
collect(
state,
w,
new Date("2026-09-01T10:02:00Z"),
() => new Date("2026-09-01T10:02:03Z"),
);
expect(pendingUsage(state).get("req_race:race")!.accountExternalId).toBe(
FIXTURE_ACCOUNT_UUID,
);
// 推送只带上待发送用量引用的账户,未归属账户因此不会到达服务端。
expect(
[...pendingUsage(state).values()].map((u) => u.accountExternalId),
).toEqual([FIXTURE_ACCOUNT_UUID]);
expect(state.pending("account", 10)).toHaveLength(0);
state.close();
});

test("读取期间切换了账户时,开始之后的记录仍无法判断", () => {
const w = workspace();
writeAccount(w.claudeJson, FIXTURE_ACCOUNT_UUID);
writeFileSync(w.session, line("first", "2026-09-01T10:00:00Z", 5));
const state = new CollectorState(":memory:");
collect(state, w, new Date("2026-09-01T10:01:00Z"));
ackAll(state);

appendFileSync(w.session, line("race", "2026-09-01T10:02:02Z", 5));
collect(state, w, new Date("2026-09-01T10:02:00Z"), () => {
writeAccount(w.claudeJson, OTHER_ACCOUNT);
return new Date("2026-09-01T10:02:03Z");
});
expect(pendingUsage(state).get("req_race:race")!.accountExternalId).toBe(
"unattributed",
);
// 未归属账户不单独入队,由引用它的用量在推送时带上。
expect(state.account("unattributed")!.name).toBe("未归属");
expect(state.pending("account", 10)).toHaveLength(0);
state.close();
});
});

describe("额度快照", () => {
Expand Down
Loading
0