Summary
ChannelsPlugin.subscribe() lazily creates self._channels[channel] = set() for each new arbitrary channel. ChannelsPlugin.unsubscribe() removes the subscriber from that set and, when the set becomes empty, unsubscribes the backend — but it never deletes the now-empty set() nor its key from self._channels.
With dynamic, per-entity channel names (e.g. one channel per user_id), self._channels therefore grows by one entry for every distinct channel name that has ever been subscribed, and never shrinks. Because the plugin is normally a long-lived singleton, this is an unbounded memory leak that only a process restart clears.
Relevant code
litestar/channels/plugin.py, unsubscribe:
for channel in channels:
channel_subscribers = self._channels[channel]
try:
channel_subscribers.remove(subscriber)
except KeyError:
continue
if not channel_subscribers:
channels_to_unsubscribe.add(channel) # backend is unsubscribed...
# ...but self._channels[channel] (now an empty set) is never deleted.
Reproduction
Confirmed on litestar 2.21.1 and on main:
import asyncio
from litestar.channels import ChannelsPlugin
from litestar.channels.backends.memory import MemoryChannelsBackend
async def main() -> None:
plugin = ChannelsPlugin(backend=MemoryChannelsBackend(history=10), arbitrary_channels_allowed=True)
async with plugin:
# at most ONE concurrent subscriber at any moment
for i in range(1000):
channel = f"user_{i}"
subscriber = await plugin.subscribe([channel])
await plugin.unsubscribe(subscriber, [channel])
assert len(plugin._channels) == 0, len(plugin._channels) # AssertionError: 1000
asyncio.run(main())
Expected behavior
After the last subscriber of a channel unsubscribes, the channel entry is removed from self._channels, so the dict size tracks active channels rather than historical ones.
Actual behavior
len(plugin._channels) == 1000 — one empty set() retained per channel ever used. Memory grows unbounded for the process lifetime.
Suggested fix
In unsubscribe(), delete the channel key once its subscriber set is empty, e.g. del self._channels[channel] alongside adding it to channels_to_unsubscribe.
Note
RedisChannelsStreamBackend.stream_events() keeps a stream_ids: dict[str, bytes] cursor map that exhibits the same unbounded-growth pattern (one entry per stream key that ever yielded an event, never pruned). Likely worth fixing in the same pass.
Environment
- litestar 2.21.1 (also reproduced on
main)
- Python 3.13 / 3.14
Summary
ChannelsPlugin.subscribe()lazily createsself._channels[channel] = set()for each new arbitrary channel.ChannelsPlugin.unsubscribe()removes the subscriber from that set and, when the set becomes empty, unsubscribes the backend — but it never deletes the now-emptyset()nor its key fromself._channels.With dynamic, per-entity channel names (e.g. one channel per
user_id),self._channelstherefore grows by one entry for every distinct channel name that has ever been subscribed, and never shrinks. Because the plugin is normally a long-lived singleton, this is an unbounded memory leak that only a process restart clears.Relevant code
litestar/channels/plugin.py,unsubscribe:Reproduction
Confirmed on litestar
2.21.1and onmain:Expected behavior
After the last subscriber of a channel unsubscribes, the channel entry is removed from
self._channels, so the dict size tracks active channels rather than historical ones.Actual behavior
len(plugin._channels) == 1000— one emptyset()retained per channel ever used. Memory grows unbounded for the process lifetime.Suggested fix
In
unsubscribe(), delete the channel key once its subscriber set is empty, e.g.del self._channels[channel]alongside adding it tochannels_to_unsubscribe.Note
RedisChannelsStreamBackend.stream_events()keeps astream_ids: dict[str, bytes]cursor map that exhibits the same unbounded-growth pattern (one entry per stream key that ever yielded an event, never pruned). Likely worth fixing in the same pass.Environment
main)