Skip to content

fix(storage): persist rotated tokens reliably and enable sqlite WAL - #3344

Open
PIKACHUIM wants to merge 2 commits into
mainfrom
fix/115-open-refresh-token-persist
Open

PIKACHUIM wants to merge 2 commits into
mainfrom
fix/115-open-refresh-token-persist

Conversation

@PIKACHUIM

@PIKACHUIM PIKACHUIM commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

fix(storage): persist rotated tokens reliably and enable sqlite WAL

fix(storage): persist rotated tokens reliably and enable sqlite WAL

Summary / 摘要

115 Open 存储会间歇性报 refresh_token 无效(40140120),重新授权后恢复,但账号侧 token 实际并未失效。定位后发现是「轮换 token 落库失败」与「SQLite 未真正启用 WAL」叠加导致,与本 PR 一并修复。

  • 用户可感知的行为变化:

    • 115 Open 存储不再因一次写库失败而在重启后变成「token 无效」,需要重新授权的场景显著减少。
    • SQLite 部署下,高并发 DAV / 列举请求造成的成片 database is locked (SQLITE_BUSY) 不再出现。
    • 115 Open 在只填写 refresh token 时不再先失败一次,直接给出准确错误。
  • 重要实现变化:

    • internal/bootstrap:SQLite DSN 参数改为按构建分支拼装。默认构建走纯 Go 的 glebarez/go-sqlite,只识别 _pragma=;原 ?_journal=WAL&_vacuum=incremental 属于 mattn 语法,被静默忽略。
    • internal/op:saveDriverStorage 对瞬时数据库错误做指数退避重试(5 次,200ms 起步)。
    • drivers/115_open:refresh token 非空才覆盖;仅配置 refresh token 时在 Init 主动刷新;两者都为空时直接返回错误。
  • 配置 / 存储 / 兼容性:

    • 无公开 API、配置项、存储格式变更,无迁移行为变更。
    • 已存在的 SQLite 数据库首次以新版本启动后会切换到 WAL 模式,目录下会新增 -wal / -shm 文件;synchronous 由默认的 FULL 调整为 WAL 推荐值 NORMAL。
  • This PR has breaking changes.
    / 此 PR 包含破坏性变更。

  • This PR changes public API, config, storage format, or migration behavior.
    / 此 PR 修改了公开 API、配置、存储格式或迁移行为。

  • This PR requires corresponding changes in related repositories.
    / 此 PR 需要关联仓库同步修改。

Related repository PRs / 关联仓库 PR:

  • OpenList-Frontend:
  • OpenList-Docs:

Related Issues / 关联 Issue

待补充。

Testing / 测试

  • go test ./...

    未执行全量测试。实际执行并通过:

    go build ./...                                      通过
    go test ./internal/op/... ./internal/bootstrap/...  通过
    
  • Manual test / 手动测试:

    DSN 语法与 PRAGMA 实际效果,在 glebarez/go-sqlite 下实测对比:

元信息

旧 DSN: journal_mode=delete  busy_timeout=5000
新 DSN: journal_mode=wal     busy_timeout=10000
项目 内容
仓库 OpenListTeam/OpenList
分支 fix/115-open-refresh-token-persist
提交 84f421f0df4ba30e06f987e730f7c03582a6a54f
基点 main
提交链接 84f421f
分支链接 https://github.com/OpenListTeam/OpenList/tree/fix/115-open-refresh-token-persist
对比链接 main...fix/115-open-refresh-token-persist
新建 PR https://github.com/OpenListTeam/OpenList/pull/new/fix/115-open-refresh-token-persist
平台:Windows amd64,Go 1.27.0,GOTOOLCHAIN=local。

问题

Checklist / 检查清单

115 Open 存储会「随机」报 refresh_token 无效(40140120),重新授权后又正常,但账号侧的 token 实际上并没有失效。同样的授权信息放到其他项目里可以正常工作。

  • I have read CONTRIBUTING.
    / 我已阅读 CONTRIBUTING。
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
    / 我确认此贡献符合仓库许可证、贡献规范和行为准则。
  • I have formatted the changed code with gofmt, go fmt, or prettier where applicable.
    / 我已按适用情况使用 gofmt、go fmt 或 prettier 格式化变更代码。
  • I have requested review from relevant maintainers or code owners where applicable.
    / 我已在适用情况下请求相关维护者或代码所有者审查。

根因

AI Disclosure / AI 使用声明

  • This PR includes AI-assisted content.
    / 此 PR 包含 AI 辅助内容。

Tools used / 使用工具:

  • ChatGPT
  • Codex
  • GitHub Copilot
  • Claude
  • Gemini
  • Other (please specify) / 其他(请注明): CodeBuddy (DeepSeek-V4.1)

Usage scope / 使用范围:

  • Code generation / 代码生成

  • Refactoring / 重构

  • Documentation / 文档

  • Tests / 测试

  • Translation / 翻译

  • Review assistance / 审查辅助

  • I have reviewed and validated all AI-assisted content included in this PR.
    / 我已审核并验证此 PR 中的所有 AI 辅助内容。

  • I have ensured that all AI-assisted commits include Co-Authored-By attribution.
    / 我已确保所有 AI 辅助提交都包含 Co-Authored-By 归属信息。

  • I can reproduce all AI-assisted content included in this PR without any AI tools.
    / 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。


附录:根因与证据

1. 轮换式 refresh token + 尽力而为的落库

115 Open 的 refresh_token 是一次性轮换的:用旧 token 换到新 token 后,旧 token 立即作废。

op.MustSaveDriverStorage 只是尽力把 Addition 写回数据库,失败时仅打一行日志、不重试也无从回滚:

	d.client = sdk.New(sdk.WithRefreshToken(d.Addition.RefreshToken),
		sdk.WithAccessToken(d.Addition.AccessToken),
		sdk.WithOnRefreshToken(func(s1, s2 string) {
			d.Addition.AccessToken = s1
			d.Addition.RefreshToken = s2
			op.MustSaveDriverStorage(d)
		}))

一旦这次写库失败,新 token 只存在于进程内存,重启后数据库里留下的是已经被作废的旧 token。
115 Open 的 refresh_token 一次性轮换,换新后旧 token 立即作废。MustSaveDriverStorage 失败只打日志,新 token 仅存在于进程内存,重启后数据库里留下的是已作废的旧 token。

2. SQLite 实际没有跑在 WAL 模式

2. SQLite 实际未运行在 WAL 模式

			dB, err = gorm.Open(openSQLite(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental",
				database.DBFile)), gormConfig)

这是 mattn/go-sqlite3 的 DSN 语法。但默认构建(build.sh 只为 loong64 / mips / 386 打 sqlite_cgo_compat 标签)走的是纯 Go 的 glebarez/go-sqlite,它只认 _pragma=,_journal / _vacuum 被静默忽略,busy timeout 也只剩驱动内置的 5000ms。
默认构建(build.sh 仅为 loong64 / mips / 386 打 sqlite_cgo_compat 标签)使用 glebarez/go-sqlite,只认 _pragma=。WAL 从未生效,busy timeout 只剩驱动内置 5000ms,rollback-journal 模式下读写互相阻塞。

结果是数据库长期处于 rollback-journal(delete)模式:读会阻塞写、写会阻塞读,DAV 高并发场景下成片出现 database is locked,正好覆盖了那次 token 落库。

3. 「判断有无」缺失

3. 「判断有无」缺失导致空 token 被写回

刷新响应缺少 refresh_token 时,回调会把内存中的值覆盖为空;ReqWithForm 的 removeEmptyForm 又会丢弃空参数,于是 115 返回 40100000 参数错误 或 40140114 refresh_token 格式错误。

  • 115_open 的刷新回调无条件执行 d.Addition.RefreshToken = s2,响应里没有 refresh_token 时会把库里的值写成空串。
  • SDK 的 ReqWithForm 会通过 removeEmptyForm 把空参数丢掉,于是刷新请求根本不带该参数,115 分别返回:
    • 40100000 参数错误(未带参数)
    • 40140114 refresh_token 格式错误(格式不合法)

日志证据

日志证据

11-04 13:52:36  ERRO failed save driver storage: failed update storage in database: database is locked (5) (SQLITE_BUSY)
11-05 00:14:53  启动 → failed get enabled storages: code: 40140120, message: refresh_token 无效
11-05 22:44:42  ERRO failed save driver storage: failed update storage in database: database is locked (5) (SQLITE_BUSY)
11-05 22:49:08  启动 → failed get enabled storages: code: 40140120, message: refresh_token 无效
11-06 00:31:18  storage/update(id=10) → failed init storage: code: 40140120, message: refresh_token 无效

「落库失败」与「重启后 token 无效」相隔数分钟,中间存在明确的启动日志(bootstrap.LoadStorages),因果链闭合。

改动

internal/bootstrap/db.go、sqlite_driver_glebarez.go、sqlite_driver_gorm.go

DSN 参数改由各构建分支自行拼接,新增 appendDSNParams 兼容已有参数:

  • glebarez(纯 Go,默认构建):_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)
  • cgo(gorm.io/driver/sqlite):_journal=WAL&_vacuum=incremental&_busy_timeout=10000&_synchronous=NORMAL

internal/op/storage.go

saveDriverStorage 对瞬时数据库错误做指数退避重试(5 次,200ms 起步,合计约 3s),非瞬时错误立即返回。对所有通过该函数持久化轮换凭证的驱动(115 Open、PikPak、OneDrive、Wopan 等)一并生效。

drivers/115_open/driver.go

  • refresh token 非空时才覆盖库里的值,避免写回空串。
  • 只配置了 refresh token 时在 Init 主动刷新一次,不再先发一个无鉴权请求换取误导性的 40100000 参数错误。
  • 两者都为空时 Init 直接返回明确错误,而不是把问题留给远端。

验证

DSN 语法与 pragma 效果实测对比:

旧 DSN: journal_mode=delete  busy_timeout=5000
新 DSN: journal_mode=wal     busy_timeout=10000
go build ./...                                      通过
go test ./internal/op/... ./internal/bootstrap/...  通过

影响面

  • 所有 SQLite 部署:WAL 生效后并发读写不再互相阻塞,符合上游推荐配置。
  • synchronous=NORMAL 是 SQLite 官方对 WAL 模式的推荐取值,极端断电场景可能丢掉最后若干事务,用于换取明显更好的写性能。
  • 非 SQLite 部署(MySQL / PostgreSQL)不受影响。

升级须知

升级须知

数据库中已经失效的 refresh token 无法通过代码恢复,升级后需要重新授权一次 115 Open 存储。
数据库中已经失效的 refresh token 无法通过代码恢复,升级后需重新授权一次 115 Open 存储。

遗留问题(不在本 PR 范围)

遗留问题(不在本 PR 范围)

115-sdk-go@v0.2.6 的 RefreshToken 缺少 mutex / singleflight 保护。access token 过期瞬间多个并发请求同时收到 401,会并发刷新同一个一次性 token,后到的请求必然失败(40140120 refresh_token 无效)。彻底解决需要修改 SDK 仓库并升级依赖。

另外 Is401Started 仅判断错误码是否以 401 开头,会把 40100000 参数错误 这类非鉴权错误也当成 token 失效而触发刷新,建议收紧到明确的鉴权错误码。
115-sdk-go@v0.2.6 的 RefreshToken 缺少 mutex / singleflight,access token 过期瞬间多个并发请求会同时刷新同一个一次性 token,后到者必然失败。另 Is401Started 仅判断错误码是否以 401 开头,会把 40100000 参数错误 也当作鉴权失败触发刷新。两者需在 SDK 仓库处理并升级依赖。


115 Open「refresh_token 无效」根因追溯

仓库:OpenListTeam/OpenList
定位时间:2026-11-06
修复提交:84f421f0df4ba30e06f987e730f7c03582a6a54f

结论摘要

115 Open 存储会间歇性报 refresh_token 无效(40140120),但账号侧 token 实际并未失效。

根因不是 115 的问题,也不是 115 Open 驱动本身写错,而是:

  1. 设计缺陷(2025-03-17 引入):115 Open 把「刷新后的一次性 token 写回数据库」实现成了 best-effort,失败只打一行日志。轮换 token 语义下,写库失败 = 永久丢 token。
  2. 触发条件(2026-04-02 引入):SQLite 从 cgo 驱动迁移到纯 Go 驱动时,DSN 仍沿用 mattn 语法,被新驱动静默忽略,WAL 从此从未生效,写锁冲突从偶发变成常态。

一句话:一次「换底层驱动但没换参数语法」的重构,把另一个模块里早就埋好的 best-effort 持久化给引爆了。

故障链

access token 过期
   ↓
多个请求收到 401,SDK 触发 refresh
   ↓
115 返回新 access_token + 新 refresh_token,旧 refresh_token 立即作废
   ↓
onRefreshToken 更新内存 → op.MustSaveDriverStorage 写库
   ↓
SQLite 正被并发写占用(WAL 未生效,只有 5s busy timeout)
   ↓
SQLITE_BUSY → 写库失败,仅记录 "failed save driver storage"
   ↓
内存里是新 token,继续正常服务(表面无异常)
   ↓
进程重启 / 重新加载存储 → 从数据库读到已作废的旧 token
   ↓
40140120 refresh_token 无效,存储初始化失败

关键提交

时间 提交 作者 作用
2023-01-17 9c7e451c Aoang 给 mattn 驱动加 _journal=WAL&_locking=EXCLUSIVE&_vacuum=incremental,当时有效
2023-01-19 98872a8f Noah Hsu 取消 EXCLUSIVE,保留 WAL
2025-03-17 3499c4db Andy Hsu 引入 115 Open 驱动,token 落库为 best-effort(设计缺陷)
2026-04-02 7bea29c1 Suyunjing SQLite 迁移到纯 Go,DSN 未同步改语法 → WAL 静默失效(引入点)
2026-11-06 84f421f0 pikachuren 修复:DSN 按构建分支拼装 + 落库重试 + 空 token 保护

引入点详解:7bea29c1(2026-04-02)

提交说明:

refactor(db): migrate sqlite to pure-go and add mips compatibility switch (#2296)

  • Switch default SQLite path to github.com/glebarez/sqlite to reduce CGO dependency pressure.
  • Introduce a unified openSQLite entry in bootstrap and split driver selection by build tags.
  • Add sqlite_cgo_compat fallback for linux mips, mips64, loong64 and mipsle ...

但 diff 对 DSN 只改了一个函数名:

-			dB, err = gorm.Open(sqlite.Open(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental",
+			dB, err = gorm.Open(openSQLite(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental",
 				database.DBFile)), gormConfig)

DSN 字符串一个字节都没动。

而新驱动 glebarez/go-sqlite 的参数解析只有:

for _, v := range q["_pragma"] {
    ...
}

_journal / _vacuum 属于 mattn 语法,被完全丢弃。于是从这一天起:

项目 迁移前(mattn) 迁移后(glebarez)
journal_mode wal delete
busy_timeout 显式配置 驱动内置 5000ms
auto_vacuum incremental 未设置

而且这是静默降级:不报错、不警告、服务正常启动,只在并发写撞锁时以 SQLITE_BUSY 的形式露面。

实测对比(本次修复时验证):

旧 DSN: journal_mode=delete  busy_timeout=5000
新 DSN: journal_mode=wal     busy_timeout=10000

佐证:从该提交到本次修复前,这两个文件再无改动。

$ git log --oneline 7bea29c1..HEAD -- internal/bootstrap/db.go internal/bootstrap/sqlite_driver_glebarez.go
(空)

即 WAL 自 2026-04-02 起整整六个月未生效。

放大因素:3499c4db(2025-03-17)

	d.client = sdk.New(sdk.WithRefreshToken(d.Addition.RefreshToken),
		sdk.WithAccessToken(d.Addition.AccessToken),
		sdk.WithOnRefreshToken(func(s1, s2 string) {
			d.Addition.AccessToken = s1
			d.Addition.RefreshToken = s2
			op.MustSaveDriverStorage(d)
		}))

两个问题:

  1. MustSaveDriverStorage 失败只 log.Errorf,无重试、无补偿,调用方也无从感知。
  2. d.Addition.RefreshToken = s2 无条件覆盖。若刷新响应缺少 refresh_token,会把库里的值写成空串;SDK 的 removeEmptyForm 又会丢弃空参数,115 于是返回 40100000 参数错误 或 40140114 refresh_token 格式错误。

为什么拖了六个月才爆发

这是概率事件,不是必然事件:

  • 115 Open 的 refresh token 约每 2 小时(access token 过期时)才轮换一次;
  • 只有「刷新成功的那一瞬间」恰好撞上 SQLite 写锁才会丢 token;
  • 丢失后只要不重启,内存中的新 token 继续有效,表面完全正常;
  • 一旦重启或重新加载存储,数据库里的死 token 才被拿出来使用。

因此现象表现为「有时候失效、重启一段时间正常、重新授权又好了」,与真正的 token 过期几乎无法区分。

日志证据

11-04 13:52:36  ERRO failed save driver storage: failed update storage in database: database is locked (5) (SQLITE_BUSY)
11-05 00:14:53  启动 → failed get enabled storages: code: 40140120, message: refresh_token 无效
11-05 22:44:42  ERRO failed save driver storage: failed update storage in database: database is locked (5) (SQLITE_BUSY)
11-05 22:49:08  启动 → failed get enabled storages: code: 40140120, message: refresh_token 无效
11-06 00:31:18  storage/update(id=10) → failed init storage: code: 40140120, message: refresh_token 无效

「落库失败」与「重启后 token 无效」相隔数分钟,中间存在明确的 bootstrap.LoadStorages 启动日志,因果闭合。

责任划分

层级 提交 性质
主因(触发) 7bea29c1(2026-04-02) 驱动迁移未同步 DSN 语法,且无 PRAGMA 运行期校验,静默降级
次因(放大) 3499c4db(2025-03-17) 一次性凭证的持久化做成 best-effort,失败仅打日志
缺失兜底 op.saveDriverStorage 无重试,瞬时锁冲突等同于永久丢失

修复对应

问题 修复位置 做法
WAL 未生效 internal/bootstrap/sqlite_driver_glebarez.go、sqlite_driver_gorm.go DSN 参数按构建分支各自拼装,glebarez 使用 _pragma= 语法
落库无兜底 internal/op/storage.go 对瞬时数据库错误做 5 次指数退避重试(200ms 起步)
空 token 覆盖 drivers/115_open/driver.go refresh token 非空才覆盖;仅有 refresh token 时先主动刷新

遗留问题(未修复)

  • 115-sdk-go@v0.2.6 的 RefreshToken 缺少 mutex / singleflight:access token 过期瞬间多个并发请求会同时刷新同一个一次性 token,后到者必然失败。需在 SDK 仓库修复并升级依赖。
  • Is401Started 仅判断错误码是否以 401 开头,会把 40100000 参数错误 这类非鉴权错误也当作 token 失效而触发刷新,建议收紧到明确的鉴权错误码。

经验教训

  1. 更换底层驱动时,必须逐项核对连接字符串/参数语义,不能只看能否编译启动。
  2. 对 pragma 这类「静默生效」的配置,应在启动时做一次运行期断言(例如读回 journal_mode 并校验)。
  3. 一次性轮换凭证的持久化不能是 best-effort,必须是「写成功才继续」,并带重试与告警。

sqlite: build the DSN per driver. The default pure-Go build (glebarez/go-sqlite) only understands '_pragma=' parameters, so the mattn style '_journal=WAL&_vacuum=incremental' was silently ignored and the database kept running in rollback-journal mode with the driver default 5000ms busy timeout. Verified: journal_mode=delete,busy_timeout=5000 before, journal_mode=wal,busy_timeout=10000 after.

op: retry storage saves on transient SQLITE_BUSY. Drivers persist rotating credentials through MustSaveDriverStorage (115 Open, PikPak, OneDrive...); the previous token is invalidated by the server as soon as a new one is issued, so a single failed write left the storage with a dead token after a restart.

115_open: never overwrite the refresh token with an empty value and refresh up front when only a refresh token is configured, so the SDK cannot send a refresh request without the parameter (which 115 reports as '40100000 参数错误' or '40140114 refresh_token 格式错误').
@PIKACHUIM
PIKACHUIM requested review from Suyunmeng and jyxjjj November 6, 2026 07:06
The SDK assigns the refresh response to its own fields before invoking onRefreshToken, so a response without refresh_token also cleared the value used by the next refresh request. Guarding only d.Addition was not enough: the running client kept sending an empty parameter.

Write the resolved pair back to the client, and add regression tests covering both the missing-field and the normal rotation cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant