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_event only 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_stop runs when the definition is deleted or edited and when the server shuts down - calling close there 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:

custom_audit:
  - name: My Audit
    host: 10.152.81.23
    port: 9990
    flush_interval: 2

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)

Learn more