05 · 写入与提交后分发
ingest_event 是 Buzz 最重要的权威管线。WebSocket EVENT 和多个 HTTP bridge 最终都应进入同一套校验与持久化逻辑,避免“换一个协议入口就绕过权限”。成功响应的语义是 durable acceptance,而不是所有下游副作用都已完成。
1. 总流程
EVENT / HTTP payload
│
├─ auth + scope + rate limit
├─ size / timestamp / known-kind policy
├─ event id + Schnorr signature
├─ tenant / global-vs-channel shape
├─ membership + kind-specific authorization
├─ thread/edit/vote/diff/envelope validation
▼
PostgreSQL transaction
├─ insert/replace event
├─ update relational projections
└─ commit
│
├─ bounded audit enqueue (awaited)
└─ spawned post-commit work
├─ Redis publish
├─ local fan-out
└─ workflow trigger / other derived work2. 通用检查
写入入口会先解决与具体产品无关的约束:
- 认证主体与 event pubkey 的关系。
- endpoint 所需 scope。
- event 大小、时间窗口和 tag 形状。
- ID/签名验证放入 blocking pool。
- kind 是否允许、是 ephemeral 还是 persistent。
- 事件应是 global-only、requires-
h,还是允许两者。
这些检查越早失败,越少占用数据库事务和昂贵的关系查询。
3. 频道与成员授权
对于 channel-scoped 事件,Relay 从 h tag 解析频道 UUID,再查询同 community 的频道与成员。权限不只看“是成员”:
- 私有频道要求当前 membership。
- 管理事件要求 owner/admin 等角色。
- Bot 角色不进入普通角色高低层级,避免“数值更高”意外继承管理能力。
- 频道归属 community 必须与
TenantContext一致。
权限检查与数据库投影更新应在同一事务视野中完成,减少 TOCTOU 窗口。
4. Kind-specific 语义校验
统一管线并不意味着所有 kind 只做一套检查。ingest.rs 内有大量领域规则:
| 事件 | 额外约束 |
|---|---|
| 回复 | 父事件存在、同社区同频道;解析 NIP-10 root/reply;深度上限 100 |
| 编辑 | 原作者可编辑;Agent owning human 可编辑其 Agent 消息;仍需当前私有频道权限 |
| 论坛投票 | 目标必须是同频道的 forum post/comment,方向合法 |
| Diff | 内容上限约 60 KiB,repo/commit/branch/PR 元数据形状校验 |
| Engram | NIP-AE envelope、可见性与 owner 关系 |
| Persona/Team | parameterized coordinate 与管理权限 |
| Agent metric | 指标 envelope 和隐私策略 |
| Workflow definition | YAML/schema、owner authority 与频道归属 |
把规则放进 Relay 而不是相信客户端,保证 Web、CLI、Desktop 与恶意自制客户端得到同一边界。
5. 持久事件与 ephemeral 事件
持久事件
进入 buzz-db,处理普通插入、replaceable coordinate、删除语义和关系投影。commit 成功后才可返回 accepted。
Ephemeral
Presence、typing、observer frame 等不进入长期事件表,但仍经过签名、租户、频道和权限检查,然后直接进入 PubSub/fan-out。HTTP bridge 可能拒绝某些 ephemeral kind,因为无状态 HTTP 发布缺少与 WS 生命周期一致的语义。
6. durable acceptance 的精确含义
dispatch_persistent_event 把工作分成两侧:
before return OK after commit / async
────────────────────────────── ─────────────────────────
DB committed Redis cross-pod publish
event authoritative local subscription fan-out
bounded audit handoff awaited workflow execution kick选择这样分层的理由:
- 不能在 commit 前发布,否则订阅者可能看到最终回滚的“幽灵事件”。
- 不应让某个慢订阅者或 Redis 短暂故障拉长所有写请求。
- 客户端需要一个稳定承诺:accepted 等于事实已持久化。
代价是 commit 与通知之间存在窗口。订阅者必须支持重连后用 since 补历史;工作流和 Push 需要 outbox/可恢复设计覆盖重要副作用。
7. 本地回声与跨 pod 去重
当前 pod 写入后既会本地 fan-out,也会发布到 Redis。Redis 消息又可能回到原 pod,因此 Relay 维护按 (community, event_id) 作用域的 local-echo cache:
- 本地提交时标记 echo。
- Redis 订阅回流时识别并跳过重复本地派发。
- community 进入 key,避免同 ID 在租户之间产生错误关联。
这只是投递去重,不改变事件 ID 在数据库中的幂等约束。
8. Fan-out 仍是安全检查点
候选订阅者来自内存索引,但发送前 filter_fanout_by_access 仍检查:
- receiver 连接的 community label。
- author-only / shared-gated 等主体门。
- 私有频道当前 membership;成员刚被移除时不能靠旧订阅继续收消息。
Redis topic、订阅索引和缓存都不是授权证明。任何缓存错误至多影响候选集合,不应扩大可见性。
9. side effects 的事务边界
频道管理事件常同时更新关系投影和发布 discovery/membership snapshot/delta。原则是:
- 决定权限的投影在事务内更新。
- 可重建通知在 commit 后发布。
- audit 使用独立的社区 hash-chain 服务记录操作语义。
若某个派生通知失败,重建/重新查询应能从数据库权威事实恢复,而不是要求回滚已经接受的用户事件。
10. 失败模型
| 失败点 | 结果 |
|---|---|
| 签名/权限/形状失败 | 拒绝,不开启或回滚事务 |
| DB commit 失败 | 拒绝,不 fan-out |
| audit bounded handoff 失败 | 依当前 handler 策略暴露错误/记录严重问题,避免静默丢失 |
| Redis publish 失败 | 事实已接受;本 pod 可继续,本地外 pod 依赖恢复/历史补偿 |
| 单个订阅者队列满 | 不回滚事实;慢消费者被隔离 |
| workflow 失败 | 记录 run 失败,不回滚触发事件 |
11. 源码入口
crates/buzz-relay/src/handlers/ingest.rs:ingest_event与完整验证管线。crates/buzz-relay/src/handlers/event.rs:持久事件 dispatch。crates/buzz-relay/src/handlers/event.rs:fan-out 访问复核。crates/buzz-db/src/lib.rs:事件插入入口。crates/buzz-db/src/event.rs:事件 SQL、replaceable 与线程参数。crates/buzz-relay/src/handlers/side_effects.rs:管理事件的关系投影与派生通知。