分期与决定

更新于 · 在 sijie.xyz 查看原条目 ↗

状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。

总线按 P0 到 P4 分期落地;P5 未排期。每期都先写测试:它的 e2e 先在原代码上看到红(make test-asis),再写实现。webhook sink 是外部 mock 上的一组路由(mock-stack/job-board/webhook_sink.go):记录每次请求的头和 body,可设置失败或延迟(/__mock/set_delay),只在 e2e 里加进 EGRESS_ALLOW_HOSTS。已有的测试见 events-test-plan。

已上线: 全套验收已于 2026-09-27 通过;发版 v0.1.76,2026-09-27 起在 sijie.xyz 上运行。

还没完成的: 部署 standmeet.com,以及 P3 的真环境验证(改笔记 60 秒内在 standmeet.com 上可见)。

flowchart LR
  P0["P0 adopt River ✓<br/>spike"] --> G0{{"River's own migrator at boot<br/>start/stop with server ctx"}}
  G0 --> P1["P1 the bus ✓<br/>index as first consumer<br/>periodic moves to River"]
  P1 --> G1{{"MCP-created wiki is searchable<br/>index survives Meili outage + restart<br/>bulk import within bound"}}
  G1 --> P2["P2 webhooks ✓<br/>endpoints · signing · retry · log"]
  P2 --> G2{{"signature verifies<br/>published slice only (sentinel)<br/>3rd attempt succeeds after 500×2"}}
  G2 --> P3["P3 embed update hook<br/>backend ✓ · standmeet.com built, not deployed"]
  P3 --> G3{{"open: note edit shows on the<br/>live page within 60 s<br/>no deploy"}}
  G1 --> P4["P4 consolidate the rest ✓"]
  P4 --> G4{{"mail arrives despite failed first send<br/>booking during restart still notifies<br/>call sites net deletion"}}
  G3 --> P5["P5 later<br/>plugin subscriptions · SSE · activity feed"]
  G4 --> P5
期 内容 验收(e2e,黑盒) 状态
P0 接入 River v0.47 启动时紧跟 pgstore.Migrate 跑 River 自己的迁移器(jobsriver.Migrate);schema.sql 不抄 River 的 DDL;worker 随 server ctx 启停 完成
P1 events 表 + corpus_notes 触发器 + relay + 订阅表;corpus.index 替掉 9 处手调、SEO 重建和 Obsidian goroutine;8 秒对账循环和 dirty 标记删掉;周期任务迁到 River;任务面板 events-index-via-bus、events-bulk-import-bound、tasks-panel、tasks-panel-more、upgrade-events-outbox 完成
P2 端点表、ops、后台页、签名、SSRF、重试计划、冷却、自动停用、投递日志、重投、webhook.test;39 个类型全部开放 webhooks、webhook-event-types、events-fault-injection 完成
P3 embed 表单的更新 hook;cards 加 updated_at;standmeet.com KV + /api/corpus-hook embed-update-hook(后端);在 sijie.xyz 改笔记,60 秒内 standmeet.com 页面更新,不部署(真环境验证并归档) 后端完成;standmeet.com 部署与 60 秒真环境验证未完成
P4 访客申请邮件、批准邮件、确认邮件、预约通知(booking.record)、supplier.invoke、构建完成信号、按源抓取职位 → 全部走总线或持久任务 events-side-effects-durable、events-build-settled;调用处 diff 净删除 完成
P5 插件订阅(cordis ctx.on)、活动流改为事件投影、后台 SSE 实时更新、IM 推送 — 未排期

P3 就是 embed-update-hook;P2 就是 webhooks;每期搬了什么逐项列在 consolidation-inventory。P1 还包括 storage-bounds 和 tasks-panel。

已决定(2026-09-26)

owner 拍板:以下全部按建议执行。

决定 结论 理由
队列实现 River,藏在 Jobs / Inspector / Runtime 接口后面(queue-behind-ports) 现成且生产验证;不加服务(why-not-a-broker);MPL-2.0 与 AGPL 兼容;以后换实现上层不感知
行变化捕获 数据库触发器(带 WHEN),语义事件仍显式 Record(two-sources-of-events) 覆盖天然完整,漏调不可能
payload thin(类型 + 主体 + id) 私有内容不出实例,事实只有一处
不进总线 访问监控 / 流量记录 高频且无消费方;搬进来会让写负载翻倍
对账循环 不要(含 standmeet.com 的定时任务和 Meili 8 秒对账) outbox + 持久任务 + 面板告警已保证送达或可见;对账是在掩盖不可靠投递,投递可靠了就不需要(message-loss-guarantees)
存疑项 按 consolidation-inventory 里的建议定案 见该节点「存疑项的定案」

实现中定下的

决定 结论 理由
合并 每个订阅自己选择;只有 corpus.index 合并 索引重读当前状态;webhook 和邮件投递的是事实,合并两件事实就丢一件(relay-claims-rows-not-cursor)
relay 每个进程一个循环 + 1 分钟兜底扫描,不是 River 任务 SKIP LOCKED 不需要选主就防住重复扇出;高频周期任务会塞满 river_job;扫描兜住丢掉的 NOTIFY
每端点一次投递 一行租约(busy_until),不用 pg_try_advisory_xact_lock advisory 事务锁意味着 POST 期间一直占着事务(concurrency-control)
给 owner 的通知突发上限 仍是丢弃(每 owner 每小时 5 封),改成 Postgres 名额 洪水不能每次都给 owner 发信;申请在后台都看得见;重试保留自己的名额
同步的 supplier 调用 只发一次;请求内的 retry.Do 已删 重试只有一个主人;面向访客的调用要快速回答(retry-has-one-owner)
完结 终态时 pg_notify;没有 job.completed outbox 事件 每个任务一条 outbox 事件就会为每个任务再扇出一遍(completion-hooks)
抓取结果 jobs.fetch_result 返回职位列表;tasks.get 只给状态 任务 ops 保持通用
定时抓取 不做 docs/design/job-loop.md 否决了每日自动抓取
门禁自测 不留;每条门禁在植入样本上证红一次 门禁就是门禁(no-bypass-by-structure)