perf: batch item_updated WebSocket events into a single per-tick payload

The per-download 500ms throttle still scales linearly with concurrency:
N active downloads produce up to 2N item_updated frames per second, each
with its own serialization pass and per-client send.

Add an ItemBatcher at the WebSocket boundary that coalesces dirty items
by _id (last write wins) and emits one item_updated frame per 500ms
tick whose payload is always a list of items, even for a single update.
Leading-edge emits keep isolated updates (e.g. renames via the API)
instant, and a trailing flush at most 500ms later delivers the rest.
Pending items are flushed on shutdown before clients disconnect.

Emitter call sites, the EventBus, and the StatusTracker throttle are
unchanged; the batcher only changes the wire format. The frontend now
types item_updated as StoreItem[] and iterates the array.

BREAKING CHANGE: the item_updated WebSocket event payload (data) is now
always an array of items instead of a single item object.
This commit is contained in:
Jesse Bate 2026-06-10 23:38:27 +09:30
parent 687c82cb32
commit 0cb3a95a41
5 changed files with 351 additions and 5 deletions

View file

@ -15,6 +15,7 @@ from app.library.Utils import load_modules
from .config import Config
from .encoder import Encoder
from .Events import Event, EventBus, Events
from .ItemBatcher import ItemBatcher
from .ItemDTO import Item
LOG = get_logger()
@ -85,7 +86,23 @@ class HttpSocket:
self.sio = sio or WebSocketHub(encoder=encoder)
self.rootPath: Path = root_path
async def _emit_items(items: list) -> None:
ev = Event(event=Events.ITEM_UPDATED, data=items)
await self.sio.emit(event=Events.ITEM_UPDATED, data=json.loads(encoder.encode(ev)))
self._batcher = ItemBatcher(emit=_emit_items)
async def event_handler(e: Event, _, **kwargs):
if Events.ITEM_UPDATED == e.event and not kwargs:
await self._batcher.add(e.data)
return
if Events.ITEM_UPDATED == e.event and kwargs:
# Targeted delivery (e.g. to=sid): wrap data in a list so the
# wire shape is always an array for item_updated.
targeted = Event(event=Events.ITEM_UPDATED, data=[e.data])
payload = json.loads(encoder.encode(targeted))
await self.sio.emit(event=e.event, data=payload, **kwargs)
return
payload = json.loads(encoder.encode(e))
await self.sio.emit(event=e.event, data=payload, **kwargs)
@ -121,6 +138,7 @@ class HttpSocket:
async def on_shutdown(self, _: web.Application):
LOG.debug("Shutting down socket server.")
await self._batcher.flush()
await self.sio.disconnect_all()
LOG.debug("Socket server shutdown complete.")

120
app/library/ItemBatcher.py Normal file
View file

@ -0,0 +1,120 @@
"""Global coalescing batcher for ITEM_UPDATED WebSocket events.
Collects dirty ItemDTOs keyed by ``_id`` (last write wins) and flushes them
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
immediately so single-item updates feel instant. Subsequent adds within the
same interval are coalesced into a single trailing flush.
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from typing import Any
from app.library.log import get_logger
LOG = get_logger()
class ItemBatcher:
"""Coalescing batcher for WebSocket item-updated payloads.
Args:
emit: Async callable that receives a list of items and delivers them
to all connected WebSocket clients.
interval: Flush interval in seconds. ``0`` or negative disables
batching every ``add()`` emits immediately.
"""
def __init__(self, emit: Callable[[list], Awaitable[None]], interval: float = 0.5) -> None:
self._emit = emit
self._interval = interval
self._pending: dict[str, Any] = {}
self._handle: asyncio.TimerHandle | None = None
self._last_flush: float = 0.0 # monotonic
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def add(self, item: Any) -> None:
"""Add (or coalesce) an item into the pending batch.
If the batcher is idle and the leading-edge condition is met the item
is emitted immediately; otherwise it is queued for the next trailing
flush.
Args:
item: The ItemDTO (or any object with a ``_id`` attribute) to
batch.
"""
item_id: str = getattr(item, "_id", None) or str(id(item))
# interval <= 0 → always emit immediately, no batching
if self._interval <= 0:
await self._emit_items([item])
return
now = time.monotonic()
# Leading edge: nothing queued yet and enough time has elapsed
if not self._pending and (now - self._last_flush) >= self._interval:
self._last_flush = now
await self._emit_items([item])
return
# Coalesce into the pending dict (last write wins per _id)
self._pending[item_id] = item
# Schedule a trailing flush only if one is not already pending
if self._handle is None:
elapsed = now - self._last_flush
remaining = max(0.0, self._interval - elapsed)
loop = asyncio.get_running_loop()
self._handle = loop.call_later(remaining, lambda: loop.create_task(self._flush_pending()))
async def flush(self) -> None:
"""Cancel any scheduled flush and immediately emit all pending items.
Intended for use during shutdown so that the last batch reaches clients
before the WebSocket is torn down.
"""
if self._handle is not None:
self._handle.cancel()
self._handle = None
if self._pending:
await self._flush_pending()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _flush_pending(self) -> None:
"""Emit everything that has accumulated in ``_pending``.
State is cleared **before** awaiting the emit so that a slow or
failing send can never wedge the batcher.
"""
items = list(self._pending.values())
self._pending = {}
self._handle = None
self._last_flush = time.monotonic()
if not items:
return
try:
await self._emit_items(items)
except Exception:
LOG.exception("ItemBatcher: emit callback raised an exception; %d item(s) lost.", len(items))
async def _emit_items(self, items: list) -> None:
"""Delegate to the provided emit callback."""
await self._emit(items)

View file

@ -0,0 +1,208 @@
"""Unit tests for app.library.ItemBatcher."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from app.library.ItemBatcher import ItemBatcher
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_item(id_: str = "id1", title: str = "Test") -> SimpleNamespace:
"""Return a minimal stand-in for ItemDTO with a ``_id`` attribute."""
return SimpleNamespace(_id=id_, title=title)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestItemBatcher:
"""Tests for ItemBatcher leading-edge and trailing-flush semantics."""
@pytest.mark.asyncio
async def test_leading_edge_emits_immediately(self) -> None:
"""First add after idle fires immediately as a one-item list."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(emit=emit, interval=0.05)
item = make_item("id1")
await batcher.add(item)
assert len(emitted) == 1
assert emitted[0] == [item]
@pytest.mark.asyncio
async def test_rapid_adds_same_id_coalesces_to_last(self) -> None:
"""Multiple adds with same _id within interval → one flush, last item wins."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(emit=emit, interval=0.1)
# Consume the leading-edge slot
item_a = make_item("id1", "first")
await batcher.add(item_a)
assert len(emitted) == 1
# Now add more within the interval these should batch
item_b = make_item("id1", "second")
item_c = make_item("id1", "third")
await batcher.add(item_b)
await batcher.add(item_c)
# Allow the trailing flush to fire
await asyncio.sleep(0.15)
assert len(emitted) == 2
flush_items = emitted[1]
assert len(flush_items) == 1
assert flush_items[0].title == "third"
@pytest.mark.asyncio
async def test_multiple_different_ids_flushed_together(self) -> None:
"""Adds for N different IDs within interval → one flush with N items."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(emit=emit, interval=0.1)
# Consume the leading-edge slot for id1
await batcher.add(make_item("id1"))
assert len(emitted) == 1
# Queue id2, id3 within the interval
item2 = make_item("id2")
item3 = make_item("id3")
await batcher.add(item2)
await batcher.add(item3)
await asyncio.sleep(0.15)
assert len(emitted) == 2
flush_items = emitted[1]
ids = {i._id for i in flush_items}
assert ids == {"id2", "id3"}
@pytest.mark.asyncio
async def test_only_one_timer_scheduled(self) -> None:
"""Only one timer handle is created while a flush is pending."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(emit=emit, interval=0.1)
# Consume the leading edge
await batcher.add(make_item("id1"))
# Add several more to build up the batch
await batcher.add(make_item("id2"))
handle_after_first = batcher._handle
await batcher.add(make_item("id3"))
handle_after_second = batcher._handle
# The same handle object should be reused (not replaced)
assert handle_after_first is handle_after_second
await asyncio.sleep(0.15)
@pytest.mark.asyncio
async def test_emit_exception_clears_state_and_allows_next_add(self) -> None:
"""A failing emit callback is logged but leaves the batcher functional."""
emit_count = 0
async def emit(items: list) -> None:
nonlocal emit_count
emit_count += 1
if emit_count == 2:
raise RuntimeError("simulated flush failure")
batcher = ItemBatcher(emit=emit, interval=0.1)
await batcher.add(make_item("id1")) # leading edge ok (count=1)
# Trigger trailing flush that will fail (count=2)
await batcher.add(make_item("id2"))
await asyncio.sleep(0.15) # trailing flush fires → raises → caught
# State must be cleared; next add should work as a fresh leading-edge
await asyncio.sleep(0.05) # ensure _last_flush is old enough
await batcher.add(make_item("id3")) # leading edge ok (count=3)
assert emit_count == 3
# Batcher handle must be None (no dangling timer)
assert batcher._handle is None
@pytest.mark.asyncio
async def test_flush_cancels_timer_and_emits_pending(self) -> None:
"""flush() cancels a scheduled timer and emits remaining items."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(emit=emit, interval=10.0) # very long interval
# Consume leading edge
await batcher.add(make_item("id1"))
assert len(emitted) == 1
# Queue an item that would normally wait 10 s
item2 = make_item("id2")
await batcher.add(item2)
assert batcher._handle is not None
# flush() must fire immediately
await batcher.flush()
assert batcher._handle is None
assert len(emitted) == 2
assert emitted[1] == [item2]
@pytest.mark.asyncio
async def test_flush_noop_when_empty(self) -> None:
"""flush() on an idle batcher does not call emit."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(emit=emit, interval=0.05)
await batcher.flush()
assert emitted == []
@pytest.mark.asyncio
async def test_interval_zero_emits_every_add_immediately(self) -> None:
"""interval=0 → every add emits immediately as a single-item list."""
emitted: list[list] = []
async def emit(items: list) -> None:
emitted.append(items)
batcher = ItemBatcher(emit=emit, interval=0)
for i in range(5):
await batcher.add(make_item(f"id{i}"))
assert len(emitted) == 5
for i, batch in enumerate(emitted):
assert len(batch) == 1
assert batch[0]._id == f"id{i}"

View file

@ -336,10 +336,10 @@ on('item_deleted', (data: WSEP['item_deleted']) => {
on('item_updated', (data: WSEP['item_updated']) => {
const queueState = getQueueState();
const id = data.data._id;
if (true === queueState.has(id)) {
queueState.update(id, data.data);
for (const item of data.data) {
if (true === queueState.has(item._id)) {
queueState.update(item._id, item);
}
}
});

View file

@ -22,7 +22,7 @@ export type WSEP = {
connect_error: { message?: string };
connected: EventPayload<{ sid: string }>;
item_added: EventPayload<StoreItem>;
item_updated: EventPayload<StoreItem>;
item_updated: EventPayload<StoreItem[]>;
item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem>;
item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>;