工程实践

批量文档处理中断后,别急着点“重新开始”

超时不一定失败,重试也不等于续跑。用一份可以直接运行的 SQLite 示例,演示怎样找回已完成的任务、避开重复提交,并把无法确认的结果留给人工核对。

阅读文本 / Reading text
假设你正在处理一批合同:先解析 PDF,再翻译,最后导出交付文件。进度跑到一半,页面报了超时。你最想知道的并不是“该等几秒重试”,而是: 刚才那份文档,到底处理过没有? 如果服务端已经完成工作,只是响应没传回来,再提交一次可能生成第二个任务。反过来,如果你只看见一个输出文件就跳过,也可能把写到一半的残缺文件当成成品。真正可靠的续跑,第一步不是循环,而是把“已完成”“明确失败”和“结果未知”分开。 先记住一条规则:超时后先核对,不是先重发 请求发出与本地记账之间,总有一个无法靠本地数据库完全覆盖的窗口。把数据库事务写得再严密,也不能让另一个服务的工作跟着本地回滚。所以,我们先承认这个窗口,再让恢复流程有据可查。 看到的现象 此刻真正知道什么 正确的下一步 接口超时,没拿到任务号 只知道响应没收到 按持久化请求键查询;查不到也先保留未知状态 查到任务仍在运行 服务端已经接单 等待同一任务,按服务方要求退避查询 查到任务成功 服务端已有结果 拉取产物,校验后落库,再标记完成 本地结果已保存 当前修订已有可用结果 核对输入与参数,命中后直接复用 这张表不是某个产品接口的保证。真实服务可能有任务查询延迟、幂等键有效期或记录保留期。能否重试,最终取决于那个接口明确承诺了什么;尤其不要把一次“未找到”理解成“绝对没有执行”。 响应丢失后,任务走向哪里? 离线教学示意 · 不发送请求,不产生费用 1 响应丢失 2 重启核对 3 再次运行 查不到任务? 本地状态 unknown 提交次数 1 是否允许盲目重发 不允许 服务端已经完成处理,但成功响应没有送达。本地只能记为“结果未知”,不能据此判断任务失败。 下一步:保留请求标识,先查询原任务。 全屏 用两个小账本,把丢失的响应找回来 下面的实验不接网络,也不做真实 PDF 解析或翻译。它把“转成大写”当作处理动作,用两个磁盘 SQLite 数据库分别代表客户端账本和演示服务的任务账本。这样,注意力就能放在恢复流程,而不是模型、网络或积分上。 我们故意让服务端先保存结果,再抛出超时。客户端保存 unknown ,关闭并重新打开本地数据库,然后查询原任务并找回结果。这是 连接重开后的恢复测试 ,不是断电、进程强杀或分布式并发测试。 保存下面的完整代码为 resume_demo.py ,运行 python resume_demo.py 。仅用 Python 标准库;本次实测环境为 Python 3.13.11 / SQLite 3.51.0。演示数据库位于临时目录,程序结束后自动清理,不会修改你的业务数据库。 """Offline, single-worker reconciliation demo. No HTTP, translation or billing.""" import hashlib import json from pathlib import Path import sqlite3 import tempfile def task_key(raw, options): # The example accepts string-valued options, not arbitrary JSON types. assert all(isinstance(k, str) and isinstance(v, str) for k, v in options.items()) config = json.dumps(options, sort_keys=True, separators=(",", ":")) return hashlib.sha256(raw + b"\0" + config.encode()).hexdigest() class DemoProvider: """Pretend remote service: durable key lookup, immediate completion.""" def __init__(self, path): self.db = sqlite3.connect(path) self.db.execute("CREATE TABLE IF NOT EXISTS remote " "(key TEXT PRIMARY KEY, result TEXT NOT NULL)") self.db.commit() self.submit_calls = 0 def submit(self, key, raw, lose_reply=False): self.submit_calls += 1 # Idempotency is an explicit promise of THIS FAKE provider. with self.db: self.db.execute("INSERT OR IGNORE INTO remote VALUES (?, ?)", (key, raw.decode().upper())) if lose_reply: raise TimeoutError("accepted, but response lost") return self.lookup(key) def lookup(self, key): row = self.db.execute("SELECT result FROM remote WHERE key=?", (key,)).fetchone() return None if row is None else row[0] def resume(db, provider, raw, options, lose_reply=False): key = task_key(raw, options) row = db.execute("SELECT state, result FROM jobs WHERE key=?", (key,)).fetchone() if row and row[0] == "done": return "cached", row[1] if row: # Uncertain requests are queried, never blindly submitted again. result = provider.lookup(key) if result is None: return "needs_review", None outcome = "recovered" else: # Commit the intent BEFORE the external side effect. with db: db.execute("INSERT INTO jobs VALUES (?, 'unknown', NULL)", (key,)) try: result = provider.submit(key, raw, lose_reply) except TimeoutError: return "unknown", None outcome = "created" with db: db.execute("UPDATE jobs SET state='done', result=? WHERE key=?", (result, key)) return outcome, result def main(): options = {"engine": "demo-v1", "target": "en"} with tempfile.TemporaryDirectory(prefix="doc-resume-") as tmp: root = Path(tmp) provider = DemoProvider(root / "provider.sqlite") db = sqlite3.connect(root / "jobs.sqlite") db.execute("CREATE TABLE jobs " "(key TEXT PRIMARY KEY, state TEXT NOT NULL, result TEXT)") db.commit() first = resume(db, provider, b"invoice", options, lose_reply=True) assert first == ("unknown", None) print("1", first) db.close() # Reopen the on-disk ledger; this is not a process-kill test. db = sqlite3.connect(root / "jobs.sqlite") recovered = resume(db, provider, b"invoice", options) assert recovered == ("recovered", "INVOICE") print("2", recovered) cached = resume(db, provider, b"invoice", options) assert cached == ("cached", "INVOICE") print("3", cached) assert provider.submit_calls == 1 print("submit_calls=1 (the recovery did not resubmit)") # Crash after local intent, before the provider accepts anything. missing = task_key(b"not-sent", options) with db: db.execute("INSERT INTO jobs VALUES (?, 'unknown', NULL)", (missing,)) assert resume(db, provider, b"not-sent", options) == ("needs_review", None) assert provider.submit_calls == 1 print("missing remote task -> needs_review; submit_calls=1") assert task_key(b"invoice", options) != task_key(b"revised", options) assert task_key(b"invoice", options) != task_key( b"invoice", {**options, "target": "ja"}) assert task_key(b"invoice", options) == task_key( b"invoice", {"target": "en", "engine": "demo-v1"}) print("PASS content / target / option-order checks") db.close() provider.db.close() if __name__ == "__main__": main() 运行结果说明了什么 1 ('unknown', None) 2 ('recovered', 'INVOICE') 3 ('cached', 'INVOICE') submit_calls=1 (the recovery did not resubmit) missing remote task -> needs_review; submit_calls=1 PASS content / target / option-order checks 第一次调用已经在演示服务里写好了结果,但客户端只收到超时。第二次返回 recovered :结果来自对原任务的查询,不是再次提交。第三次返回 cached :本地已经确认完成,直接复用。输出中的 submit_calls=1 统计的是演示服务的提交方法调用次数,不是实际扣费次数。 最后一个分支更重要:只有本地意图,服务端却查不到任务。示例不会自动“补发”,而是返回 needs_review 。这会牺牲一点自动化程度,却不会把无法确认的状态伪装成安全重试。 读代码时,别漏掉这四个细节 一、写意图在先,发请求在后。 本地先提交 unknown 记录。即使随后中断,恢复程序也知道这里有一次需要核对的尝试。这个顺序不保证任务一定送达;因此“写了意图但尚未发出”仍要有独立的恢复策略。 二、同一份任务,必须有稳定的身份。 示例用输入字节和排序后的字符串参数计算摘要。文件名不在键里:改名不该强迫重做,内容变化才应该。真实文档处理还应包含目标语言、引擎版本、OCR 模式、术语表修订等会改变结果的参数;规范化协议固定以后,不要悄悄变更默认值。 三、数据库唯一键,不是远程调用保险。 INSERT OR IGNORE 只影响它所在的数据库。这里演示服务自身用它实现稳定键去重;客户端的恢复路径则直接查询,没有再次调用 submit 。把一条唯一约束放进自己的缓存表,不能让远端接口自动具备幂等能力。 四、完成状态要对应可验证的产物。 示例把一个短字符串直接保存在数据库里,便于复现。真实 PDF、UDOC 或 HTML 通常存放在对象存储:先校验文件可读性、大小与摘要,再把对象键和修订信息提交到任务记录;不要把临时下载链接本身当作永久结果。 把示例接进真实文档流程,还差哪些东西? 这份代码刻意只做单工作者演示,没有任务认领锁,也没有租约、超时回收和指数退避。两个工作者同时处理同一个输入,会触发竞争条件;不要把它直接当生产队列。工程落地时,要把“谁有权推进这个任务”与“这个任务是否已经完成”分别设计。 接外部服务时,至少问清四件事:能否按你生成的请求键查询;重复键且参数相同时会发生什么;记录和幂等键保留多久;查询失败、未找到与任务失败是否能明确区分。AWS 的幂等接口文档展示了一种具体契约,但它不能替其他服务背书。 放到 OmniDoc 相关的解析、翻译与原生文档工作流中,也应保留同样的交付账本:输入摘要、处理参数、远端任务号、产物对象键、校验结果和用量凭据。本文没有调用 OmniDoc 翻译接口,不证明任何接口具备相同的幂等保证;对接时应按实际 API 文档逐项确认。 交付前,做一次“断在中间”的验收 正常跑通只能证明顺风时能用。至少再演练:提交前中断、响应丢失、查询暂时不可用、结果下载到一半、落库前中断,以及两个工作者撞上同一任务。每个场景都应能回答:现在有哪些证据,下一步允许做什么,谁来处理长期未知状态。 好的续跑,不是把所有失败都重试一遍,而是让每一次继续都有依据。 缓存负责少做重复工作,任务账本负责解释当前状态,服务端契约决定能否安全重发。这三件事分清了,批量文档处理才不会越“自动化”越难追查。 核对资料: Python sqlite3 文档 · SQLite 冲突处理 · AWS 幂等请求契约示例 产品入口: OmniDoc (产品链接,不代表本文对接口能力或费用的承诺)。
更新于 2026-10-10