Skip to content

Commit 2e4cf8f

Browse files
committed
feat: cache auth file metadata
1 parent 53adb3b commit 2e4cf8f

6 files changed

Lines changed: 369 additions & 6 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Auth File Metadata Cache
2+
3+
## 背景
4+
5+
当前账号页和用量归因都会触发 auth-file 读取。对于桌面客户端,这类本地文件访问本身不重,但如果每次刷新都重复读原文、重复推断元数据,就会放大 IO、CPU、日志和敏感内容暴露面。
6+
7+
## 目标
8+
9+
1. 把 auth-file 读取拆成“强一致刷新”和“缓存优先返回”两条路径。
10+
2. 降低账号列表、详情、归因 join 等普通展示场景的重复下载频率。
11+
3. 保留需要实时读取原文的场景能力,不牺牲用户可见的一致性。
12+
13+
## 范围
14+
15+
1. auth-file 元数据缓存与失效策略。
16+
2. 账号列表 / 归因 / OAuth 回填等调用链的重复读取收敛。
17+
3. 对应测试与文档写回。
18+
19+
## 非目标
20+
21+
1. 不把 auth-file 存储从文件迁移到数据库。
22+
2. 不改变现有 auth-file 作为事实源的边界。
23+
3. 不在本期引入跨进程共享缓存。
24+
25+
## 验收标准
26+
27+
1. 普通展示场景优先命中进程缓存,不再对同一批 auth-file 反复下载原文。
28+
2. 文件内容、大小或修改时间变化后,缓存能及时失效并重新读取。
29+
3. 强一致场景仍能强制走 fresh 读取。
30+
4. 相关测试通过,且 README / plan / 后续记忆写回可检索。
31+
32+
## 设计稿入口
33+
34+
- 本期设计稿:`(未产出)`
35+
- 约束:单期只保留一个 HTML 文件;若存在多稿对比,也必须收敛在同一个 HTML 文件内。
36+
37+
## Worktree 映射
38+
39+
- branch:`feat/20260526-auth-file-metadata-cache`
40+
- worktree:`../GetTokens-worktrees/20260526-auth-file-metadata-cache/`
41+
42+
## 相关链接
43+
44+
## 当前状态
45+
- 状态:implemented
46+
- 最近更新:2026-05-27
47+
48+
## 实现记录
49+
50+
### 2026-05-27
51+
52+
-`internal/wailsapp` 增加 auth-file 元数据进程缓存,缓存键为 `name + size + modified`
53+
- `ListAuthFiles` 对普通列表场景优先使用缓存;首次 fresh 下载后只缓存展示元数据,不缓存完整 auth 原文。
54+
- 上传、删除、启停状态修改成功后按文件名失效缓存;文件大小或修改时间变化会自然绕过旧缓存。
55+
- 补充 sidecar mock 测试,覆盖重复列表不重复下载、元数据仍不完整也不重复下载、fingerprint 变化后重新下载。
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Auth File Metadata Cache Plan v01
2+
3+
## 1. 问题定义
4+
5+
auth-file 当前同时承担凭证事实源和展示元数据来源。由于列表刷新、归因 join、OAuth 回填、详情展开等路径都会读取同一批文件,普通交互容易触发重复下载和重复推断。
6+
7+
## 2. 目标
8+
9+
1. 区分强一致读取和缓存优先读取。
10+
2. 让常规展示尽量命中进程缓存。
11+
3. 让文件变化时自动失效,避免长期脏数据。
12+
13+
## 3. 方案草图
14+
15+
### 3.1 读取分级
16+
17+
- `fresh`:显式需要原文的场景,先 stat/read/parse,再刷新缓存。
18+
- `cached`:列表、归因、轻量展示优先读缓存。
19+
- `cached-then-refresh`:先返回缓存,再后台补读并更新缓存,用于首屏快显。
20+
21+
### 3.2 缓存键
22+
23+
-`name + size + modifiedTime` 作为主失效依据。
24+
- 缓存内容只放展示元数据,不长期保存完整 auth 原文。
25+
26+
### 3.3 失效来源
27+
28+
- 文件上传、删除、状态修改。
29+
- 文件内容或修改时间变化。
30+
- 需要强一致的用户动作显式跳过缓存。
31+
32+
## 4. 实施顺序
33+
34+
1. 梳理重复调用点,确认哪些路径必须 fresh。
35+
2. 给 auth-file 元数据补一个进程级缓存。
36+
3. 合并账号页 / 归因路径的重复拉取。
37+
4. 补回归测试,覆盖缓存命中、失效和强制刷新。
38+
39+
## 5. 验收
40+
41+
1. 账号页反复刷新时,auth-file download 次数显著下降。
42+
2. 文件被修改后,缓存能在下一轮请求中失效。
43+
3. 账号详情或回填等强一致路径仍可拿到最新原文。
44+
4. 相关 Go / 前端测试通过。
45+
46+
## 6. 当前进展
47+
48+
- 2026-05-27:已完成进程级 metadata cache、失效逻辑、重复下载回归测试与 Go 测试验证。

internal/wailsapp/app.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type App struct {
2626
managementAPI func() *cliproxyapi.Client
2727
sidecarProxyMu sync.Mutex
2828
sidecarProxyPendingApply bool
29+
authFileCacheMu sync.RWMutex
30+
authFileMetadataCache map[string]authFileMetadataCacheEntry
2931
localUsageMu sync.RWMutex
3032
localUsage localUsageRuntimeState
3133
claudeLocalUsage localUsageRuntimeState
@@ -53,12 +55,13 @@ type sidecarRelayRequestFunc func(method string, path string, body io.Reader, co
5355

5456
func New(version string, releaseLabel string, repo string) *App {
5557
return &App{
56-
sidecar: sidecar.NewManager(),
57-
updater: updater.New(repo, version),
58-
version: version,
59-
releaseLabel: releaseLabel,
60-
codexBinary: codexbinary.NewService(codexbinary.ServiceOptions{}),
61-
menuBar: menubar.NewController(),
58+
sidecar: sidecar.NewManager(),
59+
updater: updater.New(repo, version),
60+
version: version,
61+
releaseLabel: releaseLabel,
62+
codexBinary: codexbinary.NewService(codexbinary.ServiceOptions{}),
63+
menuBar: menubar.NewController(),
64+
authFileMetadataCache: map[string]authFileMetadataCacheEntry{},
6265
}
6366
}
6467

internal/wailsapp/auth_files.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@ func (a *App) ListAuthFiles() (*AuthFilesResponse, error) {
3030

3131
for index := range result.Files {
3232
file := &result.Files[index]
33+
if cached, ok := a.cachedAuthFileMetadata(*file); ok {
34+
applyCachedAuthFileMetadata(file, cached)
35+
a.storeAuthFileMetadata(*file)
36+
continue
37+
}
38+
3339
if !needsAuthFileMetadataInference(*file) {
40+
a.storeAuthFileMetadata(*file)
3441
continue
3542
}
3643

@@ -55,6 +62,7 @@ func (a *App) ListAuthFiles() (*AuthFilesResponse, error) {
5562
file.PlanType = profile.PlanType
5663
}
5764
file.Priority = accountsdomain.ExtractAuthFilePriority(body)
65+
a.storeAuthFileMetadata(*file)
5866
}
5967

6068
return &result, nil
@@ -93,6 +101,9 @@ func (a *App) SetAuthFileStatus(name string, disabled bool) error {
93101
return err
94102
}
95103
_, _, err = a.SidecarRequest(http.MethodPatch, ManagementAPIPrefix+"/auth-files/status", nil, bytes.NewReader(b), "application/json")
104+
if err == nil {
105+
a.invalidateAuthFileMetadataCache(name)
106+
}
96107
return err
97108
}
98109

@@ -105,6 +116,9 @@ func (a *App) DeleteAuthFiles(names []string) error {
105116
return err
106117
}
107118
_, _, err = a.SidecarRequest(http.MethodDelete, ManagementAPIPrefix+"/auth-files", nil, bytes.NewReader(b), "application/json")
119+
if err == nil {
120+
a.invalidateAuthFileMetadataCache(names...)
121+
}
108122
return err
109123
}
110124

@@ -120,6 +134,7 @@ func (a *App) UploadAuthFiles(files []UploadFilePayload) error {
120134

121135
var buf bytes.Buffer
122136
w := multipart.NewWriter(&buf)
137+
resolvedNames := make([]string, 0, len(files))
123138

124139
for _, f := range files {
125140
if strings.TrimSpace(f.Name) == "" || strings.TrimSpace(f.ContentBase64) == "" {
@@ -133,6 +148,7 @@ func (a *App) UploadAuthFiles(files []UploadFilePayload) error {
133148
decoded = normalized
134149
}
135150
resolvedName := uniqueAuthFileUploadName(f.Name, existingNames)
151+
resolvedNames = append(resolvedNames, resolvedName)
136152
part, err := w.CreateFormFile("file", resolvedName)
137153
if err != nil {
138154
return err
@@ -147,6 +163,9 @@ func (a *App) UploadAuthFiles(files []UploadFilePayload) error {
147163
}
148164

149165
_, _, err = a.SidecarRequest(http.MethodPost, ManagementAPIPrefix+"/auth-files", nil, &buf, w.FormDataContentType())
166+
if err == nil {
167+
a.invalidateAuthFileMetadataCache(resolvedNames...)
168+
}
150169
return err
151170
}
152171

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package wailsapp
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
type authFileMetadataCacheEntry struct {
9+
Name string
10+
Size int64
11+
Modified int64
12+
Type string
13+
Provider string
14+
Priority int
15+
Email string
16+
PlanType string
17+
}
18+
19+
func authFileMetadataCacheKey(name string, size int64, modified int64) string {
20+
return fmt.Sprintf("%s|%d|%d", strings.TrimSpace(name), size, modified)
21+
}
22+
23+
func (a *App) cachedAuthFileMetadata(file AuthFileItem) (authFileMetadataCacheEntry, bool) {
24+
name := strings.TrimSpace(file.Name)
25+
if name == "" {
26+
return authFileMetadataCacheEntry{}, false
27+
}
28+
key := authFileMetadataCacheKey(name, file.Size, file.Modified)
29+
a.authFileCacheMu.RLock()
30+
entry, ok := a.authFileMetadataCache[key]
31+
a.authFileCacheMu.RUnlock()
32+
if !ok {
33+
return authFileMetadataCacheEntry{}, false
34+
}
35+
return entry, true
36+
}
37+
38+
func (a *App) storeAuthFileMetadata(file AuthFileItem) {
39+
name := strings.TrimSpace(file.Name)
40+
if name == "" {
41+
return
42+
}
43+
entry := authFileMetadataCacheEntry{
44+
Name: name,
45+
Size: file.Size,
46+
Modified: file.Modified,
47+
Type: strings.TrimSpace(file.Type),
48+
Provider: strings.TrimSpace(file.Provider),
49+
Priority: file.Priority,
50+
Email: strings.TrimSpace(file.Email),
51+
PlanType: strings.TrimSpace(file.PlanType),
52+
}
53+
key := authFileMetadataCacheKey(name, file.Size, file.Modified)
54+
a.authFileCacheMu.Lock()
55+
if a.authFileMetadataCache == nil {
56+
a.authFileMetadataCache = map[string]authFileMetadataCacheEntry{}
57+
}
58+
a.authFileMetadataCache[key] = entry
59+
a.authFileCacheMu.Unlock()
60+
}
61+
62+
func applyCachedAuthFileMetadata(file *AuthFileItem, entry authFileMetadataCacheEntry) {
63+
if file == nil {
64+
return
65+
}
66+
if needsAuthFileKindInference(*file) {
67+
if entry.Provider != "" {
68+
file.Provider = entry.Provider
69+
}
70+
if entry.Type != "" {
71+
file.Type = entry.Type
72+
}
73+
}
74+
if strings.TrimSpace(file.Email) == "" {
75+
file.Email = entry.Email
76+
}
77+
if strings.TrimSpace(file.PlanType) == "" {
78+
file.PlanType = entry.PlanType
79+
}
80+
if file.Priority == 0 {
81+
file.Priority = entry.Priority
82+
}
83+
}
84+
85+
func (a *App) invalidateAuthFileMetadataCache(names ...string) {
86+
a.authFileCacheMu.Lock()
87+
defer a.authFileCacheMu.Unlock()
88+
if len(a.authFileMetadataCache) == 0 {
89+
return
90+
}
91+
if len(names) == 0 {
92+
a.authFileMetadataCache = map[string]authFileMetadataCacheEntry{}
93+
return
94+
}
95+
targets := map[string]struct{}{}
96+
for _, name := range names {
97+
if trimmed := strings.TrimSpace(name); trimmed != "" {
98+
targets[trimmed] = struct{}{}
99+
}
100+
}
101+
if len(targets) == 0 {
102+
return
103+
}
104+
for key, entry := range a.authFileMetadataCache {
105+
if _, ok := targets[entry.Name]; ok {
106+
delete(a.authFileMetadataCache, key)
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)