From 2c776b84b70a0c441ba6755d7721cb7f5bccee51 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:17:30 -0400 Subject: [PATCH 01/17] EBP-3103: Add best-practice Python pub/sub reference samples - Add python-solace-connection-config.py plus guaranteed and direct publisher and subscriber samples under references/python, derived from the Solace Python samples and elevated to the Python developer guide and C API best practices - Register service listeners before connect(), use a 3000 ms reconnect interval, pair reject back pressure with a readiness listener, check publish receipts, subscribe the CREATE_ON_START queue to the topic, client-ack after processing, and shut down gracefully on SIGINT and SIGTERM - Read config.json or command-line arguments through the shared helper, mirroring jcsmp-solace-connection-config.java, with no version pin - Reword JCSMP sample comments to drop the legacy product name and the comparisons to upstream samples - Verify locally against solace-pubsubplus resolved from PyPI with a no-broker smoke test and live round trips per pair --- .../jcsmp/jcsmp-direct-publisher-sample.java | 2 +- .../jcsmp/jcsmp-direct-requestor-sample.java | 3 +- .../jcsmp-guaranteed-publisher-sample.java | 2 +- .../jcsmp-guaranteed-replier-sample.java | 7 +- .../jcsmp-guaranteed-subscriber-sample.java | 3 +- .../python/python-direct-publisher-sample.py | 315 ++++++++++++++++ .../python/python-direct-subscriber-sample.py | 268 ++++++++++++++ .../python-guaranteed-publisher-sample.py | 331 +++++++++++++++++ .../python-guaranteed-subscriber-sample.py | 340 ++++++++++++++++++ .../python/python-solace-connection-config.py | 155 ++++++++ 10 files changed, 1416 insertions(+), 10 deletions(-) create mode 100644 plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py create mode 100644 plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py create mode 100644 plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py create mode 100644 plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py create mode 100644 plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java index 93c618f..d746035 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java @@ -192,7 +192,7 @@ private static void runPublishLoop() throws IOException { } finally { // add a delay between messages try { Thread.sleep(1000 / APPROX_MSG_RATE_PER_SEC); // do Thread.sleep(0) for max speed - // Note: STANDARD Edition Solace PubSub+ broker is limited to 10k msg/s max ingress + // Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore the interrupt status, do not swallow it isShutdown = true; diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-requestor-sample.java b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-requestor-sample.java index 3bfe9a2..a564530 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-requestor-sample.java +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-requestor-sample.java @@ -60,8 +60,7 @@ public class DirectRequestor { private static final String API = "JCSMP"; // POSITIVE blocking-request timeout in milliseconds: requestor.request(...) blocks up // to this long for the correlated reply before throwing JCSMPRequestTimeoutException. - // A positive timeout is the direct reliability knob (NOT a burst); 3000 ms matches the - // upstream DirectRequestorBlocking sample. + // A positive timeout is the direct reliability knob (NOT a burst). private static final int REQUEST_TIMEOUT_MS = 3000; private static JCSMPSession session; diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java index a4d0ef1..bc1baa3 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java @@ -223,7 +223,7 @@ private static void runPublishLoop() throws IOException, InterruptedException { } finally { // add a delay between messages try { Thread.sleep(1000 / APPROX_MSG_RATE_PER_SEC); // do Thread.sleep(0) for max speed - // Note: STANDARD Edition Solace PubSub+ broker is limited to 10k msg/s max ingress + // Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore the interrupt status, do not swallow it isShutdown = true; diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-replier-sample.java b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-replier-sample.java index 471a19c..bfbacd2 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-replier-sample.java +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-replier-sample.java @@ -157,8 +157,7 @@ public void handleEvent(SessionEventArgs event) { final Queue requestQueue = JCSMPFactory.onlyInstance().createQueue(REQUEST_QUEUE_NAME); // ELEVATION: provision the durable request queue in-process and map the request - // topic onto it at startup, so a fresh broker works out of the box (the upstream - // sample provisions with flag 0, which throws "already exists" on a re-run). + // topic onto it at startup, so a fresh broker works out of the box. // best practice: confirm the broker allows client-side endpoint management first if (!session.isCapable(CapabilityType.ENDPOINT_MANAGEMENT)) { System.err.println("This client/broker does not allow client-side endpoint management; " @@ -168,8 +167,8 @@ public void handleEvent(SessionEventArgs event) { EndpointProperties endpointProps = new EndpointProperties(); endpointProps.setAccessType(EndpointProperties.ACCESSTYPE_EXCLUSIVE); // single-replier sample endpointProps.setPermission(EndpointProperties.PERMISSION_CONSUME); - // provision the durable request queue IDEMPOTENTLY so a re-run is safe (NOT the - // upstream flag 0, which throws on the second run) + // provision the durable request queue IDEMPOTENTLY so a re-run is safe (flag 0 would + // throw "already exists" on the second run) session.provision(requestQueue, endpointProps, JCSMPSession.FLAG_IGNORE_ALREADY_EXISTS); // map the request topic onto the queue with a subscription (PERSISTENT pub/sub onto // a queue); WAIT_FOR_CONFIRM blocks until the broker confirms the subscription diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-subscriber-sample.java b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-subscriber-sample.java index 6f13cbd..b2471c2 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-subscriber-sample.java +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-subscriber-sample.java @@ -153,8 +153,7 @@ public void handleEvent(SessionEventArgs event) { final Queue queue = JCSMPFactory.onlyInstance().createQueue(QUEUE_NAME); // ELEVATION: provision the durable queue in-process and map the topic onto it - // at startup, so a fresh broker works out of the box (the upstream sample expects - // the queue to pre-exist and errors out otherwise). + // at startup, so a fresh broker works out of the box. // best practice: confirm the broker allows client-side endpoint management first if (!session.isCapable(CapabilityType.ENDPOINT_MANAGEMENT)) { System.err.println("This client/broker does not allow client-side endpoint management; " diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py new file mode 100644 index 0000000..f7b5fc6 --- /dev/null +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -0,0 +1,315 @@ +""" +Derived from patterns/direct_publisher.py + SolaceSamples/solace-samples-python + https://github.com/SolaceSamples/solace-samples-python + +Elevated to documented best practices per the Python API developer guide: + Messaging Service: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Messaging-Service.md + Publishing Direct Messages: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-DM-Publish.md + the Python API reference (units, defaults, listener contracts): + https://docs.solace.com/API-Developer-Online-Ref-Documentation/python/index.html + and the C API Best Practices (the Python API wraps the C API, so its callback, + reconnect, acknowledgement, and time-to-live practices apply): + https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md + +Reference sample. Wired for a basic direct (at-most-once) pub/sub journey: +basic-auth connect with a reconnection retry strategy, a continuous DIRECT binary +publish loop with a rotating payload to a topic, a publish failure listener for the +asynchronous failures the API reports, capacity-bounded back pressure paired with a +publisher readiness listener, reconnection and service interruption listeners +registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown. Direct +messaging is at-most-once: there is no broker receipt, so there is NO publish +receipt listener and NO user context here (the inverse of the guaranteed +publisher, which acts on each ACK/NACK), and there are no outstanding receipts to +drain at shutdown. Structured as setup_solace(...), run_publish_loop(), and +teardown_solace(), with teardown run from main's finally on every exit path. + +Only practices documented in canonical Solace sources are encoded here. + +Copied into a generated project as direct_publisher.py, next to the shared helper +python-solace-connection-config.py copied as solace_connection_config.py. + +Any generated adaptation of this sample MUST begin with the exact line: + AI-assisted code. Review before production use. +(This reference sample itself carries no such header by design.) +""" + +# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], +# dict[str, object]) also import on Python 3.7 and 3.8 +from __future__ import annotations + +import logging +import signal +import sys +import threading +import uuid +from typing import Optional + +from solace.messaging.config.retry_strategy import RetryStrategy +from solace.messaging.errors.pubsubplus_client_error import PublisherOverflowError, PubSubPlusClientError +from solace.messaging.messaging_service import ( + MessagingService, + ReconnectionAttemptListener, + ReconnectionListener, + ServiceEvent, + ServiceInterruptionListener, +) +from solace.messaging.publisher.direct_message_publisher import ( + DirectMessagePublisher, + FailedPublishEvent, + PublishFailureListener, +) +from solace.messaging.publisher.outbound_message import OutboundMessageBuilder +from solace.messaging.publisher.publisher_health_check import PublisherReadinessListener +from solace.messaging.resources.topic import Topic + +from solace_connection_config import SolaceConnectionConfig + +APP_NAME = "DirectPublisher" +TOPIC_PREFIX = "solace/samples/" # used as the topic "root" +API = "Python" +APPROX_MSG_RATE_PER_SEC = 100 +PAYLOAD_SIZE = 512 +# reconnect budget: 20 attempts 3 s apart, a minute of reconnect attempts, a reasonable +# default when the design says nothing about high availability. The interval is in +# MILLISECONDS per the RetryStrategy API reference. +RECONNECT_RETRIES = 20 +RECONNECT_RETRY_INTERVAL_MS = 3000 +# For HA or replication failover resilience, follow the C API Best Practices: the +# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example +# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per +# host; the Python retry strategy sets only the reconnect count and the wait, so +# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is +# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries +# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication +# failover, and a comma-separated host list in the host property +# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the +# comma-separated form) so the API fails over between brokers. +# back pressure: the publisher buffers at most this many unsent messages before +# publish() raises PublisherOverflowError (the documented reject strategy) +PUBLISH_BUFFER_CAPACITY = 1000 +# terminate(grace_period): how long to wait for buffered sends to leave before the +# publisher stops (the API default is 600000 ms); terminate() raises +# IncompleteMessageDeliveryError when messages are still buffered after that +TERMINATE_GRACE_PERIOD_MS = 10_000 + +# API events go through standard logging; the trace() narration below is a separate channel +logger = logging.getLogger(APP_NAME) + + +class DirectPublisher: + """The app: the service, the publisher, the flags the listeners flip, and the counters.""" + + def __init__(self) -> None: + self.messaging_service: Optional[MessagingService] = None + self.publisher: Optional[DirectMessagePublisher] = None + self.message_builder: Optional[OutboundMessageBuilder] = None + self.connect_attempted = False + self.is_connected = True # tracks transport state via the reconnection listeners + self.shutdown = threading.Event() + self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room + self.msg_sent_counter = 0 # num messages sent + + def main(self, args: list[str]) -> None: + trace(f"{API} {APP_NAME} initializing...") + try: + self.setup_solace(args) + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish + # loop exits, and teardown_solace() in the finally below stops the publisher and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. + signal.signal(signal.SIGINT, self.on_shutdown_signal) + signal.signal(signal.SIGTERM, self.on_shutdown_signal) + self.run_publish_loop() + finally: + self.teardown_solace() + trace("Main thread quitting.") + + def on_shutdown_signal(self, signum: int, _frame) -> None: + trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") + self.shutdown.set() + + def setup_solace(self, args: list[str]) -> None: + # basic username/password connection details, built by the shared SolaceConnectionConfig + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + self.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(self) + self.messaging_service.add_reconnection_attempt_listener(service_events) + self.messaging_service.add_reconnection_listener(service_events) + self.messaging_service.add_service_interruption_listener(service_events) + self.connect_attempted = True + self.messaging_service.connect() # blocking connect + + # DIRECT publisher with capacity-bounded back pressure: publish() raises + # PublisherOverflowError once PUBLISH_BUFFER_CAPACITY messages wait unsent, and the + # readiness listener below reports when there is room again. The API default + # (on_back_pressure_elastic) buffers without bound and the developer guide cautions it + # can exhaust memory; on_back_pressure_wait(n) blocks publish() instead of raising. + self.publisher = ( + self.messaging_service.create_direct_message_publisher_builder() + .on_back_pressure_reject(PUBLISH_BUFFER_CAPACITY) + .build() + ) + # direct messaging has no broker receipt, so failures the API detects after publish() + # returned (a buffered message that could not be sent, a broker rejection) arrive + # asynchronously through this listener; without it they are silent + self.publisher.set_publish_failure_listener(PublishFailureHandler()) + # documented pairing with on_back_pressure_reject: ready() fires when the buffer has + # room again, so the loop waits on the event instead of spinning on overflow + self.publisher.set_publisher_readiness_listener(PublisherReadinessHandler(self.publisher_ready)) + self.publisher.start() + # best practice: one OutboundMessageBuilder, reused for every message + self.message_builder = self.messaging_service.message_builder() + + def run_publish_loop(self) -> None: + threading.Thread(target=self.print_stats, name="stats", daemon=True).start() + + trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") + trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/direct/pub/...', " + "at-most-once (no broker receipt, no redelivery).") + while not self.shutdown.is_set(): + if not self.is_connected: # transport is down: wait for the API's automatic reconnect + self.shutdown.wait(0.1) + continue + # each loop, change the payload as an example + chosen_character = chr(self.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] + # a bytearray payload travels as the binary attachment; consumers read it back with + # get_payload_as_bytes() (a str payload would arrive via get_payload_as_string()) + payload = bytearray(chosen_character.encode("ascii") * PAYLOAD_SIZE) + # NO user context: direct is at-most-once with no broker receipt, so there is + # nothing to correlate (the inverse of the guaranteed publisher, which passes the + # message id as user_context for local ACK/NACK correlation) + message = self.message_builder.with_application_message_id(str(uuid.uuid4())).build(payload) # as an example + topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/direct/pub/{chosen_character}") + try: + self.publisher.publish(message, topic) + self.msg_sent_counter += 1 + except PublisherOverflowError: + # buffer full (back pressure): wait for the ready() callback the API sends when the + # buffer has room again, with a bound so a shutdown request is still honoured, and + # retry. is_ready() closes the race with a ready() that fired before clear(). + # notify_when_ready() is not called here: the API sends ready() at once, whatever + # the buffer state, so the wait would return immediately and this loop would spin. + self.publisher_ready.clear() + if not self.publisher.is_ready(): + self.publisher_ready.wait(timeout=1.0) + continue + except PubSubPlusClientError as error: + # publish() raises when the message cannot be sent and retrying would not help + logger.warning("publish() failed, quitting: %s", error) + self.shutdown.set() # let's quit; or, could initiate a new connection attempt + break + # delay between messages; the wait returns early once shutdown is requested + self.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed + # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress + self.shutdown.set() + + def print_stats(self) -> None: + # simple way of calculating message rates; wait() returns True (ending the loop) on shutdown + while not self.shutdown.wait(1.0): + trace(f"{API} {APP_NAME} Published msgs/s: {self.msg_sent_counter:,}") + self.msg_sent_counter = 0 + + def teardown_solace(self) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an + # exception), so Solace teardown and application-side cleanup are never skipped. + self.shutdown.set() + if self.publisher is not None and self.publisher.is_running(): + # direct is at-most-once: there are no broker receipts to drain, so terminate() only + # gives the messages still in the publisher's buffer up to the grace period to leave. + # It raises IncompleteMessageDeliveryError when messages are still buffered after + # that; catch it so the disconnect below still runs + try: + self.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) + except PubSubPlusClientError as error: + logger.error("Publisher stopped with messages still buffered after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + if self.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + self.messaging_service.disconnect() + + +def trace(message: str) -> None: + """Demo narration sink: every status line in this sample funnels through this one + function. An application replaces this single body to route narration to its + logger or reporting system. The logger calls for API events (service, publish + failure, readiness) are a separate channel and stay as they are.""" + print(message, flush=True) + + +############################################################################ + + +class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, ServiceInterruptionListener): + """The three service event listeners in one class. Callbacks run on an API thread: + return promptly and do not block in them, since events and delivery stall while a + callback runs (C API Best Practices).""" + + def __init__(self, app: DirectPublisher) -> None: + self.app = app + + def on_reconnecting(self, event: ServiceEvent) -> None: + # connection lost, automatic reconnect attempt in progress: pause publishing rather + # than fill the buffer (direct is at-most-once: a buffered message the API cannot send + # is reported only through the publish failure listener, never redelivered) + logger.warning("Service reconnecting, pausing publishing: %s (%s)", event.get_message(), event.get_cause()) + self.app.is_connected = False + + def on_reconnected(self, event: ServiceEvent) -> None: + # automatic reconnect succeeded: resume publishing + logger.info("Service reconnected to %s, resuming publishing", event.get_broker_uri()) + self.app.is_connected = True + + def on_service_interrupted(self, event: ServiceEvent) -> None: + # the connection went down and cannot be restored (reconnect attempts exhausted) + logger.error("Service interrupted and cannot be restored, quitting: %s (%s)", + event.get_message(), event.get_cause()) + # Application cleanup signal: the service will not recover. Trigger application-side + # cleanup from here. This sample sets shutdown, so the publish loop exits and + # teardown_solace() runs in main's finally. + self.app.shutdown.set() + + +class PublisherReadinessHandler(PublisherReadinessListener): + """Flags the publish loop when the publisher can publish again. Runs on an API thread: + return promptly and do not block in it (C API Best Practices).""" + + def __init__(self, publisher_ready: threading.Event) -> None: + self.publisher_ready = publisher_ready + + def ready(self) -> None: + self.publisher_ready.set() # the buffer has room: the publish loop resumes + + +class PublishFailureHandler(PublishFailureListener): + """Very simple class, used for the asynchronous publish failures the API reports for + direct messages (there is no broker receipt to act on). Runs on an API thread: + return promptly and do not block in it (C API Best Practices).""" + + def on_failed_publish(self, failed_publish_event: FailedPublishEvent) -> None: + # a buffered message could not be sent (the developer guide names an invalid topic or a + # termination of the service as causes); direct has no NACK, so this never fires for one + logger.warning("Failed to publish to %s: %s", + failed_publish_event.get_destination().get_name(), failed_publish_event.get_exception()) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + DirectPublisher().main(sys.argv[1:]) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py new file mode 100644 index 0000000..b8cb159 --- /dev/null +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -0,0 +1,268 @@ +""" +Derived from patterns/direct_receiver.py + SolaceSamples/solace-samples-python + https://github.com/SolaceSamples/solace-samples-python + +Elevated to documented best practices per the Python API developer guide: + Messaging Service: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Messaging-Service.md + Consuming Direct Messages: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-DM-Receive.md + the Python API reference (units, defaults, listener contracts): + https://docs.solace.com/API-Developer-Online-Ref-Documentation/python/index.html + and the C API Best Practices (the Python API wraps the C API, so its callback, + reconnect, acknowledgement, and time-to-live practices apply): + https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md + +Reference sample. Wired for a basic direct (at-most-once) pub/sub journey: +basic-auth connect with a reconnection retry strategy, a plain topic subscription +(no queue, no provisioning), an async DIRECT receiver that detects discards, +reconnection and service interruption listeners registered BEFORE connect(), and a +graceful SIGINT/SIGTERM shutdown. Direct messaging is at-most-once: there is no +broker ACK and no redelivery, so there is NO client acknowledgement here (the +inverse of the guaranteed subscriber). Structured as setup_solace(...), +await_messages(), and teardown_solace(), with teardown run from main's finally on +every exit path. + +Only practices documented in canonical Solace sources are encoded here. + +Copied into a generated project as direct_subscriber.py, next to the shared helper +python-solace-connection-config.py copied as solace_connection_config.py. + +Any generated adaptation of this sample MUST begin with the exact line: + AI-assisted code. Review before production use. +(This reference sample itself carries no such header by design.) +""" + +# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], +# dict[str, object]) also import on Python 3.7 and 3.8 +from __future__ import annotations + +import logging +import signal +import sys +import threading +from typing import Optional + +from solace.messaging.config.retry_strategy import RetryStrategy +from solace.messaging.errors.pubsubplus_client_error import PubSubPlusClientError +from solace.messaging.messaging_service import ( + MessagingService, + ReconnectionAttemptListener, + ReconnectionListener, + ServiceEvent, + ServiceInterruptionListener, +) +from solace.messaging.receiver.direct_message_receiver import DirectMessageReceiver +from solace.messaging.receiver.inbound_message import InboundMessage +from solace.messaging.receiver.message_receiver import MessageHandler +from solace.messaging.resources.topic_subscription import TopicSubscription + +from solace_connection_config import SolaceConnectionConfig + +APP_NAME = "DirectSubscriber" +# topic to subscribe to directly; matches the direct publisher sample's topic root +TOPIC_NAME = "solace/samples/python/direct/pub/>" +API = "Python" +# reconnect budget: 20 attempts 3 s apart, a minute of reconnect attempts, a reasonable +# default when the design says nothing about high availability. The interval is in +# MILLISECONDS per the RetryStrategy API reference. +RECONNECT_RETRIES = 20 +RECONNECT_RETRY_INTERVAL_MS = 3000 +# For HA or replication failover resilience, follow the C API Best Practices: the +# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example +# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per +# host; the Python retry strategy sets only the reconnect count and the wait, so +# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is +# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries +# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication +# failover, and a comma-separated host list in the host property +# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the +# comma-separated form) so the API fails over between brokers. +# terminate(grace_period): how long to wait for the handler to drain the messages the API +# has already received before the receiver stops (the API default is 600000 ms); +# terminate() raises IncompleteMessageDeliveryError when messages remain after that +TERMINATE_GRACE_PERIOD_MS = 10_000 + +# API events go through standard logging; the trace() narration below is a separate channel +logger = logging.getLogger(APP_NAME) + + +class DirectSubscriber: + """The app: the service, the receiver, the flags the listeners flip, and the counters. + Use this type of app for receiving Direct (at-most-once) messages from a topic.""" + + def __init__(self) -> None: + self.messaging_service: Optional[MessagingService] = None + self.receiver: Optional[DirectMessageReceiver] = None + self.connect_attempted = False + self.shutdown = threading.Event() + self.msg_recv_counter = 0 # num messages received + self.has_detected_discard = False # any discards seen? + + def main(self, args: list[str]) -> None: + trace(f"{API} {APP_NAME} initializing...") + try: + self.setup_solace(args) + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main + # loop exits, and teardown_solace() in the finally below stops the receiver and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. + signal.signal(signal.SIGINT, self.on_shutdown_signal) + signal.signal(signal.SIGTERM, self.on_shutdown_signal) + self.await_messages() + finally: + self.teardown_solace() + trace("Main thread quitting.") + + def on_shutdown_signal(self, signum: int, _frame) -> None: + trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping subscriber...") + self.shutdown.set() + + def setup_solace(self, args: list[str]) -> None: + # basic username/password connection details, built by the shared SolaceConnectionConfig + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + self.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(self) + self.messaging_service.add_reconnection_attempt_listener(service_events) + self.messaging_service.add_reconnection_listener(service_events) + self.messaging_service.add_service_interruption_listener(service_events) + self.connect_attempted = True + self.messaging_service.connect() # blocking connect + + # DIRECT receiver: a topic subscription on the receiver (no queue, no provisioning). + # Direct messaging is at-most-once: messages flow straight to the subscriber with no + # broker ACK and no redelivery. start() applies the subscription and blocks until the + # broker confirms it, so the route is live before this subscriber reports ready. The + # receiver keeps an unbounded internal buffer by default (on_back_pressure_elastic); + # on_back_pressure_drop_latest(n) or on_back_pressure_drop_oldest(n) bound it, and a + # dropped message shows up as an internal discard indication on the next one delivered. + trace(f"Adding direct topic subscription '{TOPIC_NAME}'.") + self.receiver = ( + self.messaging_service.create_direct_message_receiver_builder() + .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) + .build() + ) + self.receiver.start() + # see bottom of file for DirectMessageHandler, which receives the messages from the topic + self.receiver.receive_async(DirectMessageHandler(self)) + trace(f"{APP_NAME} subscribed and consuming. Press Ctrl-C to quit.") + + def await_messages(self) -> None: + # async direct receive working now, so time to wait until done... + while not self.shutdown.wait(1.0): # wait 1 second; the wait returns True once shutdown is requested + trace(f"{API} {APP_NAME} Received msgs/s: {self.msg_recv_counter:,}") # simple way of calculating message rates + self.msg_recv_counter = 0 + if self.has_detected_discard: # at least one direct message was dropped before this subscriber + trace("*** Discard detected (at-most-once: a direct message was dropped) ***") + self.has_detected_discard = False # only show the warning once per second + + def teardown_solace(self) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an + # exception), so Solace teardown and application-side cleanup are never skipped. + self.shutdown.set() + if self.receiver is not None and self.receiver.is_running(): + # direct is at-most-once: there are no acknowledgements to drain before exit, so + # terminate() stops delivery and gives the handler up to the grace period to drain + # the messages the API has already received. It raises IncompleteMessageDeliveryError + # when messages remain after that; catch it so the disconnect below still runs + try: + self.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) + except PubSubPlusClientError as error: + logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + if self.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + self.messaging_service.disconnect() # will also release the receiver + + +def trace(message: str) -> None: + """Demo narration sink: every status line in this sample funnels through this one + function. An application replaces this single body to route narration to its + logger or reporting system. The logger calls for API events (service) are a + separate channel and stay as they are.""" + print(message, flush=True) + + +############################################################################ + + +class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, ServiceInterruptionListener): + """The three service event listeners in one class. Callbacks run on an API thread: + return promptly and do not block in them, since events and delivery stall while a + callback runs (C API Best Practices).""" + + def __init__(self, app: DirectSubscriber) -> None: + self.app = app + + def on_reconnecting(self, event: ServiceEvent) -> None: + # connection lost, automatic reconnect attempt in progress: direct delivery is paused, + # and the broker does not store direct messages for this subscriber while it is + # disconnected (at-most-once) + logger.warning("Service reconnecting; direct delivery is paused until re-established: %s (%s)", + event.get_message(), event.get_cause()) + + def on_reconnected(self, event: ServiceEvent) -> None: + # automatic reconnect succeeded; the API re-applies the topic subscription and delivery + # resumes (unlike JCSMP, no reapply-subscriptions property is needed) + logger.info("Service reconnected to %s; the subscription is restored and delivery resumes", + event.get_broker_uri()) + + def on_service_interrupted(self, event: ServiceEvent) -> None: + # the connection went down and cannot be restored (reconnect attempts exhausted) + logger.error("Service interrupted and cannot be restored, quitting: %s (%s)", + event.get_message(), event.get_cause()) + # Application cleanup signal: the service will not recover. Trigger application-side + # cleanup from here. This sample sets shutdown, so the main loop exits and + # teardown_solace() runs in main's finally. + self.app.shutdown.set() + + +class DirectMessageHandler(MessageHandler): + """Very simple class, used for receiving direct messages from the topic. on_message runs + on an API-owned receiver thread, not the main thread: return promptly and do not block in + it (hand heavy work to the application's own thread or queue), since delivery stalls while + it runs (C API Best Practices).""" + + def __init__(self, app: DirectSubscriber) -> None: + self.app = app + + def on_message(self, message: InboundMessage) -> None: + self.app.msg_recv_counter += 1 + # the publisher sample sends a bytearray payload (the binary attachment), read back with + # get_payload_as_bytes(); a str payload arrives via get_payload_as_string() instead + # best practice: handle an unexpected message format without raising (C API Best + # Practices); errors="replace" keeps a non-UTF-8 payload from raising in the callback + payload = message.get_payload_as_bytes() + text = payload.decode("utf-8", errors="replace") if payload is not None else message.get_payload_as_string() + trace(f"{API} {APP_NAME} received: {text}") + # at-most-once discard evidence (API reference): the broker sets the broker discard + # indication when it discarded direct messages destined for this subscriber for any + # reason, and the API sets the internal one when its own receiver buffer dropped + # messages. There is NO redelivery in direct messaging. + discard = message.get_message_discard_notification() + if discard.has_broker_discard_indication() or discard.has_internal_discard_indication(): + self.app.has_detected_discard = True + # Direct messaging is at-most-once: there is no consumer-side acknowledgement here (the + # inverse of the guaranteed CLIENT-ack subscriber). The message is consumed as it + # arrives; the broker holds no copy and expects no ack. + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + DirectSubscriber().main(sys.argv[1:]) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py new file mode 100644 index 0000000..3644570 --- /dev/null +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -0,0 +1,331 @@ +""" +Derived from patterns/guaranteed_publisher.py + SolaceSamples/solace-samples-python + https://github.com/SolaceSamples/solace-samples-python + +Elevated to documented best practices per the Python API developer guide: + Messaging Service: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Messaging-Service.md + Publishing Persistent Messages: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-PM-Publish.md + the Python API reference (units, defaults, listener contracts): + https://docs.solace.com/API-Developer-Online-Ref-Documentation/python/index.html + and the C API Best Practices (the Python API wraps the C API, so its callback, + reconnect, acknowledgement, and time-to-live practices apply): + https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md + +Reference sample. Wired for a basic publish-subscribe journey: basic-auth connect +with a reconnection retry strategy, PERSISTENT binary publish to a topic, publish +receipt (ACK/NACK) handling correlated by user context, capacity-bounded back +pressure paired with a publisher readiness listener, reconnection and service +interruption listeners registered BEFORE connect() that pause publishing while the +transport is down, and a graceful SIGINT/SIGTERM shutdown that drains outstanding +receipts before disconnecting. Structured as setup_solace(...), +run_publish_loop(), and teardown_solace(), with teardown run from main's finally +on every exit path. + +Only practices documented in canonical Solace sources are encoded here. + +Copied into a generated project as guaranteed_publisher.py, next to the shared +helper python-solace-connection-config.py copied as solace_connection_config.py. + +Any generated adaptation of this sample MUST begin with the exact line: + AI-assisted code. Review before production use. +(This reference sample itself carries no such header by design.) +""" + +# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], +# dict[str, object]) also import on Python 3.7 and 3.8 +from __future__ import annotations + +import logging +import signal +import sys +import threading +import uuid +from typing import Optional + +from solace.messaging.config.retry_strategy import RetryStrategy +from solace.messaging.errors.pubsubplus_client_error import PublisherOverflowError, PubSubPlusClientError +from solace.messaging.messaging_service import ( + MessagingService, + ReconnectionAttemptListener, + ReconnectionListener, + ServiceEvent, + ServiceInterruptionListener, +) +from solace.messaging.publisher.outbound_message import OutboundMessageBuilder +from solace.messaging.publisher.persistent_message_publisher import ( + MessagePublishReceiptListener, + PersistentMessagePublisher, + PublishReceipt, +) +from solace.messaging.publisher.publisher_health_check import PublisherReadinessListener +from solace.messaging.resources.topic import Topic + +from solace_connection_config import SolaceConnectionConfig + +APP_NAME = "GuaranteedPublisher" +TOPIC_PREFIX = "solace/samples/" # used as the topic "root" +API = "Python" +APPROX_MSG_RATE_PER_SEC = 100 +PAYLOAD_SIZE = 512 +# reconnect budget: 20 attempts 3 s apart, a minute of reconnect attempts, a reasonable +# default when the design says nothing about high availability. The interval is in +# MILLISECONDS per the RetryStrategy API reference. +RECONNECT_RETRIES = 20 +RECONNECT_RETRY_INTERVAL_MS = 3000 +# For HA or replication failover resilience, follow the C API Best Practices: the +# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example +# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per +# host; the Python retry strategy sets only the reconnect count and the wait, so +# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is +# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries +# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication +# failover, and a comma-separated host list in the host property +# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the +# comma-separated form) so the API fails over between brokers. +# back pressure: the publisher buffers at most this many unsent messages before +# publish() raises PublisherOverflowError (the documented reject strategy) +PUBLISH_BUFFER_CAPACITY = 1000 +# terminate(grace_period): how long to wait for buffered sends and their broker receipts +# to complete before the publisher stops (the API default is 600000 ms); terminate() raises +# IncompleteMessageDeliveryError when sends or receipts are still outstanding after that +TERMINATE_GRACE_PERIOD_MS = 10_000 + +# API events go through standard logging; the trace() narration below is a separate channel +logger = logging.getLogger(APP_NAME) + + +class GuaranteedPublisher: + """The app: the service, the publisher, the flags the listeners flip, and the counters.""" + + def __init__(self) -> None: + self.messaging_service: Optional[MessagingService] = None + self.publisher: Optional[PersistentMessagePublisher] = None + self.message_builder: Optional[OutboundMessageBuilder] = None + self.connect_attempted = False + self.is_connected = True # tracks transport state via the reconnection listeners + self.shutdown = threading.Event() + self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room + self.msg_sent_counter = 0 # num messages sent + + def main(self, args: list[str]) -> None: + trace(f"{API} {APP_NAME} initializing...") + try: + self.setup_solace(args) + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish + # loop exits, and teardown_solace() in the finally below drains the outstanding + # receipts and disconnects. Python runs signal handlers on the main thread, so the + # handler only flags; the cleanup runs on the main thread's normal exit path. + signal.signal(signal.SIGINT, self.on_shutdown_signal) + signal.signal(signal.SIGTERM, self.on_shutdown_signal) + self.run_publish_loop() + finally: + self.teardown_solace() + trace("Main thread quitting.") + + def on_shutdown_signal(self, signum: int, _frame) -> None: + trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") + self.shutdown.set() + + def setup_solace(self, args: list[str]) -> None: + # basic username/password connection details, built by the shared SolaceConnectionConfig + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + self.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(self) + self.messaging_service.add_reconnection_attempt_listener(service_events) + self.messaging_service.add_reconnection_listener(service_events) + self.messaging_service.add_service_interruption_listener(service_events) + self.connect_attempted = True + self.messaging_service.connect() # blocking connect + + # PERSISTENT publisher with capacity-bounded back pressure: publish() raises + # PublisherOverflowError once PUBLISH_BUFFER_CAPACITY messages wait unsent, and the + # readiness listener below reports when there is room again. The API default + # (on_back_pressure_elastic) buffers without bound and the developer guide cautions it + # can exhaust memory; on_back_pressure_wait(n) blocks publish() instead of raising. + self.publisher = ( + self.messaging_service.create_persistent_message_publisher_builder() + .on_back_pressure_reject(PUBLISH_BUFFER_CAPACITY) + .build() + ) + self.publisher.start() + # best practice: check every publish receipt; a receipt that carries an exception is a + # NACK or a failure to persist (see PublishReceiptHandler at the bottom of the file). + # The listener is set AFTER start(): the API raises IllegalStateError on a publisher + # that is not started yet. + self.publisher.set_message_publish_receipt_listener(PublishReceiptHandler()) + # documented pairing with on_back_pressure_reject: ready() fires when the buffer has + # room again, so the loop waits on the event instead of spinning on overflow + self.publisher.set_publisher_readiness_listener(PublisherReadinessHandler(self.publisher_ready)) + # best practice: one OutboundMessageBuilder, reused for every message + self.message_builder = self.messaging_service.message_builder() + # best practice for Guaranteed publishing (C API Best Practices): set a time-to-live so + # an unconsumed message does not sit on a queue forever. The queue must have respect-ttl + # enabled (off by default), or the TTL is ignored. Add the Solace message property to the + # builder in milliseconds (0, the default, never expires), for example + # .with_property(message_properties.PERSISTENT_TIME_TO_LIVE, 60_000) + # with `from solace.messaging.config.solace_properties import message_properties`. + + def run_publish_loop(self) -> None: + threading.Thread(target=self.print_stats, name="stats", daemon=True).start() + + trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") + trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/pers/pub/...', " + "please ensure queue has matching subscription.") + while not self.shutdown.is_set(): + if not self.is_connected: # transport is down: wait for the API's automatic reconnect + self.shutdown.wait(0.1) + continue + # each loop, change the payload as an example + chosen_character = chr(self.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] + # a bytearray payload travels as the binary attachment; consumers read it back with + # get_payload_as_bytes() (a str payload would arrive via get_payload_as_string()) + payload = bytearray(chosen_character.encode("ascii") * PAYLOAD_SIZE) + msg_id = str(uuid.uuid4()) + message = self.message_builder.with_application_message_id(msg_id).build(payload) # as an example + # NOTE: publishing to topic, so make sure the consumer's queue is subscribed to the same topic, + # or enable "Reject Message to Sender on No Subscription Match" in the client-profile + topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/pers/pub/{chosen_character}") + try: + # user_context for local ACK/NACK correlation: the API hands it back in the + # PublishReceipt, so an immutable per-send identifier (the message id) lets the + # receipt handler name the exact message the broker accepted or rejected + self.publisher.publish(message, topic, user_context=msg_id) + self.msg_sent_counter += 1 + except PublisherOverflowError: + # buffer full (back pressure): wait for the ready() callback the API sends when the + # buffer has room again, with a bound so a shutdown request is still honoured, and + # retry. is_ready() closes the race with a ready() that fired before clear(). + # notify_when_ready() is not called here: the API sends ready() at once, whatever + # the buffer state, so the wait would return immediately and this loop would spin. + self.publisher_ready.clear() + if not self.publisher.is_ready(): + self.publisher_ready.wait(timeout=1.0) + continue + except PubSubPlusClientError as error: + # publish() raises when the message cannot be sent and retrying would not help + logger.warning("publish() failed, quitting: %s", error) + self.shutdown.set() # let's quit; or, could initiate a new connection attempt + break + # delay between messages; the wait returns early once shutdown is requested + self.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed + # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress + self.shutdown.set() + + def print_stats(self) -> None: + # simple way of calculating message rates; wait() returns True (ending the loop) on shutdown + while not self.shutdown.wait(1.0): + trace(f"{API} {APP_NAME} Published msgs/s: {self.msg_sent_counter:,}") + self.msg_sent_counter = 0 + + def teardown_solace(self) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an + # exception), so Solace teardown and application-side cleanup are never skipped. + self.shutdown.set() + if self.publisher is not None and self.publisher.is_running(): + # gracefully finish outstanding receipts before exit: terminate(grace_period) first + # sends what is still buffered, then waits up to the grace period for the broker's + # receipt of every published message, so no in-flight PERSISTENT message is + # abandoned unacknowledged. This is the explicit-condition shutdown; no fixed sleep. + # It raises IncompleteMessageDeliveryError when sends or receipts are still + # outstanding after the grace period; catch it so the disconnect below still runs + try: + self.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) + except PubSubPlusClientError as error: + logger.error("Publisher stopped with sends or receipts still outstanding after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + if self.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + self.messaging_service.disconnect() + + +def trace(message: str) -> None: + """Demo narration sink: every status line in this sample funnels through this one + function. An application replaces this single body to route narration to its + logger or reporting system. The logger calls for API events (service, receipt, + readiness) are a separate channel and stay as they are.""" + print(message, flush=True) + + +############################################################################ + + +class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, ServiceInterruptionListener): + """The three service event listeners in one class. Callbacks run on an API thread: + return promptly and do not block in them, since events and delivery stall while a + callback runs (C API Best Practices).""" + + def __init__(self, app: GuaranteedPublisher) -> None: + self.app = app + + def on_reconnecting(self, event: ServiceEvent) -> None: + # connection lost, automatic reconnect attempt in progress: pause publishing + logger.warning("Service reconnecting, pausing publishing: %s (%s)", event.get_message(), event.get_cause()) + self.app.is_connected = False + + def on_reconnected(self, event: ServiceEvent) -> None: + # automatic reconnect succeeded: resume publishing + logger.info("Service reconnected to %s, resuming publishing", event.get_broker_uri()) + self.app.is_connected = True + + def on_service_interrupted(self, event: ServiceEvent) -> None: + # the connection went down and cannot be restored (reconnect attempts exhausted) + logger.error("Service interrupted and cannot be restored, quitting: %s (%s)", + event.get_message(), event.get_cause()) + # Application cleanup signal: the service will not recover. Trigger application-side + # cleanup from here. This sample sets shutdown, so the publish loop exits and + # teardown_solace() runs in main's finally. + self.app.shutdown.set() + + +class PublisherReadinessHandler(PublisherReadinessListener): + """Flags the publish loop when the publisher can publish again. Runs on an API thread: + return promptly and do not block in it (C API Best Practices).""" + + def __init__(self, publisher_ready: threading.Event) -> None: + self.publisher_ready = publisher_ready + + def ready(self) -> None: + self.publisher_ready.set() # the buffer has room: the publish loop resumes + + +class PublishReceiptHandler(MessagePublishReceiptListener): + """Very simple class, used for handling publish receipts (ACKs/NACKs) from the broker. + Runs on an API thread: + return promptly and do not block in it (C API Best Practices).""" + + def on_publish_receipt(self, publish_receipt: PublishReceipt) -> None: + msg_id = publish_receipt.user_context # the message id passed to publish() + if publish_receipt.exception is None: # ACK + logger.debug("ACK for Message ID %s", msg_id) # good enough, the broker has it now + return + # NACK: the exception says why (for example MessageRejectedByBrokerError when no queue + # subscribes to the topic and the client-profile rejects on no subscription match) + logger.warning("NACK for Message ID %s - %s", msg_id, publish_receipt.exception) + # probably want to do something here. some error handling possibilities: + # - look the message up by this ID in your application's outbound store and send it again + # - send it somewhere else (error handling queue?) + # - log and continue + # - pause and retry (backoff) - maybe set a flag to slow down the publisher + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + GuaranteedPublisher().main(sys.argv[1:]) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py new file mode 100644 index 0000000..d0e43b2 --- /dev/null +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -0,0 +1,340 @@ +""" +Derived from patterns/guaranteed_receiver.py + SolaceSamples/solace-samples-python + https://github.com/SolaceSamples/solace-samples-python + +Elevated to documented best practices per the Python API developer guide: + Messaging Service: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Messaging-Service.md + Consuming Persistent Messages: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-PM-Receive.md + Creating Queues with the Solace Python API: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Create-Queues.md + the Python API reference (units, defaults, listener contracts): + https://docs.solace.com/API-Developer-Online-Ref-Documentation/python/index.html + and the C API Best Practices (the Python API wraps the C API, so its callback, + reconnect, acknowledgement, and time-to-live practices apply): + https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md + +Reference sample. Wired for a basic publish-subscribe journey: basic-auth connect +with a reconnection retry strategy, in-process provisioning of a durable exclusive +queue plus a topic subscription so a fresh broker works out of the box, a +CLIENT-ack receiver that ACKs only after processing, receiver active/passive state +and termination handling, reconnection and service interruption listeners +registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown. Structured as +setup_solace(...), await_messages(), and teardown_solace(), with teardown run +from main's finally on every exit path. + +Only practices documented in canonical Solace sources are encoded here. + +Copied into a generated project as guaranteed_subscriber.py, next to the shared +helper python-solace-connection-config.py copied as solace_connection_config.py. + +Any generated adaptation of this sample MUST begin with the exact line: + AI-assisted code. Review before production use. +(This reference sample itself carries no such header by design.) +""" + +# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], +# dict[str, object]) also import on Python 3.7 and 3.8 +from __future__ import annotations + +import logging +import signal +import sys +import threading +from typing import Optional + +from solace.messaging.config.missing_resources_creation_configuration import MissingResourcesCreationStrategy +from solace.messaging.config.receiver_activation_passivation_configuration import ( + ReceiverState, + ReceiverStateChangeListener, +) +from solace.messaging.config.retry_strategy import RetryStrategy +from solace.messaging.errors.pubsubplus_client_error import PubSubPlusClientError +from solace.messaging.messaging_service import ( + MessagingService, + ReconnectionAttemptListener, + ReconnectionListener, + ServiceEvent, + ServiceInterruptionListener, +) +from solace.messaging.receiver.inbound_message import InboundMessage +from solace.messaging.receiver.message_receiver import MessageHandler +from solace.messaging.receiver.persistent_message_receiver import PersistentMessageReceiver +from solace.messaging.resources.queue import Queue +from solace.messaging.resources.topic_subscription import TopicSubscription +from solace.messaging.utils.life_cycle_control import TerminationEvent, TerminationNotificationListener + +from solace_connection_config import SolaceConnectionConfig + +APP_NAME = "GuaranteedSubscriber" +QUEUE_NAME = "q_python_sub" +# topic to map onto the queue; matches the publisher sample's topic root +TOPIC_NAME = "solace/samples/python/pers/pub/>" +API = "Python" +# reconnect budget: 20 attempts 3 s apart, a minute of reconnect attempts, a reasonable +# default when the design says nothing about high availability. The interval is in +# MILLISECONDS per the RetryStrategy API reference. +RECONNECT_RETRIES = 20 +RECONNECT_RETRY_INTERVAL_MS = 3000 +# For HA or replication failover resilience, follow the C API Best Practices: the +# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example +# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per +# host; the Python retry strategy sets only the reconnect count and the wait, so +# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is +# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries +# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication +# failover, and a comma-separated host list in the host property +# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the +# comma-separated form) so the API fails over between brokers. +# terminate(grace_period): how long to wait for the handler to drain the messages the API +# has already received before the receiver stops (the API default is 600000 ms); +# terminate() raises IncompleteMessageDeliveryError when messages remain after that +TERMINATE_GRACE_PERIOD_MS = 10_000 + +# API events go through standard logging; the trace() narration below is a separate channel +logger = logging.getLogger(APP_NAME) + + +class GuaranteedSubscriber: + """The app: the service, the receiver, the flags the listeners flip, and the counters. + Use this type of app for receiving Guaranteed messages (e.g. via a queue endpoint).""" + + def __init__(self) -> None: + self.messaging_service: Optional[MessagingService] = None + self.receiver: Optional[PersistentMessageReceiver] = None + self.connect_attempted = False + self.shutdown = threading.Event() + self.msg_recv_counter = 0 # num messages received + self.has_detected_redelivery = False # detected any messages being redelivered? + + def main(self, args: list[str]) -> None: + trace(f"{API} {APP_NAME} initializing...") + try: + if not self.setup_solace(args): + return + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main + # loop exits, and teardown_solace() in the finally below stops the receiver (letting + # the handler finish and ACK the message in hand) and disconnects. Python runs + # signal handlers on the main thread, so the handler only flags; the cleanup runs + # on the main thread's normal exit path. + signal.signal(signal.SIGINT, self.on_shutdown_signal) + signal.signal(signal.SIGTERM, self.on_shutdown_signal) + self.await_messages() + finally: + self.teardown_solace() + trace("Main thread quitting.") + + def on_shutdown_signal(self, signum: int, _frame) -> None: + trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping consumer...") + self.shutdown.set() + + def setup_solace(self, args: list[str]) -> bool: + # basic username/password connection details, built by the shared SolaceConnectionConfig + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + self.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(self) + self.messaging_service.add_reconnection_attempt_listener(service_events) + self.messaging_service.add_reconnection_listener(service_events) + self.messaging_service.add_service_interruption_listener(service_events) + self.connect_attempted = True + self.messaging_service.connect() # blocking connect + + # configure the queue API object locally: durable, exclusive (single-consumer sample) + queue = Queue.durable_exclusive_queue(QUEUE_NAME) + + # ELEVATION: provision the durable queue in-process and map the topic onto it at + # start, so a fresh broker works out of the box. CREATE_ON_START creates the queue + # passed to build() when start() runs, as long as the client has permission; the + # default, DO_NOT_CREATE, disables provisioning so an administrator provisions the + # queue instead. with_subscriptions() on a durable queue adds the topic subscription + # to the queue at start (pub/sub onto a queue). A re-run reuses the existing queue + # and subscription. + receiver_builder = ( + self.messaging_service.create_persistent_message_receiver_builder() + .with_missing_resources_creation_strategy(MissingResourcesCreationStrategy.CREATE_ON_START) + .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) + # best practice: client acknowledgement (also the API default), so a message leaves + # the queue only after the handler has processed it and called ack() + .with_message_client_acknowledgement() + # request active/passive state changes: on an exclusive queue only one bound receiver + # is ACTIVE; any other is PASSIVE (bound, not receiving) until the active one goes away + .with_activation_passivation_support(ReceiverStateHandler()) + ) + trace(f"Attempting to bind to queue '{QUEUE_NAME}' on the broker.") + self.receiver = receiver_builder.build(queue) + # a broker-initiated termination (for example the queue was deleted or shut down) + # surfaces here; the handler ends the main loop (see ReceiverTerminationHandler below) + self.receiver.set_termination_notification_listener(ReceiverTerminationHandler(self)) + try: + # start() provisions the queue plus its subscription (CREATE_ON_START) and binds + # to the queue; the broker then starts sending messages on this receiver + self.receiver.start() + except PubSubPlusClientError as error: + logger.error("Could not bind to queue '%s': %s. If this client may not create endpoints, " + "provision the queue and its topic subscription out-of-band or grant the " + "capability. Exiting.", QUEUE_NAME, error) + return False # teardown_solace() in main's finally disconnects + # see bottom of file for QueueMessageHandler, which receives the messages from the queue + self.receiver.receive_async(QueueMessageHandler(self, self.receiver)) + return True + + def await_messages(self) -> None: + # async queue receive working now, so time to wait until done... + trace(f"{APP_NAME} connected, and running. Press Ctrl-C to quit.") + while not self.shutdown.wait(1.0): # wait 1 second; the wait returns True once shutdown is requested + trace(f"{API} {APP_NAME} Received msgs/s: {self.msg_recv_counter:,}") # simple way of calculating message rates + self.msg_recv_counter = 0 + if self.has_detected_redelivery: # try shutting -> enabling the queue on the broker to see this + trace("*** Redelivery detected ***") + self.has_detected_redelivery = False # only show the error once per second + + def teardown_solace(self) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, receiver + # termination, or an exception), so Solace teardown and application-side cleanup + # are never skipped. + self.shutdown.set() + if self.receiver is not None and self.receiver.is_running(): + # gracefully stop delivery before exit: terminate(grace_period) stops the receiver + # and gives the handler up to the grace period to drain (and ACK) the messages the + # API has already received. A message received but not yet ACKed stays on the queue + # and is redelivered. terminate() raises IncompleteMessageDeliveryError when messages + # remain after the grace period; catch it so the disconnect below still runs + try: + self.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) + except PubSubPlusClientError as error: + logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + if self.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + self.messaging_service.disconnect() # will also release the receiver + + +def trace(message: str) -> None: + """Demo narration sink: every status line in this sample funnels through this one + function. An application replaces this single body to route narration to its + logger or reporting system. The logger calls for API events (service, receiver + state, termination) are a separate channel and stay as they are.""" + print(message, flush=True) + + +############################################################################ + + +class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, ServiceInterruptionListener): + """The three service event listeners in one class. Callbacks run on an API thread: + return promptly and do not block in them, since events and delivery stall while a + callback runs (C API Best Practices).""" + + def __init__(self, app: GuaranteedSubscriber) -> None: + self.app = app + + def on_reconnecting(self, event: ServiceEvent) -> None: + # connection lost, automatic reconnect attempt in progress + logger.warning("Service reconnecting; message delivery is paused until re-established: %s (%s)", + event.get_message(), event.get_cause()) + + def on_reconnected(self, event: ServiceEvent) -> None: + # automatic reconnect succeeded; the receiver rebinds to the queue and delivery resumes + logger.info("Service reconnected to %s; the receiver rebinds and delivery resumes", event.get_broker_uri()) + + def on_service_interrupted(self, event: ServiceEvent) -> None: + # the connection went down and cannot be restored (reconnect attempts exhausted) + logger.error("Service interrupted and cannot be restored, quitting: %s (%s)", + event.get_message(), event.get_cause()) + # Application cleanup signal: the service will not recover. Trigger application-side + # cleanup from here. This sample sets shutdown, so the main loop exits and + # teardown_solace() runs in main's finally. + self.app.shutdown.set() + + +class ReceiverStateHandler(ReceiverStateChangeListener): + """Reports whether this receiver is the one the broker delivers to. Runs on an API thread: + return promptly and do not block in it (C API Best Practices).""" + + def on_change(self, old_state: ReceiverState, new_state: ReceiverState, change_time_stamp: float) -> None: + if new_state == ReceiverState.ACTIVE: # bound and actively receiving from the queue + logger.info("Receiver active: consuming from queue %s", QUEUE_NAME) + else: # PASSIVE: bound but not receiving (another consumer is active on this exclusive queue) + logger.warning("Receiver passive: bound but not receiving (another consumer is active on " + "exclusive queue %s)", QUEUE_NAME) + + +class ReceiverTerminationHandler(TerminationNotificationListener): + """Handles a broker-initiated termination of the receiver. Runs on an API thread: + return promptly and do not block in it (C API Best Practices).""" + + def __init__(self, app: GuaranteedSubscriber) -> None: + self.app = app + + def on_termination(self, event: TerminationEvent) -> None: + # the receiver was terminated by the broker: delivery from the queue has stopped + logger.error("Receiver terminated, delivery from queue %s has stopped: %s (%s)", + QUEUE_NAME, event.message, event.cause) + # Application cleanup signal: decide here whether to recreate the receiver or shut + # down. This sample shuts down: the main loop exits and teardown_solace() runs in + # main's finally. + self.app.shutdown.set() + + +class QueueMessageHandler(MessageHandler): + """Very simple class, used for receiving messages from the queue. on_message runs on an + API-owned receiver thread, not the main thread: return promptly and do not block in it + (hand heavy work to the application's own thread or queue), since delivery from the queue + stalls while it runs (C API Best Practices).""" + + def __init__(self, app: GuaranteedSubscriber, receiver: PersistentMessageReceiver) -> None: + self.app = app + self.receiver = receiver + + def on_message(self, message: InboundMessage) -> None: + self.app.msg_recv_counter += 1 + try: + # the publisher sample sends a bytearray payload (the binary attachment), read back with + # get_payload_as_bytes(); a str payload arrives via get_payload_as_string() instead. + # errors="replace" keeps a non-UTF-8 payload from raising, but the payload read or the + # trace can still raise, hence the try/finally around the processing + payload = message.get_payload_as_bytes() + text = payload.decode("utf-8", errors="replace") if payload is not None else message.get_payload_as_string() + trace(f"{API} {APP_NAME} received: {text}") + if message.is_redelivered(): # useful check + # this is the broker telling the consumer that this message has been sent and not ACKed before. + # this can happen if an exception is thrown, or the broker restarts, or the network disconnects + # perhaps an error in processing? Should do extra checks to avoid duplicate processing + self.app.has_detected_redelivery = True + except Exception: + # best practice: handle an unexpected message format without crashing, log it, and + # still ACK below (C API Best Practices). Without this, the API swallows the exception + # with one warning and the message stays unacknowledged on the flow. + logger.exception("Processing failed for message id %s; acknowledging anyway", + message.get_application_message_id()) + finally: + # Messages are removed from the broker queue when the ACK is received. + # ACK only after processing is complete: DO NOT ACK until all processing/storing + # of this message is done. ACK even an unexpectedly formatted message (C API Best + # Practices): a message left unacknowledged stays on the queue and is redelivered. To + # NACK explicitly instead, build the receiver with + # with_required_message_outcome_support(Outcome.FAILED, Outcome.REJECTED) and call + # receiver.settle(message, outcome). + self.receiver.ack(message) # ACKs are asynchronous + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + GuaranteedSubscriber().main(sys.argv[1:]) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py new file mode 100644 index 0000000..a141bbc --- /dev/null +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py @@ -0,0 +1,155 @@ +""" +Shared connection-config helper for the Python reference samples. + +Any generated adaptation of this sample MUST begin with the exact line: + AI-assisted code. Review before production use. +(This reference sample itself carries no such header by design.) + +Copied into a generated project as solace_connection_config.py (a Python module +name needs underscores, not hyphens); every sample imports it with + from solace_connection_config import SolaceConnectionConfig + +Loads connection properties and turns them into the properties dict that +MessagingService.builder().from_properties(...) takes. Source precedence: + 1. a config.json in the working directory (the project root), when present. + Its keys are Solace Python API property-name strings, the VALUES of the + constants in solace.messaging.config.solace_properties (for example the + transport_layer_properties.HOST constant's value is + "solace.messaging.transport.host"): + "solace.messaging.transport.host" + "solace.messaging.service.vpn-name" + "solace.messaging.authentication.basic.username" + "solace.messaging.authentication.basic.password" + EVERY key in the file is passed through to from_properties() unchanged, so + further service properties (for example + "solace.messaging.transport.keep-alive-interval") work with no parser + change. A builder call made after from_properties() overrides the same key: + the samples set the reconnection strategy that way, so the + reconnection-attempts and reconnection-attempts-wait-interval keys from the + file are replaced by each sample's constants. Pass-through limitations: the + API passes every value to the native session as a string, and the native + library rejects an invalid value at build() or connect(); an unknown key is + accepted and silently ignored by the API. config.json holds broker + credentials, so it MUST be gitignored and never committed. + 2. otherwise the command-line arguments: + [password] + +The password is optional in both sources. The API requires the basic-auth password +key at build(), so an absent or blank password is passed as an empty string. + +The config.json reader is the standard-library json module, so the samples keep +their single Solace dependency (solace-pubsubplus). config.json must hold a single +flat JSON object. + +Grounding: the "load a JSON file, then from_properties()" idiom and the required +host and vpn-name keys are on the Messaging Service page of the Python API +developer guide: + https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Messaging-Service.md +""" + +# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], +# dict[str, object]) also import on Python 3.7 and 3.8 +from __future__ import annotations + +import json +import os +import sys +from typing import Optional + +from solace.messaging.config.solace_properties import ( + authentication_properties, + service_properties, + transport_layer_properties, +) + +CONFIG_FILE = "config.json" + +# the three connection values every sample needs; the password is optional +REQUIRED_KEYS = ( + transport_layer_properties.HOST, + service_properties.VPN_NAME, + authentication_properties.SCHEME_BASIC_USER_NAME, +) + + +class SolaceConnectionConfig: + """Connection details for one sample, loaded from config.json or the command line.""" + + def __init__(self, properties: dict[str, object]) -> None: + for key in REQUIRED_KEYS: + _require_non_blank(properties.get(key), key) + # password is optional: absent or blank means an empty password is sent + self._properties = properties + + @staticmethod + def load(args: list[str], app_name: str) -> "SolaceConnectionConfig": + """ + Load connection details from config.json when present in the working directory, + otherwise from the command-line arguments, and log which source was used. Prints a + usage line and exits when neither source supplies host, message-vpn, and + client-username; rejects a blank host, message-vpn, or client-username from either + source. + + Args: + args: the app's command-line arguments without the script name (the fallback source) + app_name: the application name, used only in the usage message + """ + from_file = _try_load_config_file() + if from_file is not None: + host = from_file._properties[transport_layer_properties.HOST] + trace(f"{app_name}: using connection details from {CONFIG_FILE} (host={host}).") + return from_file + if len(args) < 3: + trace(f"No {CONFIG_FILE} in the working directory; provide connection details on the command line.") + trace(f"Usage: {app_name} [password]\n") + sys.exit(1) + from_args: dict[str, object] = { + transport_layer_properties.HOST: args[0], + service_properties.VPN_NAME: args[1], + authentication_properties.SCHEME_BASIC_USER_NAME: args[2], + } + if len(args) > 3: + from_args[authentication_properties.SCHEME_BASIC_PASSWORD] = args[3] + config = SolaceConnectionConfig(from_args) + trace(f"{app_name}: using connection details from command-line arguments (host={args[0]}).") + return config + + def to_service_properties(self) -> dict[str, object]: + """ + Build the from_properties() dict from every loaded property (generic pass-through). + The basic-auth password key is always present: the API requires it at build(), so + an absent or blank password is passed as an empty string. + """ + properties: dict[str, object] = dict(self._properties) + if not properties.get(authentication_properties.SCHEME_BASIC_PASSWORD): + properties[authentication_properties.SCHEME_BASIC_PASSWORD] = "" + return properties + + +def _try_load_config_file() -> Optional[SolaceConnectionConfig]: + if not os.path.exists(CONFIG_FILE): + return None # no config file: fall back to the command-line arguments + # present but unreadable (for example a permission-locked credentials file): open() + # raises PermissionError, so this fails loud rather than silently falling back to + # different command-line connection args. A malformed file raises JSONDecodeError. + with open(CONFIG_FILE, encoding="utf-8") as config_file: + parsed = json.load(config_file) + if not isinstance(parsed, dict): + raise ValueError(f"{CONFIG_FILE} must hold a single JSON object") + for required in REQUIRED_KEYS: + if required not in parsed: + raise ValueError(f'{CONFIG_FILE} is missing required key "{required}"') + return SolaceConnectionConfig(parsed) + + +def _require_non_blank(value: object, key: str) -> None: + """Reject a missing, non-string, or blank required connection value, whatever its source.""" + if not isinstance(value, str) or not value.strip(): + raise ValueError(f'connection value for "{key}" must be a non-blank string') + + +def trace(message: str) -> None: + """Demo narration sink: every status line in this helper funnels through this one + function. An application replaces this single body to route narration to its + logger or reporting system.""" + print(message, flush=True) From f0e0655da9d061912f80c764d7104fc7f7fab3db Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:54:02 -0400 Subject: [PATCH 02/17] EBP-3103: Remove PEP 563 future import from Python samples - Drop `from __future__ import annotations` and its explanatory comment from the five Python reference samples - The built-in generics in the signatures run natively on every Python release that Solace supports, so the shim added nothing - Match the upstream SolaceSamples/solace-samples-python style, which carries no future import - Verified: all five modules import with solace-pubsubplus installed --- .../references/python/python-direct-publisher-sample.py | 4 ---- .../references/python/python-direct-subscriber-sample.py | 4 ---- .../references/python/python-guaranteed-publisher-sample.py | 4 ---- .../references/python/python-guaranteed-subscriber-sample.py | 4 ---- .../references/python/python-solace-connection-config.py | 4 ---- 5 files changed, 20 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index f7b5fc6..a47d166 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -36,10 +36,6 @@ (This reference sample itself carries no such header by design.) """ -# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], -# dict[str, object]) also import on Python 3.7 and 3.8 -from __future__ import annotations - import logging import signal import sys diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index b8cb159..6c3c52f 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -34,10 +34,6 @@ (This reference sample itself carries no such header by design.) """ -# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], -# dict[str, object]) also import on Python 3.7 and 3.8 -from __future__ import annotations - import logging import signal import sys diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 3644570..38124a2 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -34,10 +34,6 @@ (This reference sample itself carries no such header by design.) """ -# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], -# dict[str, object]) also import on Python 3.7 and 3.8 -from __future__ import annotations - import logging import signal import sys diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index d0e43b2..887f3fc 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -35,10 +35,6 @@ (This reference sample itself carries no such header by design.) """ -# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], -# dict[str, object]) also import on Python 3.7 and 3.8 -from __future__ import annotations - import logging import signal import sys diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py index a141bbc..314dda4 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py @@ -47,10 +47,6 @@ https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Messaging-Service.md """ -# PEP 563: keep annotations unevaluated so the built-in generics below (list[str], -# dict[str, object]) also import on Python 3.7 and 3.8 -from __future__ import annotations - import json import os import sys From 86f190c4efdd4a53f6b0d02b8c7207832f1707ef Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:09:14 -0400 Subject: [PATCH 03/17] EBP-3103: Condense the HA failover comment in the Python samples - Replace the ten-line HA and replication comment with a five-line HA-only comment that mirrors the JCSMP sample - List the C API Best Practices values as Python API calls: connection and reconnection retry strategies plus the CONNECTION_RETRIES_PER_HOST transport property - Drop the replication guidance, the forever_retry remark, and the comma-separated host list - Shorten the back pressure comment above PUBLISH_BUFFER_CAPACITY to one line in both publisher samples --- .../python/python-direct-publisher-sample.py | 18 ++++++------------ .../python/python-direct-subscriber-sample.py | 15 +++++---------- .../python-guaranteed-publisher-sample.py | 18 ++++++------------ .../python-guaranteed-subscriber-sample.py | 15 +++++---------- 4 files changed, 22 insertions(+), 44 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index a47d166..684d3dd 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -73,18 +73,12 @@ # MILLISECONDS per the RetryStrategy API reference. RECONNECT_RETRIES = 20 RECONNECT_RETRY_INTERVAL_MS = 3000 -# For HA or replication failover resilience, follow the C API Best Practices: the -# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example -# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per -# host; the Python retry strategy sets only the reconnect count and the wait, so -# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is -# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries -# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication -# failover, and a comma-separated host list in the host property -# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the -# comma-separated form) so the API fails over between brokers. -# back pressure: the publisher buffers at most this many unsent messages before -# publish() raises PublisherOverflowError (the documented reject strategy) +# For HA failover resilience, use the C API Best Practices values +# (https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md): +# HA failover: with_connection_retry_strategy(RetryStrategy.parametrized_retry(1, 3000)), +# with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), +# properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 +# back pressure: publish() raises PublisherOverflowError once this many messages wait unsent PUBLISH_BUFFER_CAPACITY = 1000 # terminate(grace_period): how long to wait for buffered sends to leave before the # publisher stops (the API default is 600000 ms); terminate() raises diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index 6c3c52f..9608e47 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -65,16 +65,11 @@ # MILLISECONDS per the RetryStrategy API reference. RECONNECT_RETRIES = 20 RECONNECT_RETRY_INTERVAL_MS = 3000 -# For HA or replication failover resilience, follow the C API Best Practices: the -# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example -# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per -# host; the Python retry strategy sets only the reconnect count and the wait, so -# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is -# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries -# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication -# failover, and a comma-separated host list in the host property -# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the -# comma-separated form) so the API fails over between brokers. +# For HA failover resilience, use the C API Best Practices values +# (https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md): +# HA failover: with_connection_retry_strategy(RetryStrategy.parametrized_retry(1, 3000)), +# with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), +# properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 # terminate(grace_period): how long to wait for the handler to drain the messages the API # has already received before the receiver stops (the API default is 600000 ms); # terminate() raises IncompleteMessageDeliveryError when messages remain after that diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 38124a2..69a1d5e 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -71,18 +71,12 @@ # MILLISECONDS per the RetryStrategy API reference. RECONNECT_RETRIES = 20 RECONNECT_RETRY_INTERVAL_MS = 3000 -# For HA or replication failover resilience, follow the C API Best Practices: the -# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example -# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per -# host; the Python retry strategy sets only the reconnect count and the wait, so -# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is -# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries -# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication -# failover, and a comma-separated host list in the host property -# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the -# comma-separated form) so the API fails over between brokers. -# back pressure: the publisher buffers at most this many unsent messages before -# publish() raises PublisherOverflowError (the documented reject strategy) +# For HA failover resilience, use the C API Best Practices values +# (https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md): +# HA failover: with_connection_retry_strategy(RetryStrategy.parametrized_retry(1, 3000)), +# with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), +# properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 +# back pressure: publish() raises PublisherOverflowError once this many messages wait unsent PUBLISH_BUFFER_CAPACITY = 1000 # terminate(grace_period): how long to wait for buffered sends and their broker receipts # to complete before the publisher stops (the API default is 600000 ms); terminate() raises diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index 887f3fc..a1ba9e8 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -74,16 +74,11 @@ # MILLISECONDS per the RetryStrategy API reference. RECONNECT_RETRIES = 20 RECONNECT_RETRY_INTERVAL_MS = 3000 -# For HA or replication failover resilience, follow the C API Best Practices: the -# reconnect duration "should be set to last for at least 300 seconds" for HA. Its example -# lists 1 connect retry, 20 reconnect retries, a 3000 ms wait, and 5 connect retries per -# host; the Python retry strategy sets only the reconnect count and the wait, so -# parametrized_retry(100, 3000) is one way to reach 300 s here, and the per-host value is -# the CONNECTION_RETRIES_PER_HOST transport property. Use -1 retries -# (RetryStrategy.forever_retry()) so the API retries indefinitely during a replication -# failover, and a comma-separated host list in the host property -# (tcp://host-a:55555,tcp://host-b:55555; the HOST property reference documents the -# comma-separated form) so the API fails over between brokers. +# For HA failover resilience, use the C API Best Practices values +# (https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md): +# HA failover: with_connection_retry_strategy(RetryStrategy.parametrized_retry(1, 3000)), +# with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), +# properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 # terminate(grace_period): how long to wait for the handler to drain the messages the API # has already received before the receiver stops (the API default is 600000 ms); # terminate() raises IncompleteMessageDeliveryError when messages remain after that From 7f99a822265aa0cedad57f638cca726359d94a96 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:21:38 -0400 Subject: [PATCH 04/17] EBP-3103: Condense the Python connection-config helper docstring - Shorten the module docstring of python-solace-connection-config.py while keeping every contract point (key names, pass-through, override order, gitignore rule, empty-password handling) - Move the AI-assisted code header block to the end of the docstring to match the other Python samples --- .../python/python-solace-connection-config.py | 54 ++++++++----------- 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py index 314dda4..64b4d5d 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py @@ -1,50 +1,38 @@ """ Shared connection-config helper for the Python reference samples. -Any generated adaptation of this sample MUST begin with the exact line: - AI-assisted code. Review before production use. -(This reference sample itself carries no such header by design.) - -Copied into a generated project as solace_connection_config.py (a Python module -name needs underscores, not hyphens); every sample imports it with - from solace_connection_config import SolaceConnectionConfig - -Loads connection properties and turns them into the properties dict that +Loads connection properties into the dict that MessagingService.builder().from_properties(...) takes. Source precedence: - 1. a config.json in the working directory (the project root), when present. - Its keys are Solace Python API property-name strings, the VALUES of the - constants in solace.messaging.config.solace_properties (for example the - transport_layer_properties.HOST constant's value is - "solace.messaging.transport.host"): + 1. config.json in the working directory, when present. Its keys are Solace + Python API property names (the values of the solace_properties constants): "solace.messaging.transport.host" "solace.messaging.service.vpn-name" "solace.messaging.authentication.basic.username" "solace.messaging.authentication.basic.password" - EVERY key in the file is passed through to from_properties() unchanged, so - further service properties (for example - "solace.messaging.transport.keep-alive-interval") work with no parser - change. A builder call made after from_properties() overrides the same key: - the samples set the reconnection strategy that way, so the - reconnection-attempts and reconnection-attempts-wait-interval keys from the - file are replaced by each sample's constants. Pass-through limitations: the - API passes every value to the native session as a string, and the native - library rejects an invalid value at build() or connect(); an unknown key is - accepted and silently ignored by the API. config.json holds broker - credentials, so it MUST be gitignored and never committed. + Every key passes through to from_properties() unchanged, so other service + properties need no parser change. A later builder call overrides the same + key, so each sample's reconnection strategy replaces any reconnection keys + in the file. The native library rejects an invalid value at build() or + connect(); the API silently ignores an unknown key. config.json holds broker + credentials, so it MUST be gitignored. 2. otherwise the command-line arguments: [password] -The password is optional in both sources. The API requires the basic-auth password -key at build(), so an absent or blank password is passed as an empty string. - -The config.json reader is the standard-library json module, so the samples keep -their single Solace dependency (solace-pubsubplus). config.json must hold a single -flat JSON object. +The password is optional. The API requires the basic-auth password key at build(), +so an absent or blank password is sent as an empty string. config.json must be a +single flat JSON object; the standard-library json module reads it. Grounding: the "load a JSON file, then from_properties()" idiom and the required -host and vpn-name keys are on the Messaging Service page of the Python API -developer guide: +host and vpn-name keys: https://docs.solace.com/API/API-Developer-Guide-Python/Python-API-Messaging-Service.md + +Copied into a generated project as solace_connection_config.py (a module name +needs underscores); every sample imports it with + from solace_connection_config import SolaceConnectionConfig + +Any generated adaptation of this sample MUST begin with the exact line: + AI-assisted code. Review before production use. +(This reference sample itself carries no such header by design.) """ import json From 6d26a273c64fb626ff87f2ac271af24fc7430a49 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:25:28 -0400 Subject: [PATCH 05/17] EBP-3103: Disable the trigger-evals CI job - Add a job-level `if: false` to trigger-evals: no policy permits an API key on this public repo, so the job could only self-skip and asserted nothing - Keep the job definition so it can be re-enabled by removing the `if` once a key is allowed; trigger evals continue to run locally --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b89401..ca19207 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ name: ci # + post-merge version backstop on pushes to main # link-check: CI-02: the tools/check-links.sh sweep # compile-check: EVAL-02 compile the baked JCSMP samples verbatim -# trigger-evals: lightweight skill-triggering check; self-skips green without a key +# trigger-evals: lightweight skill-triggering check; disabled until CI may hold an API key on: # Deliberately no `edited` type: running on title/body edits either re-runs # the paid eval matrix for a no-code change or, if jobs are gated, lets the @@ -133,7 +133,11 @@ jobs: # run summary. The secret is legal in the job env but illegal in a job-level # if, so the guard reads HAS_KEY at step level and the real key is scoped to # the run step only. + # Disabled: no policy permits an API key on this public repo, so the job can + # only self-skip. Run the trigger evals locally instead; remove this `if` to + # re-enable the job once a key is allowed. trigger-evals: + if: false runs-on: ubuntu-latest timeout-minutes: 120 # per model leg; 57 cases x 3 runs (~50 min observed) strategy: From 2bc274cd7ff24e033063ef32a4f5dd4e93e1218c Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:25:28 -0400 Subject: [PATCH 06/17] EBP-3103: Ignore Python bytecode caches - Add __pycache__/ to .gitignore so byte-compiling the Python reference samples leaves no untracked files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7a26224..34d846d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ # GSD planning directory (local-only) .planning/ + +# Python bytecode cache +__pycache__/ From 8d9c8657a66677aec1fa5987f66451bd067ba04a Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:46:45 -0400 Subject: [PATCH 07/17] EBP-3103: Fix shutdown and exit-code handling in the Python samples - Register the SIGINT/SIGTERM handlers before setup_solace(), so a SIGTERM during the blocking connect() still reaches teardown instead of killing the process - Return exit code 1 from every failure exit (failed queue bind, service interruption, receiver termination, publish() failure); main() now returns the code and __main__ passes it to sys.exit() - Correct the guaranteed subscriber's terminate() comments: the receiver turns TERMINATED once its buffer is empty, so the ack() of the message still in the handler can fail and the message is redelivered - Verified against a live broker: both round trips exit 0, and the bind failure, a service interruption (reconnect exhausted), and SIGTERM during connect each run teardown and exit 1 --- .../python/python-direct-publisher-sample.py | 13 ++++++--- .../python/python-direct-subscriber-sample.py | 12 +++++--- .../python-guaranteed-publisher-sample.py | 12 ++++++-- .../python-guaranteed-subscriber-sample.py | 28 +++++++++++-------- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index 684d3dd..22acc27 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -99,23 +99,26 @@ def __init__(self) -> None: self.connect_attempted = False self.is_connected = True # tracks transport state via the reconnection listeners self.shutdown = threading.Event() + self.exit_code = 0 # set to 1 on a failure exit (service interruption or a publish() failure) self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room self.msg_sent_counter = 0 # num messages sent - def main(self, args: list[str]) -> None: + def main(self, args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") try: - self.setup_solace(args) # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish # loop exits, and teardown_solace() in the finally below stops the publisher and # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # setup, so a SIGTERM during the blocking connect() still reaches teardown. signal.signal(signal.SIGINT, self.on_shutdown_signal) signal.signal(signal.SIGTERM, self.on_shutdown_signal) + self.setup_solace(args) self.run_publish_loop() finally: self.teardown_solace() trace("Main thread quitting.") + return self.exit_code # non-zero after a failure, so scripts and supervisors see it def on_shutdown_signal(self, signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") @@ -202,6 +205,7 @@ def run_publish_loop(self) -> None: except PubSubPlusClientError as error: # publish() raises when the message cannot be sent and retrying would not help logger.warning("publish() failed, quitting: %s", error) + self.exit_code = 1 self.shutdown.set() # let's quit; or, could initiate a new connection attempt break # delay between messages; the wait returns early once shutdown is requested @@ -274,6 +278,7 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the publish loop exits and # teardown_solace() runs in main's finally. + self.app.exit_code = 1 self.app.shutdown.set() @@ -302,4 +307,4 @@ def on_failed_publish(self, failed_publish_event: FailedPublishEvent) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - DirectPublisher().main(sys.argv[1:]) + sys.exit(DirectPublisher().main(sys.argv[1:])) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index 9608e47..d5eb21d 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -88,23 +88,26 @@ def __init__(self) -> None: self.receiver: Optional[DirectMessageReceiver] = None self.connect_attempted = False self.shutdown = threading.Event() + self.exit_code = 0 # set to 1 on a failure exit (service interruption) self.msg_recv_counter = 0 # num messages received self.has_detected_discard = False # any discards seen? - def main(self, args: list[str]) -> None: + def main(self, args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") try: - self.setup_solace(args) # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main # loop exits, and teardown_solace() in the finally below stops the receiver and # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # setup, so a SIGTERM during the blocking connect() still reaches teardown. signal.signal(signal.SIGINT, self.on_shutdown_signal) signal.signal(signal.SIGTERM, self.on_shutdown_signal) + self.setup_solace(args) self.await_messages() finally: self.teardown_solace() trace("Main thread quitting.") + return self.exit_code # non-zero after a failure, so scripts and supervisors see it def on_shutdown_signal(self, signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping subscriber...") @@ -221,6 +224,7 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the main loop exits and # teardown_solace() runs in main's finally. + self.app.exit_code = 1 self.app.shutdown.set() @@ -256,4 +260,4 @@ def on_message(self, message: InboundMessage) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - DirectSubscriber().main(sys.argv[1:]) + sys.exit(DirectSubscriber().main(sys.argv[1:])) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 69a1d5e..985dac3 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -97,23 +97,27 @@ def __init__(self) -> None: self.connect_attempted = False self.is_connected = True # tracks transport state via the reconnection listeners self.shutdown = threading.Event() + self.exit_code = 0 # set to 1 on a failure exit (service interruption or a publish() failure) self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room self.msg_sent_counter = 0 # num messages sent - def main(self, args: list[str]) -> None: + def main(self, args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") try: - self.setup_solace(args) # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish # loop exits, and teardown_solace() in the finally below drains the outstanding # receipts and disconnects. Python runs signal handlers on the main thread, so the # handler only flags; the cleanup runs on the main thread's normal exit path. + # Registered before setup, so a SIGTERM during the blocking connect() still reaches + # teardown. signal.signal(signal.SIGINT, self.on_shutdown_signal) signal.signal(signal.SIGTERM, self.on_shutdown_signal) + self.setup_solace(args) self.run_publish_loop() finally: self.teardown_solace() trace("Main thread quitting.") + return self.exit_code # non-zero after a failure, so scripts and supervisors see it def on_shutdown_signal(self, signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") @@ -210,6 +214,7 @@ def run_publish_loop(self) -> None: except PubSubPlusClientError as error: # publish() raises when the message cannot be sent and retrying would not help logger.warning("publish() failed, quitting: %s", error) + self.exit_code = 1 self.shutdown.set() # let's quit; or, could initiate a new connection attempt break # delay between messages; the wait returns early once shutdown is requested @@ -282,6 +287,7 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the publish loop exits and # teardown_solace() runs in main's finally. + self.app.exit_code = 1 self.app.shutdown.set() @@ -318,4 +324,4 @@ def on_publish_receipt(self, publish_receipt: PublishReceipt) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - GuaranteedPublisher().main(sys.argv[1:]) + sys.exit(GuaranteedPublisher().main(sys.argv[1:])) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index a1ba9e8..23bfba1 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -97,25 +97,27 @@ def __init__(self) -> None: self.receiver: Optional[PersistentMessageReceiver] = None self.connect_attempted = False self.shutdown = threading.Event() + self.exit_code = 0 # set to 1 on a failure exit (failed bind, service interruption, or receiver termination) self.msg_recv_counter = 0 # num messages received self.has_detected_redelivery = False # detected any messages being redelivered? - def main(self, args: list[str]) -> None: + def main(self, args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") try: - if not self.setup_solace(args): - return # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main - # loop exits, and teardown_solace() in the finally below stops the receiver (letting - # the handler finish and ACK the message in hand) and disconnects. Python runs - # signal handlers on the main thread, so the handler only flags; the cleanup runs - # on the main thread's normal exit path. + # loop exits, and teardown_solace() in the finally below stops the receiver and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # setup, so a SIGTERM during the blocking connect() still reaches teardown. signal.signal(signal.SIGINT, self.on_shutdown_signal) signal.signal(signal.SIGTERM, self.on_shutdown_signal) + if not self.setup_solace(args): + return 1 # the bind failed; the finally below still runs teardown self.await_messages() finally: self.teardown_solace() trace("Main thread quitting.") + return self.exit_code # non-zero after a failure, so scripts and supervisors see it def on_shutdown_signal(self, signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping consumer...") @@ -203,9 +205,11 @@ def teardown_solace(self) -> None: if self.receiver is not None and self.receiver.is_running(): # gracefully stop delivery before exit: terminate(grace_period) stops the receiver # and gives the handler up to the grace period to drain (and ACK) the messages the - # API has already received. A message received but not yet ACKed stays on the queue - # and is redelivered. terminate() raises IncompleteMessageDeliveryError when messages - # remain after the grace period; catch it so the disconnect below still runs + # API has already received. The receiver turns TERMINATED once its buffer is empty, + # while the handler may still hold the last message, so that message's ack() can + # fail (the API logs a warning). A message received but not yet ACKed stays on the + # queue and is redelivered. terminate() raises IncompleteMessageDeliveryError when + # messages remain after the grace period; catch it so the disconnect below still runs try: self.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) except PubSubPlusClientError as error: @@ -252,6 +256,7 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the main loop exits and # teardown_solace() runs in main's finally. + self.app.exit_code = 1 self.app.shutdown.set() @@ -281,6 +286,7 @@ def on_termination(self, event: TerminationEvent) -> None: # Application cleanup signal: decide here whether to recreate the receiver or shut # down. This sample shuts down: the main loop exits and teardown_solace() runs in # main's finally. + self.app.exit_code = 1 self.app.shutdown.set() @@ -328,4 +334,4 @@ def on_message(self, message: InboundMessage) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - GuaranteedSubscriber().main(sys.argv[1:]) + sys.exit(GuaranteedSubscriber().main(sys.argv[1:])) From 064f2a2007c02a6390b58dbfce5c7790acbd9bb7 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:08:46 -0400 Subject: [PATCH 08/17] EBP-3103: Explain the terminate() grace period in the Python samples - Describe why the samples bound terminate(): a predictable shutdown time, for example within a process supervisor's stop timeout, so the disconnect still runs - State the API default (600000 ms) as a fact and invite adjusting the value to suit the application --- .../references/python/python-direct-publisher-sample.py | 7 +++++-- .../references/python/python-direct-subscriber-sample.py | 8 ++++++-- .../python/python-guaranteed-publisher-sample.py | 8 ++++++-- .../python/python-guaranteed-subscriber-sample.py | 8 ++++++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index 22acc27..31208a5 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -81,8 +81,11 @@ # back pressure: publish() raises PublisherOverflowError once this many messages wait unsent PUBLISH_BUFFER_CAPACITY = 1000 # terminate(grace_period): how long to wait for buffered sends to leave before the -# publisher stops (the API default is 600000 ms); terminate() raises -# IncompleteMessageDeliveryError when messages are still buffered after that +# publisher stops; terminate() raises IncompleteMessageDeliveryError when messages are +# still buffered after that. A bounded grace period keeps shutdown time predictable, for +# example within the stop timeout of a process supervisor, so the disconnect still runs. +# The API default is 600000 ms (10 minutes). Adjust the value to suit the application: +# longer gives buffered sends more time to leave, shorter exits sooner. TERMINATE_GRACE_PERIOD_MS = 10_000 # API events go through standard logging; the trace() narration below is a separate channel diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index d5eb21d..4a8981c 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -71,8 +71,12 @@ # with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 # terminate(grace_period): how long to wait for the handler to drain the messages the API -# has already received before the receiver stops (the API default is 600000 ms); -# terminate() raises IncompleteMessageDeliveryError when messages remain after that +# has already received before the receiver stops; terminate() raises +# IncompleteMessageDeliveryError when messages remain after that. A bounded grace period +# keeps shutdown time predictable, for example within the stop timeout of a process +# supervisor, so the disconnect still runs. The API default is 600000 ms (10 minutes). +# Adjust the value to suit the application: longer lets the handler finish more of the +# received messages, shorter exits sooner. TERMINATE_GRACE_PERIOD_MS = 10_000 # API events go through standard logging; the trace() narration below is a separate channel diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 985dac3..2eb93c1 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -79,8 +79,12 @@ # back pressure: publish() raises PublisherOverflowError once this many messages wait unsent PUBLISH_BUFFER_CAPACITY = 1000 # terminate(grace_period): how long to wait for buffered sends and their broker receipts -# to complete before the publisher stops (the API default is 600000 ms); terminate() raises -# IncompleteMessageDeliveryError when sends or receipts are still outstanding after that +# to complete before the publisher stops; terminate() raises IncompleteMessageDeliveryError +# when sends or receipts are still outstanding after that. A bounded grace period keeps +# shutdown time predictable, for example within the stop timeout of a process supervisor, +# so the disconnect still runs. The API default is 600000 ms (10 minutes). Adjust the value +# to suit the application: longer gives sends and receipts more time to complete, shorter +# exits sooner. TERMINATE_GRACE_PERIOD_MS = 10_000 # API events go through standard logging; the trace() narration below is a separate channel diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index 23bfba1..713ddf9 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -80,8 +80,12 @@ # with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 # terminate(grace_period): how long to wait for the handler to drain the messages the API -# has already received before the receiver stops (the API default is 600000 ms); -# terminate() raises IncompleteMessageDeliveryError when messages remain after that +# has already received before the receiver stops; terminate() raises +# IncompleteMessageDeliveryError when messages remain after that. A bounded grace period +# keeps shutdown time predictable, for example within the stop timeout of a process +# supervisor, so the disconnect still runs. The API default is 600000 ms (10 minutes). +# Adjust the value to suit the application: longer lets the handler finish more of the +# received messages, shorter exits sooner. TERMINATE_GRACE_PERIOD_MS = 10_000 # API events go through standard logging; the trace() narration below is a separate channel From 9fed1c9ecb7f4b437ba58d2ab2acd3c059d3b8bc Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:01:57 -0400 Subject: [PATCH 09/17] EBP-3103: Fix review findings in the Python samples and CI docs - Name MessageDestinationDoesNotExistError for the no-subscription-match NACK, matching the library's subcode mapping - Drop "a broker rejection" from the direct publisher failure listener comment; broker events never reach that listener - Split terminate() handling into IncompleteMessageDeliveryError and other client errors, and exit non-zero on both - Update the exit_code comments to match, and drop the inaccurate "failed bind" case in the guaranteed subscriber - Describe the disabled trigger-evals CI job in the evals README and point to local runs - Include *.py in the link check so the Python sample header URLs are swept --- plugins/solace-messaging-skills/evals/README.md | 2 +- .../python/python-direct-publisher-sample.py | 14 +++++++++----- .../python/python-direct-subscriber-sample.py | 10 +++++++--- .../python/python-guaranteed-publisher-sample.py | 14 +++++++++----- .../python/python-guaranteed-subscriber-sample.py | 10 +++++++--- tools/check-links.sh | 8 ++++---- 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/plugins/solace-messaging-skills/evals/README.md b/plugins/solace-messaging-skills/evals/README.md index e8b4661..37c4773 100644 --- a/plugins/solace-messaging-skills/evals/README.md +++ b/plugins/solace-messaging-skills/evals/README.md @@ -16,7 +16,7 @@ Each prompt runs three times and the verdict is the majority result. Set `TRIGGE ## Continuous integration -In GitHub Actions, the `trigger-evals` job in `.github/workflows/ci.yml` runs this same script on every pull request as a two model matrix (`claude-haiku-4-5` and `claude-sonnet-5`), each model an independent check, and self-skips green when the `ANTHROPIC_API_KEY` secret is absent. +The `trigger-evals` job in `.github/workflows/ci.yml` is disabled (`if: false`), because no policy permits an API key on this public repository. Run the trigger evals locally as shown above. To re-enable the job once a key is allowed, remove the `if`. It then runs this same script on every pull request as a two model matrix (`claude-haiku-4-5` and `claude-sonnet-5`), each model an independent check, and self-skips green when the `ANTHROPIC_API_KEY` secret is absent. # Output evals diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index 31208a5..ac9d884 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -44,7 +44,7 @@ from typing import Optional from solace.messaging.config.retry_strategy import RetryStrategy -from solace.messaging.errors.pubsubplus_client_error import PublisherOverflowError, PubSubPlusClientError +from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PublisherOverflowError, PubSubPlusClientError from solace.messaging.messaging_service import ( MessagingService, ReconnectionAttemptListener, @@ -102,7 +102,7 @@ def __init__(self) -> None: self.connect_attempted = False self.is_connected = True # tracks transport state via the reconnection listeners self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (service interruption or a publish() failure) + self.exit_code = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room self.msg_sent_counter = 0 # num messages sent @@ -162,8 +162,8 @@ def setup_solace(self, args: list[str]) -> None: .build() ) # direct messaging has no broker receipt, so failures the API detects after publish() - # returned (a buffered message that could not be sent, a broker rejection) arrive - # asynchronously through this listener; without it they are silent + # returned (a buffered message that could not be sent) arrive asynchronously through + # this listener; without it they are silent self.publisher.set_publish_failure_listener(PublishFailureHandler()) # documented pairing with on_back_pressure_reject: ready() fires when the buffer has # room again, so the loop waits on the event instead of spinning on overflow @@ -234,9 +234,13 @@ def teardown_solace(self) -> None: # that; catch it so the disconnect below still runs try: self.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) - except PubSubPlusClientError as error: + except IncompleteMessageDeliveryError as error: logger.error("Publisher stopped with messages still buffered after %d ms: %s", TERMINATE_GRACE_PERIOD_MS, error) + self.exit_code = 1 + except PubSubPlusClientError as error: + logger.error("Publisher terminate() failed: %s", error) + self.exit_code = 1 if self.connect_attempted: # disconnect() raises IllegalStateError on a service that never attempted to connect, # hence the flag; on a service that is already down it returns quietly diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index 4a8981c..959adf0 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -41,7 +41,7 @@ from typing import Optional from solace.messaging.config.retry_strategy import RetryStrategy -from solace.messaging.errors.pubsubplus_client_error import PubSubPlusClientError +from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError from solace.messaging.messaging_service import ( MessagingService, ReconnectionAttemptListener, @@ -92,7 +92,7 @@ def __init__(self) -> None: self.receiver: Optional[DirectMessageReceiver] = None self.connect_attempted = False self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (service interruption) + self.exit_code = 0 # set to 1 on a failure exit (service interruption or a failed terminate()) self.msg_recv_counter = 0 # num messages received self.has_detected_discard = False # any discards seen? @@ -180,9 +180,13 @@ def teardown_solace(self) -> None: # when messages remain after that; catch it so the disconnect below still runs try: self.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) - except PubSubPlusClientError as error: + except IncompleteMessageDeliveryError as error: logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", TERMINATE_GRACE_PERIOD_MS, error) + self.exit_code = 1 + except PubSubPlusClientError as error: + logger.error("Receiver terminate() failed: %s", error) + self.exit_code = 1 if self.connect_attempted: # disconnect() raises IllegalStateError on a service that never attempted to connect, # hence the flag; on a service that is already down it returns quietly diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 2eb93c1..3a07e45 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -42,7 +42,7 @@ from typing import Optional from solace.messaging.config.retry_strategy import RetryStrategy -from solace.messaging.errors.pubsubplus_client_error import PublisherOverflowError, PubSubPlusClientError +from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PublisherOverflowError, PubSubPlusClientError from solace.messaging.messaging_service import ( MessagingService, ReconnectionAttemptListener, @@ -101,7 +101,7 @@ def __init__(self) -> None: self.connect_attempted = False self.is_connected = True # tracks transport state via the reconnection listeners self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (service interruption or a publish() failure) + self.exit_code = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room self.msg_sent_counter = 0 # num messages sent @@ -246,9 +246,13 @@ def teardown_solace(self) -> None: # outstanding after the grace period; catch it so the disconnect below still runs try: self.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) - except PubSubPlusClientError as error: + except IncompleteMessageDeliveryError as error: logger.error("Publisher stopped with sends or receipts still outstanding after %d ms: %s", TERMINATE_GRACE_PERIOD_MS, error) + self.exit_code = 1 + except PubSubPlusClientError as error: + logger.error("Publisher terminate() failed: %s", error) + self.exit_code = 1 if self.connect_attempted: # disconnect() raises IllegalStateError on a service that never attempted to connect, # hence the flag; on a service that is already down it returns quietly @@ -316,8 +320,8 @@ def on_publish_receipt(self, publish_receipt: PublishReceipt) -> None: if publish_receipt.exception is None: # ACK logger.debug("ACK for Message ID %s", msg_id) # good enough, the broker has it now return - # NACK: the exception says why (for example MessageRejectedByBrokerError when no queue - # subscribes to the topic and the client-profile rejects on no subscription match) + # NACK: the exception says why (for example MessageDestinationDoesNotExistError when no + # queue subscribes to the topic and the client-profile rejects on no subscription match) logger.warning("NACK for Message ID %s - %s", msg_id, publish_receipt.exception) # probably want to do something here. some error handling possibilities: # - look the message up by this ID in your application's outbound store and send it again diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index 713ddf9..f02095b 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -47,7 +47,7 @@ ReceiverStateChangeListener, ) from solace.messaging.config.retry_strategy import RetryStrategy -from solace.messaging.errors.pubsubplus_client_error import PubSubPlusClientError +from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError from solace.messaging.messaging_service import ( MessagingService, ReconnectionAttemptListener, @@ -101,7 +101,7 @@ def __init__(self) -> None: self.receiver: Optional[PersistentMessageReceiver] = None self.connect_attempted = False self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (failed bind, service interruption, or receiver termination) + self.exit_code = 0 # set to 1 on a failure exit (service interruption, receiver termination, or a failed terminate()) self.msg_recv_counter = 0 # num messages received self.has_detected_redelivery = False # detected any messages being redelivered? @@ -216,9 +216,13 @@ def teardown_solace(self) -> None: # messages remain after the grace period; catch it so the disconnect below still runs try: self.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) - except PubSubPlusClientError as error: + except IncompleteMessageDeliveryError as error: logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", TERMINATE_GRACE_PERIOD_MS, error) + self.exit_code = 1 + except PubSubPlusClientError as error: + logger.error("Receiver terminate() failed: %s", error) + self.exit_code = 1 if self.connect_attempted: # disconnect() raises IllegalStateError on a service that never attempted to connect, # hence the flag; on a service that is already down it returns quietly diff --git a/tools/check-links.sh b/tools/check-links.sh index 9bccbc5..e0dd888 100755 --- a/tools/check-links.sh +++ b/tools/check-links.sh @@ -6,8 +6,8 @@ # Complements the CI validate job (gh skill publish --dry-run), which checks the # Agent Skills spec but not links. # This sweep additionally: -# * covers the WHOLE skills/ tree — SKILL.md, references/*.md, the .java sample -# headers, and scripts/*.sh (not just *.md) — while excluding the repo-root +# * covers the WHOLE skills/ tree — SKILL.md, references/*.md, the .java and .py +# sample headers, and scripts/*.sh (not just *.md) — while excluding the repo-root # README (D-11: the sweep globs the skills/ tree only, never the repo root), # * detects Solace "soft 404s" — pages that return HTTP 200/30x but actually land # on a not-found page (docs.solace.com redirects bad paths to Not-Found.htm; @@ -74,7 +74,7 @@ should_skip() { return 1 } -# Collect unique http(s) links from the whole skills/ tree (*.md, *.java, *.sh). +# Collect unique http(s) links from the whole skills/ tree (*.md, *.java, *.py, *.sh). # README at the repo root is excluded because ROOT is the skills/ tree, never the # repo root (D-11). Doc links are now public docs.solace.com .md URLs, checked inline. # @@ -89,7 +89,7 @@ while IFS= read -r line; do links+=("$line") done < <( grep -rhoE 'https?://[^ )"`'"'"'>]+' "$ROOT" \ - --include='*.md' --include='*.java' --include='*.sh' \ + --include='*.md' --include='*.java' --include='*.py' --include='*.sh' \ --exclude-dir='docs' \ | sed -E 's/[.,;:*]+$//' \ | sort -u From d751d3391026617d06b3354d12a19cccb7971b07 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:05:55 -0400 Subject: [PATCH 10/17] EBP-3103: Stop naming specific model versions in the evals README - Use the claude CLI aliases (sonnet, opus) in the run examples so they always select the latest model in each family - Describe the runner defaults, the CI matrix, and the judge by model family instead of pinned IDs - Remove the stale claude-opus-5 reference --- plugins/solace-messaging-skills/evals/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/solace-messaging-skills/evals/README.md b/plugins/solace-messaging-skills/evals/README.md index 37c4773..f02ec65 100644 --- a/plugins/solace-messaging-skills/evals/README.md +++ b/plugins/solace-messaging-skills/evals/README.md @@ -8,15 +8,15 @@ To run the evals manually, you need the `claude` CLI and `jq` on your PATH, plus ```shell export ANTHROPIC_API_KEY= # or CLAUDE_CODE_OAUTH_TOKEN -./tools/run-trigger-evals.sh # defaults to claude-haiku-4-5 -./tools/run-trigger-evals.sh --model claude-sonnet-5 +./tools/run-trigger-evals.sh # defaults to a Haiku model +./tools/run-trigger-evals.sh --model sonnet # the latest Sonnet ``` Each prompt runs three times and the verdict is the majority result. Set `TRIGGER_EVAL_RUNS=1` for a quicker, cheaper local check, and `TRIGGER_EVAL_MODEL` to change the default model. A run passes when at least 90% of cases match their expectation, and any infrastructure failure (such as a missing credential, a malformed corpus, or zero discovered cases) fails the run regardless of the rate. A case may also set `"must_pass": true`, and any must-pass failure fails the run regardless of the pooled rate. Note that a negative case asserts only that the named skill stays silent, so another skill in the plugin may legitimately fire on the same prompt. ## Continuous integration -The `trigger-evals` job in `.github/workflows/ci.yml` is disabled (`if: false`), because no policy permits an API key on this public repository. Run the trigger evals locally as shown above. To re-enable the job once a key is allowed, remove the `if`. It then runs this same script on every pull request as a two model matrix (`claude-haiku-4-5` and `claude-sonnet-5`), each model an independent check, and self-skips green when the `ANTHROPIC_API_KEY` secret is absent. +The `trigger-evals` job in `.github/workflows/ci.yml` is disabled (`if: false`), because no policy permits an API key on this public repository. Run the trigger evals locally as shown above. To re-enable the job once a key is allowed, remove the `if`. It then runs this same script on every pull request as a two model matrix (Haiku and Sonnet), each model an independent check, and self-skips green when the `ANTHROPIC_API_KEY` secret is absent. # Output evals @@ -60,12 +60,12 @@ Run from the repository root: ```shell export ANTHROPIC_API_KEY= # or CLAUDE_CODE_OAUTH_TOKEN -./tools/run-output-evals.sh # defaults to claude-sonnet-5 -./tools/run-output-evals.sh --model claude-opus-5 +./tools/run-output-evals.sh # defaults to a Sonnet model +./tools/run-output-evals.sh --model opus # the latest Opus ./tools/run-output-evals.sh --case appdev-quickstart-implement-full ``` -Run the suite for both `claude-sonnet-5` and `claude-opus-5` before a PR that affects skill content, and record both results in the PR description, including whether live verify ran or skipped (the runner prints this in its summary). A full leg is a long run; keep the machine awake for it (on macOS, prefix the command with `caffeinate -i`), because a sleep mid-run surfaces as INFRA failures. A permission denial of a tool the runner grants (Bash, Write, and the rest of its allowlist) is also INFRA: managed settings, hooks, or local command shims can deny a command, the model then improvises, and the result no longer measures the skill. The case line names the denied tool and command; adjust the policy for the run instead of reading the result as a skill failure. Each case runs once by default (`OUTPUT_EVAL_RUNS=1`), because a full implement-flow case is expensive; raise it for a majority-vote stability study. `OUTPUT_EVAL_JUDGE_MODEL` (default `claude-sonnet-5`) stays fixed across subject models so leg differences are attributable to the subject. `OUTPUT_EVAL_WORKDIR` receives transcripts and generated projects, and the work directory is kept and printed when the run fails. +Run the suite for both the latest Sonnet and the latest Opus (`--model sonnet` and `--model opus`) before a PR that affects skill content, and record both results in the PR description, including whether live verify ran or skipped (the runner prints this in its summary). A full leg is a long run; keep the machine awake for it (on macOS, prefix the command with `caffeinate -i`), because a sleep mid-run surfaces as INFRA failures. A permission denial of a tool the runner grants (Bash, Write, and the rest of its allowlist) is also INFRA: managed settings, hooks, or local command shims can deny a command, the model then improvises, and the result no longer measures the skill. The case line names the denied tool and command; adjust the policy for the run instead of reading the result as a skill failure. Each case runs once by default (`OUTPUT_EVAL_RUNS=1`), because a full implement-flow case is expensive; raise it for a majority-vote stability study. `OUTPUT_EVAL_JUDGE_MODEL` (default a pinned Sonnet model) stays fixed across subject models so leg differences are attributable to the subject. `OUTPUT_EVAL_WORKDIR` receives transcripts and generated projects, and the work directory is kept and printed when the run fails. The gate matches the trigger evals: a run passes when at least 90% of cases pass, any infrastructure failure fails the run, and any `must_pass` failure fails the run regardless of the pooled rate. The forbidden-behavior negatives and the compile case are `must_pass`. From 0e25aa0810de3e81c50db1a34c80476fa63f1e90 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:08:23 -0400 Subject: [PATCH 11/17] EBP-3103: Restructure the Python samples as functions and bound buffers - Replace the Java-style app classes with module-level functions (main, setup_solace, run_publish_loop or await_messages, teardown_solace) that share one dataclass state - Keep the listener classes, since the Python API requires listener subclasses; they now take the shared state - Replace the SolaceConnectionConfig class with one load_service_properties() function that returns the from_properties() dict - Use X | None type hints for the Python 3.10+ floor - Publishers: on_back_pressure_wait(50) replaces on_back_pressure_reject(1000), so the readiness listener and the PublisherOverflowError retry are removed - Direct subscriber: bound the receive buffer with on_back_pressure_drop_latest(50) - Verified with the smoke test, both live round trips, and a frozen-proxy test of a blocked publish() (returns on drain, or raises after the reconnect budget) --- .../python/python-direct-publisher-sample.py | 342 ++++++++-------- .../python/python-direct-subscriber-sample.py | 259 ++++++------ .../python-guaranteed-publisher-sample.py | 369 +++++++++--------- .../python-guaranteed-subscriber-sample.py | 299 +++++++------- .../python/python-solace-connection-config.py | 91 ++--- 5 files changed, 664 insertions(+), 696 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index ac9d884..7a4abd2 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -17,14 +17,15 @@ Reference sample. Wired for a basic direct (at-most-once) pub/sub journey: basic-auth connect with a reconnection retry strategy, a continuous DIRECT binary publish loop with a rotating payload to a topic, a publish failure listener for the -asynchronous failures the API reports, capacity-bounded back pressure paired with a -publisher readiness listener, reconnection and service interruption listeners +asynchronous failures the API reports, capacity-bounded back pressure that blocks +publish() while the buffer is full, reconnection and service interruption listeners registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown. Direct messaging is at-most-once: there is no broker receipt, so there is NO publish receipt listener and NO user context here (the inverse of the guaranteed publisher, which acts on each ACK/NACK), and there are no outstanding receipts to -drain at shutdown. Structured as setup_solace(...), run_publish_loop(), and -teardown_solace(), with teardown run from main's finally on every exit path. +drain at shutdown. Structured as module-level functions, setup_solace(...), +run_publish_loop(...), and teardown_solace(...), that share one PublisherState; +main() runs teardown from its finally on every exit path. Only practices documented in canonical Solace sources are encoded here. @@ -41,10 +42,10 @@ import sys import threading import uuid -from typing import Optional +from dataclasses import dataclass, field from solace.messaging.config.retry_strategy import RetryStrategy -from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PublisherOverflowError, PubSubPlusClientError +from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError from solace.messaging.messaging_service import ( MessagingService, ReconnectionAttemptListener, @@ -57,11 +58,9 @@ FailedPublishEvent, PublishFailureListener, ) -from solace.messaging.publisher.outbound_message import OutboundMessageBuilder -from solace.messaging.publisher.publisher_health_check import PublisherReadinessListener from solace.messaging.resources.topic import Topic -from solace_connection_config import SolaceConnectionConfig +from solace_connection_config import load_service_properties APP_NAME = "DirectPublisher" TOPIC_PREFIX = "solace/samples/" # used as the topic "root" @@ -78,8 +77,8 @@ # HA failover: with_connection_retry_strategy(RetryStrategy.parametrized_retry(1, 3000)), # with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 -# back pressure: publish() raises PublisherOverflowError once this many messages wait unsent -PUBLISH_BUFFER_CAPACITY = 1000 +# back pressure: publish() blocks once this many messages wait unsent, until there is room +PUBLISH_BUFFER_CAPACITY = 50 # terminate(grace_period): how long to wait for buffered sends to leave before the # publisher stops; terminate() raises IncompleteMessageDeliveryError when messages are # still buffered after that. A bounded grace period keeps shutdown time predictable, for @@ -92,166 +91,160 @@ logger = logging.getLogger(APP_NAME) -class DirectPublisher: - """The app: the service, the publisher, the flags the listeners flip, and the counters.""" +@dataclass +class PublisherState: + """What the publish loop, the signal handler, and the API listener threads share: the + service, the publisher, the flags the listeners flip, and the counters.""" + messaging_service: MessagingService | None = None + publisher: DirectMessagePublisher | None = None + connect_attempted: bool = False + is_connected: bool = True # tracks transport state via the reconnection listeners + shutdown: threading.Event = field(default_factory=threading.Event) + exit_code: int = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) + msg_sent_counter: int = 0 # num messages sent - def __init__(self) -> None: - self.messaging_service: Optional[MessagingService] = None - self.publisher: Optional[DirectMessagePublisher] = None - self.message_builder: Optional[OutboundMessageBuilder] = None - self.connect_attempted = False - self.is_connected = True # tracks transport state via the reconnection listeners - self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) - self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room - self.msg_sent_counter = 0 # num messages sent - def main(self, args: list[str]) -> int: - trace(f"{API} {APP_NAME} initializing...") - try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish - # loop exits, and teardown_solace() in the finally below stops the publisher and - # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. Registered before - # setup, so a SIGTERM during the blocking connect() still reaches teardown. - signal.signal(signal.SIGINT, self.on_shutdown_signal) - signal.signal(signal.SIGTERM, self.on_shutdown_signal) - self.setup_solace(args) - self.run_publish_loop() - finally: - self.teardown_solace() - trace("Main thread quitting.") - return self.exit_code # non-zero after a failure, so scripts and supervisors see it - - def on_shutdown_signal(self, signum: int, _frame) -> None: +def main(args: list[str]) -> int: + trace(f"{API} {APP_NAME} initializing...") + state = PublisherState() + + def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") - self.shutdown.set() - - def setup_solace(self, args: list[str]) -> None: - # basic username/password connection details, built by the shared SolaceConnectionConfig - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() - # build() creates the native session and resolves the host, so an unresolvable host - # fails here, before connect() - self.messaging_service = ( - MessagingService.builder() - .from_properties(properties) - .with_reconnection_retry_strategy( - RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) - .build() - ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(self) - self.messaging_service.add_reconnection_attempt_listener(service_events) - self.messaging_service.add_reconnection_listener(service_events) - self.messaging_service.add_service_interruption_listener(service_events) - self.connect_attempted = True - self.messaging_service.connect() # blocking connect - - # DIRECT publisher with capacity-bounded back pressure: publish() raises - # PublisherOverflowError once PUBLISH_BUFFER_CAPACITY messages wait unsent, and the - # readiness listener below reports when there is room again. The API default - # (on_back_pressure_elastic) buffers without bound and the developer guide cautions it - # can exhaust memory; on_back_pressure_wait(n) blocks publish() instead of raising. - self.publisher = ( - self.messaging_service.create_direct_message_publisher_builder() - .on_back_pressure_reject(PUBLISH_BUFFER_CAPACITY) - .build() - ) - # direct messaging has no broker receipt, so failures the API detects after publish() - # returned (a buffered message that could not be sent) arrive asynchronously through - # this listener; without it they are silent - self.publisher.set_publish_failure_listener(PublishFailureHandler()) - # documented pairing with on_back_pressure_reject: ready() fires when the buffer has - # room again, so the loop waits on the event instead of spinning on overflow - self.publisher.set_publisher_readiness_listener(PublisherReadinessHandler(self.publisher_ready)) - self.publisher.start() - # best practice: one OutboundMessageBuilder, reused for every message - self.message_builder = self.messaging_service.message_builder() - - def run_publish_loop(self) -> None: - threading.Thread(target=self.print_stats, name="stats", daemon=True).start() - - trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") - trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/direct/pub/...', " - "at-most-once (no broker receipt, no redelivery).") - while not self.shutdown.is_set(): - if not self.is_connected: # transport is down: wait for the API's automatic reconnect - self.shutdown.wait(0.1) - continue - # each loop, change the payload as an example - chosen_character = chr(self.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] - # a bytearray payload travels as the binary attachment; consumers read it back with - # get_payload_as_bytes() (a str payload would arrive via get_payload_as_string()) - payload = bytearray(chosen_character.encode("ascii") * PAYLOAD_SIZE) - # NO user context: direct is at-most-once with no broker receipt, so there is - # nothing to correlate (the inverse of the guaranteed publisher, which passes the - # message id as user_context for local ACK/NACK correlation) - message = self.message_builder.with_application_message_id(str(uuid.uuid4())).build(payload) # as an example - topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/direct/pub/{chosen_character}") - try: - self.publisher.publish(message, topic) - self.msg_sent_counter += 1 - except PublisherOverflowError: - # buffer full (back pressure): wait for the ready() callback the API sends when the - # buffer has room again, with a bound so a shutdown request is still honoured, and - # retry. is_ready() closes the race with a ready() that fired before clear(). - # notify_when_ready() is not called here: the API sends ready() at once, whatever - # the buffer state, so the wait would return immediately and this loop would spin. - self.publisher_ready.clear() - if not self.publisher.is_ready(): - self.publisher_ready.wait(timeout=1.0) - continue - except PubSubPlusClientError as error: - # publish() raises when the message cannot be sent and retrying would not help - logger.warning("publish() failed, quitting: %s", error) - self.exit_code = 1 - self.shutdown.set() # let's quit; or, could initiate a new connection attempt - break - # delay between messages; the wait returns early once shutdown is requested - self.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed - # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress - self.shutdown.set() - - def print_stats(self) -> None: - # simple way of calculating message rates; wait() returns True (ending the loop) on shutdown - while not self.shutdown.wait(1.0): - trace(f"{API} {APP_NAME} Published msgs/s: {self.msg_sent_counter:,}") - self.msg_sent_counter = 0 - - def teardown_solace(self) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an - # exception), so Solace teardown and application-side cleanup are never skipped. - self.shutdown.set() - if self.publisher is not None and self.publisher.is_running(): - # direct is at-most-once: there are no broker receipts to drain, so terminate() only - # gives the messages still in the publisher's buffer up to the grace period to leave. - # It raises IncompleteMessageDeliveryError when messages are still buffered after - # that; catch it so the disconnect below still runs - try: - self.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) - except IncompleteMessageDeliveryError as error: - logger.error("Publisher stopped with messages still buffered after %d ms: %s", - TERMINATE_GRACE_PERIOD_MS, error) - self.exit_code = 1 - except PubSubPlusClientError as error: - logger.error("Publisher terminate() failed: %s", error) - self.exit_code = 1 - if self.connect_attempted: - # disconnect() raises IllegalStateError on a service that never attempted to connect, - # hence the flag; on a service that is already down it returns quietly - self.messaging_service.disconnect() + state.shutdown.set() + + try: + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish + # loop exits, and teardown_solace() in the finally below stops the publisher and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # setup, so a SIGTERM during the blocking connect() still reaches teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + setup_solace(state, args) + run_publish_loop(state) + finally: + teardown_solace(state) + trace("Main thread quitting.") + return state.exit_code # non-zero after a failure, so scripts and supervisors see it + + +def setup_solace(state: PublisherState, args: list[str]) -> None: + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = load_service_properties(args, APP_NAME) + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + state.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + state.messaging_service.add_reconnection_attempt_listener(service_events) + state.messaging_service.add_reconnection_listener(service_events) + state.messaging_service.add_service_interruption_listener(service_events) + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + + # DIRECT publisher with capacity-bounded back pressure: publish() blocks once + # PUBLISH_BUFFER_CAPACITY messages wait unsent, and returns when there is room again. + # The API default (on_back_pressure_elastic) buffers without bound and the developer + # guide cautions it can exhaust memory; on_back_pressure_reject(n) raises + # PublisherOverflowError instead of blocking. + state.publisher = ( + state.messaging_service.create_direct_message_publisher_builder() + .on_back_pressure_wait(PUBLISH_BUFFER_CAPACITY) + .build() + ) + # direct messaging has no broker receipt, so failures the API detects after publish() + # returned (a buffered message that could not be sent) arrive asynchronously through + # this listener; without it they are silent + state.publisher.set_publish_failure_listener(PublishFailureHandler()) + state.publisher.start() + + +def run_publish_loop(state: PublisherState) -> None: + threading.Thread(target=print_stats, args=(state,), name="stats", daemon=True).start() + # best practice: one OutboundMessageBuilder, reused for every message + message_builder = state.messaging_service.message_builder() + + trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") + trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/direct/pub/...', " + "at-most-once (no broker receipt, no redelivery).") + while not state.shutdown.is_set(): + if not state.is_connected: # transport is down: wait for the API's automatic reconnect + state.shutdown.wait(0.1) + continue + # each loop, change the payload as an example + chosen_character = chr(state.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] + # a bytearray payload travels as the binary attachment; consumers read it back with + # get_payload_as_bytes() (a str payload would arrive via get_payload_as_string()) + payload = bytearray(chosen_character.encode("ascii") * PAYLOAD_SIZE) + # NO user context: direct is at-most-once with no broker receipt, so there is + # nothing to correlate (the inverse of the guaranteed publisher, which passes the + # message id as user_context for local ACK/NACK correlation) + message = message_builder.with_application_message_id(str(uuid.uuid4())).build(payload) # as an example + topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/direct/pub/{chosen_character}") + try: + # blocks while the buffer is full (back pressure). A shutdown signal that arrives + # during that wait takes effect once publish() returns: when the buffer drains, or + # when the service goes down and the API releases the wait. + state.publisher.publish(message, topic) + state.msg_sent_counter += 1 + except PubSubPlusClientError as error: + # publish() raises when the message cannot be sent and retrying would not help + logger.warning("publish() failed, quitting: %s", error) + state.exit_code = 1 + break # let's quit; or, could initiate a new connection attempt + # delay between messages; the wait returns early once shutdown is requested + state.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed + # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress + state.shutdown.set() + + +def print_stats(state: PublisherState) -> None: + # simple way of calculating message rates; wait() returns True (ending the loop) on shutdown + while not state.shutdown.wait(1.0): + trace(f"{API} {APP_NAME} Published msgs/s: {state.msg_sent_counter:,}") + state.msg_sent_counter = 0 + + +def teardown_solace(state: PublisherState) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an + # exception), so Solace teardown and application-side cleanup are never skipped. + state.shutdown.set() + if state.publisher is not None and state.publisher.is_running(): + # direct is at-most-once: there are no broker receipts to drain, so terminate() only + # gives the messages still in the publisher's buffer up to the grace period to leave. + # It raises IncompleteMessageDeliveryError when messages are still buffered after + # that; catch it so the disconnect below still runs + try: + state.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) + except IncompleteMessageDeliveryError as error: + logger.error("Publisher stopped with messages still buffered after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + state.exit_code = 1 + except PubSubPlusClientError as error: + logger.error("Publisher terminate() failed: %s", error) + state.exit_code = 1 + if state.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + state.messaging_service.disconnect() def trace(message: str) -> None: """Demo narration sink: every status line in this sample funnels through this one function. An application replaces this single body to route narration to its logger or reporting system. The logger calls for API events (service, publish - failure, readiness) are a separate channel and stay as they are.""" + failure) are a separate channel and stay as they are.""" print(message, flush=True) @@ -263,20 +256,20 @@ class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, Ser return promptly and do not block in them, since events and delivery stall while a callback runs (C API Best Practices).""" - def __init__(self, app: DirectPublisher) -> None: - self.app = app + def __init__(self, state: PublisherState) -> None: + self.state = state def on_reconnecting(self, event: ServiceEvent) -> None: # connection lost, automatic reconnect attempt in progress: pause publishing rather # than fill the buffer (direct is at-most-once: a buffered message the API cannot send # is reported only through the publish failure listener, never redelivered) logger.warning("Service reconnecting, pausing publishing: %s (%s)", event.get_message(), event.get_cause()) - self.app.is_connected = False + self.state.is_connected = False def on_reconnected(self, event: ServiceEvent) -> None: # automatic reconnect succeeded: resume publishing logger.info("Service reconnected to %s, resuming publishing", event.get_broker_uri()) - self.app.is_connected = True + self.state.is_connected = True def on_service_interrupted(self, event: ServiceEvent) -> None: # the connection went down and cannot be restored (reconnect attempts exhausted) @@ -285,19 +278,8 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the publish loop exits and # teardown_solace() runs in main's finally. - self.app.exit_code = 1 - self.app.shutdown.set() - - -class PublisherReadinessHandler(PublisherReadinessListener): - """Flags the publish loop when the publisher can publish again. Runs on an API thread: - return promptly and do not block in it (C API Best Practices).""" - - def __init__(self, publisher_ready: threading.Event) -> None: - self.publisher_ready = publisher_ready - - def ready(self) -> None: - self.publisher_ready.set() # the buffer has room: the publish loop resumes + self.state.exit_code = 1 + self.state.shutdown.set() class PublishFailureHandler(PublishFailureListener): @@ -314,4 +296,4 @@ def on_failed_publish(self, failed_publish_event: FailedPublishEvent) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - sys.exit(DirectPublisher().main(sys.argv[1:])) + sys.exit(main(sys.argv[1:])) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index 959adf0..36e5843 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -16,13 +16,14 @@ Reference sample. Wired for a basic direct (at-most-once) pub/sub journey: basic-auth connect with a reconnection retry strategy, a plain topic subscription -(no queue, no provisioning), an async DIRECT receiver that detects discards, -reconnection and service interruption listeners registered BEFORE connect(), and a -graceful SIGINT/SIGTERM shutdown. Direct messaging is at-most-once: there is no -broker ACK and no redelivery, so there is NO client acknowledgement here (the -inverse of the guaranteed subscriber). Structured as setup_solace(...), -await_messages(), and teardown_solace(), with teardown run from main's finally on -every exit path. +(no queue, no provisioning), an async DIRECT receiver with a capacity-bounded +buffer that detects discards, reconnection and service interruption listeners +registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown. Direct +messaging is at-most-once: there is no broker ACK and no redelivery, so there is +NO client acknowledgement here (the inverse of the guaranteed subscriber). +Structured as module-level functions, setup_solace(...), await_messages(...), and +teardown_solace(...), that share one SubscriberState; main() runs teardown from +its finally on every exit path. Only practices documented in canonical Solace sources are encoded here. @@ -38,7 +39,7 @@ import signal import sys import threading -from typing import Optional +from dataclasses import dataclass, field from solace.messaging.config.retry_strategy import RetryStrategy from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError @@ -54,7 +55,7 @@ from solace.messaging.receiver.message_receiver import MessageHandler from solace.messaging.resources.topic_subscription import TopicSubscription -from solace_connection_config import SolaceConnectionConfig +from solace_connection_config import load_service_properties APP_NAME = "DirectSubscriber" # topic to subscribe to directly; matches the direct publisher sample's topic root @@ -70,6 +71,9 @@ # HA failover: with_connection_retry_strategy(RetryStrategy.parametrized_retry(1, 3000)), # with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 +# back pressure: once this many received messages wait for the handler, the receiver +# discards each new incoming message until there is room +RECEIVE_BUFFER_CAPACITY = 50 # terminate(grace_period): how long to wait for the handler to drain the messages the API # has already received before the receiver stops; terminate() raises # IncompleteMessageDeliveryError when messages remain after that. A bounded grace period @@ -83,114 +87,123 @@ logger = logging.getLogger(APP_NAME) -class DirectSubscriber: - """The app: the service, the receiver, the flags the listeners flip, and the counters. - Use this type of app for receiving Direct (at-most-once) messages from a topic.""" +@dataclass +class SubscriberState: + """What the main loop, the signal handler, and the API listener threads share: the + service, the receiver, the flags the listeners flip, and the counters. Use this type of + app for receiving Direct (at-most-once) messages from a topic.""" + messaging_service: MessagingService | None = None + receiver: DirectMessageReceiver | None = None + connect_attempted: bool = False + shutdown: threading.Event = field(default_factory=threading.Event) + exit_code: int = 0 # set to 1 on a failure exit (service interruption or a failed terminate()) + msg_recv_counter: int = 0 # num messages received + has_detected_discard: bool = False # any discards seen? - def __init__(self) -> None: - self.messaging_service: Optional[MessagingService] = None - self.receiver: Optional[DirectMessageReceiver] = None - self.connect_attempted = False - self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (service interruption or a failed terminate()) - self.msg_recv_counter = 0 # num messages received - self.has_detected_discard = False # any discards seen? - def main(self, args: list[str]) -> int: - trace(f"{API} {APP_NAME} initializing...") - try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main - # loop exits, and teardown_solace() in the finally below stops the receiver and - # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. Registered before - # setup, so a SIGTERM during the blocking connect() still reaches teardown. - signal.signal(signal.SIGINT, self.on_shutdown_signal) - signal.signal(signal.SIGTERM, self.on_shutdown_signal) - self.setup_solace(args) - self.await_messages() - finally: - self.teardown_solace() - trace("Main thread quitting.") - return self.exit_code # non-zero after a failure, so scripts and supervisors see it - - def on_shutdown_signal(self, signum: int, _frame) -> None: +def main(args: list[str]) -> int: + trace(f"{API} {APP_NAME} initializing...") + state = SubscriberState() + + def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping subscriber...") - self.shutdown.set() - - def setup_solace(self, args: list[str]) -> None: - # basic username/password connection details, built by the shared SolaceConnectionConfig - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() - # build() creates the native session and resolves the host, so an unresolvable host - # fails here, before connect() - self.messaging_service = ( - MessagingService.builder() - .from_properties(properties) - .with_reconnection_retry_strategy( - RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) - .build() - ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(self) - self.messaging_service.add_reconnection_attempt_listener(service_events) - self.messaging_service.add_reconnection_listener(service_events) - self.messaging_service.add_service_interruption_listener(service_events) - self.connect_attempted = True - self.messaging_service.connect() # blocking connect - - # DIRECT receiver: a topic subscription on the receiver (no queue, no provisioning). - # Direct messaging is at-most-once: messages flow straight to the subscriber with no - # broker ACK and no redelivery. start() applies the subscription and blocks until the - # broker confirms it, so the route is live before this subscriber reports ready. The - # receiver keeps an unbounded internal buffer by default (on_back_pressure_elastic); - # on_back_pressure_drop_latest(n) or on_back_pressure_drop_oldest(n) bound it, and a - # dropped message shows up as an internal discard indication on the next one delivered. - trace(f"Adding direct topic subscription '{TOPIC_NAME}'.") - self.receiver = ( - self.messaging_service.create_direct_message_receiver_builder() - .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) - .build() - ) - self.receiver.start() - # see bottom of file for DirectMessageHandler, which receives the messages from the topic - self.receiver.receive_async(DirectMessageHandler(self)) - trace(f"{APP_NAME} subscribed and consuming. Press Ctrl-C to quit.") - - def await_messages(self) -> None: - # async direct receive working now, so time to wait until done... - while not self.shutdown.wait(1.0): # wait 1 second; the wait returns True once shutdown is requested - trace(f"{API} {APP_NAME} Received msgs/s: {self.msg_recv_counter:,}") # simple way of calculating message rates - self.msg_recv_counter = 0 - if self.has_detected_discard: # at least one direct message was dropped before this subscriber - trace("*** Discard detected (at-most-once: a direct message was dropped) ***") - self.has_detected_discard = False # only show the warning once per second - - def teardown_solace(self) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an - # exception), so Solace teardown and application-side cleanup are never skipped. - self.shutdown.set() - if self.receiver is not None and self.receiver.is_running(): - # direct is at-most-once: there are no acknowledgements to drain before exit, so - # terminate() stops delivery and gives the handler up to the grace period to drain - # the messages the API has already received. It raises IncompleteMessageDeliveryError - # when messages remain after that; catch it so the disconnect below still runs - try: - self.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) - except IncompleteMessageDeliveryError as error: - logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", - TERMINATE_GRACE_PERIOD_MS, error) - self.exit_code = 1 - except PubSubPlusClientError as error: - logger.error("Receiver terminate() failed: %s", error) - self.exit_code = 1 - if self.connect_attempted: - # disconnect() raises IllegalStateError on a service that never attempted to connect, - # hence the flag; on a service that is already down it returns quietly - self.messaging_service.disconnect() # will also release the receiver + state.shutdown.set() + + try: + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main + # loop exits, and teardown_solace() in the finally below stops the receiver and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # setup, so a SIGTERM during the blocking connect() still reaches teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + setup_solace(state, args) + await_messages(state) + finally: + teardown_solace(state) + trace("Main thread quitting.") + return state.exit_code # non-zero after a failure, so scripts and supervisors see it + + +def setup_solace(state: SubscriberState, args: list[str]) -> None: + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = load_service_properties(args, APP_NAME) + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + state.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + state.messaging_service.add_reconnection_attempt_listener(service_events) + state.messaging_service.add_reconnection_listener(service_events) + state.messaging_service.add_service_interruption_listener(service_events) + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + + # DIRECT receiver: a topic subscription on the receiver (no queue, no provisioning). + # Direct messaging is at-most-once: messages flow straight to the subscriber with no + # broker ACK and no redelivery. start() applies the subscription and blocks until the + # broker confirms it, so the route is live before this subscriber reports ready. + # Capacity-bounded back pressure: once RECEIVE_BUFFER_CAPACITY messages wait for the + # handler, on_back_pressure_drop_latest(n) discards each new incoming message, and the + # next message delivered carries an internal discard indication. The API default + # (on_back_pressure_elastic) buffers without bound; on_back_pressure_drop_oldest(n) + # discards the oldest buffered message instead. + trace(f"Adding direct topic subscription '{TOPIC_NAME}'.") + state.receiver = ( + state.messaging_service.create_direct_message_receiver_builder() + .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) + .on_back_pressure_drop_latest(RECEIVE_BUFFER_CAPACITY) + .build() + ) + state.receiver.start() + # see bottom of file for DirectMessageHandler, which receives the messages from the topic + state.receiver.receive_async(DirectMessageHandler(state)) + trace(f"{APP_NAME} subscribed and consuming. Press Ctrl-C to quit.") + + +def await_messages(state: SubscriberState) -> None: + # async direct receive working now, so time to wait until done... + while not state.shutdown.wait(1.0): # wait 1 second; the wait returns True once shutdown is requested + trace(f"{API} {APP_NAME} Received msgs/s: {state.msg_recv_counter:,}") # simple way of calculating message rates + state.msg_recv_counter = 0 + if state.has_detected_discard: # at least one direct message was dropped before this subscriber + trace("*** Discard detected (at-most-once: a direct message was dropped) ***") + state.has_detected_discard = False # only show the warning once per second + + +def teardown_solace(state: SubscriberState) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an + # exception), so Solace teardown and application-side cleanup are never skipped. + state.shutdown.set() + if state.receiver is not None and state.receiver.is_running(): + # direct is at-most-once: there are no acknowledgements to drain before exit, so + # terminate() stops delivery and gives the handler up to the grace period to drain + # the messages the API has already received. It raises IncompleteMessageDeliveryError + # when messages remain after that; catch it so the disconnect below still runs + try: + state.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) + except IncompleteMessageDeliveryError as error: + logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + state.exit_code = 1 + except PubSubPlusClientError as error: + logger.error("Receiver terminate() failed: %s", error) + state.exit_code = 1 + if state.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + state.messaging_service.disconnect() # will also release the receiver def trace(message: str) -> None: @@ -209,8 +222,8 @@ class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, Ser return promptly and do not block in them, since events and delivery stall while a callback runs (C API Best Practices).""" - def __init__(self, app: DirectSubscriber) -> None: - self.app = app + def __init__(self, state: SubscriberState) -> None: + self.state = state def on_reconnecting(self, event: ServiceEvent) -> None: # connection lost, automatic reconnect attempt in progress: direct delivery is paused, @@ -232,8 +245,8 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the main loop exits and # teardown_solace() runs in main's finally. - self.app.exit_code = 1 - self.app.shutdown.set() + self.state.exit_code = 1 + self.state.shutdown.set() class DirectMessageHandler(MessageHandler): @@ -242,11 +255,11 @@ class DirectMessageHandler(MessageHandler): it (hand heavy work to the application's own thread or queue), since delivery stalls while it runs (C API Best Practices).""" - def __init__(self, app: DirectSubscriber) -> None: - self.app = app + def __init__(self, state: SubscriberState) -> None: + self.state = state def on_message(self, message: InboundMessage) -> None: - self.app.msg_recv_counter += 1 + self.state.msg_recv_counter += 1 # the publisher sample sends a bytearray payload (the binary attachment), read back with # get_payload_as_bytes(); a str payload arrives via get_payload_as_string() instead # best practice: handle an unexpected message format without raising (C API Best @@ -260,7 +273,7 @@ def on_message(self, message: InboundMessage) -> None: # messages. There is NO redelivery in direct messaging. discard = message.get_message_discard_notification() if discard.has_broker_discard_indication() or discard.has_internal_discard_indication(): - self.app.has_detected_discard = True + self.state.has_detected_discard = True # Direct messaging is at-most-once: there is no consumer-side acknowledgement here (the # inverse of the guaranteed CLIENT-ack subscriber). The message is consumed as it # arrives; the broker holds no copy and expects no ack. @@ -268,4 +281,4 @@ def on_message(self, message: InboundMessage) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - sys.exit(DirectSubscriber().main(sys.argv[1:])) + sys.exit(main(sys.argv[1:])) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 3a07e45..05f7430 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -17,12 +17,12 @@ Reference sample. Wired for a basic publish-subscribe journey: basic-auth connect with a reconnection retry strategy, PERSISTENT binary publish to a topic, publish receipt (ACK/NACK) handling correlated by user context, capacity-bounded back -pressure paired with a publisher readiness listener, reconnection and service +pressure that blocks publish() while the buffer is full, reconnection and service interruption listeners registered BEFORE connect() that pause publishing while the transport is down, and a graceful SIGINT/SIGTERM shutdown that drains outstanding -receipts before disconnecting. Structured as setup_solace(...), -run_publish_loop(), and teardown_solace(), with teardown run from main's finally -on every exit path. +receipts before disconnecting. Structured as module-level functions, +setup_solace(...), run_publish_loop(...), and teardown_solace(...), that share one +PublisherState; main() runs teardown from its finally on every exit path. Only practices documented in canonical Solace sources are encoded here. @@ -39,10 +39,10 @@ import sys import threading import uuid -from typing import Optional +from dataclasses import dataclass, field from solace.messaging.config.retry_strategy import RetryStrategy -from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PublisherOverflowError, PubSubPlusClientError +from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError from solace.messaging.messaging_service import ( MessagingService, ReconnectionAttemptListener, @@ -50,16 +50,14 @@ ServiceEvent, ServiceInterruptionListener, ) -from solace.messaging.publisher.outbound_message import OutboundMessageBuilder from solace.messaging.publisher.persistent_message_publisher import ( MessagePublishReceiptListener, PersistentMessagePublisher, PublishReceipt, ) -from solace.messaging.publisher.publisher_health_check import PublisherReadinessListener from solace.messaging.resources.topic import Topic -from solace_connection_config import SolaceConnectionConfig +from solace_connection_config import load_service_properties APP_NAME = "GuaranteedPublisher" TOPIC_PREFIX = "solace/samples/" # used as the topic "root" @@ -76,8 +74,8 @@ # HA failover: with_connection_retry_strategy(RetryStrategy.parametrized_retry(1, 3000)), # with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 -# back pressure: publish() raises PublisherOverflowError once this many messages wait unsent -PUBLISH_BUFFER_CAPACITY = 1000 +# back pressure: publish() blocks once this many messages wait unsent, until there is room +PUBLISH_BUFFER_CAPACITY = 50 # terminate(grace_period): how long to wait for buffered sends and their broker receipts # to complete before the publisher stops; terminate() raises IncompleteMessageDeliveryError # when sends or receipts are still outstanding after that. A bounded grace period keeps @@ -91,179 +89,173 @@ logger = logging.getLogger(APP_NAME) -class GuaranteedPublisher: - """The app: the service, the publisher, the flags the listeners flip, and the counters.""" +@dataclass +class PublisherState: + """What the publish loop, the signal handler, and the API listener threads share: the + service, the publisher, the flags the listeners flip, and the counters.""" + messaging_service: MessagingService | None = None + publisher: PersistentMessagePublisher | None = None + connect_attempted: bool = False + is_connected: bool = True # tracks transport state via the reconnection listeners + shutdown: threading.Event = field(default_factory=threading.Event) + exit_code: int = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) + msg_sent_counter: int = 0 # num messages sent - def __init__(self) -> None: - self.messaging_service: Optional[MessagingService] = None - self.publisher: Optional[PersistentMessagePublisher] = None - self.message_builder: Optional[OutboundMessageBuilder] = None - self.connect_attempted = False - self.is_connected = True # tracks transport state via the reconnection listeners - self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) - self.publisher_ready = threading.Event() # set by the readiness listener when the buffer has room - self.msg_sent_counter = 0 # num messages sent - def main(self, args: list[str]) -> int: - trace(f"{API} {APP_NAME} initializing...") - try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish - # loop exits, and teardown_solace() in the finally below drains the outstanding - # receipts and disconnects. Python runs signal handlers on the main thread, so the - # handler only flags; the cleanup runs on the main thread's normal exit path. - # Registered before setup, so a SIGTERM during the blocking connect() still reaches - # teardown. - signal.signal(signal.SIGINT, self.on_shutdown_signal) - signal.signal(signal.SIGTERM, self.on_shutdown_signal) - self.setup_solace(args) - self.run_publish_loop() - finally: - self.teardown_solace() - trace("Main thread quitting.") - return self.exit_code # non-zero after a failure, so scripts and supervisors see it - - def on_shutdown_signal(self, signum: int, _frame) -> None: +def main(args: list[str]) -> int: + trace(f"{API} {APP_NAME} initializing...") + state = PublisherState() + + def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") - self.shutdown.set() - - def setup_solace(self, args: list[str]) -> None: - # basic username/password connection details, built by the shared SolaceConnectionConfig - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() - # build() creates the native session and resolves the host, so an unresolvable host - # fails here, before connect() - self.messaging_service = ( - MessagingService.builder() - .from_properties(properties) - .with_reconnection_retry_strategy( - RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) - .build() - ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(self) - self.messaging_service.add_reconnection_attempt_listener(service_events) - self.messaging_service.add_reconnection_listener(service_events) - self.messaging_service.add_service_interruption_listener(service_events) - self.connect_attempted = True - self.messaging_service.connect() # blocking connect - - # PERSISTENT publisher with capacity-bounded back pressure: publish() raises - # PublisherOverflowError once PUBLISH_BUFFER_CAPACITY messages wait unsent, and the - # readiness listener below reports when there is room again. The API default - # (on_back_pressure_elastic) buffers without bound and the developer guide cautions it - # can exhaust memory; on_back_pressure_wait(n) blocks publish() instead of raising. - self.publisher = ( - self.messaging_service.create_persistent_message_publisher_builder() - .on_back_pressure_reject(PUBLISH_BUFFER_CAPACITY) - .build() - ) - self.publisher.start() - # best practice: check every publish receipt; a receipt that carries an exception is a - # NACK or a failure to persist (see PublishReceiptHandler at the bottom of the file). - # The listener is set AFTER start(): the API raises IllegalStateError on a publisher - # that is not started yet. - self.publisher.set_message_publish_receipt_listener(PublishReceiptHandler()) - # documented pairing with on_back_pressure_reject: ready() fires when the buffer has - # room again, so the loop waits on the event instead of spinning on overflow - self.publisher.set_publisher_readiness_listener(PublisherReadinessHandler(self.publisher_ready)) - # best practice: one OutboundMessageBuilder, reused for every message - self.message_builder = self.messaging_service.message_builder() - # best practice for Guaranteed publishing (C API Best Practices): set a time-to-live so - # an unconsumed message does not sit on a queue forever. The queue must have respect-ttl - # enabled (off by default), or the TTL is ignored. Add the Solace message property to the - # builder in milliseconds (0, the default, never expires), for example - # .with_property(message_properties.PERSISTENT_TIME_TO_LIVE, 60_000) - # with `from solace.messaging.config.solace_properties import message_properties`. - - def run_publish_loop(self) -> None: - threading.Thread(target=self.print_stats, name="stats", daemon=True).start() - - trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") - trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/pers/pub/...', " - "please ensure queue has matching subscription.") - while not self.shutdown.is_set(): - if not self.is_connected: # transport is down: wait for the API's automatic reconnect - self.shutdown.wait(0.1) - continue - # each loop, change the payload as an example - chosen_character = chr(self.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] - # a bytearray payload travels as the binary attachment; consumers read it back with - # get_payload_as_bytes() (a str payload would arrive via get_payload_as_string()) - payload = bytearray(chosen_character.encode("ascii") * PAYLOAD_SIZE) - msg_id = str(uuid.uuid4()) - message = self.message_builder.with_application_message_id(msg_id).build(payload) # as an example - # NOTE: publishing to topic, so make sure the consumer's queue is subscribed to the same topic, - # or enable "Reject Message to Sender on No Subscription Match" in the client-profile - topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/pers/pub/{chosen_character}") - try: - # user_context for local ACK/NACK correlation: the API hands it back in the - # PublishReceipt, so an immutable per-send identifier (the message id) lets the - # receipt handler name the exact message the broker accepted or rejected - self.publisher.publish(message, topic, user_context=msg_id) - self.msg_sent_counter += 1 - except PublisherOverflowError: - # buffer full (back pressure): wait for the ready() callback the API sends when the - # buffer has room again, with a bound so a shutdown request is still honoured, and - # retry. is_ready() closes the race with a ready() that fired before clear(). - # notify_when_ready() is not called here: the API sends ready() at once, whatever - # the buffer state, so the wait would return immediately and this loop would spin. - self.publisher_ready.clear() - if not self.publisher.is_ready(): - self.publisher_ready.wait(timeout=1.0) - continue - except PubSubPlusClientError as error: - # publish() raises when the message cannot be sent and retrying would not help - logger.warning("publish() failed, quitting: %s", error) - self.exit_code = 1 - self.shutdown.set() # let's quit; or, could initiate a new connection attempt - break - # delay between messages; the wait returns early once shutdown is requested - self.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed - # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress - self.shutdown.set() - - def print_stats(self) -> None: - # simple way of calculating message rates; wait() returns True (ending the loop) on shutdown - while not self.shutdown.wait(1.0): - trace(f"{API} {APP_NAME} Published msgs/s: {self.msg_sent_counter:,}") - self.msg_sent_counter = 0 - - def teardown_solace(self) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an - # exception), so Solace teardown and application-side cleanup are never skipped. - self.shutdown.set() - if self.publisher is not None and self.publisher.is_running(): - # gracefully finish outstanding receipts before exit: terminate(grace_period) first - # sends what is still buffered, then waits up to the grace period for the broker's - # receipt of every published message, so no in-flight PERSISTENT message is - # abandoned unacknowledged. This is the explicit-condition shutdown; no fixed sleep. - # It raises IncompleteMessageDeliveryError when sends or receipts are still - # outstanding after the grace period; catch it so the disconnect below still runs - try: - self.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) - except IncompleteMessageDeliveryError as error: - logger.error("Publisher stopped with sends or receipts still outstanding after %d ms: %s", - TERMINATE_GRACE_PERIOD_MS, error) - self.exit_code = 1 - except PubSubPlusClientError as error: - logger.error("Publisher terminate() failed: %s", error) - self.exit_code = 1 - if self.connect_attempted: - # disconnect() raises IllegalStateError on a service that never attempted to connect, - # hence the flag; on a service that is already down it returns quietly - self.messaging_service.disconnect() + state.shutdown.set() + + try: + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish + # loop exits, and teardown_solace() in the finally below drains the outstanding + # receipts and disconnects. Python runs signal handlers on the main thread, so the + # handler only flags; the cleanup runs on the main thread's normal exit path. + # Registered before setup, so a SIGTERM during the blocking connect() still reaches + # teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + setup_solace(state, args) + run_publish_loop(state) + finally: + teardown_solace(state) + trace("Main thread quitting.") + return state.exit_code # non-zero after a failure, so scripts and supervisors see it + + +def setup_solace(state: PublisherState, args: list[str]) -> None: + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = load_service_properties(args, APP_NAME) + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + state.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + state.messaging_service.add_reconnection_attempt_listener(service_events) + state.messaging_service.add_reconnection_listener(service_events) + state.messaging_service.add_service_interruption_listener(service_events) + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + + # PERSISTENT publisher with capacity-bounded back pressure: publish() blocks once + # PUBLISH_BUFFER_CAPACITY messages wait unsent, and returns when there is room again. + # The API default (on_back_pressure_elastic) buffers without bound and the developer + # guide cautions it can exhaust memory; on_back_pressure_reject(n) raises + # PublisherOverflowError instead of blocking. + state.publisher = ( + state.messaging_service.create_persistent_message_publisher_builder() + .on_back_pressure_wait(PUBLISH_BUFFER_CAPACITY) + .build() + ) + state.publisher.start() + # best practice: check every publish receipt; a receipt that carries an exception is a + # NACK or a failure to persist (see PublishReceiptHandler at the bottom of the file). + # The listener is set AFTER start(): the API raises IllegalStateError on a publisher + # that is not started yet. + state.publisher.set_message_publish_receipt_listener(PublishReceiptHandler()) + + +def run_publish_loop(state: PublisherState) -> None: + threading.Thread(target=print_stats, args=(state,), name="stats", daemon=True).start() + # best practice: one OutboundMessageBuilder, reused for every message + message_builder = state.messaging_service.message_builder() + # best practice for Guaranteed publishing (C API Best Practices): set a time-to-live so + # an unconsumed message does not sit on a queue forever. The queue must have respect-ttl + # enabled (off by default), or the TTL is ignored. Add the Solace message property to the + # builder in milliseconds (0, the default, never expires), for example + # .with_property(message_properties.PERSISTENT_TIME_TO_LIVE, 60_000) + # with `from solace.messaging.config.solace_properties import message_properties`. + + trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") + trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/pers/pub/...', " + "please ensure queue has matching subscription.") + while not state.shutdown.is_set(): + if not state.is_connected: # transport is down: wait for the API's automatic reconnect + state.shutdown.wait(0.1) + continue + # each loop, change the payload as an example + chosen_character = chr(state.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] + # a bytearray payload travels as the binary attachment; consumers read it back with + # get_payload_as_bytes() (a str payload would arrive via get_payload_as_string()) + payload = bytearray(chosen_character.encode("ascii") * PAYLOAD_SIZE) + msg_id = str(uuid.uuid4()) + message = message_builder.with_application_message_id(msg_id).build(payload) # as an example + # NOTE: publishing to topic, so make sure the consumer's queue is subscribed to the same topic, + # or enable "Reject Message to Sender on No Subscription Match" in the client-profile + topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/pers/pub/{chosen_character}") + try: + # user_context for local ACK/NACK correlation: the API hands it back in the + # PublishReceipt, so an immutable per-send identifier (the message id) lets the + # receipt handler name the exact message the broker accepted or rejected. + # publish() blocks while the buffer is full (back pressure). A shutdown signal that + # arrives during that wait takes effect once publish() returns: when the buffer + # drains, or when the service goes down and the API releases the wait. + state.publisher.publish(message, topic, user_context=msg_id) + state.msg_sent_counter += 1 + except PubSubPlusClientError as error: + # publish() raises when the message cannot be sent and retrying would not help + logger.warning("publish() failed, quitting: %s", error) + state.exit_code = 1 + break # let's quit; or, could initiate a new connection attempt + # delay between messages; the wait returns early once shutdown is requested + state.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed + # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress + state.shutdown.set() + + +def print_stats(state: PublisherState) -> None: + # simple way of calculating message rates; wait() returns True (ending the loop) on shutdown + while not state.shutdown.wait(1.0): + trace(f"{API} {APP_NAME} Published msgs/s: {state.msg_sent_counter:,}") + state.msg_sent_counter = 0 + + +def teardown_solace(state: PublisherState) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an + # exception), so Solace teardown and application-side cleanup are never skipped. + state.shutdown.set() + if state.publisher is not None and state.publisher.is_running(): + # gracefully finish outstanding receipts before exit: terminate(grace_period) first + # sends what is still buffered, then waits up to the grace period for the broker's + # receipt of every published message, so no in-flight PERSISTENT message is + # abandoned unacknowledged. This is the explicit-condition shutdown; no fixed sleep. + # It raises IncompleteMessageDeliveryError when sends or receipts are still + # outstanding after the grace period; catch it so the disconnect below still runs + try: + state.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) + except IncompleteMessageDeliveryError as error: + logger.error("Publisher stopped with sends or receipts still outstanding after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + state.exit_code = 1 + except PubSubPlusClientError as error: + logger.error("Publisher terminate() failed: %s", error) + state.exit_code = 1 + if state.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + state.messaging_service.disconnect() def trace(message: str) -> None: """Demo narration sink: every status line in this sample funnels through this one function. An application replaces this single body to route narration to its - logger or reporting system. The logger calls for API events (service, receipt, - readiness) are a separate channel and stay as they are.""" + logger or reporting system. The logger calls for API events (service, receipt) + are a separate channel and stay as they are.""" print(message, flush=True) @@ -275,18 +267,18 @@ class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, Ser return promptly and do not block in them, since events and delivery stall while a callback runs (C API Best Practices).""" - def __init__(self, app: GuaranteedPublisher) -> None: - self.app = app + def __init__(self, state: PublisherState) -> None: + self.state = state def on_reconnecting(self, event: ServiceEvent) -> None: # connection lost, automatic reconnect attempt in progress: pause publishing logger.warning("Service reconnecting, pausing publishing: %s (%s)", event.get_message(), event.get_cause()) - self.app.is_connected = False + self.state.is_connected = False def on_reconnected(self, event: ServiceEvent) -> None: # automatic reconnect succeeded: resume publishing logger.info("Service reconnected to %s, resuming publishing", event.get_broker_uri()) - self.app.is_connected = True + self.state.is_connected = True def on_service_interrupted(self, event: ServiceEvent) -> None: # the connection went down and cannot be restored (reconnect attempts exhausted) @@ -295,19 +287,8 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the publish loop exits and # teardown_solace() runs in main's finally. - self.app.exit_code = 1 - self.app.shutdown.set() - - -class PublisherReadinessHandler(PublisherReadinessListener): - """Flags the publish loop when the publisher can publish again. Runs on an API thread: - return promptly and do not block in it (C API Best Practices).""" - - def __init__(self, publisher_ready: threading.Event) -> None: - self.publisher_ready = publisher_ready - - def ready(self) -> None: - self.publisher_ready.set() # the buffer has room: the publish loop resumes + self.state.exit_code = 1 + self.state.shutdown.set() class PublishReceiptHandler(MessagePublishReceiptListener): @@ -332,4 +313,4 @@ def on_publish_receipt(self, publish_receipt: PublishReceipt) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - sys.exit(GuaranteedPublisher().main(sys.argv[1:])) + sys.exit(main(sys.argv[1:])) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index f02095b..8bb9f85 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -22,8 +22,9 @@ CLIENT-ack receiver that ACKs only after processing, receiver active/passive state and termination handling, reconnection and service interruption listeners registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown. Structured as -setup_solace(...), await_messages(), and teardown_solace(), with teardown run -from main's finally on every exit path. +module-level functions, setup_solace(...), await_messages(...), and +teardown_solace(...), that share one SubscriberState; main() runs teardown from its +finally on every exit path. Only practices documented in canonical Solace sources are encoded here. @@ -39,7 +40,7 @@ import signal import sys import threading -from typing import Optional +from dataclasses import dataclass, field from solace.messaging.config.missing_resources_creation_configuration import MissingResourcesCreationStrategy from solace.messaging.config.receiver_activation_passivation_configuration import ( @@ -62,7 +63,7 @@ from solace.messaging.resources.topic_subscription import TopicSubscription from solace.messaging.utils.life_cycle_control import TerminationEvent, TerminationNotificationListener -from solace_connection_config import SolaceConnectionConfig +from solace_connection_config import load_service_properties APP_NAME = "GuaranteedSubscriber" QUEUE_NAME = "q_python_sub" @@ -92,141 +93,147 @@ logger = logging.getLogger(APP_NAME) -class GuaranteedSubscriber: - """The app: the service, the receiver, the flags the listeners flip, and the counters. - Use this type of app for receiving Guaranteed messages (e.g. via a queue endpoint).""" +@dataclass +class SubscriberState: + """What the main loop, the signal handler, and the API listener threads share: the + service, the receiver, the flags the listeners flip, and the counters. Use this type of + app for receiving Guaranteed messages (e.g. via a queue endpoint).""" + messaging_service: MessagingService | None = None + receiver: PersistentMessageReceiver | None = None + connect_attempted: bool = False + shutdown: threading.Event = field(default_factory=threading.Event) + exit_code: int = 0 # set to 1 on a failure exit (service interruption, receiver termination, or a failed terminate()) + msg_recv_counter: int = 0 # num messages received + has_detected_redelivery: bool = False # detected any messages being redelivered? - def __init__(self) -> None: - self.messaging_service: Optional[MessagingService] = None - self.receiver: Optional[PersistentMessageReceiver] = None - self.connect_attempted = False - self.shutdown = threading.Event() - self.exit_code = 0 # set to 1 on a failure exit (service interruption, receiver termination, or a failed terminate()) - self.msg_recv_counter = 0 # num messages received - self.has_detected_redelivery = False # detected any messages being redelivered? - def main(self, args: list[str]) -> int: - trace(f"{API} {APP_NAME} initializing...") - try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main - # loop exits, and teardown_solace() in the finally below stops the receiver and - # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. Registered before - # setup, so a SIGTERM during the blocking connect() still reaches teardown. - signal.signal(signal.SIGINT, self.on_shutdown_signal) - signal.signal(signal.SIGTERM, self.on_shutdown_signal) - if not self.setup_solace(args): - return 1 # the bind failed; the finally below still runs teardown - self.await_messages() - finally: - self.teardown_solace() - trace("Main thread quitting.") - return self.exit_code # non-zero after a failure, so scripts and supervisors see it +def main(args: list[str]) -> int: + trace(f"{API} {APP_NAME} initializing...") + state = SubscriberState() - def on_shutdown_signal(self, signum: int, _frame) -> None: + def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping consumer...") - self.shutdown.set() - - def setup_solace(self, args: list[str]) -> bool: - # basic username/password connection details, built by the shared SolaceConnectionConfig - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = SolaceConnectionConfig.load(args, APP_NAME).to_service_properties() - # build() creates the native session and resolves the host, so an unresolvable host - # fails here, before connect() - self.messaging_service = ( - MessagingService.builder() - .from_properties(properties) - .with_reconnection_retry_strategy( - RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) - .build() - ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(self) - self.messaging_service.add_reconnection_attempt_listener(service_events) - self.messaging_service.add_reconnection_listener(service_events) - self.messaging_service.add_service_interruption_listener(service_events) - self.connect_attempted = True - self.messaging_service.connect() # blocking connect - - # configure the queue API object locally: durable, exclusive (single-consumer sample) - queue = Queue.durable_exclusive_queue(QUEUE_NAME) - - # ELEVATION: provision the durable queue in-process and map the topic onto it at - # start, so a fresh broker works out of the box. CREATE_ON_START creates the queue - # passed to build() when start() runs, as long as the client has permission; the - # default, DO_NOT_CREATE, disables provisioning so an administrator provisions the - # queue instead. with_subscriptions() on a durable queue adds the topic subscription - # to the queue at start (pub/sub onto a queue). A re-run reuses the existing queue - # and subscription. - receiver_builder = ( - self.messaging_service.create_persistent_message_receiver_builder() - .with_missing_resources_creation_strategy(MissingResourcesCreationStrategy.CREATE_ON_START) - .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) - # best practice: client acknowledgement (also the API default), so a message leaves - # the queue only after the handler has processed it and called ack() - .with_message_client_acknowledgement() - # request active/passive state changes: on an exclusive queue only one bound receiver - # is ACTIVE; any other is PASSIVE (bound, not receiving) until the active one goes away - .with_activation_passivation_support(ReceiverStateHandler()) - ) - trace(f"Attempting to bind to queue '{QUEUE_NAME}' on the broker.") - self.receiver = receiver_builder.build(queue) - # a broker-initiated termination (for example the queue was deleted or shut down) - # surfaces here; the handler ends the main loop (see ReceiverTerminationHandler below) - self.receiver.set_termination_notification_listener(ReceiverTerminationHandler(self)) + state.shutdown.set() + + try: + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main + # loop exits, and teardown_solace() in the finally below stops the receiver and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # setup, so a SIGTERM during the blocking connect() still reaches teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + if not setup_solace(state, args): + return 1 # the bind failed; the finally below still runs teardown + await_messages(state) + finally: + teardown_solace(state) + trace("Main thread quitting.") + return state.exit_code # non-zero after a failure, so scripts and supervisors see it + + +def setup_solace(state: SubscriberState, args: list[str]) -> bool: + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]) + properties = load_service_properties(args, APP_NAME) + # build() creates the native session and resolves the host, so an unresolvable host + # fails here, before connect() + state.messaging_service = ( + MessagingService.builder() + .from_properties(properties) + .with_reconnection_retry_strategy( + RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .build() + ) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + state.messaging_service.add_reconnection_attempt_listener(service_events) + state.messaging_service.add_reconnection_listener(service_events) + state.messaging_service.add_service_interruption_listener(service_events) + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + + # configure the queue API object locally: durable, exclusive (single-consumer sample) + queue = Queue.durable_exclusive_queue(QUEUE_NAME) + + # ELEVATION: provision the durable queue in-process and map the topic onto it at + # start, so a fresh broker works out of the box. CREATE_ON_START creates the queue + # passed to build() when start() runs, as long as the client has permission; the + # default, DO_NOT_CREATE, disables provisioning so an administrator provisions the + # queue instead. with_subscriptions() on a durable queue adds the topic subscription + # to the queue at start (pub/sub onto a queue). A re-run reuses the existing queue + # and subscription. + receiver_builder = ( + state.messaging_service.create_persistent_message_receiver_builder() + .with_missing_resources_creation_strategy(MissingResourcesCreationStrategy.CREATE_ON_START) + .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) + # best practice: client acknowledgement (also the API default), so a message leaves + # the queue only after the handler has processed it and called ack() + .with_message_client_acknowledgement() + # request active/passive state changes: on an exclusive queue only one bound receiver + # is ACTIVE; any other is PASSIVE (bound, not receiving) until the active one goes away + .with_activation_passivation_support(ReceiverStateHandler()) + ) + trace(f"Attempting to bind to queue '{QUEUE_NAME}' on the broker.") + state.receiver = receiver_builder.build(queue) + # a broker-initiated termination (for example the queue was deleted or shut down) + # surfaces here; the handler ends the main loop (see ReceiverTerminationHandler below) + state.receiver.set_termination_notification_listener(ReceiverTerminationHandler(state)) + try: + # start() provisions the queue plus its subscription (CREATE_ON_START) and binds + # to the queue; the broker then starts sending messages on this receiver + state.receiver.start() + except PubSubPlusClientError as error: + logger.error("Could not bind to queue '%s': %s. If this client may not create endpoints, " + "provision the queue and its topic subscription out-of-band or grant the " + "capability. Exiting.", QUEUE_NAME, error) + return False # teardown_solace() in main's finally disconnects + # see bottom of file for QueueMessageHandler, which receives the messages from the queue + state.receiver.receive_async(QueueMessageHandler(state, state.receiver)) + return True + + +def await_messages(state: SubscriberState) -> None: + # async queue receive working now, so time to wait until done... + trace(f"{APP_NAME} connected, and running. Press Ctrl-C to quit.") + while not state.shutdown.wait(1.0): # wait 1 second; the wait returns True once shutdown is requested + trace(f"{API} {APP_NAME} Received msgs/s: {state.msg_recv_counter:,}") # simple way of calculating message rates + state.msg_recv_counter = 0 + if state.has_detected_redelivery: # try shutting -> enabling the queue on the broker to see this + trace("*** Redelivery detected ***") + state.has_detected_redelivery = False # only show the error once per second + + +def teardown_solace(state: SubscriberState) -> None: + # Application cleanup belongs here: teardown_solace() runs in main's finally on + # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, receiver + # termination, or an exception), so Solace teardown and application-side cleanup + # are never skipped. + state.shutdown.set() + if state.receiver is not None and state.receiver.is_running(): + # gracefully stop delivery before exit: terminate(grace_period) stops the receiver + # and gives the handler up to the grace period to drain (and ACK) the messages the + # API has already received. The receiver turns TERMINATED once its buffer is empty, + # while the handler may still hold the last message, so that message's ack() can + # fail (the API logs a warning). A message received but not yet ACKed stays on the + # queue and is redelivered. terminate() raises IncompleteMessageDeliveryError when + # messages remain after the grace period; catch it so the disconnect below still runs try: - # start() provisions the queue plus its subscription (CREATE_ON_START) and binds - # to the queue; the broker then starts sending messages on this receiver - self.receiver.start() + state.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) + except IncompleteMessageDeliveryError as error: + logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) + state.exit_code = 1 except PubSubPlusClientError as error: - logger.error("Could not bind to queue '%s': %s. If this client may not create endpoints, " - "provision the queue and its topic subscription out-of-band or grant the " - "capability. Exiting.", QUEUE_NAME, error) - return False # teardown_solace() in main's finally disconnects - # see bottom of file for QueueMessageHandler, which receives the messages from the queue - self.receiver.receive_async(QueueMessageHandler(self, self.receiver)) - return True - - def await_messages(self) -> None: - # async queue receive working now, so time to wait until done... - trace(f"{APP_NAME} connected, and running. Press Ctrl-C to quit.") - while not self.shutdown.wait(1.0): # wait 1 second; the wait returns True once shutdown is requested - trace(f"{API} {APP_NAME} Received msgs/s: {self.msg_recv_counter:,}") # simple way of calculating message rates - self.msg_recv_counter = 0 - if self.has_detected_redelivery: # try shutting -> enabling the queue on the broker to see this - trace("*** Redelivery detected ***") - self.has_detected_redelivery = False # only show the error once per second - - def teardown_solace(self) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, receiver - # termination, or an exception), so Solace teardown and application-side cleanup - # are never skipped. - self.shutdown.set() - if self.receiver is not None and self.receiver.is_running(): - # gracefully stop delivery before exit: terminate(grace_period) stops the receiver - # and gives the handler up to the grace period to drain (and ACK) the messages the - # API has already received. The receiver turns TERMINATED once its buffer is empty, - # while the handler may still hold the last message, so that message's ack() can - # fail (the API logs a warning). A message received but not yet ACKed stays on the - # queue and is redelivered. terminate() raises IncompleteMessageDeliveryError when - # messages remain after the grace period; catch it so the disconnect below still runs - try: - self.receiver.terminate(TERMINATE_GRACE_PERIOD_MS) - except IncompleteMessageDeliveryError as error: - logger.error("Receiver stopped with messages still undelivered to the handler after %d ms: %s", - TERMINATE_GRACE_PERIOD_MS, error) - self.exit_code = 1 - except PubSubPlusClientError as error: - logger.error("Receiver terminate() failed: %s", error) - self.exit_code = 1 - if self.connect_attempted: - # disconnect() raises IllegalStateError on a service that never attempted to connect, - # hence the flag; on a service that is already down it returns quietly - self.messaging_service.disconnect() # will also release the receiver + logger.error("Receiver terminate() failed: %s", error) + state.exit_code = 1 + if state.connect_attempted: + # disconnect() raises IllegalStateError on a service that never attempted to connect, + # hence the flag; on a service that is already down it returns quietly + state.messaging_service.disconnect() # will also release the receiver def trace(message: str) -> None: @@ -245,8 +252,8 @@ class ServiceEventHandler(ReconnectionAttemptListener, ReconnectionListener, Ser return promptly and do not block in them, since events and delivery stall while a callback runs (C API Best Practices).""" - def __init__(self, app: GuaranteedSubscriber) -> None: - self.app = app + def __init__(self, state: SubscriberState) -> None: + self.state = state def on_reconnecting(self, event: ServiceEvent) -> None: # connection lost, automatic reconnect attempt in progress @@ -264,8 +271,8 @@ def on_service_interrupted(self, event: ServiceEvent) -> None: # Application cleanup signal: the service will not recover. Trigger application-side # cleanup from here. This sample sets shutdown, so the main loop exits and # teardown_solace() runs in main's finally. - self.app.exit_code = 1 - self.app.shutdown.set() + self.state.exit_code = 1 + self.state.shutdown.set() class ReceiverStateHandler(ReceiverStateChangeListener): @@ -284,8 +291,8 @@ class ReceiverTerminationHandler(TerminationNotificationListener): """Handles a broker-initiated termination of the receiver. Runs on an API thread: return promptly and do not block in it (C API Best Practices).""" - def __init__(self, app: GuaranteedSubscriber) -> None: - self.app = app + def __init__(self, state: SubscriberState) -> None: + self.state = state def on_termination(self, event: TerminationEvent) -> None: # the receiver was terminated by the broker: delivery from the queue has stopped @@ -294,8 +301,8 @@ def on_termination(self, event: TerminationEvent) -> None: # Application cleanup signal: decide here whether to recreate the receiver or shut # down. This sample shuts down: the main loop exits and teardown_solace() runs in # main's finally. - self.app.exit_code = 1 - self.app.shutdown.set() + self.state.exit_code = 1 + self.state.shutdown.set() class QueueMessageHandler(MessageHandler): @@ -304,12 +311,12 @@ class QueueMessageHandler(MessageHandler): (hand heavy work to the application's own thread or queue), since delivery from the queue stalls while it runs (C API Best Practices).""" - def __init__(self, app: GuaranteedSubscriber, receiver: PersistentMessageReceiver) -> None: - self.app = app + def __init__(self, state: SubscriberState, receiver: PersistentMessageReceiver) -> None: + self.state = state self.receiver = receiver def on_message(self, message: InboundMessage) -> None: - self.app.msg_recv_counter += 1 + self.state.msg_recv_counter += 1 try: # the publisher sample sends a bytearray payload (the binary attachment), read back with # get_payload_as_bytes(); a str payload arrives via get_payload_as_string() instead. @@ -322,7 +329,7 @@ def on_message(self, message: InboundMessage) -> None: # this is the broker telling the consumer that this message has been sent and not ACKed before. # this can happen if an exception is thrown, or the broker restarts, or the network disconnects # perhaps an error in processing? Should do extra checks to avoid duplicate processing - self.app.has_detected_redelivery = True + self.state.has_detected_redelivery = True except Exception: # best practice: handle an unexpected message format without crashing, log it, and # still ACK below (C API Best Practices). Without this, the API swallows the exception @@ -342,4 +349,4 @@ def on_message(self, message: InboundMessage) -> None: if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - sys.exit(GuaranteedSubscriber().main(sys.argv[1:])) + sys.exit(main(sys.argv[1:])) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py index 64b4d5d..7fd2b1c 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py @@ -28,7 +28,7 @@ Copied into a generated project as solace_connection_config.py (a module name needs underscores); every sample imports it with - from solace_connection_config import SolaceConnectionConfig + from solace_connection_config import load_service_properties Any generated adaptation of this sample MUST begin with the exact line: AI-assisted code. Review before production use. @@ -38,7 +38,6 @@ import json import os import sys -from typing import Optional from solace.messaging.config.solace_properties import ( authentication_properties, @@ -56,61 +55,47 @@ ) -class SolaceConnectionConfig: - """Connection details for one sample, loaded from config.json or the command line.""" - - def __init__(self, properties: dict[str, object]) -> None: - for key in REQUIRED_KEYS: - _require_non_blank(properties.get(key), key) - # password is optional: absent or blank means an empty password is sent - self._properties = properties - - @staticmethod - def load(args: list[str], app_name: str) -> "SolaceConnectionConfig": - """ - Load connection details from config.json when present in the working directory, - otherwise from the command-line arguments, and log which source was used. Prints a - usage line and exits when neither source supplies host, message-vpn, and - client-username; rejects a blank host, message-vpn, or client-username from either - source. - - Args: - args: the app's command-line arguments without the script name (the fallback source) - app_name: the application name, used only in the usage message - """ - from_file = _try_load_config_file() - if from_file is not None: - host = from_file._properties[transport_layer_properties.HOST] - trace(f"{app_name}: using connection details from {CONFIG_FILE} (host={host}).") - return from_file - if len(args) < 3: - trace(f"No {CONFIG_FILE} in the working directory; provide connection details on the command line.") - trace(f"Usage: {app_name} [password]\n") - sys.exit(1) - from_args: dict[str, object] = { +def load_service_properties(args: list[str], app_name: str) -> dict[str, object]: + """ + Load connection details from config.json when present in the working directory, + otherwise from the command-line arguments, log which source was used, and return the + from_properties() dict with every loaded property (generic pass-through). Prints a + usage line and exits when neither source supplies host, message-vpn, and + client-username; rejects a blank host, message-vpn, or client-username from either + source. The basic-auth password key is always present: the API requires it at build(), + so an absent or blank password is passed as an empty string. + + Args: + args: the app's command-line arguments without the script name (the fallback source) + app_name: the application name, used only in the usage message + """ + properties = _load_config_file() + if properties is not None: + source = CONFIG_FILE + elif len(args) >= 3: + source = "command-line arguments" + properties = { transport_layer_properties.HOST: args[0], service_properties.VPN_NAME: args[1], authentication_properties.SCHEME_BASIC_USER_NAME: args[2], } if len(args) > 3: - from_args[authentication_properties.SCHEME_BASIC_PASSWORD] = args[3] - config = SolaceConnectionConfig(from_args) - trace(f"{app_name}: using connection details from command-line arguments (host={args[0]}).") - return config - - def to_service_properties(self) -> dict[str, object]: - """ - Build the from_properties() dict from every loaded property (generic pass-through). - The basic-auth password key is always present: the API requires it at build(), so - an absent or blank password is passed as an empty string. - """ - properties: dict[str, object] = dict(self._properties) - if not properties.get(authentication_properties.SCHEME_BASIC_PASSWORD): - properties[authentication_properties.SCHEME_BASIC_PASSWORD] = "" - return properties - - -def _try_load_config_file() -> Optional[SolaceConnectionConfig]: + properties[authentication_properties.SCHEME_BASIC_PASSWORD] = args[3] + else: + trace(f"No {CONFIG_FILE} in the working directory; provide connection details on the command line.") + trace(f"Usage: {app_name} [password]\n") + sys.exit(1) + for key in REQUIRED_KEYS: + _require_non_blank(properties.get(key), key) + # password is optional: absent or blank means an empty password is sent + if not properties.get(authentication_properties.SCHEME_BASIC_PASSWORD): + properties[authentication_properties.SCHEME_BASIC_PASSWORD] = "" + host = properties[transport_layer_properties.HOST] + trace(f"{app_name}: using connection details from {source} (host={host}).") + return properties + + +def _load_config_file() -> dict[str, object] | None: if not os.path.exists(CONFIG_FILE): return None # no config file: fall back to the command-line arguments # present but unreadable (for example a permission-locked credentials file): open() @@ -123,7 +108,7 @@ def _try_load_config_file() -> Optional[SolaceConnectionConfig]: for required in REQUIRED_KEYS: if required not in parsed: raise ValueError(f'{CONFIG_FILE} is missing required key "{required}"') - return SolaceConnectionConfig(parsed) + return parsed def _require_non_blank(value: object, key: str) -> None: From 6922b574fbd91b21542c1e259587e79490a20282 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:23:32 -0400 Subject: [PATCH 12/17] EBP-3103: Build Solace resources before connect in the Python samples - setup_solace() now builds the service and the publisher or receiver before any connection exists, and returns a fully populated state, so no state object is created empty and filled in later (no | None fields) - New connect_solace() connects the service and starts the endpoint; main() calls setup_solace() before its try and runs teardown in finally - Remove the reconnect pause from both publishers: a guaranteed publish waits in the buffer during a reconnect, and the direct publisher now documents that messages published during a reconnect are lost and reported through the publish failure listener, pointing to guaranteed messaging for lossless delivery - Raise the back pressure buffer from 50 to 1000, the value the Python developer guide uses in its examples (the API has no default capacity), and note that it counts messages and is tunable to deployment memory --- .../python/python-direct-publisher-sample.py | 131 ++++++++++-------- .../python/python-direct-subscriber-sample.py | 99 +++++++------ .../python-guaranteed-publisher-sample.py | 124 ++++++++++------- .../python-guaranteed-subscriber-sample.py | 94 +++++++------ 4 files changed, 257 insertions(+), 191 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index 7a4abd2..97aa7be 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -23,9 +23,11 @@ messaging is at-most-once: there is no broker receipt, so there is NO publish receipt listener and NO user context here (the inverse of the guaranteed publisher, which acts on each ACK/NACK), and there are no outstanding receipts to -drain at shutdown. Structured as module-level functions, setup_solace(...), -run_publish_loop(...), and teardown_solace(...), that share one PublisherState; -main() runs teardown from its finally on every exit path. +drain at shutdown. Messages published while the API reconnects are lost, and each one +is reported through the publish failure listener. Structured as module-level functions, setup_solace(...), +connect_solace(...), run_publish_loop(...), and teardown_solace(...), that share one +PublisherState; setup_solace() creates the service and the publisher before any +connection exists, and main() runs teardown from its finally on every exit path after that. Only practices documented in canonical Solace sources are encoded here. @@ -42,7 +44,7 @@ import sys import threading import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from solace.messaging.config.retry_strategy import RetryStrategy from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError @@ -78,7 +80,12 @@ # with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 # back pressure: publish() blocks once this many messages wait unsent, until there is room -PUBLISH_BUFFER_CAPACITY = 50 +# The capacity is a count of messages, not a size in bytes: the memory it holds depends on +# the size of each message. Tune it to the memory the application's deployment can give +# the buffer. The API has no default capacity (its default, on_back_pressure_elastic, +# buffers without bound); 1000 is the value the Python developer guide uses in its back +# pressure examples. +PUBLISH_BUFFER_CAPACITY = 1000 # terminate(grace_period): how long to wait for buffered sends to leave before the # publisher stops; terminate() raises IncompleteMessageDeliveryError when messages are # still buffered after that. A bounded grace period keeps shutdown time predictable, for @@ -94,33 +101,37 @@ @dataclass class PublisherState: """What the publish loop, the signal handler, and the API listener threads share: the - service, the publisher, the flags the listeners flip, and the counters.""" - messaging_service: MessagingService | None = None - publisher: DirectMessagePublisher | None = None + service, the publisher, the shutdown event, and the counters. The service and the + publisher are required, so every PublisherState holds both (built, though not + necessarily connected or started).""" + messaging_service: MessagingService + publisher: DirectMessagePublisher + shutdown: threading.Event connect_attempted: bool = False - is_connected: bool = True # tracks transport state via the reconnection listeners - shutdown: threading.Event = field(default_factory=threading.Event) exit_code: int = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) msg_sent_counter: int = 0 # num messages sent def main(args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") - state = PublisherState() + shutdown = threading.Event() def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") - state.shutdown.set() - + shutdown.set() + + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish + # loop exits, and teardown_solace() in the finally below stops the publisher and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # connect, so a SIGTERM during the blocking connect() still reaches teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + # setup_solace() raises before any connection exists, so there is nothing to tear down + # if it fails + state = setup_solace(args, shutdown) try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish - # loop exits, and teardown_solace() in the finally below stops the publisher and - # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. Registered before - # setup, so a SIGTERM during the blocking connect() still reaches teardown. - signal.signal(signal.SIGINT, on_shutdown_signal) - signal.signal(signal.SIGTERM, on_shutdown_signal) - setup_solace(state, args) + connect_solace(state) run_publish_loop(state) finally: teardown_solace(state) @@ -128,45 +139,51 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(state: PublisherState, args: list[str]) -> None: +def setup_solace(args: list[str], shutdown: threading.Event) -> PublisherState: # basic username/password connection details, built by the shared connection-config # helper: read from a config.json in the working directory if present, else from the # command line ( [password]) properties = load_service_properties(args, APP_NAME) # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() - state.messaging_service = ( + messaging_service = ( MessagingService.builder() .from_properties(properties) .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) .build() ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(state) - state.messaging_service.add_reconnection_attempt_listener(service_events) - state.messaging_service.add_reconnection_listener(service_events) - state.messaging_service.add_service_interruption_listener(service_events) - state.connect_attempted = True - state.messaging_service.connect() # blocking connect - # DIRECT publisher with capacity-bounded back pressure: publish() blocks once # PUBLISH_BUFFER_CAPACITY messages wait unsent, and returns when there is room again. # The API default (on_back_pressure_elastic) buffers without bound and the developer # guide cautions it can exhaust memory; on_back_pressure_reject(n) raises - # PublisherOverflowError instead of blocking. - state.publisher = ( - state.messaging_service.create_direct_message_publisher_builder() + # PublisherOverflowError instead of blocking. Building the publisher needs only the + # built service; start() is what requires the connection. One service can serve + # several publishers. + publisher = ( + messaging_service.create_direct_message_publisher_builder() .on_back_pressure_wait(PUBLISH_BUFFER_CAPACITY) .build() ) # direct messaging has no broker receipt, so failures the API detects after publish() # returned (a buffered message that could not be sent) arrive asynchronously through # this listener; without it they are silent - state.publisher.set_publish_failure_listener(PublishFailureHandler()) - state.publisher.start() + publisher.set_publish_failure_listener(PublishFailureHandler()) + state = PublisherState(messaging_service=messaging_service, publisher=publisher, shutdown=shutdown) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + messaging_service.add_reconnection_attempt_listener(service_events) + messaging_service.add_reconnection_listener(service_events) + messaging_service.add_service_interruption_listener(service_events) + return state + + +def connect_solace(state: PublisherState) -> None: + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + state.publisher.start() # raises IllegalStateError unless the service is connected def run_publish_loop(state: PublisherState) -> None: @@ -178,9 +195,6 @@ def run_publish_loop(state: PublisherState) -> None: trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/direct/pub/...', " "at-most-once (no broker receipt, no redelivery).") while not state.shutdown.is_set(): - if not state.is_connected: # transport is down: wait for the API's automatic reconnect - state.shutdown.wait(0.1) - continue # each loop, change the payload as an example chosen_character = chr(state.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] # a bytearray payload travels as the binary attachment; consumers read it back with @@ -192,9 +206,11 @@ def run_publish_loop(state: PublisherState) -> None: message = message_builder.with_application_message_id(str(uuid.uuid4())).build(payload) # as an example topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/direct/pub/{chosen_character}") try: - # blocks while the buffer is full (back pressure). A shutdown signal that arrives - # during that wait takes effect once publish() returns: when the buffer drains, or - # when the service goes down and the API releases the wait. + # blocks while the buffer is full (back pressure) on a connected transport; during + # a reconnect it does not block (see ServiceEventHandler.on_reconnecting). A + # shutdown signal that arrives during that wait takes effect once publish() + # returns: when the buffer drains, or when the service goes down and the API + # releases the wait. state.publisher.publish(message, topic) state.msg_sent_counter += 1 except PubSubPlusClientError as error: @@ -216,11 +232,12 @@ def print_stats(state: PublisherState) -> None: def teardown_solace(state: PublisherState) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an - # exception), so Solace teardown and application-side cleanup are never skipped. + # Application cleanup belongs here: once setup_solace() returns, teardown_solace() runs + # in main's finally on EVERY exit path (normal quit, SIGINT/SIGTERM, service + # interruption, or an exception), so Solace teardown and application-side cleanup are + # never skipped. state.shutdown.set() - if state.publisher is not None and state.publisher.is_running(): + if state.publisher.is_running(): # direct is at-most-once: there are no broker receipts to drain, so terminate() only # gives the messages still in the publisher's buffer up to the grace period to leave. # It raises IncompleteMessageDeliveryError when messages are still buffered after @@ -260,16 +277,18 @@ def __init__(self, state: PublisherState) -> None: self.state = state def on_reconnecting(self, event: ServiceEvent) -> None: - # connection lost, automatic reconnect attempt in progress: pause publishing rather - # than fill the buffer (direct is at-most-once: a buffered message the API cannot send - # is reported only through the publish failure listener, never redelivered) - logger.warning("Service reconnecting, pausing publishing: %s (%s)", event.get_message(), event.get_cause()) - self.state.is_connected = False + # connection lost, automatic reconnect attempt in progress. Direct messaging is + # at-most-once: while the transport is down, publish() does not block, and the API + # fails each message it cannot send and reports it through the publish failure + # listener (PublishFailureHandler below). An application that must not lose messages + # uses guaranteed messaging instead (see the guaranteed publisher sample); one that + # must not produce messages during an outage can pause its publish loop between this + # event and on_reconnected(). + logger.warning("Service reconnecting: %s (%s)", event.get_message(), event.get_cause()) def on_reconnected(self, event: ServiceEvent) -> None: - # automatic reconnect succeeded: resume publishing - logger.info("Service reconnected to %s, resuming publishing", event.get_broker_uri()) - self.state.is_connected = True + # automatic reconnect succeeded: messages published from now on are sent again + logger.info("Service reconnected to %s", event.get_broker_uri()) def on_service_interrupted(self, event: ServiceEvent) -> None: # the connection went down and cannot be restored (reconnect attempts exhausted) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index 36e5843..c7c80a9 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -21,9 +21,10 @@ registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown. Direct messaging is at-most-once: there is no broker ACK and no redelivery, so there is NO client acknowledgement here (the inverse of the guaranteed subscriber). -Structured as module-level functions, setup_solace(...), await_messages(...), and -teardown_solace(...), that share one SubscriberState; main() runs teardown from -its finally on every exit path. +Structured as module-level functions, setup_solace(...), connect_solace(...), +await_messages(...), and teardown_solace(...), that share one SubscriberState; +setup_solace() creates the service and the receiver before any connection exists, +and main() runs teardown from its finally on every exit path after that. Only practices documented in canonical Solace sources are encoded here. @@ -39,7 +40,7 @@ import signal import sys import threading -from dataclasses import dataclass, field +from dataclasses import dataclass from solace.messaging.config.retry_strategy import RetryStrategy from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError @@ -73,7 +74,12 @@ # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 # back pressure: once this many received messages wait for the handler, the receiver # discards each new incoming message until there is room -RECEIVE_BUFFER_CAPACITY = 50 +# The capacity is a count of messages, not a size in bytes: the memory it holds depends on +# the size of each message. Tune it to the memory the application's deployment can give +# the buffer. The API has no default capacity (its default, on_back_pressure_elastic, +# buffers without bound); 1000 is the value the Python developer guide uses in its back +# pressure examples. +RECEIVE_BUFFER_CAPACITY = 1000 # terminate(grace_period): how long to wait for the handler to drain the messages the API # has already received before the receiver stops; terminate() raises # IncompleteMessageDeliveryError when messages remain after that. A bounded grace period @@ -90,12 +96,14 @@ @dataclass class SubscriberState: """What the main loop, the signal handler, and the API listener threads share: the - service, the receiver, the flags the listeners flip, and the counters. Use this type of - app for receiving Direct (at-most-once) messages from a topic.""" - messaging_service: MessagingService | None = None - receiver: DirectMessageReceiver | None = None + service, the receiver, the shutdown event, the flags the listeners flip, and the + counters. The service and the receiver are required, so every SubscriberState holds + both (built, though not necessarily connected or started). Use this type of app for + receiving Direct (at-most-once) messages from a topic.""" + messaging_service: MessagingService + receiver: DirectMessageReceiver + shutdown: threading.Event connect_attempted: bool = False - shutdown: threading.Event = field(default_factory=threading.Event) exit_code: int = 0 # set to 1 on a failure exit (service interruption or a failed terminate()) msg_recv_counter: int = 0 # num messages received has_detected_discard: bool = False # any discards seen? @@ -103,21 +111,24 @@ class SubscriberState: def main(args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") - state = SubscriberState() + shutdown = threading.Event() def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping subscriber...") - state.shutdown.set() - + shutdown.set() + + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main + # loop exits, and teardown_solace() in the finally below stops the receiver and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # connect, so a SIGTERM during the blocking connect() still reaches teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + # setup_solace() raises before any connection exists, so there is nothing to tear down + # if it fails + state = setup_solace(args, shutdown) try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main - # loop exits, and teardown_solace() in the finally below stops the receiver and - # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. Registered before - # setup, so a SIGTERM during the blocking connect() still reaches teardown. - signal.signal(signal.SIGINT, on_shutdown_signal) - signal.signal(signal.SIGTERM, on_shutdown_signal) - setup_solace(state, args) + connect_solace(state) await_messages(state) finally: teardown_solace(state) @@ -125,30 +136,20 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(state: SubscriberState, args: list[str]) -> None: +def setup_solace(args: list[str], shutdown: threading.Event) -> SubscriberState: # basic username/password connection details, built by the shared connection-config # helper: read from a config.json in the working directory if present, else from the # command line ( [password]) properties = load_service_properties(args, APP_NAME) # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() - state.messaging_service = ( + messaging_service = ( MessagingService.builder() .from_properties(properties) .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) .build() ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(state) - state.messaging_service.add_reconnection_attempt_listener(service_events) - state.messaging_service.add_reconnection_listener(service_events) - state.messaging_service.add_service_interruption_listener(service_events) - state.connect_attempted = True - state.messaging_service.connect() # blocking connect - # DIRECT receiver: a topic subscription on the receiver (no queue, no provisioning). # Direct messaging is at-most-once: messages flow straight to the subscriber with no # broker ACK and no redelivery. start() applies the subscription and blocks until the @@ -157,14 +158,29 @@ def setup_solace(state: SubscriberState, args: list[str]) -> None: # handler, on_back_pressure_drop_latest(n) discards each new incoming message, and the # next message delivered carries an internal discard indication. The API default # (on_back_pressure_elastic) buffers without bound; on_back_pressure_drop_oldest(n) - # discards the oldest buffered message instead. - trace(f"Adding direct topic subscription '{TOPIC_NAME}'.") - state.receiver = ( - state.messaging_service.create_direct_message_receiver_builder() + # discards the oldest buffered message instead. Building the receiver needs only the + # built service; start() is what requires the connection. + receiver = ( + messaging_service.create_direct_message_receiver_builder() .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) .on_back_pressure_drop_latest(RECEIVE_BUFFER_CAPACITY) .build() ) + state = SubscriberState(messaging_service=messaging_service, receiver=receiver, shutdown=shutdown) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + messaging_service.add_reconnection_attempt_listener(service_events) + messaging_service.add_reconnection_listener(service_events) + messaging_service.add_service_interruption_listener(service_events) + return state + + +def connect_solace(state: SubscriberState) -> None: + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + trace(f"Adding direct topic subscription '{TOPIC_NAME}'.") state.receiver.start() # see bottom of file for DirectMessageHandler, which receives the messages from the topic state.receiver.receive_async(DirectMessageHandler(state)) @@ -182,11 +198,12 @@ def await_messages(state: SubscriberState) -> None: def teardown_solace(state: SubscriberState) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an - # exception), so Solace teardown and application-side cleanup are never skipped. + # Application cleanup belongs here: once setup_solace() returns, teardown_solace() runs + # in main's finally on EVERY exit path (normal quit, SIGINT/SIGTERM, service + # interruption, or an exception), so Solace teardown and application-side cleanup are + # never skipped. state.shutdown.set() - if state.receiver is not None and state.receiver.is_running(): + if state.receiver.is_running(): # direct is at-most-once: there are no acknowledgements to drain before exit, so # terminate() stops delivery and gives the handler up to the grace period to drain # the messages the API has already received. It raises IncompleteMessageDeliveryError diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 05f7430..f79ad25 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -18,11 +18,12 @@ with a reconnection retry strategy, PERSISTENT binary publish to a topic, publish receipt (ACK/NACK) handling correlated by user context, capacity-bounded back pressure that blocks publish() while the buffer is full, reconnection and service -interruption listeners registered BEFORE connect() that pause publishing while the -transport is down, and a graceful SIGINT/SIGTERM shutdown that drains outstanding +interruption listeners registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown that drains outstanding receipts before disconnecting. Structured as module-level functions, -setup_solace(...), run_publish_loop(...), and teardown_solace(...), that share one -PublisherState; main() runs teardown from its finally on every exit path. +setup_solace(...), connect_solace(...), run_publish_loop(...), and teardown_solace(...), +that share one PublisherState; setup_solace() creates the service and the publisher +before any connection exists, and main() runs teardown from its finally on every exit +path after that. Only practices documented in canonical Solace sources are encoded here. @@ -39,7 +40,7 @@ import sys import threading import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from solace.messaging.config.retry_strategy import RetryStrategy from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError @@ -75,7 +76,12 @@ # with_reconnection_retry_strategy(RetryStrategy.parametrized_retry(20, 3000)), # properties[transport_layer_properties.CONNECTION_RETRIES_PER_HOST] = 5 # back pressure: publish() blocks once this many messages wait unsent, until there is room -PUBLISH_BUFFER_CAPACITY = 50 +# The capacity is a count of messages, not a size in bytes: the memory it holds depends on +# the size of each message. Tune it to the memory the application's deployment can give +# the buffer. The API has no default capacity (its default, on_back_pressure_elastic, +# buffers without bound); 1000 is the value the Python developer guide uses in its back +# pressure examples. +PUBLISH_BUFFER_CAPACITY = 1000 # terminate(grace_period): how long to wait for buffered sends and their broker receipts # to complete before the publisher stops; terminate() raises IncompleteMessageDeliveryError # when sends or receipts are still outstanding after that. A bounded grace period keeps @@ -92,34 +98,38 @@ @dataclass class PublisherState: """What the publish loop, the signal handler, and the API listener threads share: the - service, the publisher, the flags the listeners flip, and the counters.""" - messaging_service: MessagingService | None = None - publisher: PersistentMessagePublisher | None = None + service, the publisher, the shutdown event, and the counters. The service and the + publisher are required, so every PublisherState holds both (built, though not + necessarily connected or started).""" + messaging_service: MessagingService + publisher: PersistentMessagePublisher + shutdown: threading.Event connect_attempted: bool = False - is_connected: bool = True # tracks transport state via the reconnection listeners - shutdown: threading.Event = field(default_factory=threading.Event) exit_code: int = 0 # set to 1 on a failure exit (service interruption, a publish() failure, or a failed terminate()) msg_sent_counter: int = 0 # num messages sent def main(args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") - state = PublisherState() + shutdown = threading.Event() def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping publisher...") - state.shutdown.set() - + shutdown.set() + + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish + # loop exits, and teardown_solace() in the finally below drains the outstanding + # receipts and disconnects. Python runs signal handlers on the main thread, so the + # handler only flags; the cleanup runs on the main thread's normal exit path. + # Registered before connect, so a SIGTERM during the blocking connect() still reaches + # teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + # setup_solace() raises before any connection exists, so there is nothing to tear down + # if it fails + state = setup_solace(args, shutdown) try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the publish - # loop exits, and teardown_solace() in the finally below drains the outstanding - # receipts and disconnects. Python runs signal handlers on the main thread, so the - # handler only flags; the cleanup runs on the main thread's normal exit path. - # Registered before setup, so a SIGTERM during the blocking connect() still reaches - # teardown. - signal.signal(signal.SIGINT, on_shutdown_signal) - signal.signal(signal.SIGTERM, on_shutdown_signal) - setup_solace(state, args) + connect_solace(state) run_publish_loop(state) finally: teardown_solace(state) @@ -127,41 +137,47 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(state: PublisherState, args: list[str]) -> None: +def setup_solace(args: list[str], shutdown: threading.Event) -> PublisherState: # basic username/password connection details, built by the shared connection-config # helper: read from a config.json in the working directory if present, else from the # command line ( [password]) properties = load_service_properties(args, APP_NAME) # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() - state.messaging_service = ( + messaging_service = ( MessagingService.builder() .from_properties(properties) .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) .build() ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(state) - state.messaging_service.add_reconnection_attempt_listener(service_events) - state.messaging_service.add_reconnection_listener(service_events) - state.messaging_service.add_service_interruption_listener(service_events) - state.connect_attempted = True - state.messaging_service.connect() # blocking connect - # PERSISTENT publisher with capacity-bounded back pressure: publish() blocks once # PUBLISH_BUFFER_CAPACITY messages wait unsent, and returns when there is room again. # The API default (on_back_pressure_elastic) buffers without bound and the developer # guide cautions it can exhaust memory; on_back_pressure_reject(n) raises - # PublisherOverflowError instead of blocking. - state.publisher = ( - state.messaging_service.create_persistent_message_publisher_builder() + # PublisherOverflowError instead of blocking. Building the publisher needs only the + # built service; start() is what requires the connection. One service can serve + # several publishers. + publisher = ( + messaging_service.create_persistent_message_publisher_builder() .on_back_pressure_wait(PUBLISH_BUFFER_CAPACITY) .build() ) - state.publisher.start() + state = PublisherState(messaging_service=messaging_service, publisher=publisher, shutdown=shutdown) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + messaging_service.add_reconnection_attempt_listener(service_events) + messaging_service.add_reconnection_listener(service_events) + messaging_service.add_service_interruption_listener(service_events) + return state + + +def connect_solace(state: PublisherState) -> None: + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + state.publisher.start() # raises IllegalStateError unless the service is connected # best practice: check every publish receipt; a receipt that carries an exception is a # NACK or a failure to persist (see PublishReceiptHandler at the bottom of the file). # The listener is set AFTER start(): the API raises IllegalStateError on a publisher @@ -184,9 +200,6 @@ def run_publish_loop(state: PublisherState) -> None: trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/pers/pub/...', " "please ensure queue has matching subscription.") while not state.shutdown.is_set(): - if not state.is_connected: # transport is down: wait for the API's automatic reconnect - state.shutdown.wait(0.1) - continue # each loop, change the payload as an example chosen_character = chr(state.msg_sent_counter % 26 + 65) # choose a "random" letter [A-Z] # a bytearray payload travels as the binary attachment; consumers read it back with @@ -201,7 +214,11 @@ def run_publish_loop(state: PublisherState) -> None: # user_context for local ACK/NACK correlation: the API hands it back in the # PublishReceipt, so an immutable per-send identifier (the message id) lets the # receipt handler name the exact message the broker accepted or rejected. - # publish() blocks while the buffer is full (back pressure). A shutdown signal that + # publish() blocks while the buffer is full (back pressure). During an automatic + # reconnect the messages wait in the buffer for the transport and go out after + # it, and publish() blocks only once the buffer fills; the loop needs no pause of + # its own (the direct publisher, whose messages fail instead of waiting, does need + # one). A shutdown signal that # arrives during that wait takes effect once publish() returns: when the buffer # drains, or when the service goes down and the API releases the wait. state.publisher.publish(message, topic, user_context=msg_id) @@ -225,11 +242,12 @@ def print_stats(state: PublisherState) -> None: def teardown_solace(state: PublisherState) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, or an - # exception), so Solace teardown and application-side cleanup are never skipped. + # Application cleanup belongs here: once setup_solace() returns, teardown_solace() runs + # in main's finally on EVERY exit path (normal quit, SIGINT/SIGTERM, service + # interruption, or an exception), so Solace teardown and application-side cleanup are + # never skipped. state.shutdown.set() - if state.publisher is not None and state.publisher.is_running(): + if state.publisher.is_running(): # gracefully finish outstanding receipts before exit: terminate(grace_period) first # sends what is still buffered, then waits up to the grace period for the broker's # receipt of every published message, so no in-flight PERSISTENT message is @@ -271,14 +289,14 @@ def __init__(self, state: PublisherState) -> None: self.state = state def on_reconnecting(self, event: ServiceEvent) -> None: - # connection lost, automatic reconnect attempt in progress: pause publishing - logger.warning("Service reconnecting, pausing publishing: %s (%s)", event.get_message(), event.get_cause()) - self.state.is_connected = False + # connection lost, automatic reconnect attempt in progress: new messages wait in the + # buffer (publish() blocks once it is full) and go out after the reconnect, so no + # guaranteed message is lost + logger.warning("Service reconnecting: %s (%s)", event.get_message(), event.get_cause()) def on_reconnected(self, event: ServiceEvent) -> None: - # automatic reconnect succeeded: resume publishing - logger.info("Service reconnected to %s, resuming publishing", event.get_broker_uri()) - self.state.is_connected = True + # automatic reconnect succeeded: the waiting messages go out + logger.info("Service reconnected to %s", event.get_broker_uri()) def on_service_interrupted(self, event: ServiceEvent) -> None: # the connection went down and cannot be restored (reconnect attempts exhausted) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index 8bb9f85..ca8d233 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -22,9 +22,10 @@ CLIENT-ack receiver that ACKs only after processing, receiver active/passive state and termination handling, reconnection and service interruption listeners registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown. Structured as -module-level functions, setup_solace(...), await_messages(...), and -teardown_solace(...), that share one SubscriberState; main() runs teardown from its -finally on every exit path. +module-level functions, setup_solace(...), connect_solace(...), await_messages(...), +and teardown_solace(...), that share one SubscriberState; setup_solace() creates the +service and the receiver before any connection exists, and main() runs teardown from +its finally on every exit path after that. Only practices documented in canonical Solace sources are encoded here. @@ -40,7 +41,7 @@ import signal import sys import threading -from dataclasses import dataclass, field +from dataclasses import dataclass from solace.messaging.config.missing_resources_creation_configuration import MissingResourcesCreationStrategy from solace.messaging.config.receiver_activation_passivation_configuration import ( @@ -96,12 +97,14 @@ @dataclass class SubscriberState: """What the main loop, the signal handler, and the API listener threads share: the - service, the receiver, the flags the listeners flip, and the counters. Use this type of - app for receiving Guaranteed messages (e.g. via a queue endpoint).""" - messaging_service: MessagingService | None = None - receiver: PersistentMessageReceiver | None = None + service, the receiver, the shutdown event, the flags the listeners flip, and the + counters. The service and the receiver are required, so every SubscriberState holds + both (built, though not necessarily connected or started). Use this type of app for + receiving Guaranteed messages (e.g. via a queue endpoint).""" + messaging_service: MessagingService + receiver: PersistentMessageReceiver + shutdown: threading.Event connect_attempted: bool = False - shutdown: threading.Event = field(default_factory=threading.Event) exit_code: int = 0 # set to 1 on a failure exit (service interruption, receiver termination, or a failed terminate()) msg_recv_counter: int = 0 # num messages received has_detected_redelivery: bool = False # detected any messages being redelivered? @@ -109,21 +112,24 @@ class SubscriberState: def main(args: list[str]) -> int: trace(f"{API} {APP_NAME} initializing...") - state = SubscriberState() + shutdown = threading.Event() def on_shutdown_signal(signum: int, _frame) -> None: trace(f"Shutdown signal received ({signal.Signals(signum).name}), stopping consumer...") - state.shutdown.set() - + shutdown.set() + + # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main + # loop exits, and teardown_solace() in the finally below stops the receiver and + # disconnects. Python runs signal handlers on the main thread, so the handler only + # flags; the cleanup runs on the main thread's normal exit path. Registered before + # connect, so a SIGTERM during the blocking connect() still reaches teardown. + signal.signal(signal.SIGINT, on_shutdown_signal) + signal.signal(signal.SIGTERM, on_shutdown_signal) + # setup_solace() raises before any connection exists, so there is nothing to tear down + # if it fails + state = setup_solace(args, shutdown) try: - # graceful shutdown: SIGINT (Ctrl-C) or SIGTERM sets the shutdown event, the main - # loop exits, and teardown_solace() in the finally below stops the receiver and - # disconnects. Python runs signal handlers on the main thread, so the handler only - # flags; the cleanup runs on the main thread's normal exit path. Registered before - # setup, so a SIGTERM during the blocking connect() still reaches teardown. - signal.signal(signal.SIGINT, on_shutdown_signal) - signal.signal(signal.SIGTERM, on_shutdown_signal) - if not setup_solace(state, args): + if not connect_solace(state): return 1 # the bind failed; the finally below still runs teardown await_messages(state) finally: @@ -132,30 +138,20 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(state: SubscriberState, args: list[str]) -> bool: +def setup_solace(args: list[str], shutdown: threading.Event) -> SubscriberState: # basic username/password connection details, built by the shared connection-config # helper: read from a config.json in the working directory if present, else from the # command line ( [password]) properties = load_service_properties(args, APP_NAME) # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() - state.messaging_service = ( + messaging_service = ( MessagingService.builder() .from_properties(properties) .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) .build() ) - # best practice: register the service event listeners BEFORE connect(), so no - # reconnection or interruption event raised during or right after the connect is lost, - # and handle each event appropriately rather than only logging it - service_events = ServiceEventHandler(state) - state.messaging_service.add_reconnection_attempt_listener(service_events) - state.messaging_service.add_reconnection_listener(service_events) - state.messaging_service.add_service_interruption_listener(service_events) - state.connect_attempted = True - state.messaging_service.connect() # blocking connect - # configure the queue API object locally: durable, exclusive (single-consumer sample) queue = Queue.durable_exclusive_queue(QUEUE_NAME) @@ -167,7 +163,7 @@ def setup_solace(state: SubscriberState, args: list[str]) -> bool: # to the queue at start (pub/sub onto a queue). A re-run reuses the existing queue # and subscription. receiver_builder = ( - state.messaging_service.create_persistent_message_receiver_builder() + messaging_service.create_persistent_message_receiver_builder() .with_missing_resources_creation_strategy(MissingResourcesCreationStrategy.CREATE_ON_START) .with_subscriptions([TopicSubscription.of(TOPIC_NAME)]) # best practice: client acknowledgement (also the API default), so a message leaves @@ -177,11 +173,27 @@ def setup_solace(state: SubscriberState, args: list[str]) -> bool: # is ACTIVE; any other is PASSIVE (bound, not receiving) until the active one goes away .with_activation_passivation_support(ReceiverStateHandler()) ) - trace(f"Attempting to bind to queue '{QUEUE_NAME}' on the broker.") - state.receiver = receiver_builder.build(queue) + # building the receiver needs only the built service; start() is what requires the + # connection + receiver = receiver_builder.build(queue) + state = SubscriberState(messaging_service=messaging_service, receiver=receiver, shutdown=shutdown) # a broker-initiated termination (for example the queue was deleted or shut down) # surfaces here; the handler ends the main loop (see ReceiverTerminationHandler below) - state.receiver.set_termination_notification_listener(ReceiverTerminationHandler(state)) + receiver.set_termination_notification_listener(ReceiverTerminationHandler(state)) + # best practice: register the service event listeners BEFORE connect(), so no + # reconnection or interruption event raised during or right after the connect is lost, + # and handle each event appropriately rather than only logging it + service_events = ServiceEventHandler(state) + messaging_service.add_reconnection_attempt_listener(service_events) + messaging_service.add_reconnection_listener(service_events) + messaging_service.add_service_interruption_listener(service_events) + return state + + +def connect_solace(state: SubscriberState) -> bool: + state.connect_attempted = True + state.messaging_service.connect() # blocking connect + trace(f"Attempting to bind to queue '{QUEUE_NAME}' on the broker.") try: # start() provisions the queue plus its subscription (CREATE_ON_START) and binds # to the queue; the broker then starts sending messages on this receiver @@ -208,12 +220,12 @@ def await_messages(state: SubscriberState) -> None: def teardown_solace(state: SubscriberState) -> None: - # Application cleanup belongs here: teardown_solace() runs in main's finally on - # EVERY exit path (normal quit, SIGINT/SIGTERM, service interruption, receiver - # termination, or an exception), so Solace teardown and application-side cleanup - # are never skipped. + # Application cleanup belongs here: once setup_solace() returns, teardown_solace() runs + # in main's finally on EVERY exit path (normal quit, SIGINT/SIGTERM, service + # interruption, receiver termination, or an exception), so Solace teardown and + # application-side cleanup are never skipped. state.shutdown.set() - if state.receiver is not None and state.receiver.is_running(): + if state.receiver.is_running(): # gracefully stop delivery before exit: terminate(grace_period) stops the receiver # and gives the handler up to the grace period to drain (and ACK) the messages the # API has already received. The receiver turns TERMINATED once its buffer is empty, From 250de38f1b3110c5f28faa66a3f898caa33599d6 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:42:00 -0400 Subject: [PATCH 13/17] EBP-3103: Publish direct messages builderless and template guaranteed ones - Direct publisher passes the payload straight to publish() with no OutboundMessageBuilder, and shows additional_message_properties for per-message headers; a comment notes the builder suits "templated" messages with many fixed headers - Guaranteed publisher uses the builder as a template: the time-to-live (new MESSAGE_TTL_MS, 60 s) is set once, since build() copies the template into each message, and the per-message application message id goes through build()'s additional_message_properties so it never changes the template --- .../python/python-direct-publisher-sample.py | 17 +++++--- .../python-guaranteed-publisher-sample.py | 39 ++++++++++++------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index 97aa7be..e04d4ef 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -24,7 +24,8 @@ receipt listener and NO user context here (the inverse of the guaranteed publisher, which acts on each ACK/NACK), and there are no outstanding receipts to drain at shutdown. Messages published while the API reconnects are lost, and each one -is reported through the publish failure listener. Structured as module-level functions, setup_solace(...), +is reported through the publish failure listener. Publishes the payload directly, with +no OutboundMessageBuilder. Structured as module-level functions, setup_solace(...), connect_solace(...), run_publish_loop(...), and teardown_solace(...), that share one PublisherState; setup_solace() creates the service and the publisher before any connection exists, and main() runs teardown from its finally on every exit path after that. @@ -43,7 +44,6 @@ import signal import sys import threading -import uuid from dataclasses import dataclass from solace.messaging.config.retry_strategy import RetryStrategy @@ -188,8 +188,6 @@ def connect_solace(state: PublisherState) -> None: def run_publish_loop(state: PublisherState) -> None: threading.Thread(target=print_stats, args=(state,), name="stats", daemon=True).start() - # best practice: one OutboundMessageBuilder, reused for every message - message_builder = state.messaging_service.message_builder() trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/direct/pub/...', " @@ -203,7 +201,6 @@ def run_publish_loop(state: PublisherState) -> None: # NO user context: direct is at-most-once with no broker receipt, so there is # nothing to correlate (the inverse of the guaranteed publisher, which passes the # message id as user_context for local ACK/NACK correlation) - message = message_builder.with_application_message_id(str(uuid.uuid4())).build(payload) # as an example topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/direct/pub/{chosen_character}") try: # blocks while the buffer is full (back pressure) on a connected transport; during @@ -211,7 +208,15 @@ def run_publish_loop(state: PublisherState) -> None: # shutdown signal that arrives during that wait takes effect once publish() # returns: when the buffer drains, or when the service goes down and the API # releases the wait. - state.publisher.publish(message, topic) + # builderless publish: the payload goes to publish() directly, and the API builds the + # message. Set a per-message header with additional_message_properties, for example + # additional_message_properties={message_properties.APPLICATION_MESSAGE_ID: msg_id} + # with `from solace.messaging.config.solace_properties import message_properties`. + # Note: an OutboundMessageBuilder (messaging_service.message_builder()) is very + # useful for "templated" messages: set the headers that are the same on every + # message once on the builder, and build each message with only its few + # differences (see the guaranteed publisher sample). + state.publisher.publish(payload, topic) state.msg_sent_counter += 1 except PubSubPlusClientError as error: # publish() raises when the message cannot be sent and retrying would not help diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index f79ad25..16237be 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -15,11 +15,12 @@ https://docs.solace.com/API/API-Developer-Guide/C-API-Best-Practices.md Reference sample. Wired for a basic publish-subscribe journey: basic-auth connect -with a reconnection retry strategy, PERSISTENT binary publish to a topic, publish -receipt (ACK/NACK) handling correlated by user context, capacity-bounded back -pressure that blocks publish() while the buffer is full, reconnection and service -interruption listeners registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown that drains outstanding -receipts before disconnecting. Structured as module-level functions, +with a reconnection retry strategy, PERSISTENT binary publish to a topic from a +templated message builder that sets the time-to-live once, publish receipt (ACK/NACK) +handling correlated by user context, capacity-bounded back pressure that blocks +publish() while the buffer is full, reconnection and service interruption listeners +registered BEFORE connect(), and a graceful SIGINT/SIGTERM shutdown that drains +outstanding receipts before disconnecting. Structured as module-level functions, setup_solace(...), connect_solace(...), run_publish_loop(...), and teardown_solace(...), that share one PublisherState; setup_solace() creates the service and the publisher before any connection exists, and main() runs teardown from its finally on every exit @@ -43,6 +44,7 @@ from dataclasses import dataclass from solace.messaging.config.retry_strategy import RetryStrategy +from solace.messaging.config.solace_properties import message_properties from solace.messaging.errors.pubsubplus_client_error import IncompleteMessageDeliveryError, PubSubPlusClientError from solace.messaging.messaging_service import ( MessagingService, @@ -90,6 +92,11 @@ # to suit the application: longer gives sends and receipts more time to complete, shorter # exits sooner. TERMINATE_GRACE_PERIOD_MS = 10_000 +# best practice for Guaranteed publishing (C API Best Practices): set a time-to-live so an +# unconsumed message does not sit on a queue forever. In MILLISECONDS; 0, the API default, +# never expires. The queue must have respect-ttl enabled (off by default), or the broker +# ignores the TTL. Adjust the value to how long a message stays useful to its consumers. +MESSAGE_TTL_MS = 60_000 # API events go through standard logging; the trace() narration below is a separate channel logger = logging.getLogger(APP_NAME) @@ -187,14 +194,14 @@ def connect_solace(state: PublisherState) -> None: def run_publish_loop(state: PublisherState) -> None: threading.Thread(target=print_stats, args=(state,), name="stats", daemon=True).start() - # best practice: one OutboundMessageBuilder, reused for every message - message_builder = state.messaging_service.message_builder() - # best practice for Guaranteed publishing (C API Best Practices): set a time-to-live so - # an unconsumed message does not sit on a queue forever. The queue must have respect-ttl - # enabled (off by default), or the TTL is ignored. Add the Solace message property to the - # builder in milliseconds (0, the default, never expires), for example - # .with_property(message_properties.PERSISTENT_TIME_TO_LIVE, 60_000) - # with `from solace.messaging.config.solace_properties import message_properties`. + # one OutboundMessageBuilder as a message "template", reused for every message: set the + # headers that are the same on every message (here the TTL) once on the builder. + # build() starts each message as a copy of the template, so there is no set call per + # message for these headers + message_builder = ( + state.messaging_service.message_builder() + .with_property(message_properties.PERSISTENT_TIME_TO_LIVE, MESSAGE_TTL_MS) + ) trace(f"{API} {APP_NAME} connected, and running. Press Ctrl-C to quit.") trace(f"Publishing to topic '{TOPIC_PREFIX}{API.lower()}/pers/pub/...', " @@ -206,7 +213,11 @@ def run_publish_loop(state: PublisherState) -> None: # get_payload_as_bytes() (a str payload would arrive via get_payload_as_string()) payload = bytearray(chosen_character.encode("ascii") * PAYLOAD_SIZE) msg_id = str(uuid.uuid4()) - message = message_builder.with_application_message_id(msg_id).build(payload) # as an example + # the per-message difference goes in build()'s additional_message_properties, which + # applies to this message only; a with_*() call on the builder would change the + # template, so every later message would carry it too + message = message_builder.build( + payload, additional_message_properties={message_properties.APPLICATION_MESSAGE_ID: msg_id}) # NOTE: publishing to topic, so make sure the consumer's queue is subscribed to the same topic, # or enable "Reject Message to Sender on No Subscription Match" in the client-profile topic = Topic.of(f"{TOPIC_PREFIX}{API.lower()}/pers/pub/{chosen_character}") From 7b5ea8375ca35977fa84ac17f8ac298e1fc07238 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:10:40 -0400 Subject: [PATCH 14/17] EBP-3103: Remove the publish rate throttle from the samples - Remove APPROX_MSG_RATE_PER_SEC and the delay after each send from the Python and JCSMP direct and guaranteed publisher samples, so the loop publishes as fast as send() or publish() returns, the way an application publishes each message as soon as it has one - Update the demo-harness rule in implement-mode.md: the continuous publish loop replaces the pacing Thread.sleep in the list, and generated applications never add a delay or rate limit between sends that the application does not ask for - Output evals not run for the implement-mode.md change --- .../references/jcsmp/implement-mode.md | 2 +- .../jcsmp/jcsmp-direct-publisher-sample.java | 12 +++--------- .../jcsmp/jcsmp-guaranteed-publisher-sample.java | 12 +++--------- .../python/python-direct-publisher-sample.py | 5 ++--- .../python/python-guaranteed-publisher-sample.py | 5 ++--- 5 files changed, 11 insertions(+), 25 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/implement-mode.md b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/implement-mode.md index 76b78ee..a13aa14 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/implement-mode.md +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/implement-mode.md @@ -126,7 +126,7 @@ Hold every invariant while adapting: keep the shared connection helper, the sess **Preserve the marker contract.** A variant adapts the SHAPE, never the marker strings. It MUST still emit its leaf's `VERIFY:` markers, character for character, so the fixed `verify.sh` stages drive it unchanged (this is exactly why a durable-reply-queue requestor still prints `VERIFY: REPLY_RECEIVED`). Never rename or drop a marker to fit a variant. -**Demo harness is not application logic.** The samples carry demo-harness elements that exist only to make a standalone run observable: the ENTER-to-quit `System.in` loop, the once-per-second stats-printing thread, the pacing `Thread.sleep` between sends, and the rotating example payload. When the developer's request describes a real application domain, do NOT carry these into the generated classes: replace the example payload and send cadence with the application's real messages and triggers, and keep only the lifecycle pieces the leaf's steps call for (the SIGINT hook on the long-running role, the self-exit on the foreground role). When the request IS a demo or a try-it run, keeping the harness is fine. Two things are NOT demo harness: the `trace(...)` narration method (keep it; applications replace its single body to route narration to their logging or reporting system) and the `VERIFY:` markers, which are emitted through `trace(...)` like every other status line. Because `verify.sh` greps captured stdout for the markers and for the shutdown hook's `Shutdown signal received` proof line (both flow through `trace(...)`), the generated `trace(...)` body MUST stay `System.out.println` through the Step 5 verification stages; rerouting that body to a logger or reporting system is the developer's step AFTER the verify stages pass, never part of generation. Never treat the logging backend (Step 3) as demo harness: the backend and its config file ship in every generated app. +**Demo harness is not application logic.** The samples carry demo-harness elements that exist only to make a standalone run observable: the ENTER-to-quit `System.in` loop, the once-per-second stats-printing thread, the continuous publish loop, and the rotating example payload. When the developer's request describes a real application domain, do NOT carry these into the generated classes: replace the example payload and the publish loop with the application's real messages and triggers, and never add a delay or rate limit between sends that the application does not ask for, and keep only the lifecycle pieces the leaf's steps call for (the SIGINT hook on the long-running role, the self-exit on the foreground role). When the request IS a demo or a try-it run, keeping the harness is fine. Two things are NOT demo harness: the `trace(...)` narration method (keep it; applications replace its single body to route narration to their logging or reporting system) and the `VERIFY:` markers, which are emitted through `trace(...)` like every other status line. Because `verify.sh` greps captured stdout for the markers and for the shutdown hook's `Shutdown signal received` proof line (both flow through `trace(...)`), the generated `trace(...)` body MUST stay `System.out.println` through the Step 5 verification stages; rerouting that body to a logger or reporting system is the developer's step AFTER the verify stages pass, never part of generation. Never treat the logging backend (Step 3) as demo harness: the backend and its config file ship in every generated app. **Comments follow their code.** The samples' comments are part of the reference, not decoration: they carry the documented best practices (the session-independent `createMessage`, the binary payload and the legacy `XML` naming, `getData()` versus `getBytes()`, registering a session event handler at session creation, the ACK/NACK handling options, the correlation-key rule) into the code a developer reads long after this session. Generation carries them under ONE contract, on every door and every app shape: diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java index d746035..69beb5f 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-direct-publisher-sample.java @@ -57,7 +57,6 @@ public class DirectPublisher { private static final String APP_NAME = DirectPublisher.class.getSimpleName(); static final String TOPIC_PREFIX = "solace/samples/"; // used as the topic "root" private static final String API = "JCSMP"; - private static final int APPROX_MSG_RATE_PER_SEC = 100; private static final int PAYLOAD_SIZE = 512; // remember to add log4j2.xml to your classpath @@ -189,15 +188,10 @@ private static void runPublishLoop() throws IOException { if (e instanceof JCSMPTransportException) { // all reconnect attempts failed isShutdown = true; // let's quit; or, could initiate a new connection attempt } - } finally { // add a delay between messages - try { - Thread.sleep(1000 / APPROX_MSG_RATE_PER_SEC); // do Thread.sleep(0) for max speed - // Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // restore the interrupt status, do not swallow it - isShutdown = true; - } } + // no delay between messages: the loop publishes as fast as send() returns, the way an + // application publishes each message as soon as it has one + // Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress } isShutdown = true; } diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java index bc1baa3..e0e8a5f 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/jcsmp/jcsmp-guaranteed-publisher-sample.java @@ -58,7 +58,6 @@ public class GuaranteedPublisher { static final String TOPIC_PREFIX = "solace/samples/"; // used as the topic "root" private static final String API = "JCSMP"; private static final int PUBLISH_WINDOW_SIZE = 255; // max guaranteed-publish window (range 1-255) - private static final int APPROX_MSG_RATE_PER_SEC = 100; private static final int PAYLOAD_SIZE = 512; // remember to add log4j2.xml to your classpath @@ -220,15 +219,10 @@ private static void runPublishLoop() throws IOException, InterruptedException { if (e instanceof JCSMPTransportException) { // all reconnect attempts failed isShutdown = true; // let's quit; or, could initiate a new connection attempt } - } finally { // add a delay between messages - try { - Thread.sleep(1000 / APPROX_MSG_RATE_PER_SEC); // do Thread.sleep(0) for max speed - // Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // restore the interrupt status, do not swallow it - isShutdown = true; - } } + // no delay between messages: the loop publishes as fast as send() returns, the way an + // application publishes each message as soon as it has one + // Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress } isShutdown = true; } diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index e04d4ef..037427c 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -67,7 +67,6 @@ APP_NAME = "DirectPublisher" TOPIC_PREFIX = "solace/samples/" # used as the topic "root" API = "Python" -APPROX_MSG_RATE_PER_SEC = 100 PAYLOAD_SIZE = 512 # reconnect budget: 20 attempts 3 s apart, a minute of reconnect attempts, a reasonable # default when the design says nothing about high availability. The interval is in @@ -223,8 +222,8 @@ def run_publish_loop(state: PublisherState) -> None: logger.warning("publish() failed, quitting: %s", error) state.exit_code = 1 break # let's quit; or, could initiate a new connection attempt - # delay between messages; the wait returns early once shutdown is requested - state.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed + # no delay between messages: the loop publishes as fast as publish() returns, the way + # an application publishes each message as soon as it has one # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress state.shutdown.set() diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 16237be..65c16ba 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -65,7 +65,6 @@ APP_NAME = "GuaranteedPublisher" TOPIC_PREFIX = "solace/samples/" # used as the topic "root" API = "Python" -APPROX_MSG_RATE_PER_SEC = 100 PAYLOAD_SIZE = 512 # reconnect budget: 20 attempts 3 s apart, a minute of reconnect attempts, a reasonable # default when the design says nothing about high availability. The interval is in @@ -239,8 +238,8 @@ def run_publish_loop(state: PublisherState) -> None: logger.warning("publish() failed, quitting: %s", error) state.exit_code = 1 break # let's quit; or, could initiate a new connection attempt - # delay between messages; the wait returns early once shutdown is requested - state.shutdown.wait(1.0 / APPROX_MSG_RATE_PER_SEC) # wait(0) for max speed + # no delay between messages: the loop publishes as fast as publish() returns, the way + # an application publishes each message as soon as it has one # Note: STANDARD Edition Solace broker is limited to 10k msg/s max ingress state.shutdown.set() From d30d3efaa4015cd5400ed9863f4125f4c474d282 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:03:53 -0400 Subject: [PATCH 15/17] EBP-3103: Let configuration override the reconnect defaults in Python - Set the reconnection retry strategy before from_properties() in all four samples: the builder merges calls in order and a later call wins, so the code's strategy is now a default that reconnection keys in the properties (for example config.json) override; update the connection config helper docstring to match - Load the Solace properties in main() and pass them to setup_solace(), so converting the application's input (config.json or argv) stays out of the Solace setup - Log a buffered-message loss at direct publisher shutdown as a warning and keep exit code 0, since direct (at-most-once) messaging allows it --- .../python/python-direct-publisher-sample.py | 29 ++++++++++++------- .../python/python-direct-subscriber-sample.py | 19 +++++++----- .../python-guaranteed-publisher-sample.py | 19 +++++++----- .../python-guaranteed-subscriber-sample.py | 19 +++++++----- .../python/python-solace-connection-config.py | 4 +-- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py index 037427c..8a5d262 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py @@ -126,9 +126,15 @@ def on_shutdown_signal(signum: int, _frame) -> None: # connect, so a SIGTERM during the blocking connect() still reaches teardown. signal.signal(signal.SIGINT, on_shutdown_signal) signal.signal(signal.SIGTERM, on_shutdown_signal) + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]). Turning the + # application's input into Solace properties is the application's job, so it stays out + # of setup_solace(), which works from the properties alone. + properties = load_service_properties(args, APP_NAME) # setup_solace() raises before any connection exists, so there is nothing to tear down # if it fails - state = setup_solace(args, shutdown) + state = setup_solace(properties, shutdown) try: connect_solace(state) run_publish_loop(state) @@ -138,18 +144,17 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(args: list[str], shutdown: threading.Event) -> PublisherState: - # basic username/password connection details, built by the shared connection-config - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = load_service_properties(args, APP_NAME) +def setup_solace(properties: dict, shutdown: threading.Event) -> PublisherState: # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() messaging_service = ( MessagingService.builder() - .from_properties(properties) + # the builder merges each call in order and a later call wins, so the reconnection + # strategy comes first: it is the default, and reconnection keys in the properties + # (for example from config.json) override it .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .from_properties(properties) .build() ) # DIRECT publisher with capacity-bounded back pressure: publish() blocks once @@ -245,13 +250,15 @@ def teardown_solace(state: PublisherState) -> None: # direct is at-most-once: there are no broker receipts to drain, so terminate() only # gives the messages still in the publisher's buffer up to the grace period to leave. # It raises IncompleteMessageDeliveryError when messages are still buffered after - # that; catch it so the disconnect below still runs + # that; catch it so the disconnect below still runs. Those messages are lost, which + # direct (at-most-once) messaging allows, so this is a warning and the exit code stays + # 0. An application that must not lose messages at shutdown can raise the level (or + # use guaranteed messaging). try: state.publisher.terminate(TERMINATE_GRACE_PERIOD_MS) except IncompleteMessageDeliveryError as error: - logger.error("Publisher stopped with messages still buffered after %d ms: %s", - TERMINATE_GRACE_PERIOD_MS, error) - state.exit_code = 1 + logger.warning("Publisher stopped with messages still buffered after %d ms: %s", + TERMINATE_GRACE_PERIOD_MS, error) except PubSubPlusClientError as error: logger.error("Publisher terminate() failed: %s", error) state.exit_code = 1 diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index c7c80a9..0ef8df3 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -124,9 +124,15 @@ def on_shutdown_signal(signum: int, _frame) -> None: # connect, so a SIGTERM during the blocking connect() still reaches teardown. signal.signal(signal.SIGINT, on_shutdown_signal) signal.signal(signal.SIGTERM, on_shutdown_signal) + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]). Turning the + # application's input into Solace properties is the application's job, so it stays out + # of setup_solace(), which works from the properties alone. + properties = load_service_properties(args, APP_NAME) # setup_solace() raises before any connection exists, so there is nothing to tear down # if it fails - state = setup_solace(args, shutdown) + state = setup_solace(properties, shutdown) try: connect_solace(state) await_messages(state) @@ -136,18 +142,17 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(args: list[str], shutdown: threading.Event) -> SubscriberState: - # basic username/password connection details, built by the shared connection-config - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = load_service_properties(args, APP_NAME) +def setup_solace(properties: dict, shutdown: threading.Event) -> SubscriberState: # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() messaging_service = ( MessagingService.builder() - .from_properties(properties) + # the builder merges each call in order and a later call wins, so the reconnection + # strategy comes first: it is the default, and reconnection keys in the properties + # (for example from config.json) override it .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .from_properties(properties) .build() ) # DIRECT receiver: a topic subscription on the receiver (no queue, no provisioning). diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py index 65c16ba..320975d 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py @@ -131,9 +131,15 @@ def on_shutdown_signal(signum: int, _frame) -> None: # teardown. signal.signal(signal.SIGINT, on_shutdown_signal) signal.signal(signal.SIGTERM, on_shutdown_signal) + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]). Turning the + # application's input into Solace properties is the application's job, so it stays out + # of setup_solace(), which works from the properties alone. + properties = load_service_properties(args, APP_NAME) # setup_solace() raises before any connection exists, so there is nothing to tear down # if it fails - state = setup_solace(args, shutdown) + state = setup_solace(properties, shutdown) try: connect_solace(state) run_publish_loop(state) @@ -143,18 +149,17 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(args: list[str], shutdown: threading.Event) -> PublisherState: - # basic username/password connection details, built by the shared connection-config - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = load_service_properties(args, APP_NAME) +def setup_solace(properties: dict, shutdown: threading.Event) -> PublisherState: # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() messaging_service = ( MessagingService.builder() - .from_properties(properties) + # the builder merges each call in order and a later call wins, so the reconnection + # strategy comes first: it is the default, and reconnection keys in the properties + # (for example from config.json) override it .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .from_properties(properties) .build() ) # PERSISTENT publisher with capacity-bounded back pressure: publish() blocks once diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index ca8d233..7a86525 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -125,9 +125,15 @@ def on_shutdown_signal(signum: int, _frame) -> None: # connect, so a SIGTERM during the blocking connect() still reaches teardown. signal.signal(signal.SIGINT, on_shutdown_signal) signal.signal(signal.SIGTERM, on_shutdown_signal) + # basic username/password connection details, built by the shared connection-config + # helper: read from a config.json in the working directory if present, else from the + # command line ( [password]). Turning the + # application's input into Solace properties is the application's job, so it stays out + # of setup_solace(), which works from the properties alone. + properties = load_service_properties(args, APP_NAME) # setup_solace() raises before any connection exists, so there is nothing to tear down # if it fails - state = setup_solace(args, shutdown) + state = setup_solace(properties, shutdown) try: if not connect_solace(state): return 1 # the bind failed; the finally below still runs teardown @@ -138,18 +144,17 @@ def on_shutdown_signal(signum: int, _frame) -> None: return state.exit_code # non-zero after a failure, so scripts and supervisors see it -def setup_solace(args: list[str], shutdown: threading.Event) -> SubscriberState: - # basic username/password connection details, built by the shared connection-config - # helper: read from a config.json in the working directory if present, else from the - # command line ( [password]) - properties = load_service_properties(args, APP_NAME) +def setup_solace(properties: dict, shutdown: threading.Event) -> SubscriberState: # build() creates the native session and resolves the host, so an unresolvable host # fails here, before connect() messaging_service = ( MessagingService.builder() - .from_properties(properties) + # the builder merges each call in order and a later call wins, so the reconnection + # strategy comes first: it is the default, and reconnection keys in the properties + # (for example from config.json) override it .with_reconnection_retry_strategy( RetryStrategy.parametrized_retry(RECONNECT_RETRIES, RECONNECT_RETRY_INTERVAL_MS)) + .from_properties(properties) .build() ) # configure the queue API object locally: durable, exclusive (single-consumer sample) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py index 7fd2b1c..a0e4343 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-solace-connection-config.py @@ -11,8 +11,8 @@ "solace.messaging.authentication.basic.password" Every key passes through to from_properties() unchanged, so other service properties need no parser change. A later builder call overrides the same - key, so each sample's reconnection strategy replaces any reconnection keys - in the file. The native library rejects an invalid value at build() or + key, and each sample sets its reconnection strategy before from_properties(), + so reconnection keys in the file override the sample's default. The native library rejects an invalid value at build() or connect(); the API silently ignores an unknown key. config.json holds broker credentials, so it MUST be gitignored. 2. otherwise the command-line arguments: From 7a542a154f24725123118cf993d7636d59a27c32 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:32:00 -0400 Subject: [PATCH 16/17] EBP-3103: Set the message handler before starting the Python receivers - Call receive_async() before receiver.start() in both subscriber samples, so the handler is in place when delivery begins and messages do not wait in the API buffer - Keep receive_async() after connect(), since _can_receive_message() requires a connected service - Address PR #8 review comment on the guaranteed subscriber sample --- .../references/python/python-direct-subscriber-sample.py | 7 +++++-- .../python/python-guaranteed-subscriber-sample.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py index 0ef8df3..3da08ee 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py @@ -185,10 +185,13 @@ def setup_solace(properties: dict, shutdown: threading.Event) -> SubscriberState def connect_solace(state: SubscriberState) -> None: state.connect_attempted = True state.messaging_service.connect() # blocking connect - trace(f"Adding direct topic subscription '{TOPIC_NAME}'.") - state.receiver.start() + # set the message handler BEFORE start(), so the handler is in place when delivery + # begins and received messages do not wait in the API's buffer for it. receive_async() + # needs a connected service, so it runs here after connect(), not in setup_solace(). # see bottom of file for DirectMessageHandler, which receives the messages from the topic state.receiver.receive_async(DirectMessageHandler(state)) + trace(f"Adding direct topic subscription '{TOPIC_NAME}'.") + state.receiver.start() trace(f"{APP_NAME} subscribed and consuming. Press Ctrl-C to quit.") diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py index 7a86525..0c67bde 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py @@ -198,6 +198,11 @@ def setup_solace(properties: dict, shutdown: threading.Event) -> SubscriberState def connect_solace(state: SubscriberState) -> bool: state.connect_attempted = True state.messaging_service.connect() # blocking connect + # set the message handler BEFORE start(), so the handler is in place when delivery + # begins and received messages do not wait in the API's buffer for it. receive_async() + # needs a connected service, so it runs here after connect(), not in setup_solace(). + # see bottom of file for QueueMessageHandler, which receives the messages from the queue + state.receiver.receive_async(QueueMessageHandler(state, state.receiver)) trace(f"Attempting to bind to queue '{QUEUE_NAME}' on the broker.") try: # start() provisions the queue plus its subscription (CREATE_ON_START) and binds @@ -208,8 +213,6 @@ def connect_solace(state: SubscriberState) -> bool: "provision the queue and its topic subscription out-of-band or grant the " "capability. Exiting.", QUEUE_NAME, error) return False # teardown_solace() in main's finally disconnects - # see bottom of file for QueueMessageHandler, which receives the messages from the queue - state.receiver.receive_async(QueueMessageHandler(state, state.receiver)) return True From 87fcee9542f9b6f6b0e8a0965f1e21e0fc755b05 Mon Sep 17 00:00:00 2001 From: adiel-sammak <71227923+aelsammak@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:21:31 -0400 Subject: [PATCH 17/17] EBP-3103: Add an async suffix to the Python sample names - Rename the four Python samples to python---async-sample.py - Update the generated project file names to match, for example guaranteed_subscriber_async.py - Use the async/sync terms from the Python developer guide, so later sync samples (publish_await_acknowledgement, receive_message) fit the scheme - Add guidance to the guaranteed publisher on how to choose the message TTL: at least the longest consumer recovery time (restart plus reconnect), or a multiple of it --- ...er-sample.py => python-direct-publisher-async-sample.py} | 2 +- ...r-sample.py => python-direct-subscriber-async-sample.py} | 2 +- ...ample.py => python-guaranteed-publisher-async-sample.py} | 6 +++++- ...mple.py => python-guaranteed-subscriber-async-sample.py} | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) rename plugins/solace-messaging-skills/skills/solace-application-development/references/python/{python-direct-publisher-sample.py => python-direct-publisher-async-sample.py} (99%) rename plugins/solace-messaging-skills/skills/solace-application-development/references/python/{python-direct-subscriber-sample.py => python-direct-subscriber-async-sample.py} (99%) rename plugins/solace-messaging-skills/skills/solace-application-development/references/python/{python-guaranteed-publisher-sample.py => python-guaranteed-publisher-async-sample.py} (98%) rename plugins/solace-messaging-skills/skills/solace-application-development/references/python/{python-guaranteed-subscriber-sample.py => python-guaranteed-subscriber-async-sample.py} (99%) diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-async-sample.py similarity index 99% rename from plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py rename to plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-async-sample.py index 8a5d262..1ea90bd 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-publisher-async-sample.py @@ -32,7 +32,7 @@ Only practices documented in canonical Solace sources are encoded here. -Copied into a generated project as direct_publisher.py, next to the shared helper +Copied into a generated project as direct_publisher_async.py, next to the shared helper python-solace-connection-config.py copied as solace_connection_config.py. Any generated adaptation of this sample MUST begin with the exact line: diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-async-sample.py similarity index 99% rename from plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py rename to plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-async-sample.py index 3da08ee..4f1319c 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-direct-subscriber-async-sample.py @@ -28,7 +28,7 @@ Only practices documented in canonical Solace sources are encoded here. -Copied into a generated project as direct_subscriber.py, next to the shared helper +Copied into a generated project as direct_subscriber_async.py, next to the shared helper python-solace-connection-config.py copied as solace_connection_config.py. Any generated adaptation of this sample MUST begin with the exact line: diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-async-sample.py similarity index 98% rename from plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py rename to plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-async-sample.py index 320975d..a94e4b5 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-publisher-async-sample.py @@ -28,7 +28,7 @@ Only practices documented in canonical Solace sources are encoded here. -Copied into a generated project as guaranteed_publisher.py, next to the shared +Copied into a generated project as guaranteed_publisher_async.py, next to the shared helper python-solace-connection-config.py copied as solace_connection_config.py. Any generated adaptation of this sample MUST begin with the exact line: @@ -95,6 +95,10 @@ # unconsumed message does not sit on a queue forever. In MILLISECONDS; 0, the API default, # never expires. The queue must have respect-ttl enabled (off by default), or the broker # ignores the TTL. Adjust the value to how long a message stays useful to its consumers. +# For most applications, make it at least the longest time a consumer can take to come +# back (restart plus reconnect), or a multiple of that time for a safety margin, so a +# message does not expire while its consumer recovers; use a shorter or longer TTL only +# for a specific need. MESSAGE_TTL_MS = 60_000 # API events go through standard logging; the trace() narration below is a separate channel diff --git a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-async-sample.py similarity index 99% rename from plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py rename to plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-async-sample.py index 0c67bde..dd0936e 100644 --- a/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-sample.py +++ b/plugins/solace-messaging-skills/skills/solace-application-development/references/python/python-guaranteed-subscriber-async-sample.py @@ -29,7 +29,7 @@ Only practices documented in canonical Solace sources are encoded here. -Copied into a generated project as guaranteed_subscriber.py, next to the shared +Copied into a generated project as guaranteed_subscriber_async.py, next to the shared helper python-solace-connection-config.py copied as solace_connection_config.py. Any generated adaptation of this sample MUST begin with the exact line: