Splits publish_event.main into three pieces with no behaviour change:
prepare_event load the job, consume the seq, build the signed payload,
decide retain. The seq is burned here, before any transport
runs, so a failed publish can never leave a number a replayed
packet could reuse.
record_outcome audit record, registry event log and status sync -- all three
best effort, all three regardless of the publish result. That
is exactly the contract G-1/G-2/G-4 pin down, now in one place.
main parse, prepare, publish with retry, record.
publisher.py holds the two transports behind one interface. OneShotPublisher is
the current behaviour. PersistentPublisher keeps a single connection and lets
paho reconnect underneath, raising on an unacknowledged publish so the caller's
retry policy stays the only thing deciding attempt counts. It refuses an event
for a different broker rather than publishing to the wrong place.
Nothing in production uses the persistent path: the CLI and shell helpers exit
after one publish. The consumer is the phase 2a engine, which pays the CONNACK
cost on every event today -- this job's own started event took ~12.5s because
of one CONNACK timeout plus reconnect.
bench.py measures both arms. The fake transport is deterministic and is what
the tests assert on; the real-broker arm is informational, since the PoC
default is a public broker whose latency is not ours to control.
Verified: N publishes cost N connections one-shot and 1 persistent; the two
transports leave field-for-field identical registry and audit records including
the HMAC signature and retain flag; an unacknowledged publish retries on the
same connection and records published=false; a second broker is refused. No
test opens a socket.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
170 lines
6.2 KiB
Python
170 lines
6.2 KiB
Python
"""Publish transports: one connection per event, or one connection reused.
|
|
|
|
Phase 1 ships the persistent transport as a prototype only. Nothing in
|
|
production uses it: the CLI and the shell helpers exit after a single publish,
|
|
so reusing a connection buys them nothing. The consumer is the phase 2a engine,
|
|
which is long-running and pays the CONNACK cost on every event today.
|
|
|
|
Both transports raise on failure so the caller's `with_retry` policy stays the
|
|
single place that decides how many attempts an event gets.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Optional, Tuple
|
|
|
|
from lib_py.delegation import mqtt_common
|
|
|
|
logger = logging.getLogger("delegate_job.publisher")
|
|
|
|
CONNECT_ACK_TIMEOUT = 10 # seconds to wait for CONNACK
|
|
PUBLISH_ACK_TIMEOUT = 5 # seconds to wait for QoS-1 PUBACK
|
|
|
|
|
|
class _BasePublisher(object):
|
|
"""Shared retry wrapper so both transports report outcomes identically."""
|
|
|
|
def publish(self, prepared): # pragma: no cover - interface
|
|
raise NotImplementedError
|
|
|
|
def publish_with_retry(self, prepared, attempts=3) -> Tuple[bool, Optional[str]]:
|
|
runner = mqtt_common.with_retry(
|
|
self.publish,
|
|
attempts=attempts,
|
|
exceptions=(OSError, TimeoutError, ConnectionError, ValueError),
|
|
)
|
|
try:
|
|
runner(prepared)
|
|
except Exception as exc:
|
|
logger.error("publish failed after %d attempts: %s", attempts, exc)
|
|
return False, str(exc)
|
|
return True, None
|
|
|
|
def open(self):
|
|
return self
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
|
|
class OneShotPublisher(_BasePublisher):
|
|
"""Connect, publish, wait for the ACK, disconnect. The current behaviour."""
|
|
|
|
def __init__(self, client_factory=None):
|
|
self.client_factory = client_factory or mqtt_common.make_client
|
|
self.connect_count = 0
|
|
|
|
def publish(self, prepared):
|
|
client = self.client_factory("publisher", prepared.config)
|
|
self.connect_count += 1
|
|
connected = {"rc": None}
|
|
|
|
def on_connect(_c, _u, _flags, reason_code, _props):
|
|
connected["rc"] = reason_code
|
|
|
|
client.on_connect = on_connect
|
|
client.connect(prepared.config.host, prepared.config.port, prepared.config.keepalive)
|
|
client.loop_start()
|
|
try:
|
|
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
|
while connected["rc"] is None and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
if connected["rc"] is None:
|
|
raise TimeoutError("no CONNACK from broker")
|
|
if mqtt_common.reason_code_value(connected["rc"]) != 0:
|
|
raise ConnectionError("broker refused connection: rc={}".format(connected["rc"]))
|
|
info = client.publish(prepared.topic, payload=prepared.body,
|
|
qos=1, retain=prepared.retain)
|
|
info.wait_for_publish(timeout=PUBLISH_ACK_TIMEOUT)
|
|
if not info.is_published():
|
|
raise TimeoutError("publish not acknowledged within timeout")
|
|
finally:
|
|
client.loop_stop()
|
|
try:
|
|
client.disconnect()
|
|
except Exception: # pragma: no cover - disconnect best effort
|
|
pass
|
|
|
|
|
|
class PersistentPublisher(_BasePublisher):
|
|
"""One connection reused across events; paho handles reconnect underneath.
|
|
|
|
Bound to a single broker: the connection is established from the first
|
|
event's config, so an event for a job on a different broker is refused
|
|
rather than silently published to the wrong place.
|
|
"""
|
|
|
|
def __init__(self, client_factory=None):
|
|
self.client_factory = client_factory or mqtt_common.make_client
|
|
self.connect_count = 0
|
|
self._client = None
|
|
self._config = None
|
|
self._connected = {"rc": None}
|
|
|
|
def __enter__(self):
|
|
return self.open()
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
self.close()
|
|
return False
|
|
|
|
def open(self):
|
|
return self
|
|
|
|
def _ensure_client(self, config):
|
|
if self._client is not None:
|
|
if (config.host, config.port) != (self._config.host, self._config.port):
|
|
raise ValueError(
|
|
"PersistentPublisher is bound to {}:{}; refusing an event for {}:{}".format(
|
|
self._config.host, self._config.port, config.host, config.port)
|
|
)
|
|
return self._client
|
|
|
|
client = self.client_factory("publisher", config)
|
|
self.connect_count += 1
|
|
|
|
def on_connect(_c, _u, _flags, reason_code, _props):
|
|
self._connected["rc"] = reason_code
|
|
|
|
client.on_connect = on_connect
|
|
client.connect(config.host, config.port, config.keepalive)
|
|
client.loop_start()
|
|
reconnect_delay_set = getattr(client, "reconnect_delay_set", None)
|
|
if reconnect_delay_set is not None:
|
|
reconnect_delay_set(min_delay=1, max_delay=16)
|
|
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
|
while self._connected["rc"] is None and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
if self._connected["rc"] is None:
|
|
client.loop_stop()
|
|
raise TimeoutError("no CONNACK from broker")
|
|
if mqtt_common.reason_code_value(self._connected["rc"]) != 0:
|
|
client.loop_stop()
|
|
raise ConnectionError("broker refused connection: rc={}".format(self._connected["rc"]))
|
|
self._client = client
|
|
self._config = config
|
|
return client
|
|
|
|
def publish(self, prepared):
|
|
client = self._ensure_client(prepared.config)
|
|
info = client.publish(prepared.topic, payload=prepared.body,
|
|
qos=1, retain=prepared.retain)
|
|
info.wait_for_publish(timeout=PUBLISH_ACK_TIMEOUT)
|
|
if not info.is_published():
|
|
# Surfaced to the caller's with_retry; paho reconnects underneath.
|
|
raise TimeoutError("publish not acknowledged within timeout")
|
|
|
|
def close(self):
|
|
if self._client is None:
|
|
return
|
|
try:
|
|
self._client.loop_stop()
|
|
self._client.disconnect()
|
|
except Exception: # pragma: no cover - best effort
|
|
pass
|
|
finally:
|
|
self._client = None
|
|
self._config = None
|
|
self._connected = {"rc": None}
|