114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
"""
|
|
Unix-domain socket client for sending opportunity signals to the executor.
|
|
|
|
Connects to the executor's SignalSocketServer and keeps the connection open for
|
|
burst sending. If the connection is lost the background reconnect loop will
|
|
retry every 2 seconds. All send operations are non-blocking: a warning is
|
|
logged if the underlying writer is not connected rather than raising.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import structlog
|
|
|
|
logger = structlog.get_logger().bind(component="signal_socket_client")
|
|
|
|
|
|
class SignalSocketClient:
|
|
"""
|
|
Non-blocking signal sender that maintains a persistent Unix-socket connection.
|
|
|
|
The caller invokes send_signal() for each opportunity; the client serialises
|
|
the payload and writes it to the socket. If the socket is not connected
|
|
(e.g. after a server restart) the signal is dropped with a warning log.
|
|
"""
|
|
|
|
def __init__(self, socket_path: Path) -> None:
|
|
self._socket_path = socket_path
|
|
self._log = logger
|
|
self._writer: asyncio.StreamWriter | None = None
|
|
self._running = False
|
|
self._reconnect_task: asyncio.Task | None = None
|
|
|
|
async def start(self) -> asyncio.Task:
|
|
"""Start the background reconnect loop and return the task."""
|
|
self._running = True
|
|
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
|
return self._reconnect_task
|
|
|
|
async def _reconnect_loop(self) -> None:
|
|
"""
|
|
Attempt to connect and then wait for the connection to close.
|
|
|
|
On connection failure a 2-second backoff is applied before retrying.
|
|
The loop exits when self._running becomes False (see close()).
|
|
"""
|
|
while self._running:
|
|
try:
|
|
reader, writer = await asyncio.open_unix_connection(path=str(self._socket_path))
|
|
self._writer = writer
|
|
self._log.info("connected", path=str(self._socket_path))
|
|
try:
|
|
await writer.wait_closed()
|
|
except Exception:
|
|
pass
|
|
except (ConnectionRefusedError, FileNotFoundError) as e:
|
|
if not self._running:
|
|
break
|
|
self._log.warning("connection_retrying", error=str(e))
|
|
await asyncio.sleep(2.0)
|
|
except Exception as e:
|
|
self._log.error("reconnect_error", error=str(e))
|
|
await asyncio.sleep(5.0)
|
|
finally:
|
|
self._writer = None
|
|
|
|
async def send_signal(self, signal: dict) -> None:
|
|
"""
|
|
Serialise a signal dict and write it to the socket.
|
|
|
|
If the socket is not connected this is a no-op (logged as warning).
|
|
The correlation_id is assigned here if not already set.
|
|
"""
|
|
writer = self._writer
|
|
if not writer:
|
|
self._log.warning("not_connected")
|
|
return
|
|
|
|
correlation_id = signal.get("correlation_id", "") or str(uuid.uuid4())
|
|
signal["correlation_id"] = correlation_id
|
|
|
|
msg = json.dumps(signal) + "\n"
|
|
try:
|
|
writer.write(msg.encode())
|
|
await writer.drain()
|
|
self._log.debug("signal_sent", correlation_id=correlation_id)
|
|
except Exception as e:
|
|
self._log.error("signal_send_failed", correlation_id=correlation_id, error=str(e))
|
|
|
|
async def close(self) -> None:
|
|
"""
|
|
Stop the reconnect loop and close the writer if open.
|
|
|
|
Safe to call multiple times; after close() any send_signal() call
|
|
will be a no-op.
|
|
"""
|
|
self._running = False
|
|
if self._reconnect_task:
|
|
self._reconnect_task.cancel()
|
|
try:
|
|
await self._reconnect_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._reconnect_task = None
|
|
writer = self._writer
|
|
if writer:
|
|
self._writer = None
|
|
writer.close()
|
|
try:
|
|
await writer.wait_closed()
|
|
except Exception:
|
|
pass
|