Fire-and-forget senders
Client-side buffering, batched delivery and flush-on-stop for senders that expect no reply.
Audit trails, metrics and notifications flow one way and expect no reply. Events are buffered client-side, delivered in batches, and when the connection stops - because its definition is deleted or edited - whatever remains in the buffer is flushed on the way out.
The buffering is ordinary client code. The Connector SDK supplies the lifecycle - on_stop runs when the connection stops, so flush-on-stop is one line.
The connector module
The example wraps an audit collector. Events accumulate in a buffer, a background loop flushes them in batches and close - called from on_stop - flushes the remainder:
# -*- coding: utf-8 -*-
# stdlib
import socket
import threading
import time
# Zato
from zato.common.sdk import Connector, Field
class AuditClient:
""" A fire-and-forget sender for an audit collector - events are buffered client-side
and flushed in batches, each batch over its own short-lived connection, so nothing is lost
when the collector is briefly down.
"""
def __init__(self, host:'str', port:'int', flush_interval:'int') -> 'None':
self.host = host
self.port = port
self.flush_interval = flush_interval
# Guards the buffer.
self.lock = threading.Lock()
# Events waiting for the next flush.
self.buffer = []
# Set by close, which stops the flusher loop.
self.is_stopped = False
# Flush periodically in the background.
flusher_thread = threading.Thread(target=self._flush_loop, daemon=True)
flusher_thread.start()
def _flush_loop(self) -> 'None':
while not self.is_stopped:
# Waiting in small slices makes close take effect quickly even with long intervals.
waited = 0.0
while waited < self.flush_interval and not self.is_stopped:
time.sleep(0.1)
waited += 0.1
if self.is_stopped:
return
try:
self.flush()
except OSError:
# The collector is down - the events stay in the buffer for the next flush.
pass
def add(self, event:'str') -> 'None':
with self.lock:
self.buffer.append(event)
def flush(self) -> 'None':
# Take the whole buffer under the lock ..
with self.lock:
batch = self.buffer[:]
self.buffer.clear()
# .. an empty batch means there is nothing to send.
if not batch:
return
# .. and send it over one short-lived connection.
payload = ''.join(f'{event}\n' for event in batch)
try:
with socket.create_connection((self.host, self.port)) as conn:
conn.sendall(payload.encode('utf8'))
except OSError:
# The collector is down - put the batch back so nothing is lost.
with self.lock:
self.buffer[0:0] = batch
raise
def close(self) -> 'None':
""" Stops the flusher and sends whatever remains in the buffer - flush-on-stop.
"""
self.is_stopped = True
self.flush()
class AuditConnector(Connector):
""" Wraps the audit collector as a connection type that services access through self.out.audit.
"""
type = 'audit'
# Configuration schema
host = Field.Text()
port = Field.Int(default=9990)
flush_interval = Field.Int(default=2)
def create_client(self) -> 'AuditClient':
return AuditClient(self.config.host, self.config.port, self.config.flush_interval)
def ping(self, client:'AuditClient') -> 'None':
# The collector never replies, so reaching it is the whole check.
conn = socket.create_connection((client.host, client.port))
conn.close()
def on_stop(self, client:'AuditClient') -> 'None':
client.close()
def send_event(self, event:'str') -> 'None':
self.client.add(event)
In the code above:
send_eventonly appends to a buffer, so services never wait for the collector - that is the fire-and-forget part- Each batch travels over its own short-lived connection and a failed batch goes back into the buffer, so a collector that is briefly down loses nothing
on_stopruns when the definition is deleted or edited and when the server shuts down - callingclosethere is what guarantees the buffered remainder is delivered
Creating a definition
Definitions are managed with enmasse under a key derived from the connector's type - custom_ plus the type name:
Using it from services
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class SendAuditEvent(Service):
""" Sends one fire-and-forget event to the audit collector.
"""
name = 'demo.audit.send-event'
input = 'event'
def handle(self) -> 'None':
conn = self.out.audit['My Audit']
conn.send_event(self.request.input.event)