Skip to content

fix(driver-sqlite-wasm): persist RETURNING writes — stop() 返回即落盘,冷启动 e2e 解封 (#4518) - #4564

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-4518-bootstack-durable-flush
Aug 2, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-4518-bootstack-durable-flush

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4518

结论先说:不在 harness,在驱动

issue 给了三个候选方向(查 stop() 链路 / harness 里显式 flush() / 换 persist 策略),实测下来一个都不是——三者都建立在"flush 该发生却没发生"这个前提上,而真实情况是驱动压根不认为自己脏了,所以再怎么 flush 都是空转。

用探针把链路拆开看:

PROBE connection cfg {"filename":"/tmp/probe-4518-…/v.sqlite","persist":"on-write"}
PROBE[after-write-before-stop] suspend_note = [[0]]   ← 内存里读得到,盘上 0 行
PROBE[after-write-before-stop] sys_user     = [[1]]   ← 但早期某次写是落盘了的
PROBE[after-stop]              suspend_note = [[0]]   ← stop() 之后仍然 0

两条线索合在一起就指向答案了:persist 其实是 on-write(不是 issue 猜的 on-disconnect,default-datasource-driver-factory.ts 对文件型 sqlite-wasm 就是这么配的),也就是说每次写都该 flush;但盘上只留下了 boot 早期的一次快照(所以文件大小两次完全一致 1073152)。既然 on-write 都不生效,stop() 那条链路是否走全就无关紧要了——它最后调用的 close() → flush() 第一行就是 if (!this.dirty) return。

根因

packages/plugins/driver-sqlite-wasm/src/knex-wasm-dialect.ts 的 _query 用**「这条语句返不返回行」挑执行分支,然后只在无行返回的那条分支上**调 markDirty():

if (isReadMethod(obj.method, obj.returning) || PRAGMA) {
  const stmt = db.prepare(obj.sql);   // ← INSERT … RETURNING * 走这里
  …
  return obj;                          // ← 没有 markDirty
}
db.run(…);
connection.markDirty(obj.method);      // ← 只有这条分支标脏

INSERT … RETURNING * 是返回行的,于是走上面那条分支。而 ObjectQL 的写路径就是用 RETURNING *(它要把落库后的整行还给调用方),所以受影响的是几乎全部业务数据——这也是为什么它表现成"harness 冷启动不了"而不是"某个驱动 bug"。knex.raw('INSERT …') 这类不带 Knex method 的语句同样掉进这条分支,同样丢。

on-disconnect 也救不了它:最后那次 flush 用的是同一个 dirty 标志位。两种持久化策略一起漏。

sys_user 之所以还在,是因为 better-auth 的插入没走 RETURNING;这也是当初这个 bug 能长期潜伏的原因之一——objectstack dev 那种"跑到被杀"的用法里,身份/配置类数据看起来是持久的。

修法:让"是否变更了数据库"只有一个答案

不在 harness 里补 flush()(那是把驱动的错误结论当成既定事实去绕开),而是把分类和执行彻底拆开:

export function statementMutatesDatabase(sql: string, method?: string): boolean
  • 同时看 Knex method 和 SQL 文本,所以既不会因为"走了返回行的分支"漏掉,也不会因为"没带 method"漏掉;
  • _query 重构成两段:① EXECUTE(三种执行形态,都不做持久化决策)→ ② PERSIST(唯一一处决策)。这样"某条分支忘了说自己写过"在结构上不再可能;
  • 事务控制语句仍然只交给 noteTransactionControl,继续把 flush 推迟到事务真正关闭(sql.js 的 export() 会 close+reopen,事务中导出会把事务冲掉 —— sqlite-wasm: authenticated record insert fails with "COMMIT; - cannot commit - no transaction is active" #1494);
  • 顺带把会改文件的 PRAGMA 赋值(auto_vacuum、user_version)也算成写。

同时 WasmSqliteConnection.markDirty(method?) 改成 markDirty()。它原本会拿调用方给的 method 再过一遍自己的白名单——"这条语句写了吗"于是有两个判断点,而这两个点恰好就是分歧发生的地方。现在方言分类、连接执行,一个决策一个归属。

结果:stop() 返回 ⇒ 数据已落盘

这条保证现在是结构性的,不需要调用方配合。packages/verify 里 databaseFile 的 TSDoc 补了这句话,顺带写明"冷启动看到表没有行 = 驱动 bug,不是 fixture 忘了等"。

测试

flow-durable-suspend.dogfood.test.ts 的 KNOWN GAP 注释删掉了,换成它当初就是为之而写的那个用例:

✓ ordinary business rows survive the restart — `stop()` returning means DURABLE
✓ the `paused` row survives the restart with its continuation intact
✓ the cold kernel RESUMES the run and takes the right branch
✓ the resumed result is itself durable — a THIRD boot still reads it

第一条是刻意排在最前的:当初把这个问题识别成驱动缺陷、而不是挂起存储缺陷的关键事实,就是普通业务记录也活不过重启。留着它,以后回归时能直接告诉你坏在哪一层。第四条则堵住"resume 只在 WASM 堆里成功"这种假绿。

驱动层新增 sqlite-wasm-driver-returning-persist.test.ts,逐条钉住每种执行形态(INSERT … RETURNING、UPDATE … RETURNING、knex.raw、事务内提交、on-disconnect),外加对 statementMutatesDatabase 本身的分类断言。

回滚验证(把两个源文件 stash 掉重新 build):

驱动:  Tests  6 failed (6)
        AssertionError: expected [] to deeply equal [ 'a1' ]
dogfood: Tests  4 failed | 6 passed (10)
        AssertionError: the note written by the first kernel is gone:
          {"error":"Record 8XE_7uCP_utEcCQb not found in suspend_note", …}: expected 404 to be 200
        AssertionError: {"code":"RESOURCE_NOT_FOUND","message":"No suspended run 'run_c7e3…'"}: expected 404 to be less than 300

注:on-disconnect 那条用例一开始是"假绿"的——建表 DDL 本来就会标脏,而 on-disconnect 直到 close 才 flush,于是那次导出顺手把没标脏的行也带上了。加了一次显式 flush() 把标志位清掉,才复现出 on-write 在生产里的真实处境(每次 flush 都清标志,之后再没人置位)。这一条现在也随回滚失败。

全量:

@objectstack/driver-sqlite-wasm   Test Files  15 passed (15)   Tests  214 passed (214)
@objectstack/dogfood              Test Files  76 passed | 1 skipped (77)   Tests  442 passed | 3 skipped (445)
@objectstack/driver-sql           Test Files  54 passed | 4 skipped (58)   Tests  623 passed | 38 skipped (661)
@objectstack/service-datasource   Test Files  11 passed (11)   Tests  197 passed (197)
@objectstack/runtime              Test Files  74 passed (74)   Tests  1046 passed (1046)
@objectstack/verify               Test Files   2 passed (2)    Tests    7 passed (7)
typecheck (driver-sqlite-wasm / verify)  clean
eslint --no-inline-config (driver-sqlite-wasm, qa/dogfood, verify)  clean

已 merge 最新 origin/main(a7163ea)并按 AGENTS.md §9 重装 + 全量 build 后复跑。

影响面提醒

文件型 wasm SQLite 现在真的会按 on-write 落盘,写入次数会明显多于从前——从前几乎为零。:memory: 不受影响。

🤖 Generated with Claude Code


Generated by Claude Code

… e2e (#4518)

A file-backed sqlite-wasm database flushed its schema at boot and then
recorded nothing else: every table on disk, every subsequent row only in
the WASM heap. `bootStack({ databaseFile })` therefore could not cold-boot,
which blocked #4470's third minimal form.

Root cause is in the Knex dialect, not the harness. `_query` picked its
execution branch from "does this statement return rows" and then set the
dirty flag only on the other, row-less branch. `INSERT ... RETURNING *`
returns rows — and that is the shape ObjectQL writes with — so it executed
on the row-returning branch and never marked the database dirty. The
`on-disconnect` flush is gated on the same flag, so both persist strategies
dropped the write; `knex.raw('INSERT ...')` (no Knex `method`) was lost the
same way.

"Does this statement change the database?" is now one exported predicate,
`statementMutatesDatabase(sql, method)`, classifying by method AND SQL text
and applied at a single funnel after execution — independent of which branch
ran it. Transaction control still routes to `noteTransactionControl` so
flushes stay deferred until a transaction closes (#1494); mutating PRAGMA
assignments now count as writes. `WasmSqliteConnection.markDirty()` loses
its method argument: re-filtering there made the same decision in two places
that could disagree, which is precisely how the branches diverged.

Tests: a new driver-level suite pins every execution branch (all six fail
when the fix is reverted), and `flow-durable-suspend.dogfood.test.ts` loses
its KNOWN GAP — it now suspends, shuts the kernel down, cold-boots a second
kernel over the same file, resumes there, and proves the result survives a
third boot, plus the plain-record assertion that identified this as a driver
defect rather than a suspended-run one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 2, 2026 3:21am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sqlite-wasm, @objectstack/verify.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-sqlite-wasm, @objectstack/verify)
  • content/docs/releases/v15.mdx (via @objectstack/verify)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[verify] bootStack({ databaseFile }) 的数据活不过进程内重启 —— 表在、行没了,挡住冷启动类 e2e

2 participants