feat(issue-5): persistent MQTT publisher prototype and benchmark harness
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
This commit is contained in:
1 parent
78cb9ecfa9
commit
a4b7b45e13
4 files changed
+600
-44
No files matched your search
@@ -124,6 +124,85 @@ def _publish_once(config, topic: str, body: bytes, retain: bool) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class PreparedEvent(object):
|
||||
"""Everything decided before the wire: payload, topic, broker, retain flag.
|
||||
|
||||
Splitting this out lets a different transport (one-shot vs persistent) be
|
||||
swapped in without touching the parts that must not vary: the seq is already
|
||||
consumed, the HMAC is already computed over the final payload, and the audit
|
||||
record is written from these exact fields.
|
||||
"""
|
||||
|
||||
__slots__ = ("job_id", "event", "detail", "seq", "topic", "retain",
|
||||
"payload", "body", "config")
|
||||
|
||||
def __init__(self, job_id, event, detail, seq, topic, retain, payload, body, config):
|
||||
self.job_id = job_id
|
||||
self.event = event
|
||||
self.detail = detail
|
||||
self.seq = seq
|
||||
self.topic = topic
|
||||
self.retain = retain
|
||||
self.payload = payload
|
||||
self.body = body
|
||||
self.config = config
|
||||
|
||||
|
||||
def prepare_event(job_id, event, detail="", data=None,
|
||||
registry_dir=DEFAULT_REGISTRY_DIR, retained=False) -> PreparedEvent:
|
||||
"""Load the job, consume the next seq, and build the signed payload.
|
||||
|
||||
Consuming the seq here (before any transport runs) is deliberate: a failed
|
||||
publish must still burn the number, so a replayed packet can never carry a
|
||||
seq the subscriber would accept.
|
||||
"""
|
||||
job = load_job(job_id, registry_dir)
|
||||
config = broker_config_from_job(job)
|
||||
topic = job.get("topic_prefix")
|
||||
topic = f"{topic}/events" if topic else events_topic_for(job_id)
|
||||
seq = next_seq(job_id, registry_dir)
|
||||
payload = build_payload(
|
||||
job_id=job_id,
|
||||
seq=seq,
|
||||
event=event,
|
||||
detail=detail,
|
||||
data=data,
|
||||
auth_token=job.get("auth_token"),
|
||||
)
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
retain = retained or event in TERMINAL_EVENTS
|
||||
return PreparedEvent(job_id, event, detail, seq, topic, retain, payload, body, config)
|
||||
|
||||
|
||||
def record_outcome(prepared: PreparedEvent, registry_dir: str,
|
||||
publish_ok: bool, publish_error: Optional[str]) -> None:
|
||||
"""Persist the audit record, the registry event log and the status sync.
|
||||
|
||||
All three are best effort and all three happen whether or not the publish
|
||||
succeeded — that is the contract G-1/G-2/G-4 pin down.
|
||||
"""
|
||||
mqtt_common.append_event(prepared.job_id, {
|
||||
"event": "published",
|
||||
"source_event": prepared.event,
|
||||
"seq": prepared.seq,
|
||||
"topic": prepared.topic,
|
||||
"retain": prepared.retain,
|
||||
"timestamp": prepared.payload["timestamp"],
|
||||
"detail": prepared.detail,
|
||||
"payload": prepared.payload,
|
||||
"published": publish_ok,
|
||||
"publish_error": publish_error,
|
||||
})
|
||||
|
||||
registry.append_event(prepared.job_id, registry_dir, prepared.payload)
|
||||
new_status = EVENT_TO_STATUS.get(prepared.event)
|
||||
if new_status:
|
||||
try:
|
||||
mqtt_common.update_job_status(prepared.job_id, registry_dir, status=new_status)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("status sync failed: %s", exc)
|
||||
|
||||
|
||||
def _resolve_job_id(args) -> Optional[str]:
|
||||
if args.pick_pending:
|
||||
return registry.pick_pending(args.agent_session, args.registry_dir)
|
||||
@@ -168,26 +247,20 @@ def main(argv=None) -> int:
|
||||
return 1
|
||||
|
||||
try:
|
||||
job = load_job(job_id, args.registry_dir)
|
||||
prepared = prepare_event(
|
||||
job_id=job_id,
|
||||
event=args.event,
|
||||
detail=args.detail,
|
||||
data=data,
|
||||
registry_dir=args.registry_dir,
|
||||
retained=args.retained,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
config = broker_config_from_job(job)
|
||||
topic = job.get("topic_prefix")
|
||||
topic = f"{topic}/events" if topic else events_topic_for(job_id)
|
||||
seq = next_seq(job_id, args.registry_dir)
|
||||
payload = build_payload(
|
||||
job_id=job_id,
|
||||
seq=seq,
|
||||
event=args.event,
|
||||
detail=args.detail,
|
||||
data=data,
|
||||
auth_token=job.get("auth_token"),
|
||||
)
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
retain = args.retained or args.event in TERMINAL_EVENTS
|
||||
|
||||
# Policy Note: seq consumption on failure is intentional (see prepare_event)
|
||||
# and the failure is documented explicitly in the audit record.
|
||||
publish = with_retry(
|
||||
_publish_once,
|
||||
attempts=args.attempts,
|
||||
@@ -196,43 +269,19 @@ def main(argv=None) -> int:
|
||||
publish_ok = True
|
||||
publish_error: Optional[str] = None
|
||||
try:
|
||||
publish(config, topic, body, retain)
|
||||
publish(prepared.config, prepared.topic, prepared.body, prepared.retain)
|
||||
except Exception as exc:
|
||||
publish_ok = False
|
||||
publish_error = str(exc)
|
||||
logger.error("publish failed after %d attempts: %s", args.attempts, exc)
|
||||
|
||||
# Persistent audit log: record the exact payload we put on the wire (or intended to).
|
||||
# Best-effort (isolated inside append_event) — never fails the publish.
|
||||
# Policy Note: Seq consumption on failure is intentionally maintained for monotonic replay
|
||||
# defense (> highest accepted seq), with failure documented explicitly in the audit record.
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "published",
|
||||
"source_event": args.event,
|
||||
"seq": seq,
|
||||
"topic": topic,
|
||||
"retain": retain,
|
||||
"timestamp": payload["timestamp"],
|
||||
"detail": args.detail,
|
||||
"payload": payload,
|
||||
"published": publish_ok,
|
||||
"publish_error": publish_error,
|
||||
})
|
||||
|
||||
# Best-effort side effects: registry status sync + (debug) event log. Never
|
||||
# fail the publish on these.
|
||||
registry.append_event(job_id, args.registry_dir, payload)
|
||||
new_status = EVENT_TO_STATUS.get(args.event)
|
||||
if new_status:
|
||||
try:
|
||||
mqtt_common.update_job_status(job_id, args.registry_dir, status=new_status)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("status sync failed: %s", exc)
|
||||
record_outcome(prepared, args.registry_dir, publish_ok, publish_error)
|
||||
|
||||
if not publish_ok:
|
||||
return 2
|
||||
|
||||
logger.info("published %s seq=%d job=%s retain=%s", args.event, seq, job_id, retain)
|
||||
logger.info("published %s seq=%d job=%s retain=%s",
|
||||
args.event, prepared.seq, job_id, prepared.retain)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user