Utility FFT dispatcher implementations for distributed engines.
"""Utility FFT dispatcher implementations for distributed engines."""
from __future__ import annotations
import base64
import json
import pickle
import queue
import threading
import uuid
from dataclasses import dataclass
from typing import Any, Callable, Mapping
try: # Optional dependency
import requests # type: ignore[import]
except Exception: # pragma: no cover - requests not available
requests = None # type: ignore[assignment]
from .fft_workers import default_fft_worker
Dispatcher = Callable[[str, dict[str, Any]], Any]
@dataclass
class HTTPFFTDispatcher:
"""Simple HTTP dispatcher that exchanges payloads via base64 pickles."""
base_url: str
auth_token: str | None = None
timeout: float = 30.0
session_factory: Callable[[], Any] | None = None
def __post_init__(self) -> None:
if not self.base_url:
raise ValueError("HTTP dispatcher requires a base URL")
self._session = self._build_session()
def _build_session(self) -> Any: # type: ignore[override]
if requests is None:
raise RuntimeError("requests is required for HTTPFFTDispatcher")
if self.session_factory is not None:
return self.session_factory()
return requests.Session()
def dispatch(self, action: str, payload: dict[str, Any]) -> Any:
if requests is None:
raise RuntimeError("requests is required for HTTPFFTDispatcher")
encoded_payload = _encode_payload(payload)
headers = {"Content-type": "application/json"}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
url = f"{self.base_url.rstrip('/')}/{action}"
response = self._session.post(
url,
data=json.dumps({"payload": encoded_payload}),
headers=headers,
timeout=self.timeout,
)
response.raise_for_status()
data = response.json()
encoded_result = data.get("payload")
if encoded_result is None:
return data
return _decode_payload(encoded_result)
@dataclass
class LocalWorkerDispatcher:
"""Dispatcher that routes actions to in-process callables."""
registry: Mapping[str, Dispatcher]
def dispatch(self, action: str, payload: dict[str, Any]) -> Any:
if action not in self.registry:
raise KeyError(f"No registered worker for action '{action}'")
worker = self.registry[action]
return worker(action, payload)
RequestEncoder = Callable[[dict[str, Any]], Any]
RequestDecoder = Callable[[Any], dict[str, Any]]
ResponseEncoder = Callable[[Any], Any]
ResponseDecoder = Callable[[Any], Any]
class ThreadedQueueDispatcher:
"""Dispatcher backed by one or more background worker threads and queues."""
def __init__(
self,
worker: Dispatcher | None = None,
*,
timeout: float = 60.0,
queue_maxsize: int = 0,
max_workers: int = 1,
request_serializer: RequestEncoder | None = None,
request_deserializer: RequestDecoder | None = None,
response_serializer: ResponseEncoder | None = None,
response_deserializer: ResponseDecoder | None = None,
) -> None:
self._worker = worker or default_fft_worker
self._timeout = timeout
self._requests: "queue.Queue[tuple[str, str, Any]]" = queue.Queue(
maxsize=queue_maxsize
)
self._responses: dict[str, dict[str, Any]] = {}
self._response_lock = threading.Lock()
self._request_serializer = request_serializer or (lambda payload: payload)
self._request_deserializer = request_deserializer or (lambda payload: payload)
self._response_serializer = response_serializer or (lambda payload: payload)
self._response_deserializer = response_deserializer or (lambda payload: payload)
self._threads: list[threading.Thread] = []
for index in range(max(1, max_workers)):
thread = threading.Thread(
target=self._loop,
name=f"tnfr-fft-dispatcher-{index}",
daemon=True,
)
thread.start()
self._threads.append(thread)
def dispatch(self, action: str, payload: dict[str, Any]) -> Any:
request_id = uuid.uuid4().hex
event = threading.Event()
serialized = self._request_serializer(payload)
with self._response_lock:
self._responses[request_id] = {"event": event}
self._requests.put((request_id, action, serialized))
if not event.wait(self._timeout):
with self._response_lock:
self._responses.pop(request_id, None)
raise TimeoutError(
f"FFT dispatcher timed out waiting for action '{action}'"
)
with self._response_lock:
envelope = self._responses.pop(request_id, None) or {}
if "error" in envelope:
raise envelope["error"]
result_payload = envelope.get("result")
return self._response_deserializer(result_payload)
def _loop(self) -> None:
while True:
request_id, action, serialized_payload = self._requests.get()
with self._response_lock:
handle = self._responses.get(request_id)
if handle is None:
continue
try:
payload = self._request_deserializer(serialized_payload)
result = self._worker(action, payload)
encoded_result = self._response_serializer(result)
except Exception as exc: # pragma: no cover - defensive path
handle["error"] = exc
else:
handle["result"] = encoded_result
finally:
event = handle.get("event")
if isinstance(event, threading.Event):
event.set()
def _encode_payload(payload: Mapping[str, Any]) -> str:
blob = pickle.dumps(payload)
return base64.b64encode(blob).decode("ascii")
def _decode_payload(blob: str) -> Any:
data = base64.b64decode(blob.encode("ascii"))
return pickle.loads(data)
__all__ = [
"Dispatcher",
"HTTPFFTDispatcher",
"LocalWorkerDispatcher",
"ThreadedQueueDispatcher",
]