事件、取消与任务恢复
前置为第一章与 Python 字典。用户关闭浏览器时,生成请求可能结束,也可能仍在服务器执行。把连接状态当作任务状态,会造成“页面显示取消,订单仍然提交”的矛盾。需要给任务一个独立 ID,通过持久化状态判断进展。
状态转移比布尔值明确
queued → running → succeeded 是成功路径;running 还可以进入 failed 或 cancelled。终态不允许被迟到事件改回成功。下面的纯函数是事件投影器:读取旧状态和新事件,产生新状态;它本身不调用模型。
TRANSITIONS = {
('queued', 'start'): 'running',
('queued', 'cancel'): 'cancelled',
('running', 'finish'): 'succeeded',
('running', 'error'): 'failed',
('running', 'cancel'): 'cancelled',
}
def apply(state, event):
if event['seq'] <= state['seq']:
return state
if event['seq'] != state['seq'] + 1:
raise ValueError('event gap')
target = TRANSITIONS.get((state['status'], event['type']))
if target is None:
raise ValueError('invalid transition')
return {'status': target, 'seq': event['seq']}
state = {'status': 'queued', 'seq': 0}
for event in [{'seq': 1, 'type': 'start'}, {'seq': 2, 'type': 'cancel'}]:
state = apply(state, event)
assert state == {'status': 'cancelled', 'seq': 2}
assert apply(state, {'seq': 2, 'type': 'cancel'}) is state
try:
apply(state, {'seq': 3, 'type': 'finish'})
except ValueError as error:
assert str(error) == 'invalid transition'
else:
raise AssertionError('late success published')
print('cancel wins')保存为 events.py,输出 cancel wins。先接受的取消使状态终结;随后 finish 被拒绝。若 finish 先被事务提交,取消也应该被拒绝并返回已经完成。这里没有声称撤销已经发生的副作用。
从纯函数到持久化
多个执行者都可能从 seq=1 读到 running。写入时使用比较并交换:只有数据库仍处于 seq=1 才能提交 seq=2,另一方必须重新读取。这把竞态转化为可发现的冲突。内存字典没有跨进程保证,生产实现需事务和唯一事件序号,不能仅复制上面的循环。
恢复时从快照的 seq 开始重放更晚事件。事件相同 seq 但载荷不同属于数据损坏,应由存储的事件 ID/哈希检查,而不能用“序号旧了”静默掩盖。模型调用也不能随每次投影重放:重放只恢复状态,新的调度决定才允许新调用。
流式文本是展示增量,终态事件是结果协议。收到若干 token 后连接中断,只能标记连接断开;客户端用任务 ID 查询服务器状态。断线续传携带已确认序号,服务端返回之后的事件;缺口要补拉,不能把未知间隔当作成功。
练习
增加 queued 状态的 reject → failed 事件,用完整轨迹检验拒绝后不能 start;检查 seq 从 0 直接跳到 2 必须报 event gap。再解释为何 UI 的取消按钮禁用不能取代服务端的终态守卫。