06 · 查询、订阅与读权限
Buzz 的读取不是“把 Nostr filter 翻成 SQL”这么简单。它必须在 LIMIT 前构造访问范围,处理搜索与计数的不同预算,并在返回/实时派发前执行同一套 reader-aware 隐私门。安全目标是:历史查询与实时订阅对同一主体给出一致可见集合。
1. REQ 生命周期
REQ(subscription_id, filters...)
│
├─ validate id/filter/count/limits
├─ authenticate reader context
├─ register subscription indexes
├─ retain exact Redis topics
├─ build access-scoped query
├─ SQL or generated-column FTS
├─ hydrate + per-event visibility check
├─ send historical EVENT frames
└─ EOSE; keep subscription for live eventsCLOSE 会移除内存索引,并在引用计数降到零后 debounce 释放 Redis topic。
2. 订阅索引
进程内 registry 维护多种候选索引:
- community。
(community, channel, kind)。(community, channel, wildcard-kind)。- global
(community, p, kind)。 - global
(community, kind)。 - global wildcard。
关键语义是channel-scoped 与 global subscription 对称隔离:频道事件不会因为一个宽泛 global filter 被错误灌入,global 事件也不会掉进只想监听某频道的订阅。
索引只缩小候选集。取出 subscription 后仍以其权威 filter/scope 复核,容忍索引异步删除或陈旧。
3. Access scope 必须先于 LIMIT
错误做法:
SELECT * FROM events
ORDER BY created_at DESC
LIMIT 100;
-- 然后在应用层过滤私密事件这既可能制造侧信道,也会让合法用户在“前 100 条都不可见”时得到空页。Buzz 在构造查询时先加入 community、频道集合和基础访问谓词,再应用排序/limit;复杂的 per-event gate 最后复核。
4. event_visible_to_reader
req.rs 的中心 helper 汇合多类策略:
- author-only。
- p-gated。
- shared-gated。
- result-gated。
- engram/Agent metric 等特殊 envelope。
- 私有频道当前 membership。
- 社区与事件 channel scope。
实时 fan-out 有对应检查,目标是避免“历史看不到但在线能偷听”或反过来的双轨权限。
5. 搜索不是旁路索引器
buzz-search 使用 PostgreSQL events.search_tsv generated column,而不是 Elasticsearch 或异步索引服务:
query text
├─ full-text tsquery
└─ prefix mode
│
▼
WHERE community_id = $1
AND deleted = FALSE
AND search_tsv @@ query
AND access-scope predicates...
│
▼
hit IDs → hydrate events → event_visible_to_reader优势:索引与事件事务一致、运维面小。限制:高级相关性、多语言分词和横向扩展受 PostgreSQL FTS 能力约束。
ChannelScope 使用四个显式 variant 表达“全局、指定频道、可访问频道、无频道权限”等情况,避免空数组到底表示“不限制”还是“无权访问”的歧义。
6. FTS 迁移的棕地差异
迁移历史透露一个容易遗漏的部署事实:
- 早期 generated expression 使用 privacy denylist。
- 后续迁移对空/新库切换为消息 kind positive allowlist。
- 对已有数据的安装,为避免重写大表和长锁,保留旧表达式,等待维护脚本窗口。
- 后续 Push lease 排除又包裹现有表达式。
因此 schema.sql、新部署和长期升级部署可能暂时拥有不同的 FTS expression。这不是随机漂移,而是用一致的安全外层 + 运维维护窗口换取在线升级可用性。站点/运维文档应显式记录这种 brownfield 行为。
7. COUNT 的预算
COUNT 看似只返回数字,成本和泄露风险反而更高。Relay 会:
- 限制 filter 数量和查询形状。
- 优先使用数据库 count 路径。
- 必要 fallback 只扫描有界候选(当前代码有 5000 级预算),再执行可见性过滤。
不能直接给任意未授权 filter 返回原始全局计数,否则即使正文不泄露,也会泄露私密活动规模。
8. 动态 Redis 订阅
每个 pod 不使用 PSUBSCRIBE buzz:* 接收所有租户所有频道,而是按本地活跃 subscription 引用计数 SUBSCRIBE 精确 topic:
- 第一个本地订阅 retain topic。
- 后续订阅只增加 refcount。
- 最后一个关闭后延迟 500 ms unsubscribe,吸收 UI 快速切换抖动。
- 断线重连时从 desired set 恢复。
这降低无关流量,但让订阅 registry 与 Redis manager 之间存在协调复杂度;权威 subscription 复核是处理竞态的关键。
9. 背压与慢消费者
历史查询有结果上限;实时输出进入每连接有界队列。慢客户端不能要求 Relay 为其无限保存 live stream。可靠性由客户端协议行为完成:
- 保存最后已见时间/事件。
- 重连。
- 用
since拉取缺口。 - 以 event ID 幂等合并。
这与写入侧“durable fact、best-effort low-latency notification”的分层一致。
10. 源码入口
crates/buzz-relay/src/handlers/req.rs:REQ 总入口。crates/buzz-relay/src/handlers/req.rs:access scope 与 SQL limit 次序。crates/buzz-relay/src/handlers/req.rs:event_visible_to_reader。crates/buzz-relay/src/subscription.rs:订阅索引、对称 scope 与 PubSub retain/release。crates/buzz-search/src/lib.rs:FTS 查询服务与ChannelScope。migrations/0008_fresh_install_search_allowlist.sql:新库/棕地搜索表达式策略。