Skip to content

Commit 454a953

Browse files
authored
Merge pull request #9 from pj-workspace/fix/max-realtime-usage-sync
fix: harden live usage sync realtime and spend accuracy
2 parents a8a4dd1 + 4ee52f3 commit 454a953

8 files changed

Lines changed: 360 additions & 130 deletions

File tree

‎Sources/CursorUsageMenuBar/Localization/L10n.swift‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,8 @@ enum L10n {
392392
.clear: "Clear",
393393
.autoRefresh: "Auto refresh",
394394
.requestInterval: "Request interval",
395-
.requestIntervalValue: "10–20s random",
396-
.pacerHint: "Each API call and refresh cycle waits a random interval to avoid fixed traffic patterns.",
395+
.requestIntervalValue: "panel 3s / cycle 5–8s",
396+
.pacerHint: "While the panel is open, event details refresh every ~3s and metrics every ~8s. Background sync waits 5–8s between cycles; requests inside a cycle are not delayed.",
397397
.status: "Status",
398398
.configured: "Configured",
399399
.notConfigured: "Not configured",
@@ -637,8 +637,8 @@ enum L10n {
637637
.clear: "清除",
638638
.autoRefresh: "自动刷新",
639639
.requestInterval: "请求间隔",
640-
.requestIntervalValue: "10–20 秒随机",
641-
.pacerHint: "每次 API 请求与自动刷新轮次之间都会随机等待,降低固定访问节奏。",
640+
.requestIntervalValue: "面板 3 秒 / 后台 5–8 秒",
641+
.pacerHint: "面板打开时:明细约每 3 秒刷新,指标约每 8 秒。后台轮次间隔 5–8 秒;同一轮内请求不再逐个等待。",
642642
.status: "状态",
643643
.configured: "已配置",
644644
.notConfigured: "未配置",

‎Sources/CursorUsageMenuBar/Models/UsageModels.swift‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,19 +241,21 @@ struct UsageEvent: Codable, Sendable, Identifiable {
241241
let totalCents: Double?
242242
}
243243

244-
var id: String {
244+
/// 稳定身份:不含金额/Token。进行中请求的 chargedCents 会变,若写入 id 会导致每次轮询都当成新事件。
245+
var identityKey: String {
245246
[
246247
timestamp,
247248
model,
248249
kind,
249250
cloudAgentId,
250251
conversationId,
251-
chargedCents.map { String($0) },
252252
]
253253
.compactMap { $0 }
254254
.joined(separator: "|")
255255
}
256256

257+
var id: String { identityKey }
258+
257259
var eventDate: Date? {
258260
guard let timestamp, let milliseconds = Double(timestamp) else { return nil }
259261
return Date(timeIntervalSince1970: milliseconds / 1000)

‎Sources/CursorUsageMenuBar/Services/RequestPacer.swift‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import Foundation
22

3-
/// 在每次 API 请求前随机等待 10–20 秒,降低固定节奏触发风控的概率。
4-
/// 同一操作内的连续分页(如图表全量拉取)可进入 burst 模式,页与页之间不再等待。
3+
/// API 节奏控制:轮次之间拉开间隔;**同一次同步波次内**不再逐请求等待。
4+
///
5+
/// 历史问题:并发 `async let` 会串行排队在 actor 上,每个请求各睡 10–20s,
6+
/// 导致一轮同步拖到一分钟以上。现改为「波次前等一次,波次内 burst」。
57
actor RequestPacer {
68
static let shared = RequestPacer()
79

8-
private let minSeconds = 10.0
9-
private let maxSeconds = 20.0
10+
/// 后台整轮指标同步的间隔(秒)
11+
private let cycleMinSeconds = 5.0
12+
private let cycleMaxSeconds = 8.0
13+
1014
private var skipNextWait = true
1115
private var burstCount = 0
16+
private var waveActive = false
1217

1318
func beginBurst() {
1419
burstCount += 1
@@ -24,23 +29,44 @@ actor RequestPacer {
2429
return try await operation()
2530
}
2631

32+
/// 一整轮 dashboard 同步:先按轮次间隔等待(或跳过首次),波次内所有请求免等待。
33+
func withSyncWave<T>(_ operation: () async throws -> T) async rethrows -> T {
34+
await waitForNextWaveGate()
35+
waveActive = true
36+
defer { waveActive = false }
37+
return try await operation()
38+
}
39+
2740
func waitBeforeRequest() async {
28-
if burstCount > 0 { return }
41+
if burstCount > 0 || waveActive { return }
2942
if skipNextWait {
3043
skipNextWait = false
3144
return
3245
}
33-
let seconds = nextDelaySeconds()
34-
let nanoseconds = UInt64(seconds * 1_000_000_000)
35-
try? await Task.sleep(nanoseconds: nanoseconds)
46+
await sleep(seconds: nextCycleDelaySeconds())
3647
}
3748

3849
func nextDelaySeconds() -> Double {
39-
Double.random(in: minSeconds...maxSeconds)
50+
nextCycleDelaySeconds()
4051
}
4152

4253
func sleepUntilNextCycle() async {
43-
let seconds = nextDelaySeconds()
54+
await sleep(seconds: nextCycleDelaySeconds())
55+
}
56+
57+
private func waitForNextWaveGate() async {
58+
if skipNextWait {
59+
skipNextWait = false
60+
return
61+
}
62+
await sleep(seconds: nextCycleDelaySeconds())
63+
}
64+
65+
private func nextCycleDelaySeconds() -> Double {
66+
Double.random(in: cycleMinSeconds...cycleMaxSeconds)
67+
}
68+
69+
private func sleep(seconds: Double) async {
4470
let nanoseconds = UInt64(seconds * 1_000_000_000)
4571
try? await Task.sleep(nanoseconds: nanoseconds)
4672
}

‎Sources/CursorUsageMenuBar/Services/UsageAnalytics.swift‎

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ enum UsageAnalytics {
7777
return explicitSpend
7878
}
7979

80-
/// Auto 池花费:接口常不再返回 bonusSpend;优先 aggregated 第一方汇总,其次 total − API 已用
80+
/// Auto 池花费:优先 aggregated 第一方模型汇总。
81+
/// 注意:`bonusSpend` 常是 period.totalSpend − includedSpend(超额/bonus 口径),
82+
/// **不是** Auto+Composer 池花费,不能优先采用(否则会显示成 ~$253 这种偏小值)。
8183
static func resolveAutoSpendCents(
8284
summary: UsageSummary?,
8385
period: PeriodUsageResponse?,
@@ -87,13 +89,6 @@ enum UsageAnalytics {
8789
) -> Double? {
8890
let periodPlan = period?.planUsage
8991

90-
if let bonusSpend = periodPlan?.bonusSpend, bonusSpend > 0 {
91-
return bonusSpend
92-
}
93-
if let autoSpend = periodPlan?.autoSpend, autoSpend > 0 {
94-
return autoSpend
95-
}
96-
9792
if let fromAggregated = poolSpendCents(
9893
from: aggregated,
9994
autoBucketModels: autoBucketModels,
@@ -102,14 +97,23 @@ enum UsageAnalytics {
10297
return fromAggregated
10398
}
10499

105-
let totalSpend = periodPlan?.totalSpend
106-
?? aggregated?.totalCostCents
100+
if let autoSpend = periodPlan?.autoSpend, autoSpend > 0 {
101+
return autoSpend
102+
}
103+
104+
let totalSpend = aggregated?.totalCostCents
105+
?? periodPlan?.totalSpend
107106
?? summary?.billingPlan?.used
108107
if let totalSpend, let apiUsedCents, totalSpend >= apiUsedCents {
109108
let derived = totalSpend - apiUsedCents
110109
if derived > 0 { return derived }
111110
}
112111

112+
// 仅作最后兜底;多数账号此字段含义已不是 Auto 池
113+
if let bonusSpend = periodPlan?.bonusSpend, bonusSpend > 0 {
114+
return bonusSpend
115+
}
116+
113117
return nil
114118
}
115119

@@ -844,24 +848,97 @@ enum UsageAnalytics {
844848
)
845849
}
846850

847-
/// 将最新一页事件合并进缓存:同 id 以 fresh 为准,按时间新→旧排序。
851+
/// 将最新一页合并进缓存。
852+
/// - 新鲜第 1 页覆盖其时间窗(避免轮询把已从服务端消失的中间快照堆成「飞涨」)
853+
/// - 同 identity 保留 Token/金额更大的版本
854+
/// - 折叠同对话+模型下被更大快照取代的进行中中间态
848855
static func mergeEvents(fresh: [UsageEvent], into cached: [UsageEvent]) -> [UsageEvent] {
849-
guard !fresh.isEmpty else { return cached }
850-
guard !cached.isEmpty else { return sortEventsByNewest(fresh) }
856+
guard !fresh.isEmpty else {
857+
return sortEventsByNewest(collapseSupersededSnapshots(cached))
858+
}
851859

852-
var byID: [String: UsageEvent] = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) })
853-
for event in fresh {
854-
byID[event.id] = event
860+
let freshDeduped = dedupeByIdentity(fresh)
861+
guard !cached.isEmpty else {
862+
return sortEventsByNewest(collapseSupersededSnapshots(freshDeduped))
855863
}
856-
return sortEventsByNewest(Array(byID.values))
864+
865+
let freshOldest = freshDeduped.compactMap(\.eventDate).min() ?? .distantPast
866+
// 第 1 页是该时间窗内的权威数据:丢掉窗内旧缓存,再写入 fresh
867+
let retainedOlder = cached.filter { ($0.eventDate ?? .distantPast) < freshOldest }
868+
let merged = dedupeByIdentity(retainedOlder + freshDeduped)
869+
return sortEventsByNewest(collapseSupersededSnapshots(merged))
870+
}
871+
872+
private static func dedupeByIdentity(_ events: [UsageEvent]) -> [UsageEvent] {
873+
var byKey: [String: UsageEvent] = [:]
874+
for event in events {
875+
let key = event.identityKey
876+
if let existing = byKey[key] {
877+
byKey[key] = preferredEvent(event, existing)
878+
} else {
879+
byKey[key] = event
880+
}
881+
}
882+
return Array(byKey.values)
883+
}
884+
885+
private static func preferredEvent(_ a: UsageEvent, _ b: UsageEvent) -> UsageEvent {
886+
if a.totalTokens != b.totalTokens {
887+
return a.totalTokens > b.totalTokens ? a : b
888+
}
889+
let aCents = a.chargedCents ?? -1
890+
let bCents = b.chargedCents ?? -1
891+
if aCents != bCents {
892+
return aCents > bCents ? a : b
893+
}
894+
return a
895+
}
896+
897+
/// 同一 conversation + model 下,短时间内 Token 明显增长的前序事件视为进行中快照并丢弃。
898+
/// 正常连续 tool 调用(Token 接近)不会被折叠。
899+
static func collapseSupersededSnapshots(_ events: [UsageEvent]) -> [UsageEvent] {
900+
guard events.count > 1 else { return events }
901+
902+
let grouped = Dictionary(grouping: events) { event in
903+
[
904+
event.conversationId ?? "",
905+
event.model ?? "",
906+
event.cloudAgentId ?? "",
907+
].joined(separator: "|")
908+
}
909+
910+
var survivors: [UsageEvent] = []
911+
survivors.reserveCapacity(events.count)
912+
913+
for (_, group) in grouped {
914+
let ordered = group.sorted {
915+
($0.eventDate ?? .distantPast) < ($1.eventDate ?? .distantPast)
916+
}
917+
var kept: [UsageEvent] = []
918+
for event in ordered {
919+
kept.removeAll { isSupersededSnapshot($0, by: event) }
920+
kept.append(event)
921+
}
922+
survivors.append(contentsOf: kept)
923+
}
924+
return survivors
925+
}
926+
927+
private static func isSupersededSnapshot(_ previous: UsageEvent, by next: UsageEvent) -> Bool {
928+
guard let previousDate = previous.eventDate, let nextDate = next.eventDate else { return false }
929+
let delta = nextDate.timeIntervalSince(previousDate)
930+
guard delta > 0, delta <= 120 else { return false }
931+
guard previous.totalTokens > 0, next.totalTokens > previous.totalTokens else { return false }
932+
let growth = Double(next.totalTokens) / Double(previous.totalTokens)
933+
return growth >= 1.15
857934
}
858935

859936
private static func sortEventsByNewest(_ events: [UsageEvent]) -> [UsageEvent] {
860937
events.sorted { lhs, rhs in
861938
let left = lhs.eventDate ?? .distantPast
862939
let right = rhs.eventDate ?? .distantPast
863940
if left != right { return left > right }
864-
return lhs.id > rhs.id
941+
return lhs.identityKey > rhs.identityKey
865942
}
866943
}
867944

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import Foundation
2+
3+
/// 用量明细的内存 + 磁盘缓存读写,供 ViewModel 调用。
4+
@MainActor
5+
final class UsageEventsMemoryCache {
6+
private var memory: (key: String, store: UsageEventsCache.Store)?
7+
8+
func clearAll() {
9+
memory = nil
10+
UsageEventsCache.clearAll()
11+
}
12+
13+
func clear(startMs: String, endMs: String) {
14+
UsageEventsCache.clear(startMs: startMs, endMs: endMs)
15+
if memory?.key == key(startMs: startMs, endMs: endMs) {
16+
memory = nil
17+
}
18+
}
19+
20+
func load(startMs: String, endMs: String) -> UsageEventsCache.Store? {
21+
let cacheKey = key(startMs: startMs, endMs: endMs)
22+
if let memory, memory.key == cacheKey {
23+
return memory.store
24+
}
25+
guard let store = UsageEventsCache.load(startMs: startMs, endMs: endMs) else { return nil }
26+
27+
// 清理历史 live 轮询堆出来的进行中中间快照
28+
let normalizedEvents = UsageAnalytics.mergeEvents(fresh: store.events, into: [])
29+
let removed = store.events.count - normalizedEvents.count
30+
let normalized = UsageEventsCache.Store(
31+
billingCycleStartMs: store.billingCycleStartMs,
32+
billingCycleEndMs: store.billingCycleEndMs,
33+
totalCount: max(store.totalCount - max(0, removed), normalizedEvents.count),
34+
fetchedAt: store.fetchedAt,
35+
events: normalizedEvents
36+
)
37+
if removed > 0 {
38+
UsageEventsCache.save(
39+
events: normalized.events,
40+
totalCount: normalized.totalCount,
41+
startMs: startMs,
42+
endMs: endMs
43+
)
44+
}
45+
memory = (cacheKey, normalized)
46+
return normalized
47+
}
48+
49+
func save(events: [UsageEvent], totalCount: Int, startMs: String, endMs: String) {
50+
UsageEventsCache.save(events: events, totalCount: totalCount, startMs: startMs, endMs: endMs)
51+
memory = (
52+
key(startMs: startMs, endMs: endMs),
53+
UsageEventsCache.Store(
54+
billingCycleStartMs: startMs,
55+
billingCycleEndMs: endMs,
56+
totalCount: totalCount,
57+
fetchedAt: Date(),
58+
events: events
59+
)
60+
)
61+
}
62+
63+
func page(startMs: String, endMs: String, page: Int, pageSize: Int) -> UsageEventsPage? {
64+
guard UsageEventsCache.canServePage(
65+
startMs: startMs,
66+
endMs: endMs,
67+
page: page,
68+
pageSize: pageSize
69+
) else { return nil }
70+
71+
if let store = load(startMs: startMs, endMs: endMs) {
72+
return UsageEventsPage.slice(
73+
from: store.events,
74+
totalCount: store.totalCount,
75+
page: page,
76+
pageSize: pageSize
77+
)
78+
}
79+
return UsageEventsCache.page(
80+
startMs: startMs,
81+
endMs: endMs,
82+
page: page,
83+
pageSize: pageSize
84+
)
85+
}
86+
87+
func isStale(startMs: String?, endMs: String?, expectedTotal: Int) -> Bool {
88+
guard let startMs, let endMs, let store = load(startMs: startMs, endMs: endMs) else {
89+
return true
90+
}
91+
return store.events.count < expectedTotal
92+
}
93+
94+
private func key(startMs: String, endMs: String) -> String {
95+
"\(startMs)|\(endMs)"
96+
}
97+
}

0 commit comments

Comments
 (0)