Server push and subscriptions
Server push, automatic resubscription after reconnects and delivery to services and topics.
Some systems do not wait to be asked. You subscribe once and, from then on, the remote side pushes messages whenever it has something to say - market data feeds, event streams, monitoring systems.
Two things make such connections different from request-reply ones. First, the received messages need somewhere to go - the Connector SDK gives every connector self.invoke to hand them over to services and self.publish to publish them to pub/sub topics, both matching their counterparts in services. Second, after every reconnect the subscription has to be established anew - which is what the on_started hook of SubscribingConnector is for. The platform calls it when the connection first starts and again after every reconnect, on its own.
The connector module
The example wraps a data feed. The client keeps one persistent socket with a reader loop and the connector routes every pushed message to a service and a topic:
# -*- coding: utf-8 -*-
# stdlib
import socket
import threading
# Zato
from zato.common.sdk import ConnectionLost, Field, SubscribingConnector
# How long to wait for the feed to confirm a subscription or answer a ping, in seconds.
_reply_timeout = 5
class FeedClient:
""" A client for a data feed that pushes messages on its own - after subscribing, the feed
sends messages whenever it wants and a reader loop hands each one to the on_message callback.
"""
def __init__(self, host:'str', port:'int', on_message:'any_') -> 'None':
# Where pushed messages go.
self.on_message = on_message
# The one persistent socket the feed pushes into.
self.socket = socket.create_connection((host, port))
self.reader_file = self.socket.makefile('r', encoding='utf8')
# Set when the feed confirms the subscription and answers a ping, respectively.
self.subscribed_event = threading.Event()
self.pong_event = threading.Event()
# Set to False by the reader loop once the socket is gone.
self.is_connected = True
reader_thread = threading.Thread(target=self._read_loop, daemon=True)
reader_thread.start()
def _read_loop(self) -> 'None':
for line in self.reader_file:
text = line.strip()
# A message the feed pushed on its own.
if text.startswith('push '):
self.on_message(text[len('push '):])
# The feed confirmed our subscription.
elif text == 'subscribed':
self.subscribed_event.set()
# The feed answered a ping.
elif text == 'pong':
self.pong_event.set()
# The loop ended, which means the socket is gone.
self.is_connected = False
def _send_line(self, data:'str') -> 'None':
self.socket.sendall(f'{data}\n'.encode('utf8'))
def subscribe(self, topic:'str') -> 'None':
self.subscribed_event.clear()
self._send_line(f'subscribe {topic}')
if not self.subscribed_event.wait(_reply_timeout):
raise ConnectionLost('The feed did not confirm the subscription')
def ping(self) -> 'None':
# A dead socket cannot answer - the framework will reconnect.
if not self.is_connected:
raise ConnectionLost('The feed connection is down')
self.pong_event.clear()
self._send_line('ping')
if not self.pong_event.wait(_reply_timeout):
raise ConnectionLost('The feed did not answer a ping')
def close(self) -> 'None':
self.reader_file.close()
self.socket.close()
class FeedConnector(SubscribingConnector):
""" Wraps the data feed as a connection type - received messages are handed over to a service
with self.invoke and published to a topic with self.publish, and after every reconnect
the framework calls on_started again, which resubscribes.
"""
type = 'feed'
# Configuration schema
host = Field.Text()
port = Field.Int(default=9980)
topic = Field.Text()
# The service that receives each pushed message and the pub/sub topic each one is published to.
service = Field.Text()
topic_name = Field.Text()
def create_client(self) -> 'FeedClient':
return FeedClient(self.config.host, self.config.port, self._handle_message)
def _handle_message(self, message:'str') -> 'None':
# Hand the message over to a service ..
self.invoke(self.config.service, {'message': message})
# .. and publish it to a topic too.
self.publish(self.config.topic_name, message)
def ping(self, client:'FeedClient') -> 'None':
client.ping()
def on_started(self, client:'FeedClient') -> 'None':
client.subscribe(self.config.topic)
self.logger.info('Feed `%s` subscribed to `%s`', self.name, self.config.topic)
def on_stop(self, client:'FeedClient') -> 'None':
client.close()
In the code above:
on_startedis where subscriptions belong - the platform runs it when the connection first starts and re-runs it after every reconnect, so a feed that went down and came back is resubscribed automatically, with no code beyond the hook itself- The platform watches subscribing connections in the background - when a ping stops being answered, it evicts the client, reconnects with backoff and runs
on_startedagain self.invokeandself.publishare ambient attributes every connector has - which service and which topic to use are ordinary config fields here, so each definition decides for itself
Creating a definition
Definitions are managed with enmasse under a key derived from the connector's type - custom_ plus the type name:
custom_feed:
- name: My Feed
host: 10.152.81.22
port: 9980
topic: prices
service: demo.feed.recorder
topic_name: feed.prices