Skip to content

feat(pubsub): stateless space-scoped pub/sub over any-sync#726

Open
requilence wants to merge 3 commits into
mainfrom
feat/pubsub
Open

feat(pubsub): stateless space-scoped pub/sub over any-sync#726
requilence wants to merge 3 commits into
mainfrom
feat/pubsub

Conversation

@requilence

Copy link
Copy Markdown
Contributor

What

Adds an ephemeral, fire-and-forget, at-most-once publish/subscribe channel scoped to a space, carried over a dedicated PubSub DRPC stream that is fully isolated from the sync engine. Design and research docs are in docs/stateless-pubsub/.

Use cases this unlocks (built downstream in heart): typing indicators, presence, live cursors, read-position signals — anything ephemeral that shouldn't touch the DAG/KV persistence layer.

How it works

  • Wire protocol (commonspace/pubsub/pubsubproto): one bidi PubSubStream multiplexing all spaces/topics, with Subscribe/Unsubscribe/Publish/Status frames.
  • Topics: flat + NATS-style wildcards (* one segment, > tail), matched by a per-space sublist-shaped trie. Reserved acc/…/<accId> namespace is publishable only by that account.
  • Security: subscribe and publish gated on space membership (new enforcement — the sync path has none); per-message account-key signatures (length-prefixed, domain-separated) verified by receivers; payloads encrypted with the space ReadKey via a pluggable Crypto. Removed members are cut off actively via EvictMember/RevalidateMembers and passively via key rotation.
  • Topology: node relay with a strict one-hop rule (client-originated messages forwarded once to the other responsible nodes, relayed messages never re-forwarded); LAN peers symmetric; echo and duplicate-path suppression via a bounded msgId ring.
  • Memory-bounded (R2): interest keyed by streamId, bounded queues with drop-on-overflow, per-peer publish rate limiting, fixed dedup ring, timestamp-staleness replay defense. No path grows with message volume or offline duration.

net/streampool gains a standalone NewStreamPool constructor (WithStreamCloseHook carrying streamId, WithMetric), RemoveTagsById, CtxStreamId, and cross-tag dedup in Broadcast — all additive/backward-compatible.

Testing

Unit tests for validation/trie/dedup/signing plus a multi-peer in-memory fixture (2 relay nodes, 3 clients over real DRPC) covering fan-out with wildcards, cross-node relay + one-hop, membership rejection, acc/ ownership at all three enforcement layers, echo/duplicate suppression, reconnect interest survival, CloseSpace/EvictMember/RevalidateMembers, and stale-message rejection. Race-clean; full repo suite green.

Review

Went through six independent review lenses (concurrency, security, spec-conformance, resource-bounds, API, plus a follow-up) — findings on interest keying, the reconnect watcher, and receive-path ordering were fixed; see docs/stateless-pubsub/DESIGN.md §12 for what's implemented vs deferred.

Downstream

The node relay lives in anyproto/any-sync-node#(pending). Client wiring in anytype-heart is the remaining piece.

Add an ephemeral, fire-and-forget, at-most-once publish/subscribe channel
scoped to a space, carried over a dedicated PubSub DRPC stream that is fully
isolated from the sync engine.

commonspace/pubsub:
- Wire protocol (pubsubproto): PubSubStream bidi RPC with Subscribe/
  Unsubscribe/Publish/Status frames.
- Flat + NATS-style wildcard topics (* one segment, > tail) matched by a
  per-space sublist-shaped trie; reserved acc/.../<accId> self-owned namespace.
- ACL-gated subscribe and publish; per-message account-key signatures
  (length-prefixed, domain-separated) verified by receivers; payloads
  encrypted with the space ReadKey via a pluggable Crypto.
- Node relay with the one-hop rule (client-originated msgs forwarded once to
  other responsible nodes, relayed msgs never re-forwarded); LAN peers
  symmetric; echo and duplicate-path suppression via a bounded msgId ring;
  per-peer publish token bucket.
- Serving-side interest keyed by streamId (trie refcounts subscribing
  streams) so a reconnecting peer's fresh stream keeps its interest and a
  mid-subscribe close cannot orphan state.
- Reconnect watcher (periodic + on-close resync) and peer TTL so a pure
  subscriber survives stream drops and idle pool GC.
- CloseSpace teardown and EvictMember (active drop on ACL removal, strips
  tags so delivery stops even on an open stream).
- Receive path runs cheap filters (interest, membership, ownership,
  timestamp staleness) before the Ed25519 verify and records dedup only
  after verify, shedding forged-signature floods and closing a replay window.

net/streampool:
- NewStreamPool standalone constructor with WithStreamCloseHook (carries
  streamId) and WithMetric options; CtxStreamId accessor; RemoveTagsById;
  cross-tag stream dedup in Broadcast.

docs/stateless-pubsub: research and design docs.
Add Service.RevalidateMembers(spaceId, isMember) so a relay node can evict
every subscriber whose account no longer passes the membership predicate in a
single pass on an ACL change, refactoring EvictMember to share the core
eviction path.
- WithMetric no longer registers the private pool into the shared single-slot
  sync metric (RegisterStreamPoolSyncMetric): that slot belongs to the app-level
  sync streampool, so a second pool there overwrote its OutgoingMsg telemetry and
  nulled it on Close. WithMetric now only registers the namespaced prometheus
  gauges — independent observability, no clobber. (F1, medium)
- handleSubscribe cap rejection reports only the rejected patterns in the
  TooManyTopics status, not the whole subscribe batch. (F4)
- removeStream always clones tags for its debug log. (F5)
- drop the dead streamInterest.peerId field. (F6)
- doc: dedup is a FIFO ring (not LRU); rate limiter is per-peer; note that Close
  blocks on in-flight handlers and that resync coverage is bounded by DialQueueSize.

Adds TestCapExceededReportsOnlyRejected. The remaining review items were verified
correct (lock ordering, interest+tag rollback, receive-path membership-before-verify,
no double-decrement) — no change needed.
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

New Coverage 59.9% of statements
Patch Coverage 83.2% of changed statements (589/708)

Coverage provided by https://github.com/seriousben/go-patch-cover-action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant