分期与决定
上级:events
状态: 已在 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 上可见)。
| 期 | 内容 | 验收(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) |