简体中文 English 繁體中文 日本語 Русский
本文为静态镜像,内容以交互版为准 在交互式文档中心打开 →

Connection Pool and Broadcasting

Connections (server-side WebSocket / SSE / client-side outbound WebSocket) are first-class resources at the framework level in ErisPulse:
They are automatically registered into a unified registry upon establishment, supporting broadcasting, business group subscription, and cross-module transfer/reuse. When a module is unloaded, the framework automatically closes and recovers them—business logic no longer needs to manually create _ws_clients collections, nor will memory leaks occur due to forgotten cleanup.

The examples in this document use root import syntax (recommended for 2.10+):
from ErisPulse import connections, router, client, WebSocketConnection

Overview

from ErisPulse import connections, router, WebSocketConnection

# Server side: Connection is automatically registered; join business group in handler
@router.ws("MyModule", "/ws")
async def handle(ws: WebSocketConnection):
    ws.join_group("room:1")            # Group naming is entirely business-defined
    async for msg in ws.iter_text():
        ...                             # When disconnected, framework automatically unregisters and cleans up the group

# Anywhere: Broadcast to a group (returns success/failure details)
result = await connections.broadcast({"type": "notify"}, group="room:1")
if not result.ok:
    ...                                 # result.failed: {connection_id: exception}

All three types of connections enter the registry:

kind source owner description
server @router.ws(...) / register_websocket(...) module name that registered the route automatically unregisters on disconnect
sse @router.sse(...) / register_sse(...) module name that registered the route broadcasting uses sse.send()
client await client.ws_connect(url) current module (or explicitly owner=) closes automatically on disconnect or adapter stop; framework closes all connections when adapter stops

To disable automatic registration: pass track=False to the route, or pass track=False to outbound connections—the behavior of legacy code remains unchanged.

Viewing the Connection Pool

from ErisPulse import connections

# View the connection pool of a module ("how many live connections does this module currently have?")
conns = connections.list(namespace="Dashboard")
for conn in conns:
    print(conn.id, conn.kind, conn.groups, conn.meta)

# Filter by other dimensions (all conditions AND)
connections.list(owner="MyAdapter", kind="client")
connections.list(group="tenant:acme")

# Global statistics (counts across all dimensions)
stats = connections.stats()
# {'total': 5, 'by_kind': {...}, 'by_namespace': {...}, 'by_owner': {...}, 'groups': {...}}

# Ownership audit counts (ownership facade is also visible)
from ErisPulse import ownership
ownership.counts("Dashboard")   # {'connections': 3, ...}

Each connection has readable identity information: conn.id (e.g., Dashboard:1a2b3c4d), conn.namespace,
conn.owner, conn.kind, conn.groups, conn.meta (business-defined dictionary).

Broadcasting and Subscription

Groups (Subscription Model)

Groups are flat strings, and naming is entirely business-defined (rooms / tenants / topics...),
with nested levels expressed via naming conventions (e.g., tenant:acme/room:1); the framework does not constrain the format.

Both directions can be operated:

# Direction 1: Connection side actively joins/leaves (inside handler)
@router.ws("Chat", "/ws")
async def handle(ws: WebSocketConnection):
    ws.join_group("room:1")        # Equivalent to connections.assign(ws.id, "room:1")
    ws.leave_group("room:1")

# Direction 2: Management side passively assigns (route side / other modules)
connections.assign(conn_id, "tenant:acme")
connections.dismiss(conn_id, "tenant:acme")   # If no group name is passed, leaves all groups

When a connection disconnects, it automatically leaves all groups; no business cleanup is required.

Broadcasting

result = await connections.broadcast(
    data,                       # For WS: send_json; for SSE: send (automatically JSON-serializes)
    namespace="Dashboard",      # Filter by namespace
    group="room:1",             # Filter by group
    kind="sse",                 # Filter by connection type
    owner="MyAdapter",          # Filter by owner
    ids={conn_id, ...},         # Explicitly specify candidate set (filter conditions still apply)
    exclude={conn_id},          # Exclude certain connections
    timeout=10.0,               # Timeout per connection (not total duration)
    concurrency=64,             # Maximum concurrent sends
    raise_on_error=False,       # If True, raises the first exception if there are failed items
)
result.total      # Number of target connections (before exclusions)
result.sent       # List of successfully sent connection IDs
result.failed     # {connection ID: exception object} (timeout/disconnected/send failed)
result.excluded   # Excluded connection IDs
result.ok         # Whether all were successfully delivered

Filter conditions are AND; failure of a single item does not affect others; connections in ids that no longer exist are counted in failed (so the business knows the full picture).

Lifecycle Events

Lifecycle events are emitted for connection registration/unregistration/group changes, allowing Dashboard and others to subscribe for real-time display:

from ErisPulse import lifecycle

@lifecycle.on("connection.registered")
async def on_registered(data): ...
@lifecycle.on("connection.unregistered")
async def on_unregistered(data): ...
@lifecycle.on("connection.group.joined")
async def on_joined(data): ...
@lifecycle.on("connection.group.left")
async def on_left(data): ...

Cross-Module Transfer / Reuse

Connections are framework-registered resources; any module can retrieve and reuse them via connection ID—
no need to create another one, nor to know who created the connection:

from ErisPulse import connections

conn = connections.get("Dashboard:1a2b3c4d")   # Raises ConnectionNotFoundError if not exists or disconnected
await conn.send_json({"ping": 1})              # Any module can send
conn.join_group("someone-elses-room")          # Also can participate in groups

Ownership and Closing Rights:

Attribution note: Closing rights rely on runtime owner context (owner_scope / event dispatch / spawn_background etc. automatically carry it). Calls without context (legacy code) are allowed to ensure 0-breaking changes; new code in owner context is protected.

Outbound Connections (Client Side)

from ErisPulse import client

ws = await client.ws_connect("wss://example.com/ws")
ws.id          # Registered, can be queried/broadcasted via connections
ws.owner       # Current module (suggest explicit `owner=` if owner context is unavailable)

# Explicitly specify owner if owner context is unavailable (e.g., in utility thread callbacks) for automatic cleanup on unload:
ws = await client.ws_connect("wss://example.com/ws", owner="MyAdapter")

Automatic unregistration occurs on remote disconnect or local close(). When a module is unloaded, the framework closes all its outbound connections. The era of old code manually managing connections and leaking due to forgotten cleanup is over.

Cross-Thread Dispatching

To reach connections (broadcast / push / close) from a child thread, use the framework's standard tools to dispatch back to the main loop—do not hand-write run_coroutine_threadsafe:

from ErisPulse import run_main_loop, spawn_later, spawn_thread

# Blocking result retrieval from a child thread:
run_main_loop(connections.broadcast({"tick": 1}, group="room:1"), timeout=5)

# Background thread + delayed task (with owner attribution, automatically canceled on unload):
def push_tick():
    return connections.broadcast({"tick": 1}, group="room:1")
spawn_later(30, push_tick, owner="MyModule")

Unload and Cleanup Semantics

The ownership recovery chain on module unload / adapter stop will sequentially: cancel associated background tasks and timers → close and unregister associated connections → trigger on_cleanup hooks → unregister route/handler and other registered resources. See Ownership (owner) System.

Warning


Starting from 2.10, module unload will close all registered WS/SSE/outbound connections under its name. Downstream components relying on "connections remaining alive after unload" (e.g., old Dashboard push) must be aware—this is the expected behavior to prevent connection leaks.

API Reference

API Description
connections.get(id) Get a connection (cross-module entry point), raises ConnectionNotFoundError if not exists
connections.list(namespace=, owner=, group=, kind=) Filter and query connection list
connections.stats(namespace=, owner=) Count statistics across all dimensions
connections.assign(id, *groups) / dismiss(id, *groups) Management-side join/leave groups
await connections.broadcast(data, ...) Broadcast, returns BroadcastResult
await connections.close(id, force=False) Close and unregister (with permission check)
await connections.close_owner(owner) Close and unregister all connections under a given owner (called by framework unload chain)
conn.join_group(*groups) / conn.leave_group(*groups) Connection-side group operations
conn.send_json / send_text / iter_text / ... Send/receive (server/client interface consistent)
conn.meta Business metadata dictionary
conn.close(force=False) Close (raises ConnectionPermissionError if not owner)