perf: extend WebSocket batching to item_moved events

Bursts of completions (e.g. small playlist entries) fire one item_moved
frame per item. Route item_moved through the same ItemBatcher mechanism
as item_updated: one frame per 500ms tick, payload always a list.

ItemBatcher gains an optional key callable since item_moved payloads are
{"to", "preset", "item"} dicts rather than ItemDTOs; moves coalesce by
item _id with last write wins, so an item that moves twice within a tick
delivers only its final destination. The notifications service consumes
ITEM_MOVED from the event bus directly and is unaffected.

BREAKING CHANGE: the item_moved WebSocket event payload (data) is now
always an array of {to, preset, item} objects instead of a single one.
This commit is contained in:
Jesse Bate 2026-06-10 23:47:25 +09:30
parent 0cb3a95a41
commit f1365f6fba
6 changed files with 86 additions and 29 deletions

View file

@ -86,20 +86,29 @@ class HttpSocket:
self.sio = sio or WebSocketHub(encoder=encoder) self.sio = sio or WebSocketHub(encoder=encoder)
self.rootPath: Path = root_path self.rootPath: Path = root_path
async def _emit_items(items: list) -> None: def _make_emit(event_name: str):
ev = Event(event=Events.ITEM_UPDATED, data=items) async def _emit_items(items: list) -> None:
await self.sio.emit(event=Events.ITEM_UPDATED, data=json.loads(encoder.encode(ev))) ev = Event(event=event_name, data=items)
await self.sio.emit(event=event_name, data=json.loads(encoder.encode(ev)))
self._batcher = ItemBatcher(emit=_emit_items) return _emit_items
self._batchers: dict[str, ItemBatcher] = {
Events.ITEM_UPDATED: ItemBatcher(emit=_make_emit(Events.ITEM_UPDATED)),
Events.ITEM_MOVED: ItemBatcher(
emit=_make_emit(Events.ITEM_MOVED),
key=lambda d: getattr(d.get("item"), "_id", None) if isinstance(d, dict) else None,
),
}
async def event_handler(e: Event, _, **kwargs): async def event_handler(e: Event, _, **kwargs):
if Events.ITEM_UPDATED == e.event and not kwargs: if batcher := self._batchers.get(e.event):
await self._batcher.add(e.data) if not kwargs:
return await batcher.add(e.data)
if Events.ITEM_UPDATED == e.event and kwargs: return
# Targeted delivery (e.g. to=sid): wrap data in a list so the # Targeted delivery (e.g. to=sid): wrap data in a list so the
# wire shape is always an array for item_updated. # wire shape is always an array for batched events.
targeted = Event(event=Events.ITEM_UPDATED, data=[e.data]) targeted = Event(event=e.event, data=[e.data])
payload = json.loads(encoder.encode(targeted)) payload = json.loads(encoder.encode(targeted))
await self.sio.emit(event=e.event, data=payload, **kwargs) await self.sio.emit(event=e.event, data=payload, **kwargs)
return return
@ -138,7 +147,8 @@ class HttpSocket:
async def on_shutdown(self, _: web.Application): async def on_shutdown(self, _: web.Application):
LOG.debug("Shutting down socket server.") LOG.debug("Shutting down socket server.")
await self._batcher.flush() for batcher in self._batchers.values():
await batcher.flush()
await self.sio.disconnect_all() await self.sio.disconnect_all()
LOG.debug("Socket server shutdown complete.") LOG.debug("Socket server shutdown complete.")

View file

@ -1,6 +1,6 @@
"""Global coalescing batcher for ITEM_UPDATED WebSocket events. """Global coalescing batcher for high-frequency WebSocket item events.
Collects dirty ItemDTOs keyed by ``_id`` (last write wins) and flushes them Collects dirty items keyed per item (last write wins) and flushes them
as a single list to an async emit callback at most once per *interval* seconds. as a single list to an async emit callback at most once per *interval* seconds.
Leading-edge semantics: the very first ``add()`` after an idle period fires Leading-edge semantics: the very first ``add()`` after an idle period fires
@ -21,19 +21,27 @@ LOG = get_logger()
class ItemBatcher: class ItemBatcher:
"""Coalescing batcher for WebSocket item-updated payloads. """Coalescing batcher for WebSocket item event payloads.
Args: Args:
emit: Async callable that receives a list of items and delivers them emit: Async callable that receives a list of items and delivers them
to all connected WebSocket clients. to all connected WebSocket clients.
interval: Flush interval in seconds. ``0`` or negative disables interval: Flush interval in seconds. ``0`` or negative disables
batching every ``add()`` emits immediately. batching every ``add()`` emits immediately.
key: Optional callable that extracts the coalescing key from an item.
Defaults to the item's ``_id`` attribute.
""" """
def __init__(self, emit: Callable[[list], Awaitable[None]], interval: float = 0.5) -> None: def __init__(
self,
emit: Callable[[list], Awaitable[None]],
interval: float = 0.5,
key: Callable[[Any], str | None] | None = None,
) -> None:
self._emit = emit self._emit = emit
self._interval = interval self._interval = interval
self._key = key
self._pending: dict[str, Any] = {} self._pending: dict[str, Any] = {}
self._handle: asyncio.TimerHandle | None = None self._handle: asyncio.TimerHandle | None = None
self._last_flush: float = 0.0 # monotonic self._last_flush: float = 0.0 # monotonic
@ -50,11 +58,11 @@ class ItemBatcher:
flush. flush.
Args: Args:
item: The ItemDTO (or any object with a ``_id`` attribute) to item: The payload to batch. Without a ``key`` callable, this is
batch. expected to expose a ``_id`` attribute.
""" """
item_id: str = getattr(item, "_id", None) or str(id(item)) item_id: str = (self._key(item) if self._key else getattr(item, "_id", None)) or str(id(item))
# interval <= 0 → always emit immediately, no batching # interval <= 0 → always emit immediately, no batching
if self._interval <= 0: if self._interval <= 0:

View file

@ -189,6 +189,39 @@ class TestItemBatcher:
assert emitted == [] assert emitted == []
@pytest.mark.asyncio
async def test_custom_key_coalesces_dict_payloads(self) -> None:
"""A key callable extracts the coalescing key from non-ItemDTO payloads."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(
emit=emit,
interval=0.1,
key=lambda d: getattr(d.get("item"), "_id", None) if isinstance(d, dict) else None,
)
# Consume the leading-edge slot
await batcher.add({"to": "history", "item": make_item("id1")})
assert len(emitted) == 1
# Two moves for the same item within the interval → last wins
await batcher.add({"to": "history", "item": make_item("id2", "first")})
await batcher.add({"to": "queue", "item": make_item("id2", "second")})
await batcher.add({"to": "history", "item": make_item("id3")})
await asyncio.sleep(0.15)
assert len(emitted) == 2
flush_items = emitted[1]
assert len(flush_items) == 2
by_id = {moved["item"]._id: moved for moved in flush_items}
assert set(by_id) == {"id2", "id3"}
assert by_id["id2"]["to"] == "queue"
assert by_id["id2"]["item"].title == "second"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_interval_zero_emits_every_add_immediately(self) -> None: async def test_interval_zero_emits_every_add_immediately(self) -> None:
"""interval=0 → every add emits immediately as a single-item list.""" """interval=0 → every add emits immediately as a single-item list."""

View file

@ -345,16 +345,18 @@ on('item_updated', (data: WSEP['item_updated']) => {
on('item_moved', (data: WSEP['item_moved']) => { on('item_moved', (data: WSEP['item_moved']) => {
const queueState = getQueueState(); const queueState = getQueueState();
const to = data.data.to;
const id = data.data.item._id;
if ('queue' === to) { for (const moved of data.data) {
queueState.add(id, data.data.item); const id = moved.item._id;
}
if ('history' === to) { if ('queue' === moved.to) {
if (true === queueState.has(id)) { queueState.add(id, moved.item);
queueState.remove(id); }
if ('history' === moved.to) {
if (true === queueState.has(id)) {
queueState.remove(id);
}
} }
} }
}); });

View file

@ -231,11 +231,15 @@ const moveHandler = (
shouldHandle: () => boolean = () => isLoaded.value, shouldHandle: () => boolean = () => isLoaded.value,
): ((payload: WSEP['item_moved']) => void) => { ): ((payload: WSEP['item_moved']) => void) => {
return (payload: WSEP['item_moved']): void => { return (payload: WSEP['item_moved']): void => {
if ('history' !== payload.data.to || !shouldHandle()) { if (!shouldHandle()) {
return; return;
} }
upsert(payload.data.item); for (const moved of payload.data) {
if ('history' === moved.to) {
upsert(moved.item);
}
}
}; };
}; };

View file

@ -26,7 +26,7 @@ export type WSEP = {
item_cancelled: EventPayload<StoreItem>; item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem>; item_deleted: EventPayload<StoreItem>;
item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>; item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>;
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>; item_moved: EventPayload<Array<{ to: 'queue' | 'history'; item: StoreItem }>>;
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>; item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>;
paused: EventPayload<{ paused?: boolean }>; paused: EventPayload<{ paused?: boolean }>;
resumed: EventPayload<{ paused?: boolean }>; resumed: EventPayload<{ paused?: boolean }>;