triangular_arbitrage_bot/oe_em/socket_client.py

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