OCI Streaming Service: Building a Real-Time Event Pipeline with Terraform

Every production system generates events: orders placed, payments processed, errors thrown, user actions taken. The question is not whether events exist but what you do with them. If they go nowhere, you have no real-time visibility and no way to drive downstream processes from upstream state changes. If they go into a queue, you get decoupling but limited replay and fan-out. If they go into a streaming platform, you get all of that plus ordered, replayable, partitioned event streams that multiple consumers can read independently at their own pace.

OCI Streaming is Oracle’s managed Apache Kafka-compatible streaming service. It runs inside your tenancy, integrates with OCI IAM, connects natively to OCI Functions and Service Connector Hub, and exposes a Kafka-compatible endpoint so existing Kafka producers and consumers work without code changes. This post covers deploying OCI Streaming with Terraform, writing producers and consumers in Python, connecting streams to downstream OCI services, and monitoring throughput and lag.

Architecture

Orders API (producer)
        |
   OCI Streaming (orders-events stream, 3 partitions)
        |
   -----------------------------------
   |               |                 |
Notification   Analytics         Audit Log
Consumer       Consumer          Consumer
(Function)     (Data Flow)       (Object Storage via SCH)

Three independent consumer groups read the same stream simultaneously. Each maintains its own offset. The notification consumer processes events in real time. The analytics consumer feeds a batch processing job. The audit consumer archives everything to Object Storage via Service Connector Hub. None of them interfere with each other.

Step 1: IAM Policy

resource "oci_identity_dynamic_group" "streaming_producers_dg" {
  compartment_id = var.tenancy_ocid
  name           = "streaming-producer-instances"
  description    = "Instances and Functions that produce to OCI Streaming"
  matching_rule  = "Any {instance.compartment.id = '${var.compartment_id}', resource.type = 'fnfunc'}"
}

resource "oci_identity_dynamic_group" "streaming_consumers_dg" {
  compartment_id = var.tenancy_ocid
  name           = "streaming-consumer-instances"
  description    = "Instances and Functions that consume from OCI Streaming"
  matching_rule  = "Any {instance.compartment.id = '${var.compartment_id}', resource.type = 'fnfunc'}"
}

resource "oci_identity_policy" "streaming_policy" {
  compartment_id = var.compartment_id
  name           = "oci-streaming-policy"
  description    = "Permissions for OCI Streaming producers and consumers"

  statements = [
    "Allow dynamic-group streaming-producer-instances to use stream-push in compartment id ${var.compartment_id}",
    "Allow dynamic-group streaming-consumer-instances to use stream-pull in compartment id ${var.compartment_id}",
    "Allow dynamic-group streaming-consumer-instances to use stream-family in compartment id ${var.compartment_id}",
    "Allow service sch to use stream-push in compartment id ${var.compartment_id}",
    "Allow service sch to use stream-pull in compartment id ${var.compartment_id}"
  ]
}

Step 2: Stream Pool and Stream

resource "oci_streaming_stream_pool" "orders_pool" {
  compartment_id = var.compartment_id
  name           = "orders-stream-pool"

  kafka_settings {
    auto_create_topics_enable = false
    log_retention_hours       = 24
    num_partitions            = 3
  }

  custom_encryption_key {
    kms_key_id = var.vault_key_id
  }

  private_endpoint_settings {
    subnet_id           = var.private_subnet_id
    private_endpoint_ip = "10.0.2.50"
    nsg_ids             = [var.streaming_nsg_id]
  }

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.ManagedBy"   = "terraform"
  }
}

resource "oci_streaming_stream" "orders_events" {
  compartment_id     = var.compartment_id
  name               = "orders-events"
  partitions         = 3
  retention_in_hours = 24
  stream_pool_id     = oci_streaming_stream_pool.orders_pool.id

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.Application" = "orders"
  }
}

output "stream_id" {
  value = oci_streaming_stream.orders_events.id
}

output "stream_endpoint" {
  value = oci_streaming_stream_pool.orders_pool.endpoint_fqdn
}

The private_endpoint_settings block places the stream pool inside your VCN. Producers and consumers connect to the private endpoint IP rather than the public OCI Streaming endpoint. No streaming traffic leaves your network.

Three partitions allow up to three parallel consumer instances to process messages concurrently within the same consumer group. A partition is the unit of parallelism in Kafka and OCI Streaming. One consumer per partition is the maximum effective parallelism.

Step 3: Producer Implementation

import oci
import json
import base64
import logging
import hashlib
from datetime import datetime, timezone
from typing import List

logger = logging.getLogger(__name__)

class OrderEventProducer:
    def __init__(self, stream_id: str, stream_endpoint: str):
        self.stream_id      = stream_id
        self.stream_endpoint = stream_endpoint
        config = oci.config.from_file()
        self.client = oci.streaming.StreamClient(
            config,
            service_endpoint=f"https://{stream_endpoint}"
        )

    def publish(self, order_id: str, event_type: str, payload: dict) -> str:
        message = json.dumps({
            "event_type":   event_type,
            "order_id":     order_id,
            "payload":      payload,
            "published_at": datetime.now(timezone.utc).isoformat(),
            "version":      "1.0"
        })

        # Use order_id as the partition key so all events
        # for the same order land on the same partition in order
        partition_key = hashlib.md5(order_id.encode()).hexdigest()[:8]

        response = self.client.put_messages(
            stream_id=self.stream_id,
            put_messages_details=oci.streaming.models.PutMessagesDetails(
                messages=[
                    oci.streaming.models.PutMessagesDetailsEntry(
                        key=base64.b64encode(partition_key.encode()).decode(),
                        value=base64.b64encode(message.encode()).decode()
                    )
                ]
            )
        )

        if response.data.failures > 0:
            logger.error(f"Failed to publish event: {response.data.entries[0].error_message}")
            raise RuntimeError(f"Stream publish failed: {response.data.entries[0].error_message}")

        offset = response.data.entries[0].offset
        logger.info(f"Published {event_type} for order {order_id} at offset {offset}")
        return str(offset)

    def publish_batch(self, events: List[dict]) -> List[str]:
        entries = [
            oci.streaming.models.PutMessagesDetailsEntry(
                key=base64.b64encode(
                    hashlib.md5(e["order_id"].encode()).hexdigest()[:8].encode()
                ).decode(),
                value=base64.b64encode(json.dumps(e).encode()).decode()
            )
            for e in events
        ]

        response = self.client.put_messages(
            stream_id=self.stream_id,
            put_messages_details=oci.streaming.models.PutMessagesDetails(messages=entries)
        )

        offsets = [str(entry.offset) for entry in response.data.entries]
        logger.info(f"Published batch of {len(offsets)} events")
        return offsets

The partition key derived from order_id ensures all events for the same order are written to the same partition. This guarantees ordering: a consumer reading partition 0 sees all events for the orders that hash to partition 0 in the sequence they were written. Without a consistent partition key, events for the same order can land on different partitions and arrive at the consumer out of order.

Step 4: Consumer Implementation with Offset Management

import oci
import json
import base64
import logging
import time
from typing import Callable, List

logger = logging.getLogger(__name__)

class OrderEventConsumer:
    def __init__(
        self,
        stream_id: str,
        stream_endpoint: str,
        group_name: str,
        instance_name: str
    ):
        self.stream_id      = stream_id
        self.stream_endpoint = stream_endpoint
        self.group_name     = group_name
        self.instance_name  = instance_name
        self.running        = False
        config = oci.config.from_file()
        self.client = oci.streaming.StreamClient(
            config,
            service_endpoint=f"https://{stream_endpoint}"
        )

    def _get_or_create_cursor(self) -> str:
        response = self.client.create_group_cursor(
            stream_id=self.stream_id,
            create_group_cursor_details=oci.streaming.models.CreateGroupCursorDetails(
                group_name=self.group_name,
                instance_name=self.instance_name,
                type=oci.streaming.models.CreateGroupCursorDetails.TYPE_TRIM_HORIZON,
                commit_on_get=False
            )
        )
        return response.data.value

    def _commit_offsets(self, cursor: str, messages: list):
        offsets = [
            oci.streaming.models.CursorDetails(
                partition=msg.partition,
                offset=msg.offset
            )
            for msg in messages
        ]
        self.client.consumer_commit(
            stream_id=self.stream_id,
            cursor=cursor,
            cursor_details=oci.streaming.models.CursorDetails()
        )

    def consume(self, handler: Callable, batch_size: int = 100):
        self.running = True
        cursor = self._get_or_create_cursor()
        logger.info(f"Consumer {self.group_name}/{self.instance_name} started")

        while self.running:
            try:
                response = self.client.get_messages(
                    stream_id=self.stream_id,
                    cursor=cursor,
                    limit=batch_size
                )

                messages = response.data
                cursor   = response.headers["opc-next-cursor"]

                if not messages:
                    time.sleep(1)
                    continue

                processed = []
                for msg in messages:
                    try:
                        content = json.loads(
                            base64.b64decode(msg.value).decode()
                        )
                        handler(content)
                        processed.append(msg)
                    except Exception as e:
                        logger.error(f"Failed to process message at offset {msg.offset}: {e}")

                if processed:
                    self.client.consumer_heartbeat(
                        stream_id=self.stream_id,
                        cursor=cursor
                    )

                logger.debug(f"Processed {len(processed)}/{len(messages)} messages")

            except oci.exceptions.ServiceError as e:
                if e.status == 404:
                    logger.warning("Cursor expired, recreating")
                    cursor = self._get_or_create_cursor()
                else:
                    logger.error(f"Stream service error: {e}")
                    time.sleep(5)
            except KeyboardInterrupt:
                self.running = False

The consumer uses TYPE_TRIM_HORIZON on first connection, which starts reading from the oldest available message. On reconnect, the group cursor picks up from where it left off. The consumer_heartbeat call keeps the cursor alive during long processing batches. If the cursor goes without a heartbeat for too long, OCI Streaming expires it and the consumer must recreate it, which triggers the 404 handling in the except block.

Step 5: Service Connector Hub for Archival

Service Connector Hub connects OCI Streaming to Object Storage without writing any consumer code. Every message published to the stream is automatically archived to a bucket, partitioned by date and hour.

resource "oci_sch_service_connector" "stream_to_storage" {
  compartment_id = var.compartment_id
  display_name   = "orders-events-archive"
  description    = "Archive all orders events from Streaming to Object Storage"

  source {
    kind = "streaming"
    stream_id = oci_streaming_stream.orders_events.id
    cursor {
      kind = "TRIM_HORIZON"
    }
  }

  target {
    kind        = "objectStorage"
    bucket_name = var.archive_bucket_name
    namespace   = var.object_storage_namespace
    object_name_prefix = "orders-events"
    batch_size_in_kbs  = 1024
    batch_time_in_sec  = 60
  }

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.ManagedBy"   = "terraform"
  }
}

resource "oci_sch_service_connector" "stream_to_function" {
  compartment_id = var.compartment_id
  display_name   = "orders-events-processor"
  description    = "Route orders events to notification Function"

  source {
    kind      = "streaming"
    stream_id = oci_streaming_stream.orders_events.id
    cursor {
      kind = "LATEST"
    }
  }

  target {
    kind        = "functions"
    function_id = var.notification_function_id
    batch_size_in_num  = 100
    batch_time_in_sec  = 10
  }
}

Step 6: Monitoring Stream Lag and Throughput

resource "oci_monitoring_alarm" "stream_put_failures" {
  compartment_id        = var.compartment_id
  display_name          = "orders-stream-put-failures"
  is_enabled            = true
  metric_compartment_id = var.compartment_id
  namespace             = "oci_streaming"
  query                 = "PutMessageFailures[5m]{streamId = '${oci_streaming_stream.orders_events.id}'}.sum() > 10"
  severity              = "CRITICAL"
  pending_duration      = "PT2M"
  destinations          = [var.ops_notification_topic_id]
  body                  = "Orders stream is experiencing publish failures. Producers may be losing events. Check application logs immediately."
}

resource "oci_monitoring_alarm" "stream_consumer_lag" {
  compartment_id        = var.compartment_id
  display_name          = "orders-stream-consumer-lag-high"
  is_enabled            = true
  metric_compartment_id = var.compartment_id
  namespace             = "oci_streaming"
  query                 = "GetMessagesCount[5m]{streamId = '${oci_streaming_stream.orders_events.id}'}.mean() < 1"
  severity              = "WARNING"
  pending_duration      = "PT10M"
  destinations          = [var.ops_notification_topic_id]
  body                  = "Orders stream consumer appears idle for 10 minutes. Consumer group may have stopped processing."
}

Using the Kafka-Compatible Endpoint

If you already have Kafka producers or consumers in your codebase, point them at the OCI Streaming Kafka-compatible endpoint without changing any application code. The only changes are the bootstrap server, the SASL credentials, and the topic name.

from kafka import KafkaProducer, KafkaConsumer
import json

# OCI Streaming Kafka-compatible connection config
KAFKA_CONFIG = {
    "bootstrap_servers": f"{STREAM_POOL_ENDPOINT}:9092",
    "security_protocol": "SASL_SSL",
    "sasl_mechanism":    "PLAIN",
    "sasl_plain_username": f"{TENANCY_NAME}/{USERNAME}/{STREAM_POOL_OCID}",
    "sasl_plain_password": AUTH_TOKEN,
    "api_version":       (0, 10, 1),
}

# Producer - identical to any Kafka producer
producer = KafkaProducer(
    **KAFKA_CONFIG,
    value_serializer=lambda v: json.dumps(v).encode("utf-8")
)

producer.send(
    topic="orders-events",
    key=b"ORD-001",
    value={"event_type": "ORDER_PLACED", "order_id": "ORD-001"}
)
producer.flush()

# Consumer - identical to any Kafka consumer
consumer = KafkaConsumer(
    "orders-events",
    **KAFKA_CONFIG,
    group_id="notification-consumer-group",
    auto_offset_reset="earliest",
    value_deserializer=lambda v: json.loads(v.decode("utf-8"))
)

for message in consumer:
    print(f"Partition: {message.partition}, Offset: {message.offset}")
    print(f"Event: {message.value}")

The SASL username format for OCI Streaming Kafka endpoint is tenancy-name/username/stream-pool-ocid. The password is an OCI Auth Token, not your console password. Generate one under your user profile in the OCI Console under Auth Tokens.

Operational Notes

Partition count is fixed at creation time. You cannot increase partitions on an existing stream without creating a new stream and migrating consumers. Size your partition count based on your expected peak throughput and maximum desired consumer parallelism, then add a buffer. Three partitions work for most workloads under 3000 messages per second. Scale to 10 or more for high-throughput pipelines.

Retention is 24 hours by default and configurable up to 7 days. Set retention based on your recovery window. If a consumer goes down for a weekend, 24 hours retention means it loses events from Saturday evening onward. 7 days gives you the full weekend to recover without data loss.

Always use a partition key derived from a business entity ID, not a random value. Random keys distribute load evenly but destroy ordering guarantees. Business entity keys preserve ordering per entity while still distributing across partitions when the entity population is large enough.

Regards,
Osama

#OCI #OracleCloud #Streaming #EventDriven #Terraform #CloudNative #IaC #OracleCloudInfrastructure #PlatformEngineering #CloudArchitecture #DevOps #RealTime #BackendEngineering #TechBlog #Oracle #Kafka #MessageStreaming #ServiceConnectorHub #CloudEngineering #DistributedSystems

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.