[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/pythonnative/pythonnative/main/src/pythonnative/query.py [Back]  [Original]

"""Shared async query cache with deduplication and explicit invalidation."""

from __future__ import annotations

import asyncio
import time
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Hashable

from .runtime import TaskScope


@dataclass(frozen=True)
class QuerySnapshot:
    """Immutable state shared by every subscriber to a query key."""

    data: Any = None
    loading: bool = False
    error: BaseException | None = None
    revision: int = 0


class _Entry:
    def __init__(self, initial: Any) -> None:
        self.snapshot = QuerySnapshot(data=initial, loading=True)
        self.listeners: set[Callable[[], None]] = set()
        self.fetcher: Callable[[], Awaitable[Any]] | None = None
        self.task: Any = None
        self.expires = 0.0
        self.generation = 0

    def publish(self, *, data: Any, loading: bool, error: BaseException | None = None) -> None:
        self.snapshot = QuerySnapshot(data, loading, error, self.snapshot.revision + 1)
        for listener in tuple(self.listeners):
            listener()


class QueryClient:
    """Own shared requests and cached immutable results for one application.

    A final unsubscribe cancels pending work. Cached values remain bounded by
    capacity. Invalidation cancels older requests before starting replacements.
    """

    def __init__(self, *, capacity: int = 128, stale_time: float = 30) -> None:
        if capacity < 1 or stale_time < 0:
            raise ValueError("Invalid query cache limits")
        self.capacity = capacity
        self.stale_time = stale_time
        self._entries: OrderedDict[Hashable, _Entry] = OrderedDict()
        self._scope = TaskScope("queries")

    def _entry(self, key: Hashable, initial: Any = None) -> _Entry:
        hash(key)
        if key not in self._entries:
            self._entries[key] = _Entry(initial)
        self._entries.move_to_end(key)
        for old_key, old in tuple(self._entries.items()):
            if len(self._entries)  QuerySnapshot:
        """Read a stable snapshot without starting network work."""
        return self._entry(key, initial).snapshot

    def subscribe(
        self, key: Hashable, fetcher: Callable[[], Awaitable[Any]], listener: Callable[[], None]
    ) -> Callable[[], None]:
        """Subscribe and start one shared request when cached data is stale."""
        entry = self._entry(key)
        entry.fetcher = fetcher
        entry.listeners.add(listener)
        if entry.expires  None:
            entry.listeners.discard(listener)
            if not entry.listeners and entry.task is not None and not entry.task.done():
                entry.generation += 1
                entry.task.cancel()
                entry.task = None
                entry.publish(data=entry.snapshot.data, loading=False)

        return remove

    def _fetch(self, entry: _Entry) -> None:
        if entry.fetcher is None or entry.task is not None and not entry.task.done():
            return
        generation = entry.generation
        fetcher = entry.fetcher
        entry.publish(data=entry.snapshot.data, loading=True)

        async def run() -> None:
            try:
                result = await fetcher()
                if generation == entry.generation:
                    entry.expires = time.monotonic() + self.stale_time
                    entry.publish(data=result, loading=False)
            except asyncio.CancelledError:
                raise
            except Exception as error:
                if generation == entry.generation:
                    entry.publish(data=entry.snapshot.data, loading=False, error=error)

        entry.task = self._scope.create_task(run())

    def invalidate(self, key: Hashable | None = None) -> None:
        """Invalidate one key or every query and refresh active subscribers."""
        for entry in [self._entry(key)] if key is not None else list(self._entries.values()):
            entry.generation += 1
            entry.expires = 0
            if entry.task is not None:
                entry.task.cancel()
                entry.task = None
            if entry.listeners:
                self._fetch(entry)

    def set_data(self, key: Hashable, value: Any) -> None:
        """Publish an immutable optimistic result and supersede older requests."""
        entry = self._entry(key)
        entry.generation += 1
        if entry.task is not None:
            entry.task.cancel()
            entry.task = None
        entry.expires = time.monotonic() + self.stale_time
        entry.publish(data=value, loading=False)

    def close(self) -> None:
        """Cancel shared tasks and release all cached data and listeners."""
        self._scope.close()
        self._entries.clear()


_default: QueryClient | None = None


def default_client() -> QueryClient:
    """Return the application's default query cache."""
    global _default
    if _default is None or _default._scope.closed:
        _default = QueryClient()
    return _default


def _reset_for_tests() -> None:
    global _default
    if _default is not None:
        _default.close()
    _default = None

Web Proxy Viewer  |  New URL  |  Original Page