Source code for herostools.actor.archiver.base
# coding:utf-8
from typing import Iterable, Any
from abc import abstractmethod
import threading
import queue
from heros import EventObserver
from herostools.helper import log
[docs]
class HERODataArchiver(EventObserver):
"""Base EventObserver that subscribes to a HERO event and archives its payload.
The event payload may be a ``(data, metadata)`` tuple or a plain dict.
Subclasses implement :meth:`_store`.
Args:
object_selector: Zenoh object selector for the devices to subscribe to.
event_name: Name of the event.
default_metadata: Default metadata merged with every incoming payload's metadata.
max_retries: Number of times to retry a failed store before dropping the item.
"""
def __init__(
self,
object_selector: str,
event_name: str,
default_metadata: dict | None = None,
max_retries: int = 5,
*args,
**kwargs,
):
self.metadata = default_metadata if default_metadata is not None else {}
self.max_retries = int(max_retries)
EventObserver.__init__(self, object_selector=object_selector, event_name=event_name, **kwargs)
self._payload_queue = queue.Queue()
self._stop_event = threading.Event()
self._worker_thread = threading.Thread(target=self._process_queue, daemon=True)
self._worker_thread.start()
self.register_callback(self.feed)
[docs]
def _process_queue(self) -> None:
"""Background worker that consumes the payload queue."""
while not self._stop_event.is_set():
try:
source_name, payload, metadata, retry_count = self._payload_queue.get(timeout=1)
log.debug(
f"Got data from queue source: {source_name}, metadata: {metadata}, retry_count: {retry_count}."
)
try:
self._store(source_name, payload, metadata)
except Exception as e: # noqa: BLE001
log.warning(f"Storing data from source: {source_name}, metadata: {metadata} failed with {e}.")
if retry_count < self.max_retries:
log.info(f"Re-queuing data from source: {source_name}, metadata: {metadata}.")
self.feed(source_name, (payload, metadata), retry_count + 1)
else:
log.error(
f"Max-retries exceeded for data from source: {source_name}, metadata: {metadata}. Dropping data!"
)
finally:
self._payload_queue.task_done()
except queue.Empty:
continue
[docs]
def is_queue_drained(self) -> bool:
"""Return True when all queued items have been processed.
Returns:
True if every item put on the queue has had task_done() called.
"""
return self._payload_queue.unfinished_tasks == 0
[docs]
def _stop(self):
"""Stop the background thread gracefully."""
self._stop_event.set()
self._worker_thread.join()
[docs]
def _teardown(self) -> None:
"""Called by boss on shutdown."""
self._stop()
[docs]
def feed(self, source_name: str, data: Iterable, retry_count: int = 0) -> None:
"""Callback registered with the source event.
``data`` may be a ``(payload, metadata)`` tuple or a plain dict.
When a plain dict is received it is used as both payload and metadata
so downstream ``_store`` implementations can still locate identifier
keys regardless of which field they are in.
Args:
source_name: Name of the event source (the HERO).
data: Either a ``(payload, metadata)`` tuple or a plain dict payload.
retry_count: Number of times this item has already been retried.
"""
if isinstance(data, dict):
payload = data
metadata = self.metadata | data
else:
payload = data[0]
metadata = self.metadata | data[1]
log.debug(f"Feeding data from {source_name} with metadata {metadata}.")
self._payload_queue.put((source_name, payload, metadata, retry_count))
[docs]
@abstractmethod
def _store(self, source_name: str, payload: Any, metadata: dict) -> None:
"""Store the payload. Must be implemented by subclasses.
Args:
source_name: Name of the event source (the HERO).
payload: The actual data.
metadata: Incoming metadata merged with ``default_metadata``.
"""
pass