freenet-core icon indicating copy to clipboard operation
freenet-core copied to clipboard

Implement subscription tree branch pruning

Open sanity opened this issue 5 months ago • 1 comments

Problem

The subscription tree for contract updates doesn't properly prune branch nodes when their downstream subscribers disconnect.

Current Behavior

When a peer receives an Unsubscribed message, it:

  1. Removes the peer from its subscribers list
  2. Does nothing else

Expected Behavior

When a downstream subscriber unsubscribes, a branch peer should:

  1. Remove the peer from its downstream subscribers
  2. Check if it has any remaining downstream subscribers
  3. If no downstream subscribers AND not seeding locally (is_seeding_contract), send Unsubscribed to its upstream peer
  4. This cascades up the tree, pruning unnecessary branches

Root Cause

The current SeedingManager::subscribers stores a flat Vec<PeerKeyLocation> (soon to be Vec<SocketAddr>) that doesn't distinguish between:

  • Upstream: The peer we subscribed through (toward the root/contract location)
  • Downstream: Peers that subscribed through us (leaves or other branches)

Suggested Data Model

```rust struct ContractSubscription { upstream: Option<SocketAddr>, // who we're subscribed to (toward root) downstream: Vec<SocketAddr>, // who subscribed through us is_local: bool, // local user subscribed (leaf node) } ```

Impact

Without branch pruning:

  • Intermediate nodes stay subscribed longer than needed
  • Updates are forwarded to branches with no leaves
  • Network resources wasted on unnecessary update propagation

Context

  • Discovered during #2164 Phase 4 planning (connection-based routing refactor)
  • The routing refactor changes subscribers to Vec<SocketAddr> but keeps the flat structure
  • This issue can be addressed independently after the routing refactor lands

Related

  • Part of #2164 peer identity simplification effort
  • Subscription tree design: https://freenet.org/news/summary-delta-sync/

[AI-assisted - Claude]

sanity avatar Nov 28 '25 04:11 sanity

🤖 Auto-labeled

Applied labels:

  • T-bug (96% confidence)
  • P-medium (90% confidence)
  • E-medium (75% confidence)
  • A-networking (90% confidence)
  • A-contracts (80% confidence)

Reasoning: The report describes incorrect behavior where branch nodes are not pruned when downstream subscribers disconnect, causing unnecessary subscriptions and wasted network resources — this is a defect (T-bug). It is not immediately release-blocking but impacts resource usage and correctness across peers, so P-medium is appropriate. The fix requires changing the subscription data model and cascading Unsubscribed messages, involving moderate complexity (E-medium). The problem concerns peer routing/topology and subscription propagation, so A-networking applies; it also directly relates to contract subscription handling, so A-contracts is relevant.

Previous labels: none

If these labels are incorrect, please update them. This helps improve the auto-labeling system.

github-actions[bot] avatar Nov 28 '25 04:11 github-actions[bot]

Subscription Tree Pruning - Implementation

Problem

When a peer unsubscribes from a contract, the current implementation only removes that peer from the local subscribers list. It doesn't check if the unsubscription should propagate upstream, causing "dead branches" - nodes that receive updates they no longer need to forward.

Gateway ──► Peer-A ──► Peer-B ──► ✗ (Peer-C gone)
                           │
                      (dead branch - wasted bandwidth)

Solution: Role-Based Subscription Tracking

Each node tracks its position in the subscription tree:

  • Upstream: The peer we receive updates FROM
  • Downstream: Peers that receive updates FROM us
  • Has client: Whether a WebSocket client (app like River) is connected

Pruning Rule

When a downstream peer unsubscribes:

if downstream == [] AND has_client == false:
    → Send Unsubscribed to upstream

This cascades up the tree, cleaning the entire branch.

Example: Full Cascade

Initial:
    Gateway ──► Peer-A ──► Peer-B ──► Peer-C (has client)

Peer-C's client disconnects:

1. Peer-C: downstream=[], has_client=false → Unsubscribed to Peer-B
2. Peer-B: downstream=[], has_client=false → Unsubscribed to Peer-A
3. Peer-A: downstream=[], has_client=false → Unsubscribed to Gateway
4. Gateway: downstream=[], has_client=true (source) → STOP

Final:
    Gateway (still seeding, no forwarding)

Example: Partial Prune (Multiple Downstream)

Initial:
    Peer-A
      ├──► Peer-B ──► Peer-C (has client)
      └──► Peer-D (has client)

Peer-C disconnects:

1. Peer-B: downstream=[], has_client=false → Unsubscribed to Peer-A
2. Peer-A: downstream=[Peer-D], has_client=false → STOP (still has downstream)

Final:
    Peer-A
      └──► Peer-D (has client)

Example: Client Prevents Cascade

Initial:
    Gateway ──► Peer-A ──► Peer-B (has client) ──► Peer-C (has client)

Peer-C disconnects:

1. Peer-B: downstream=[], has_client=true → STOP

Final:
    Gateway ──► Peer-A ──► Peer-B (has client)

Peer-B keeps receiving updates for its local client.

Implementation

Data Model

enum SubscriptionRole {
    Upstream,   // We receive updates from this peer
    Downstream, // This peer receives updates from us
}

struct SubscriptionEntry {
    peer: PeerKeyLocation,
    role: SubscriptionRole,
}

Subscriptions stored as: Map<ContractKey, Vec<SubscriptionEntry>>

Client subscriptions tracked separately: Map<ContractKey, Set<ClientId>>

Key Methods

Method Description
set_upstream() Set the peer we receive updates from
add_downstream() Add a peer that wants updates from us
remove_subscriber() Remove peer, returns upstream to notify if pruning needed
add_client_subscription() Register WebSocket client interest
has_client_subscriptions() Check if any clients connected

netsirius avatar Dec 13 '25 16:12 netsirius

Upstream: The peer we receive updates FROM Downstream: Peers that receive updates FROM us

This might be pedantic, but updates can propagate both up and down a tree, see the visualization at the bottom of this page. Updates can start at a leaf and then propagate first up and then down the tree.

That's not to say there isn't a concept of up and down, generally a peer subscribed to a contract will itself try to subscribe to the contract via whichever connected peer is closest to the contract location, so the "upstream" peer will be that peer's closest neighbor to the contract.

There is an open question of whether a peer should resubscribe if it acquires a new connection closer to a contract than the one it's currently subscribed through but that's probably icing on the cake. There is also a question of whether a peer should ever accept a subscription request from a peer that is closer to it than the subscribed contract.

sanity avatar Dec 13 '25 19:12 sanity