ytptube/app/library/ItemBatcher.py
Jesse Bate f1365f6fba 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.
2026-06-10 23:47:25 +09:30

128 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Global coalescing batcher for high-frequency WebSocket item events.
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.
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 event 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.
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,
key: Callable[[Any], str | None] | None = None,
) -> None:
self._emit = emit
self._interval = interval
self._key = key
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 payload to batch. Without a ``key`` callable, this is
expected to expose a ``_id`` attribute.
"""
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
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)