Skip to main content

Creating Custom Streaming Processes

Overview

Streaming processes consume messages from Kafka, transform them, and publish results back to Kafka. They bridge the consumer and producer patterns to create data transformation pipelines.

What streaming processes do:

  • Subscribe to input topics
  • Transform or enrich data
  • Publish results to output topics
  • Enable chained processing pipelines

Key difference from consumers: Streaming processes always produce output back to Kafka.

Streaming Process Architecture

Base Class Structure

All streaming processes extend KafkaStreamingProcessBase:

from smocs.cores import KafkaStreamingProcessBase

class MyStreamingProcess(KafkaStreamingProcessBase):
def __init__(self):
kafka_broker_url = os.getenv('KAFKA_BROKER_URL', 'kafka-broker:9092')
group_id = 'my-streaming-group'
input_topics = ['input-topic']

super().__init__(kafka_broker_url, group_id, input_topics)

# Your initialization

def process_message(self, message, topic, partition, offset):
# Your transformation logic
# Return (success, [(output_topic, output_message)])
pass

What's provided by base class:

  • Kafka consumer setup
  • Kafka producer setup (via composition)
  • Message polling and publishing loop
  • Input/output message validation
  • Error handling and retries
  • Resource cleanup

What you implement:

  • process_message() - transform input to output
  • State management (optional)
  • External service connections (optional)

Example: Temperature Unit Converter

Scenario

Convert temperature readings from Celsius to Fahrenheit, enriching the data stream for downstream consumers.

Input message:

{
"timestamp": 1704067200.0,
"channels": {
"temp_c": 22.5,
"humidity": 45.0,
"sensor_id": "temp-01"
},
"source_topic": "sensor-raw"
}

Output message:

{
"timestamp": 1704067200.0,
"channels": {
"temp_c": 22.5,
"temp_f": 72.5,
"humidity": 45.0,
"sensor_id": "temp-01"
},
"source_topic": "sensor-converted"
}

Step 1: Create Streaming Process File

Create smocs/streaming_processes/temperature_converter.py:

import os
import json
import logging
import time

from smocs.cores import KafkaStreamingProcessBase
from smocs.utils import setup_logging


class TemperatureConverterProcess(KafkaStreamingProcessBase):
"""
Streaming process that converts Celsius to Fahrenheit.
"""

def __init__(self):
# Kafka configuration
kafka_broker_url = os.getenv('KAFKA_BROKER_URL', 'kafka-broker:9092')
group_id = os.getenv('CONSUMER_GROUP_ID', 'temperature-converter')
input_topics = os.getenv('INPUT_TOPICS', 'sensor-raw').split(',')

super().__init__(kafka_broker_url, group_id, input_topics)

# Processing configuration
self.output_topic = os.getenv('OUTPUT_TOPIC', 'sensor-converted')
self.temp_channel = os.getenv('TEMP_CHANNEL', 'temp_c')

logging.info("Temperature Converter initialized")
logging.info(f" Input topics: {input_topics}")
logging.info(f" Output topic: {self.output_topic}")
logging.info(f" Temperature channel: {self.temp_channel}")

def celsius_to_fahrenheit(self, celsius):
"""Convert Celsius to Fahrenheit."""
return (celsius * 9/5) + 32

def process_message(self, message, topic, partition, offset):
"""
Convert temperature from Celsius to Fahrenheit.

Args:
message: Message value (JSON string, already validated)
topic: Input topic name (already validated)
partition: Kafka partition
offset: Message offset

Returns:
Tuple[bool, List[Tuple]]:
- bool: True if successful, False on error
- List: [(output_topic, output_message)] for publishing
"""
try:
# Parse input message
if isinstance(message, bytes):
message = message.decode('utf-8')

data = json.loads(message)

# Validate structure
if 'channels' not in data:
logging.warning(f"No channels in message from {topic}:{offset}")
return False, []

channels = data['channels']

# Check if temperature channel exists
if self.temp_channel not in channels:
logging.debug(f"No {self.temp_channel} in message, passing through")
# Pass through unchanged
output_message = json.dumps(data)
return True, [(self.output_topic, output_message)]

# Convert temperature
try:
celsius = float(channels[self.temp_channel])
fahrenheit = round(self.celsius_to_fahrenheit(celsius), 2)

# Add Fahrenheit value
fahrenheit_key = self.temp_channel.replace('_c', '_f')
channels[fahrenheit_key] = fahrenheit

logging.debug(f"Converted {celsius}°C to {fahrenheit}°F")

except (ValueError, TypeError) as e:
logging.warning(f"Invalid temperature value: {e}")
return False, []

# Create output message
output_data = {
'timestamp': data.get('timestamp', time.time()),
'channels': channels,
'source_topic': self.output_topic
}

output_message = json.dumps(output_data)

# Return success and output tuple
return True, [(self.output_topic, output_message)]

except json.JSONDecodeError as e:
logging.error(f"JSON decode error at {topic}:{offset}: {e}")
return False, []
except Exception as e:
logging.error(f"Error processing message: {e}")
return False, []


def main():
"""Main entry point."""
setup_logging()
logging.info("Starting Temperature Converter streaming process")

process = TemperatureConverterProcess()
process.start()


if __name__ == "__main__":
main()

Step 2: Create Dockerfile

Create orchestration/containers/temperature-converter/Dockerfile:

FROM python:3.10-slim

WORKDIR /app

# Copy requirements
COPY orchestration/containers/temperature-converter/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy SMOCS package
COPY smocs ./smocs

# Set Python path
ENV PYTHONPATH=/app

CMD ["python", "-m", "smocs.streaming_processes.temperature_converter"]

Step 3: Create Requirements File

Create orchestration/containers/temperature-converter/requirements.txt:

kafka-python==2.0.2

Step 4: Add to Docker Compose

Add to orchestration/docker-compose.yml:

temperature-converter:
build:
context: ../
dockerfile: ./orchestration/containers/temperature-converter/Dockerfile
profiles: ["converter"]
environment:
KAFKA_BROKER_URL: kafka-broker:9092
CONSUMER_GROUP_ID: temperature-converter
INPUT_TOPICS: sensor-raw
OUTPUT_TOPIC: sensor-converted
TEMP_CHANNEL: temp_c
SMOCS_LOG_LEVEL: INFO
networks:
- kafka-net
depends_on:
kafka-broker:
condition: service_healthy
restart: unless-stopped

Step 5: Configure Environment

Add to .env:

COMPOSE_PROFILES=converter

Step 6: Test Streaming Process

# Build and start
docker compose up --build temperature-converter

# In another terminal, publish test message to input topic
docker exec kafka-broker kafka-console-producer.sh \
--topic sensor-raw \
--bootstrap-server localhost:9092 << EOF
{"timestamp": 1704067200.0, "channels": {"temp_c": 22.5, "humidity": 45.0}}
EOF

# Consume from output topic to verify transformation
docker exec kafka-broker kafka-console-consumer.sh \
--topic sensor-converted \
--bootstrap-server localhost:9092 \
--from-beginning \
--max-messages 1

# Expected output includes both temp_c and temp_f
# {"timestamp": 1704067200.0, "channels": {"temp_c": 22.5, "temp_f": 72.5, "humidity": 45.0}, ...}

# Check logs for processing confirmation
docker compose logs temperature-converter | grep "Converted"

Key Implementation Details

Return Format

The process_message() method must return a tuple:

return (success_bool, list_of_output_tuples)

Success boolean: True if processing succeeded, False on error

Output tuples: Each tuple is (output_topic, output_message) or (output_topic, output_message, key)

Examples:

# Single output
return True, [('output-topic', json.dumps(data))]

# Multiple outputs to different topics
return True, [
('topic-1', json.dumps(data1)),
('topic-2', json.dumps(data2))
]

# With message key
return True, [('output-topic', json.dumps(data), 'sensor-01')]

# No output (filtering)
return True, []

# Processing failed
return False, []

Message Validation

Both input and output messages are automatically validated by the base class:

  • Input validation occurs before process_message() is called
  • Output validation occurs before publishing to Kafka
  • Invalid messages are logged and skipped

Error Handling

Processing errors: Return False, [] to indicate failure

Pass-through on error: Return the original message unchanged

try:
# Process message
result = self.transform(data)
return True, [(self.output_topic, json.dumps(result))]
except Exception as e:
logging.warning(f"Transform failed: {e}, passing through")
return True, [(self.output_topic, message)]

Common Patterns

Conditional Output

def process_message(self, message, topic, partition, offset):
data = json.loads(message)

# Only output if condition met
if data['channels'].get('temperature', 0) > 30:
return True, [(self.output_topic, json.dumps(data))]

# Filter out (no output)
return True, []

Multi-Topic Output

def process_message(self, message, topic, partition, offset):
data = json.loads(message)

# Send to different topics based on content
outputs = []

if data['channels'].get('is_alert'):
outputs.append(('alerts', json.dumps(data)))
else:
outputs.append(('normal-data', json.dumps(data)))

return True, outputs

Stateful Processing

def __init__(self):
super().__init__(...)
self.message_count = 0

def process_message(self, message, topic, partition, offset):
self.message_count += 1

data = json.loads(message)
data['channels']['message_number'] = self.message_count

return True, [(self.output_topic, json.dumps(data))]

Best Practices

Keep transformations fast: Process messages in milliseconds

Preserve SMOCS format: Maintain timestamp, channels, source_topic structure

Handle missing fields: Don't fail on missing data, use defaults

Log processing metrics: Track throughput and errors

Idempotent operations: Handle duplicate messages gracefully

Return empty list to filter: Use return True, [] to skip output

Use explicit topic names: Avoid dynamic topic creation when possible