sqlite-utils 4.0rc2, mostly written by Claude Fable (for about $149.25)

Simon Willison

sqlite-utils 4.0rc2,主体由 Claude Fable 编写(花费约 149.25 美元)

原文由 Simon Willison 发布,订阅该博客

几周前我写过关于 sqlite-utils 4.0rc1 发布的文章。我们的 Max 订阅还能使用 Claude Fable 的时间只剩下几天,于是我决定看看它能否帮我打磨出一个让我真正放心的 4.0 正式版,毕竟我一直遵守 SemVer,也希望不兼容的大版本更新越少越好。

我在 iPhone 上的 Claude Code 网页版中,用这样一条提示词作为起点:

Final review before shipping a stable 4.0 release - very important to spot any last minute things that would be a breaking change if we fix them later

这是它为我生成的初始报告。其中有一些我自己还没遇到过的重大问题——Fable 将其中 5 个归为“发布阻断性问题”。下面是最严重的一个:

1. delete_where() 从不提交并会污染连接(导致数据丢失)

Table.delete_where()sqlite_utils/db.py:2948)通过裸露的 self.db.execute() 执行 DELETE,没有用 atomic() 包裹——对比 db.py:2944 处的 Table.delete(),后者做了正确的包裹。连接会处于 in_transaction=True 的状态,因此之后每一次 后续的 atomic() 调用都会走 savepoint 分支(db.py:430-440),同样永远不会提交。

可完整复现如下:

db = sqlite_utils.Database("dw.db")
db["t"].insert_all([{"id": i} for i in range(3)], pk="id")
db["t"].delete_where("id = ?", [0])   # conn.in_transaction is now True
db["t"].insert({"id": 50})
db["u"].insert({"a": 1})
db.close()
# Reopen: rows are [0, 1, 2] — the delete, row 50, AND table u are all gone.

这是个非常严重的 bug!很庆幸没有带着它发布,好在即便发布了,也可以在 4.0.1 这样的补丁版本中修复,而不是会迫使我发布 5.0 的设计缺陷。

在 37 条提示词、34 次提交、跨 30 个文件的 +1,321 -190 行代码改动之后,我们逐一处理了全部反馈,期间还顺带做了其他几处设计上的改进。

编程智能体有一个奇怪的地方:越是像这样困难的任务,反而越能让你同时去做别的事,因为智能体有时需要花上 10 到 15 分钟来处理一项新任务。我就趁机出门去看了半月湾的 7 月 4 日游行,期间偶尔在手机上查看进度,给 Fable 下达下一步指令。

完整细节见 这个 PR这份共享的对话记录。最后的复审我切换到了笔记本电脑,通过 GitHub 的 PR 界面完成。

最重要的改动与事务处理有关,这也是上一个 RC 中的标志性新功能。新 RC 现在包含了关于新事务模型的完整文档,下面全文引用其引言部分:

该库中每一个写入数据库的方法——insert()upsert()update()delete()delete_where()transform()create_table()create_index()enable_fts() 等等——都会在各自的事务中运行,并在返回前提交。方法调用一结束,你的改动就会保存到磁盘:

db = Database("data.db")
db.table("news").insert({"headline": "Dog wins award"})
# The new row is already saved - no commit() required

使用 db.execute() 执行的原生 SQL 也遵循同样的规则——写入语句一经执行就会被提交。

你永远不需要调用 commit(),也不需要关闭数据库来持久化改动。只有两种情况需要你考虑事务:

  1. 你想把多个写入操作打包在一起,要么全部成功,要么全部失败——请使用 db.atomic()

  2. 你正在自行管理事务,使用了 db.begin(),此时在你提交之前不会有任何内容被提交——对于你手动开启的事务,库不会擅自提交。

在审阅 Fable 写的文档时——我发现先看文档的修改是快速搞清楚改了什么的绝佳方式——我注意到了这样一个细节

db.atomic() 和按方法自动执行的事务是为 Python 默认事务处理模式下的连接设计的。不支持使用 Python 3.12+ 中 sqlite3.connect(..., autocommit=True)autocommit=False 选项创建的连接,因为 commit()rollback() 在这些连接上的行为有所不同。

我得承认,我之前没考虑过 sqlite-utils 会如何应对 Python 3.12 中新增的 autocommit 设置。结果“在这些连接上行为不同”意味着几乎整个测试套件都会失败,于是我与模型合作,确保这种差异不会破坏库的正常运行。

以及由 GPT-5.5 完成的最终复审

我过去觉得让一个模型去复审另一个模型的工作有点荒谬——感觉带着一种奇怪的迷信色彩。问题在于它真的管用——我已经养成了习惯,让 Anthropic 最强的模型去复审 OpenAI 的成果,反之亦然,因为这种做法已经足够多次带来了有价值的发现。

我用以下提示词让 Codex Desktop 和 GPT-5.5 xhigh 进行复审:

Review changes since the last RC. Also confirm that the changelog is up-to-date.

仅这一句就找出了两个值得进一步研究的问题:

发现的问题

  • [P1] sqlite_utils/db.py:663 db.query() 现在只有在调用 db.execute() 之后才会拒绝非返回行的语句,而 sqlite_utils/db.py:705 会先自动提交这些写入。因此 db.query("update ...") 会抛出 ValueError,但更新已经提交。对于一个文档中描述为“只能用于返回行的 SQL”的方法来说,这是一个令人意外的副作用。
  • [P1] sqlite_utils/db.py:672 通过 db.query() 执行的 INSERT ... RETURNING 只有在返回的生成器被完全耗尽后才会提交。不进行迭代的 db.query("insert ... returning ..."),或常见的 next(db.query(...)) 用法,会让事务保持开启状态,写入在关闭时可能被回滚。这与 docs/changelog.rst:15docs/python-api.rst:232 中所说的无需迭代即可生效相矛盾。

我把这份结果粘贴到一个新的 Fable 会话中,它通过一些实验确认了问题:

两个发现都得到了确认。db.query() 会先调用 self.execute(),后者会自动提交写入,然后才检查 cursor.description——因此 db.query("update ...") 会在抛出 ValueError 之前就提交更新。而 INSERT ... RETURNING 的提交逻辑位于返回生成器的末尾,所以除非耗尽迭代器否则永远不会触发——next(db.query(...)) 或未进行迭代的调用会让事务保持开启,与 changelog 和文档中的承诺相矛盾。

修复见这个 PR,以及完整的 Claude Code 对话记录。复审这些代码帮助我对 SQLite 事务语义的边界情况建立了更清晰的心智模型!

预估(未补贴)成本约为 149.25 美元

为了在7 月 7 日的 Fablepocalypse 到来之前——届时即便是 Claude Max 订阅用户也需为该模型支付全额 API 费用——尽可能增加 Fable 的使用额度,我将订阅从之前的 100 美元/月升级到了 Claude Max 200 美元/月。

我很好奇如果直接按量付费,这些工作会花掉我多少钱。起初我以为拿不到这些数字,因为我是通过 Claude Code 网页版远程运行的,后来才意识到我可以在现有会话中运行 AgentsView 来估算成本!

Run "uvx agentsview --help"​ and then use that tool to calculate the cost of this session

Claude 弄清楚了如何使用 session list --include-children 命令,并得出了如下结果:

对话记录模型费用
主会话claude-fable-5$141.02
API 表面扫描智能体claude-fable-5$2.40
事务/atomic 复审智能体claude-fable-5$2.39
rc1 之后提交复审智能体claude-fable-5$1.72
迁移复审智能体claude-fable-5$1.40
提示词计数智能体claude-opus-4-8$0.32
总计$149.25

很庆幸我用的是订阅制!我真该听从自己的建议,更多地借助更便宜模型的子智能体。

这是 claude.ai/settings/usage 目前显示给我的用量情况:

Claude 套餐用量限制面板的截图:“套餐用量限制 Max (20x)”;“当前会话”显示“3 小时 52 分钟后重置”,进度条为“已用 7%”;“每周额度”标题及“了解更多用量限制”链接;“所有模型”显示“周三 12:00 PM 重置”,进度条为“已用 32%”;“Fable”显示“周三 12:00 PM 重置”,进度条为“已用 63%”。

我手头还有几个其他由 Fable 驱动的重要项目正在进行,目标就是赶在涨价前把 Fable 的用量条用满 100%。

sqlite-utils 4.0rc2 完整发布说明

这是该 RC 的完整发布说明。我让 Fable 在每项改动落地时就将其添加到 changelog 的“Unreleased”部分,并一边进行一边复审。这样做有一个巧妙的副作用:changelog 的提交历史就成了本次发布中各项改动的简明总结。

过去我一直坚持手写发布说明,但说实话,这些说明比我自己写的更好。发布说明正是那种我愿意外包给智能体的写作——因为它们需要的是枯燥、可预期且准确。

不兼容变更:

  • 使用 db.execute() 执行的写入语句现在会自动提交,除非已有事务处于开启状态,此时则会加入该事务。此前它们会开启一个隐式事务并保持开启,直到有其他操作提交——在同一连接上读取时写入看似生效,但在连接关闭时会被静默回滚。依赖回滚未提交的 db.execute() 写入的代码,应改用新的 db.begin() 方法先显式开启事务。完整的事务模型见 Transactions and saving your changes
  • db.query() 现在在调用时就会立即执行 SQL,而不是等到首次迭代返回的生成器时才执行。行数据仍在迭代过程中惰性获取。SQL 错误现在会在调用处抛出,诸如 INSERT ... RETURNING 这类语句会立即执行并提交,无需迭代其结果,而传入不返回任何行的语句——此前会被静默忽略——现在会抛出 ValueError 并建议改用 db.execute()。以这种方式被拒绝的语句会在抛出错误前回滚,因此不会对数据库产生任何影响。
  • Python API 的校验错误现在抛出 ValueError,而非 AssertionError。此前,无效参数——例如不带任何列的 create_table()、对不存在的表调用 transform(),或同时传入 ignore=Truereplace=True——是通过裸 assert 语句来拒绝的,而在 Python 使用 -O 标志运行时这些 assert 会被静默跳过。原先捕获 AssertionError 的代码应改为捕获 ValueError
  • table.upsert()table.upsert_all() 现在会在某条记录缺少任一主键列的值,或其中某一主键值为 None 时抛出 PrimaryKeyRequired。此前,这类永远无法匹配现有行的记录会被悄悄作为全新行插入,或在插入完成后触发令人困惑的 KeyError
  • 如果在事务开启期间调用 db.enable_wal()db.disable_wal(),现在会抛出 sqlite_utils.db.TransactionError。此前它们会以更改日志模式的副作用静默提交已开启的事务,从而破坏 db.atomic() 和用户自行管理事务的回滚保证。
  • View 类不再拥有 enable_fts() 方法。该方法过去仅用于抛出 NotImplementedError,因为视图不支持全文搜索——现在调用它会改为抛出 AttributeError,且该方法已不再出现在 API 参考中。当 sqlite-utils enable-fts 命令指向视图时会显示清晰的错误提示。
  • 已从 insertupsert 命令中移除无实际作用的 -d/--detect-types 标志。自 4.0a1 起,对 CSV/TSV 数据的类型检测已是默认行为,因此该标志实际上不起任何作用——使用它的调用只需去掉该标志即可。--no-detect-types 仍可用于禁用检测。
  • 如果向 Database() 传入了使用 Python 3.12+ 中 sqlite3.connect(..., autocommit=True)autocommit=False 选项创建的连接,现在会抛出 sqlite_utils.db.TransactionErrorcommit()rollback() 在这些连接上的行为不同,此前会导致库执行的所有写入在连接关闭时被静默丢弃。

其他变更:

  • 修复了 table.delete_where()table.optimize()table.rebuild_fts() 未提交改动、导致连接滞留在开启事务中的 bug。它们的改动——以及之后的任何写入——都可能在连接关闭时被静默回滚。三者现在均使用 db.atomic(),与其他写入方法的行为保持一致。
  • sqlite-utils drop-table 命令现在会拒绝删除视图,drop-view 则会拒绝删除表。此前如果名称匹配,它们会静默删除错误类型的对象。现在两者都会报错退出,并提示应使用的正确命令。
  • 由新的 迁移系统应用的迁移现在会在事务中运行,并与“迁移已应用”的记录一起提交。如果某个迁移抛出异常,其改动会被回滚且该迁移保持待处理状态,因此在修复错误后可以安全地重新应用。无法在事务中运行的迁移,例如执行 VACUUM 的迁移,可使用 @migrations(transactional=False) 选择退出——详见 Migrations and transactions
  • table.upsert()table.upsert_all() 现在会自动检测已存在表的主键或复合主键,因此在向已有主键的表执行 upsert 时不再需要传入 pk= 参数。
  • 现在可以使用 db.table(table_name).insert({}) 向已存在的表中插入一行完全由默认值组成的记录,底层使用 INSERT INTO ... DEFAULT VALUES。(#759
  • sqlite-utils migrate 命令的改进:与任何已知迁移都不匹配的 --stop-before 值现在会报错,而不是被静默忽略;--stop-before 现在也能与仍使用旧版 sqlite_migrate.Migrations 类的迁移文件正确配合;--list 现在是只读操作,不再创建数据库文件或迁移记录表。migrations.applied() 现在会按应用顺序返回迁移。
  • 新增 db.begin()db.commit()db.rollback() 方法,用于手动控制事务,作为 db.atomic() 上下文管理器的替代方案。
  • 新增文档:Transactions and saving your changes 介绍了事务的工作原理以及改动的提交时机,新增的 Upgrading 页面则详细说明了在不同大版本之间升级所需的改动。

本文章由 muse-spark-1.2-contributor 进行翻译

评论