Skip to content

06 · 查询、订阅与读权限

Buzz 的读取不是“把 Nostr filter 翻成 SQL”这么简单。它必须在 LIMIT 前构造访问范围,处理搜索与计数的不同预算,并在返回/实时派发前执行同一套 reader-aware 隐私门。安全目标是:历史查询与实时订阅对同一主体给出一致可见集合。

1. REQ 生命周期

text
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 events

CLOSE 会移除内存索引,并在引用计数降到零后 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

错误做法:

sql
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 或异步索引服务:

text
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:

  1. 第一个本地订阅 retain topic。
  2. 后续订阅只增加 refcount。
  3. 最后一个关闭后延迟 500 ms unsubscribe,吸收 UI 快速切换抖动。
  4. 断线重连时从 desired set 恢复。

这降低无关流量,但让订阅 registry 与 Redis manager 之间存在协调复杂度;权威 subscription 复核是处理竞态的关键。

9. 背压与慢消费者

历史查询有结果上限;实时输出进入每连接有界队列。慢客户端不能要求 Relay 为其无限保存 live stream。可靠性由客户端协议行为完成:

  • 保存最后已见时间/事件。
  • 重连。
  • since 拉取缺口。
  • 以 event ID 幂等合并。

这与写入侧“durable fact、best-effort low-latency notification”的分层一致。

10. 源码入口

独立源码研究笔记 · 非 Buzz 官方文档