Skip to content

ComponentWebSocketManager loses pushes under multi-worker deployments (needs Redis pub/sub) #51

Description

@fsecada01

Summary

ComponentWebSocketManager (component_framework/core/websocket.py) keeps WebSocket connections and component subscriptions in plain in-memory dicts:

self.connections: dict[str, list[WebSocketConnection]] = {}
self.component_subscribers: dict[str, set[str]] = {}

push_update()/broadcast_update() no-op silently when component_id not in self.component_subscribers — which is exactly what happens whenever the code calling push_update() runs in a different OS process than the one that received the browser's subscribe WebSocket message.

Reproduction

Any consumer app that:

  1. Serves multiple Uvicorn/Gunicorn worker processes behind one listening socket, and
  2. Spawns a background asyncio.create_task(...) from a component event handler that eventually calls ws_manager.push_update(component_id, html, state)

...will intermittently see that push vanish. The browser's WS connection and subscribe message land on worker A; the background task (and its later push_update calls) may run on worker B, C, or D. Worker B's ws_manager.component_subscribers never contains component_id, so the push is dropped with no error, log, or client-visible signal.

Real-world impact (525_OP_Site)

We hit this in production with RagProgressComponent — a long-running AI query streams progress via push_update() from a module-level background task. Under our 4-worker Uvicorn deployment (uvicorn.run(..., workers=4)), the query would complete successfully server-side (confirmed via logs — 14.71s round trip), but the final "complete" push would land on a worker that never subscribed the browser, so the UI stayed stuck on "Preparing query…" indefinitely with no error.

We worked around this in our own app with an app-level polling fallback (persist state to Redis on every push(), add a polling GET endpoint, poll from the client after dispatching the initiating event) — but this duplicates work that belongs in component-framework itself, and every other multi-worker consumer of ComponentWebSocketManager has the same latent bug.

Suggested fix

Back ComponentWebSocketManager with Redis pub/sub (or another cross-process broker) instead of in-memory dicts, so push_update()/broadcast_update() work correctly regardless of which worker process originated the call:

  • Each worker subscribes to a Redis channel (e.g. component:{component_id}) when it receives a browser's subscribe message, and holds the actual WebSocketConnection locally (sockets can't cross processes).
  • push_update()/broadcast_update() publish to the Redis channel instead of looking up self.component_subscribers directly; every worker holding a live connection for that channel receives the publish and forwards it over its own local WebSocket(s).
  • Should degrade gracefully (or fail loudly in dev) when no Redis URL is configured / a single-worker deployment is in use, rather than silently dropping updates as it does today.

Happy to discuss approach further or help test against a real multi-worker deployment if useful — this is what surfaced the bug for us.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:2-highHigh priority

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions